gentle-pi 4.0.0 → 4.2.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 (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,978 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { PassThrough } from "node:stream";
6
+ import test from "node:test";
7
+ import { askLine, runBundledSelfUninstall, runSelfUninstall, SHARED_CONFIG_FILES } from "../lib/gentle-shell-uninstall.ts";
8
+
9
+ const posixOnly = { skip: process.platform === "win32" };
10
+ const SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
11
+
12
+ type Owner = { name: "pnpm" | "npm"; command: string; prefix: string[] };
13
+ type Options = {
14
+ ask?: (question: string) => Promise<string>;
15
+ owner?: Owner | Error;
16
+ env?: Record<string, string>;
17
+ interactive?: boolean;
18
+ answers?: string[];
19
+ platform?: string;
20
+ runCode?: number;
21
+ };
22
+
23
+ function write(path: string, text = "{}\n") {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ writeFileSync(path, text);
26
+ }
27
+
28
+ /** A sandboxed $HOME holding a full Gentle Shell installation; the real home is never touched. */
29
+ function world() {
30
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "self-uninstall-")));
31
+ const home = join(root, "home");
32
+ const config = join(home, ".pi", "gentle-ai");
33
+ const shell = join(home, ".gentle-shell");
34
+ const isolated = join(shell, "agent");
35
+ const piHome = join(home, ".pi", "agent");
36
+ const paths = {
37
+ main: join(config, "main"),
38
+ channel: join(config, "channel.json"),
39
+ devBinary: join(config, "dev-binary.json"),
40
+ go: join(config, "tools", "go"),
41
+ isolated,
42
+ launcherConfig: join(shell, "config.json"),
43
+ };
44
+ write(join(paths.main, "gentle-ai", SHA, "gentle-ai"), "binary");
45
+ write(paths.channel, JSON.stringify({ schema: "gentle-shell.channel/v1", channel: "main", shellCommit: SHA, gentleAiCommit: SHA }));
46
+ write(paths.devBinary, JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: join(paths.main, "gentle-ai", SHA, "gentle-ai") }));
47
+ write(join(paths.go, "1.25.10", "go", "bin", "go"), "go");
48
+ write(join(paths.go, "1.25.10", ".gentle-shell-go"), "https://go.dev/dl/go1.25.10.darwin-arm64.tar.gz\n");
49
+ write(join(isolated, "settings.json"));
50
+ write(join(isolated, ".gentle-shell-home"));
51
+ write(paths.launcherConfig, JSON.stringify({ home: "isolated" }));
52
+ for (const name of ["profiles.json", "persona.json"]) write(join(config, name));
53
+ write(join(config, "unknown.json"));
54
+ write(join(piHome, "settings.json"));
55
+ const calls: { command: string; argv: string[]; ownDataLeft: string[]; sharedLeft: boolean; shellLeft: boolean }[] = [];
56
+ const out: string[] = [];
57
+ const err: string[] = [];
58
+ const questions: string[] = [];
59
+ const ownLeft = () => Object.values(paths).filter((path) => existsSync(path));
60
+ const uninstall = (args: string[], options: Options = {}) => {
61
+ const answers = [...(options.answers ?? [])];
62
+ return runSelfUninstall({
63
+ args,
64
+ env: options.env ?? {},
65
+ homedir: home,
66
+ platform: options.platform ?? "darwin",
67
+ interactive: options.interactive ?? false,
68
+ resolveOwner: async () => {
69
+ const owner = options.owner ?? { name: "pnpm", command: "/usr/bin/pnpm", prefix: [] };
70
+ if (owner instanceof Error) throw owner;
71
+ return owner;
72
+ },
73
+ run: async (command: string, argv: string[]) => {
74
+ calls.push({ command, argv, ownDataLeft: ownLeft(), sharedLeft: existsSync(join(config, "profiles.json")), shellLeft: existsSync(shell) });
75
+ return { code: options.runCode ?? 0 };
76
+ },
77
+ ask: options.ask ?? (async (question: string) => {
78
+ questions.push(question);
79
+ return answers.shift() ?? "";
80
+ }),
81
+ out: (line: string) => out.push(line),
82
+ err: (line: string) => err.push(line),
83
+ });
84
+ };
85
+ return { root, home, config, shell, isolated, piHome, paths, calls, out, err, questions, ownLeft, uninstall, cleanup: () => rmSync(root, { recursive: true, force: true }) };
86
+ }
87
+
88
+ type World = ReturnType<typeof world>;
89
+
90
+ function untouched(w: World) {
91
+ assert.deepEqual(w.ownLeft().sort(), Object.values(w.paths).sort());
92
+ assert.equal(existsSync(join(w.config, "profiles.json")), true);
93
+ assert.deepEqual(w.calls, []);
94
+ }
95
+
96
+ /** The indented lines of one plan section, such as "Remove Gentle Shell data:". */
97
+ function section(out: string[], heading: string): string[] {
98
+ const start = out.findIndex((line) => line.startsWith(heading));
99
+ if (start < 0) return [];
100
+ const end = out.findIndex((line, index) => index > start && !line.startsWith(" "));
101
+ return out.slice(start + 1, end < 0 ? undefined : end);
102
+ }
103
+
104
+ /** A refusal: exit 1, a named reason, nothing removed and no path both removed and not touched. */
105
+ async function refused(w: World, env: Record<string, string>, reason: RegExp) {
106
+ assert.equal(await w.uninstall(["--yes"], { env }), 1, JSON.stringify(env));
107
+ assert.match(w.err.join("\n"), reason, JSON.stringify(env));
108
+ assert.match(w.err.join("\n"), /Nothing was removed\./);
109
+ assert.deepEqual(w.calls, []);
110
+ assert.deepEqual(section(w.out, "Remove Gentle Shell data:"), [" (refused, see below)"]);
111
+ }
112
+
113
+ test("unknown self-uninstall arguments are a usage error that removes nothing", async () => {
114
+ for (const args of [["--force"], ["now"], ["--yes=1"]]) {
115
+ const w = world();
116
+ try {
117
+ assert.equal(await w.uninstall(args), 2);
118
+ assert.match(w.err.join("\n"), /usage: gentle-shell self-uninstall \[--dry-run\] \[--yes\] \[--include-shared\]/);
119
+ untouched(w);
120
+ } finally { w.cleanup(); }
121
+ }
122
+ });
123
+
124
+ test("--dry-run prints the whole plan and removes nothing", async () => {
125
+ const w = world();
126
+ try {
127
+ assert.equal(await w.uninstall(["--dry-run"]), 0);
128
+ const plan = w.out.join("\n");
129
+ for (const path of Object.values(w.paths)) assert.ok(plan.includes(path), path);
130
+ assert.ok(plan.includes("pnpm remove -g gentle-pi"));
131
+ assert.ok(plan.includes(join(w.config, "profiles.json")));
132
+ assert.ok(plan.includes(w.piHome));
133
+ assert.match(plan, /Dry run: nothing was removed\./);
134
+ untouched(w);
135
+ assert.deepEqual(w.questions, []);
136
+ } finally { w.cleanup(); }
137
+ });
138
+
139
+ test("without a terminal and without --yes it prints the plan and exits 2", async () => {
140
+ const w = world();
141
+ try {
142
+ assert.equal(await w.uninstall([]), 2);
143
+ assert.ok(w.out.join("\n").includes(w.paths.isolated));
144
+ assert.match(w.err.join("\n"), /--yes/);
145
+ untouched(w);
146
+ } finally { w.cleanup(); }
147
+ });
148
+
149
+ test("--yes removes Gentle Shell's own data, then the package, then the empty ~/.gentle-shell, keeping shared configuration", async () => {
150
+ const w = world();
151
+ try {
152
+ assert.equal(await w.uninstall(["--yes"]), 0);
153
+ assert.deepEqual(w.calls.map(({ command, argv }) => `${command} ${argv.join(" ")}`), ["/usr/bin/pnpm remove -g gentle-pi"]);
154
+ assert.deepEqual(w.calls[0].ownDataLeft, [], "own data is removed before the package");
155
+ assert.equal(w.calls[0].shellLeft, true, "~/.gentle-shell is removed only after the package");
156
+ assert.equal(existsSync(w.shell), false);
157
+ assert.deepEqual(w.ownLeft(), []);
158
+ assert.equal(existsSync(join(w.config, "tools")), false);
159
+ for (const name of ["profiles.json", "persona.json", "unknown.json"]) assert.equal(existsSync(join(w.config, name)), true, name);
160
+ assert.equal(existsSync(join(w.piHome, "settings.json")), true);
161
+ assert.deepEqual(w.questions, []);
162
+ assert.match(w.out.join("\n"), /Gentle Shell was removed\./);
163
+ } finally { w.cleanup(); }
164
+ });
165
+
166
+ test("an npm-owned installation is removed with npm uninstall -g", async () => {
167
+ const w = world();
168
+ try {
169
+ assert.equal(await w.uninstall(["--yes"], { owner: { name: "npm", command: "/usr/bin/npm", prefix: [] } }), 0);
170
+ assert.deepEqual(w.calls.map(({ command, argv }) => `${command} ${argv.join(" ")}`), ["/usr/bin/npm uninstall -g gentle-pi"]);
171
+ } finally { w.cleanup(); }
172
+ });
173
+
174
+ test("Windows: the package is removed through the owner's verified invocation, never a .cmd", async () => {
175
+ const node = "C:\\nodejs\\node.exe";
176
+ for (const [owner, expected] of [
177
+ [{ name: "pnpm", command: node, prefix: ["C:\\tools\\pnpm\\package\\bin\\pnpm.mjs"] }, ["C:\\tools\\pnpm\\package\\bin\\pnpm.mjs", "remove", "-g", "gentle-pi"]],
178
+ [{ name: "npm", command: node, prefix: ["C:\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"] }, ["C:\\nodejs\\node_modules\\npm\\bin\\npm-cli.js", "uninstall", "-g", "gentle-pi"]],
179
+ ] as const) {
180
+ const w = world();
181
+ try {
182
+ assert.equal(await w.uninstall(["--yes"], { owner: { ...owner, prefix: [...owner.prefix] }, platform: "win32" }), 0);
183
+ assert.deepEqual(w.calls.map(({ command, argv }) => [command, argv]), [[node, expected]]);
184
+ assert.ok(w.out.includes(` ${owner.name} ${expected.slice(1).join(" ")}`), "the plan shows the command a person would type");
185
+ } finally { w.cleanup(); }
186
+ }
187
+ });
188
+
189
+ test("POSIX: the package manager runs as it was found, with no prefix", async () => {
190
+ const w = world();
191
+ try {
192
+ assert.equal(await w.uninstall(["--yes"], { platform: "linux" }), 0);
193
+ assert.deepEqual(w.calls.map(({ command, argv }) => [command, argv]), [["/usr/bin/pnpm", ["remove", "-g", "gentle-pi"]]]);
194
+ } finally { w.cleanup(); }
195
+ });
196
+
197
+ test("inside a Gentle Shell session the isolated home in PI_CODING_AGENT_DIR is not the user's Pi home", async () => {
198
+ const w = world();
199
+ try {
200
+ assert.equal(await w.uninstall(["--yes"], { env: { PI_CODING_AGENT_DIR: w.isolated, GENTLE_SHELL_USER_PI_HOME: w.piHome } }), 0, w.err.join("\n"));
201
+ assert.equal(existsSync(w.isolated), false);
202
+ assert.equal(existsSync(join(w.piHome, "settings.json")), true);
203
+ assert.ok(section(w.out, "Not touched:").some((line) => line.includes(`${w.piHome} (your Pi home)`)));
204
+ } finally { w.cleanup(); }
205
+ });
206
+
207
+ test("inside a session the user's Pi home and any other PI_CODING_AGENT_DIR stay protected", async () => {
208
+ const cases = (w: World) => [
209
+ // GENTLE_SHELL_USER_PI_HOME pointing at the isolated home itself.
210
+ { PI_CODING_AGENT_DIR: w.isolated, GENTLE_SHELL_USER_PI_HOME: w.isolated },
211
+ // A PI_CODING_AGENT_DIR other than the isolated home, inside it.
212
+ { PI_CODING_AGENT_DIR: join(w.isolated, "pi"), GENTLE_SHELL_USER_PI_HOME: w.piHome },
213
+ // Without GENTLE_SHELL_USER_PI_HOME, PI_CODING_AGENT_DIR is the user's Pi home.
214
+ { PI_CODING_AGENT_DIR: w.isolated },
215
+ // A GENTLE_SHELL_USER_PI_HOME that is not an existing directory proves no session.
216
+ { PI_CODING_AGENT_DIR: w.isolated, GENTLE_SHELL_USER_PI_HOME: join(w.root, "missing-pi-home") },
217
+ ];
218
+ for (let index = 0; index < 4; index += 1) {
219
+ const w = world();
220
+ try {
221
+ await refused(w, cases(w)[index], /your Pi home/);
222
+ untouched(w);
223
+ } finally { w.cleanup(); }
224
+ }
225
+ });
226
+
227
+ test("--yes --include-shared also removes exactly the shared Gentle AI configuration files", async () => {
228
+ const w = world();
229
+ try {
230
+ for (const name of SHARED_CONFIG_FILES) write(join(w.config, name));
231
+ assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0);
232
+ for (const name of SHARED_CONFIG_FILES) assert.equal(existsSync(join(w.config, name)), false, name);
233
+ assert.equal(w.calls[0].sharedLeft, false, "shared configuration is removed before the package");
234
+ assert.equal(existsSync(join(w.config, "unknown.json")), true);
235
+ assert.equal(existsSync(join(w.piHome, "settings.json")), true);
236
+ } finally { w.cleanup(); }
237
+ });
238
+
239
+ test("the shared files are the Gentle AI configuration Gentle Shell writes", () => {
240
+ assert.deepEqual([...SHARED_CONFIG_FILES].sort(), [
241
+ "background-subagents.json", "banner.json", "builtin-codemode-optout.json", "double-esc-cancel.json",
242
+ "persona.json", "profiles.export.json", "profiles.json", "runtime-guardrails.json",
243
+ ]);
244
+ });
245
+
246
+ test("a dev binary the user registered is kept", async () => {
247
+ const w = world();
248
+ try {
249
+ write(w.paths.devBinary, JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/Users/me/src/gentle-ai/gentle-ai" }));
250
+ assert.equal(await w.uninstall(["--yes"]), 0);
251
+ assert.equal(existsSync(w.paths.devBinary), true);
252
+ assert.match(w.out.join("\n"), /dev-binary\.json: points at a binary you registered yourself/);
253
+ } finally { w.cleanup(); }
254
+ });
255
+
256
+ test("in a terminal the default answer cancels and removes nothing", async () => {
257
+ for (const answer of ["", "n", "no", "maybe"]) {
258
+ const w = world();
259
+ try {
260
+ assert.equal(await w.uninstall([], { interactive: true, answers: [answer] }), 1);
261
+ assert.equal(w.questions.length, 1);
262
+ assert.match(w.out.join("\n"), /Cancelled; nothing was removed\./);
263
+ untouched(w);
264
+ } finally { w.cleanup(); }
265
+ }
266
+ });
267
+
268
+ test("in a terminal the shared configuration is asked about separately and kept by default", async () => {
269
+ for (const [answer, removed] of [["", false], ["n", false], ["y", true], ["YES", true]] as const) {
270
+ const w = world();
271
+ try {
272
+ assert.equal(await w.uninstall([], { interactive: true, answers: ["y", answer] }), 0);
273
+ assert.equal(w.questions.length, 2);
274
+ assert.match(w.questions[1], /shared Gentle AI configuration/);
275
+ assert.match(w.questions[1], /\[y\/N\]/);
276
+ assert.equal(existsSync(join(w.config, "profiles.json")), !removed, answer);
277
+ assert.deepEqual(w.ownLeft(), []);
278
+ } finally { w.cleanup(); }
279
+ }
280
+ });
281
+
282
+ test("in a terminal --include-shared skips the separate question", async () => {
283
+ const w = world();
284
+ try {
285
+ assert.equal(await w.uninstall(["--include-shared"], { interactive: true, answers: ["y"] }), 0);
286
+ assert.equal(w.questions.length, 1);
287
+ assert.equal(existsSync(join(w.config, "profiles.json")), false);
288
+ } finally { w.cleanup(); }
289
+ });
290
+
291
+ test("an installation no package manager owns, such as npm link, is refused before anything is removed", async () => {
292
+ const w = world();
293
+ try {
294
+ const owner = new Error("uninstall-owner-unknown: neither pnpm nor npm owns /src/gentle-pi");
295
+ for (const args of [["--yes"], ["--dry-run"]]) {
296
+ assert.equal(await w.uninstall(args, { owner }), 1);
297
+ assert.match(w.err.join("\n"), /neither pnpm nor npm owns \/src\/gentle-pi/);
298
+ untouched(w);
299
+ }
300
+ } finally { w.cleanup(); }
301
+ });
302
+
303
+ test("an isolated home that is or contains the user's Pi home, $HOME or the config home is refused", async () => {
304
+ const cases = (w: World) => [
305
+ { GENTLE_SHELL_HOME: w.piHome },
306
+ { GENTLE_SHELL_HOME: join(w.home, ".pi") },
307
+ { GENTLE_SHELL_HOME: w.home },
308
+ { GENTLE_SHELL_HOME: w.root },
309
+ { GENTLE_SHELL_HOME: w.config },
310
+ { GENTLE_SHELL_HOME: join(w.root, "agent"), PI_CODING_AGENT_DIR: join(w.root, "agent", "pi") },
311
+ { GENTLE_SHELL_HOME: join(w.root, "agent"), GENTLE_SHELL_USER_PI_HOME: join(w.root, "agent") },
312
+ ];
313
+ for (let index = 0; index < 7; index += 1) {
314
+ const w = world();
315
+ try {
316
+ const env = cases(w)[index];
317
+ writeFileSync(join(w.home, "keep.txt"), "x");
318
+ assert.equal(await w.uninstall(["--yes"], { env }), 1, JSON.stringify(env));
319
+ assert.match(w.err.join("\n"), /refusing to remove anything/);
320
+ untouched(w);
321
+ assert.equal(existsSync(join(w.home, "keep.txt")), true);
322
+ } finally { w.cleanup(); }
323
+ }
324
+ });
325
+
326
+ test("a relative GENTLE_SHELL_HOME or GENTLE_PI_CONFIG_HOME is refused", async () => {
327
+ for (const env of [{ GENTLE_SHELL_HOME: "agent" }, { GENTLE_PI_CONFIG_HOME: "gentle-ai" }]) {
328
+ const w = world();
329
+ try {
330
+ assert.equal(await w.uninstall(["--yes"], { env }), 1);
331
+ assert.match(w.err.join("\n"), /is not an absolute path/);
332
+ untouched(w);
333
+ } finally { w.cleanup(); }
334
+ }
335
+ });
336
+
337
+ test("GENTLE_SHELL_HOME and GENTLE_PI_CONFIG_HOME select what is removed", async () => {
338
+ const w = world();
339
+ try {
340
+ const custom = join(w.root, "custom-agent");
341
+ const config = join(w.root, "custom-config");
342
+ write(join(custom, ".gentle-shell-home"));
343
+ write(join(config, "channel.json"));
344
+ write(join(config, "persona.json"));
345
+ assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: custom, GENTLE_PI_CONFIG_HOME: config } }), 0);
346
+ assert.equal(existsSync(custom), false);
347
+ assert.equal(existsSync(join(config, "channel.json")), false);
348
+ assert.equal(existsSync(join(config, "persona.json")), true);
349
+ assert.equal(existsSync(w.isolated), true, "the default isolated home is not the selected one");
350
+ assert.equal(existsSync(w.paths.channel), true, "the default config home is not the selected one");
351
+ } finally { w.cleanup(); }
352
+ });
353
+
354
+ test("a GENTLE_SHELL_HOME without Gentle Shell's ownership evidence is kept", async () => {
355
+ const w = world();
356
+ try {
357
+ const custom = join(w.root, "projects");
358
+ write(join(custom, "notes.txt"), "mine");
359
+ assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: custom } }), 0);
360
+ assert.equal(readFileSync(join(custom, "notes.txt"), "utf8"), "mine");
361
+ assert.match(w.out.join("\n"), /projects: not created by Gentle Shell/);
362
+ } finally { w.cleanup(); }
363
+ });
364
+
365
+ test("a GENTLE_SHELL_HOME recorded as provisioned in ~/.gentle-shell/config.json is removed", async () => {
366
+ const w = world();
367
+ try {
368
+ const custom = join(w.root, "provisioned-agent");
369
+ write(join(custom, "settings.json"));
370
+ write(w.paths.launcherConfig, JSON.stringify({ provisioned: { [custom]: { gentleAi: "4.0.0", gentlePi: "4.0.0", at: "now" } } }));
371
+ assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: custom } }), 0);
372
+ assert.equal(existsSync(custom), false);
373
+ } finally { w.cleanup(); }
374
+ });
375
+
376
+ test("symbolic links are never followed or removed", posixOnly, async () => {
377
+ const w = world();
378
+ try {
379
+ const target = join(w.root, "elsewhere");
380
+ write(join(target, ".gentle-shell-home"));
381
+ write(join(target, "keep.txt"));
382
+ rmSync(w.paths.main, { recursive: true });
383
+ symlinkSync(target, w.paths.main);
384
+ const linkedHome = join(w.root, "linked-agent");
385
+ symlinkSync(target, linkedHome);
386
+ assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: linkedHome } }), 0);
387
+ assert.equal(existsSync(join(target, "keep.txt")), true);
388
+ assert.equal(existsSync(w.paths.main), true);
389
+ assert.equal(existsSync(linkedHome), true);
390
+ assert.match(w.out.join("\n"), /main: a symbolic link, not followed/);
391
+ } finally { w.cleanup(); }
392
+ });
393
+
394
+ test("custom --home homes and the user's Pi home are listed as not touched", async () => {
395
+ const w = world();
396
+ try {
397
+ const persisted = join(w.root, "persisted-home");
398
+ const provisioned = join(w.root, "provisioned-home");
399
+ for (const dir of [persisted, provisioned]) write(join(dir, "settings.json"));
400
+ write(w.paths.launcherConfig, JSON.stringify({ home: persisted, provisioned: { [provisioned]: { gentleAi: "4.0.0", at: "now" } } }));
401
+ assert.equal(await w.uninstall(["--yes"]), 0);
402
+ const plan = w.out.join("\n");
403
+ assert.match(plan, /Not touched:/);
404
+ for (const dir of [persisted, provisioned, w.piHome]) {
405
+ assert.ok(plan.includes(dir), dir);
406
+ assert.equal(existsSync(join(dir, "settings.json")), true, dir);
407
+ }
408
+ } finally { w.cleanup(); }
409
+ });
410
+
411
+ test("tools the installer may have added are listed with how to remove them, never removed", async () => {
412
+ for (const platform of ["darwin", "win32"]) {
413
+ const w = world();
414
+ try {
415
+ assert.equal(await w.uninstall(["--dry-run"], { platform }), 0);
416
+ const plan = w.out.join("\n");
417
+ assert.match(plan, /remove -g @earendil-works\/pi-coding-agent/);
418
+ assert.match(plan, /Engram/);
419
+ assert.match(plan, /pnpm setup/);
420
+ assert.equal(plan.includes("%USERPROFILE%\\.pnpm"), platform === "win32", platform);
421
+ } finally { w.cleanup(); }
422
+ }
423
+ });
424
+
425
+ test("a failed package removal exits 1 and prints the command to run", async () => {
426
+ const w = world();
427
+ try {
428
+ assert.equal(await w.uninstall(["--yes"], { runCode: 1 }), 1);
429
+ assert.match(w.err.join("\n"), /pnpm remove -g gentle-pi/);
430
+ assert.deepEqual(w.ownLeft(), []);
431
+ } finally { w.cleanup(); }
432
+ });
433
+
434
+ test("~/.gentle-shell is kept when it still holds other files", async () => {
435
+ const w = world();
436
+ try {
437
+ write(join(w.shell, "notes.txt"), "mine");
438
+ assert.equal(await w.uninstall(["--yes"]), 0);
439
+ assert.equal(readFileSync(join(w.shell, "notes.txt"), "utf8"), "mine");
440
+ } finally { w.cleanup(); }
441
+ });
442
+
443
+ test("a config home that is, contains or lies inside the user's Pi home is refused", async () => {
444
+ const w = world();
445
+ try {
446
+ write(join(w.piHome, "main", "sessions.txt"), "mine");
447
+ await refused(w, { GENTLE_PI_CONFIG_HOME: w.piHome }, /GENTLE_PI_CONFIG_HOME .*Pi's default home/);
448
+ assert.equal(readFileSync(join(w.piHome, "main", "sessions.txt"), "utf8"), "mine");
449
+ untouched(w);
450
+ } finally { w.cleanup(); }
451
+ const v = world();
452
+ try {
453
+ const piDir = join(v.root, "pi", "agent");
454
+ write(join(piDir, "settings.json"));
455
+ await refused(v, { PI_CODING_AGENT_DIR: piDir, GENTLE_PI_CONFIG_HOME: join(piDir, "cfg") }, /your Pi home/);
456
+ await refused(v, { GENTLE_SHELL_USER_PI_HOME: piDir, GENTLE_PI_CONFIG_HOME: join(v.root, "pi") }, /your Pi home/);
457
+ untouched(v);
458
+ } finally { v.cleanup(); }
459
+ });
460
+
461
+ test("a config home that is $HOME never removes main/, tools/ or channel.json from it", async () => {
462
+ const w = world();
463
+ try {
464
+ write(join(w.home, "main", "myproject", "src.txt"), "code");
465
+ write(join(w.home, "tools", "go", "1.25.10", ".gentle-shell-go"), "x");
466
+ write(join(w.home, "channel.json"), "{}");
467
+ await refused(w, { GENTLE_PI_CONFIG_HOME: w.home }, /GENTLE_PI_CONFIG_HOME .*your home directory/);
468
+ for (const path of [join("main", "myproject", "src.txt"), join("tools", "go"), "channel.json"]) assert.equal(existsSync(join(w.home, path)), true, path);
469
+ untouched(w);
470
+ } finally { w.cleanup(); }
471
+ });
472
+
473
+ test("a config home reached through a symbolic link is checked at its real path", posixOnly, async () => {
474
+ const w = world();
475
+ try {
476
+ write(join(w.home, "main", "gentle-ai", SHA, "gentle-ai"), "binary");
477
+ const link = join(w.home, "cfglink");
478
+ symlinkSync(w.home, link);
479
+ await refused(w, { GENTLE_PI_CONFIG_HOME: link }, /GENTLE_PI_CONFIG_HOME .*your home directory/);
480
+ assert.equal(existsSync(join(w.home, "main", "gentle-ai", SHA, "gentle-ai")), true);
481
+ untouched(w);
482
+ } finally { w.cleanup(); }
483
+ });
484
+
485
+ test("a ~/.gentle-shell reached through a symbolic link to $HOME is refused", posixOnly, async () => {
486
+ const w = world();
487
+ try {
488
+ rmSync(w.shell, { recursive: true });
489
+ symlinkSync(w.home, w.shell);
490
+ write(join(w.home, "agent", ".gentle-shell-home"));
491
+ write(join(w.home, "config.json"), JSON.stringify({ home: "isolated" }));
492
+ assert.equal(await w.uninstall(["--yes"]), 1);
493
+ assert.match(w.err.join("\n"), /your home directory/);
494
+ assert.deepEqual(w.calls, []);
495
+ for (const path of ["agent", "config.json"]) assert.equal(existsSync(join(w.home, path)), true, path);
496
+ } finally { w.cleanup(); }
497
+ });
498
+
499
+ test("Gentle AI's state and any .pi directory are protected for the isolated home and the config home", async () => {
500
+ const cases = (w: World) => {
501
+ const repo = join(w.root, "repo");
502
+ return [
503
+ [{ GENTLE_SHELL_HOME: join(w.home, ".gentle-ai") }, /Gentle AI's state/],
504
+ [{ GENTLE_SHELL_HOME: join(w.home, ".gentle-ai", "agent") }, /Gentle AI's state/],
505
+ [{ GENTLE_SHELL_HOME: join(repo, ".pi") }, /a \.pi directory/],
506
+ [{ GENTLE_SHELL_HOME: join(repo, ".pi", "gentle-ai") }, /a \.pi directory/],
507
+ [{ GENTLE_SHELL_HOME: repo }, /a \.pi directory/],
508
+ [{ GENTLE_PI_CONFIG_HOME: join(w.home, ".gentle-ai") }, /Gentle AI's state/],
509
+ [{ GENTLE_PI_CONFIG_HOME: join(repo, ".pi", "gentle-ai") }, /a \.pi directory/],
510
+ [{ GENTLE_PI_CONFIG_HOME: join(repo, ".pi") }, /a \.pi directory/],
511
+ [{ GENTLE_PI_CONFIG_HOME: repo }, /a \.pi directory/],
512
+ ] as const;
513
+ };
514
+ for (let index = 0; index < 9; index += 1) {
515
+ const w = world();
516
+ try {
517
+ const repo = join(w.root, "repo");
518
+ const state = join(w.home, ".gentle-ai", "state.json");
519
+ write(state, "{}");
520
+ write(join(w.home, ".gentle-ai", ".gentle-shell-home"));
521
+ write(join(w.home, ".gentle-ai", "agent", ".gentle-shell-home"));
522
+ for (const dir of [repo, join(repo, ".pi"), join(repo, ".pi", "gentle-ai")]) write(join(dir, ".gentle-shell-home"));
523
+ write(join(repo, ".pi", "gentle-ai", "main", "gentle-ai", SHA, "gentle-ai"), "binary");
524
+ const [env, reason] = cases(w)[index];
525
+ await refused(w, env, reason);
526
+ assert.equal(existsSync(state), true);
527
+ assert.equal(existsSync(join(repo, ".pi", "gentle-ai", "main")), true);
528
+ untouched(w);
529
+ } finally { w.cleanup(); }
530
+ }
531
+ });
532
+
533
+ test("a custom --home home that overlaps what would be removed is refused", async () => {
534
+ const w = world();
535
+ try {
536
+ const custom = join(w.isolated, "work");
537
+ write(join(custom, "settings.json"));
538
+ write(w.paths.launcherConfig, JSON.stringify({ home: custom }));
539
+ await refused(w, {}, /custom --home home/);
540
+ assert.equal(existsSync(join(custom, "settings.json")), true);
541
+ } finally { w.cleanup(); }
542
+ });
543
+
544
+ test("main/ outside Gentle Shell's known layout is listed as not touched", async () => {
545
+ for (const extra of [join("myproject", "src.txt"), join("gentle-ai", "not-a-commit", "gentle-ai"), join("gentle-ai", SHA, "notes.txt"), join("packages", "notes.txt")]) {
546
+ const w = world();
547
+ try {
548
+ write(join(w.paths.main, extra), "mine");
549
+ assert.equal(await w.uninstall(["--yes"]), 0, extra);
550
+ assert.equal(readFileSync(join(w.paths.main, extra), "utf8"), "mine", extra);
551
+ assert.ok(section(w.out, "Kept:").some((line) => line.includes(`${w.paths.main}: not Gentle Shell's main-channel layout`)), extra);
552
+ assert.equal(existsSync(w.paths.channel), false);
553
+ } finally { w.cleanup(); }
554
+ }
555
+ });
556
+
557
+ test("main/ in Gentle Shell's known layout is removed", async () => {
558
+ const w = world();
559
+ try {
560
+ write(join(w.paths.main, "packages", "gentle-pi-4.1.0-main.1f9d5e6423e3.tgz"), "tgz");
561
+ write(join(w.paths.main, ".build", "gocache", "x"), "x");
562
+ write(join(w.paths.main, ".source", "src", "package.json"), "{}");
563
+ assert.equal(await w.uninstall(["--yes"]), 0);
564
+ assert.equal(existsSync(w.paths.main), false);
565
+ } finally { w.cleanup(); }
566
+ });
567
+
568
+ test("tools/go with anything but marked pinned-Go version folders is listed as not touched", async () => {
569
+ for (const extra of [join("1.24.0", "go", "bin", "go"), join(".stage-abc", "go"), "notes.txt"]) {
570
+ const w = world();
571
+ try {
572
+ write(join(w.paths.go, extra), "mine");
573
+ assert.equal(await w.uninstall(["--yes"]), 0, extra);
574
+ assert.equal(existsSync(join(w.paths.go, extra)), true, extra);
575
+ assert.equal(existsSync(join(w.paths.go, "1.25.10")), true, extra);
576
+ assert.ok(section(w.out, "Kept:").some((line) => line.includes(`${w.paths.go}: not only the installer's pinned Go`)), extra);
577
+ } finally { w.cleanup(); }
578
+ }
579
+ });
580
+
581
+ test("end of input at a confirmation prompt cancels and removes nothing", async () => {
582
+ for (const answers of [[], ["y"]]) {
583
+ const w = world();
584
+ try {
585
+ const input = new PassThrough();
586
+ const output = new PassThrough();
587
+ output.resume();
588
+ for (const answer of answers) input.write(`${answer}\n`);
589
+ input.end();
590
+ const ask = (question: string) => askLine(question, { input, output });
591
+ const code = await w.uninstall([], { interactive: true, ask });
592
+ if (answers.length === 0) {
593
+ assert.equal(code, 1);
594
+ assert.match(w.out.join("\n"), /Cancelled; nothing was removed\./);
595
+ untouched(w);
596
+ } else {
597
+ // Confirmed, then end of input at the shared-configuration question keeps it.
598
+ assert.equal(code, 0);
599
+ assert.equal(existsSync(join(w.config, "profiles.json")), true);
600
+ }
601
+ } finally { w.cleanup(); }
602
+ }
603
+ });
604
+
605
+ /** Answers each question in terminal mode by typing `keys` (one entry per question) once it is asked. */
606
+ function typing(keys: string[]) {
607
+ const input = new PassThrough();
608
+ const output = new PassThrough();
609
+ output.resume();
610
+ return (question: string) => {
611
+ const answer = askLine(question, { input, output, terminal: true });
612
+ input.write(keys.shift() ?? "");
613
+ return answer;
614
+ };
615
+ }
616
+
617
+ test("Ctrl-C at any confirmation prompt aborts the whole uninstall", async () => {
618
+ for (const keys of [["\x03"], ["y\r", "\x03"]]) {
619
+ const w = world();
620
+ try {
621
+ assert.equal(await w.uninstall([], { interactive: true, ask: typing([...keys]) }), 1, JSON.stringify(keys));
622
+ assert.match(w.err.join("\n"), /Interrupted; nothing was removed\./);
623
+ untouched(w);
624
+ } finally { w.cleanup(); }
625
+ }
626
+ });
627
+
628
+ test("in a terminal, Ctrl-D at the first prompt cancels and at the shared question keeps it", async () => {
629
+ const first = world();
630
+ try {
631
+ assert.equal(await first.uninstall([], { interactive: true, ask: typing(["\x04"]) }), 1);
632
+ assert.match(first.out.join("\n"), /Cancelled; nothing was removed\./);
633
+ untouched(first);
634
+ } finally { first.cleanup(); }
635
+ const second = world();
636
+ try {
637
+ assert.equal(await second.uninstall([], { interactive: true, ask: typing(["y\r", "\x04"]) }), 0);
638
+ assert.equal(existsSync(join(second.config, "profiles.json")), true);
639
+ assert.deepEqual(second.ownLeft(), []);
640
+ } finally { second.cleanup(); }
641
+ });
642
+
643
+ test("askLine returns the typed line", async () => {
644
+ const input = new PassThrough();
645
+ const output = new PassThrough();
646
+ output.resume();
647
+ input.write("yes\n");
648
+ assert.equal(await askLine("Remove? ", { input, output }), "yes");
649
+ input.end();
650
+ });
651
+
652
+ test("a failed removal stops before the package is removed", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async () => {
653
+ const w = world();
654
+ const locked = join(w.paths.main, "gentle-ai");
655
+ try {
656
+ chmodSync(locked, 0o500);
657
+ assert.equal(await w.uninstall(["--yes"]), 1);
658
+ assert.match(w.err.join("\n"), /could not remove/);
659
+ assert.deepEqual(w.calls, []);
660
+ assert.equal(existsSync(w.isolated), true, "nothing after the failure is removed");
661
+ } finally {
662
+ chmodSync(locked, 0o700);
663
+ w.cleanup();
664
+ }
665
+ });
666
+
667
+ // T6 (S6, S7): self-uninstall run from the bundled launcher removes the prefix's
668
+ // own folders and exactly the PATH entry its receipt records; the Gentle Shell
669
+ // home is shared data. POSIX fixtures use the real `current` symlink; the
670
+ // Windows ones simulate the platform with real temporary paths.
671
+ const BUNDLED_ID = "4.1.0-1.1.0";
672
+ type BundledOptions = {
673
+ platform?: string;
674
+ env?: Record<string, string>;
675
+ interactive?: boolean;
676
+ answers?: string[];
677
+ ask?: (question: string) => Promise<string>;
678
+ pathEntry?: "recorded" | "none" | "invalid" | "fails" | "edited";
679
+ execPath?: string;
680
+ root?: string;
681
+ };
682
+
683
+ function bundledWorld({ platform = "darwin" } = {}) {
684
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "bundled-uninstall-")));
685
+ const home = join(base, "home");
686
+ const windows = platform === "win32";
687
+ const shellHome = join(home, ".gentle-shell");
688
+ const prefix = windows ? join(home, "AppData", "Local", "gentle-shell") : shellHome;
689
+ const node = windows ? join(prefix, "runtime", "node-24.21.0", "node.exe") : join(prefix, "runtime", "node-24.21.0", "bin", "node");
690
+ const launcher = join(prefix, "bin", windows ? "gentle-shell.cmd" : "gentle-shell");
691
+ const own = ["bin", "current", "versions", "versions.history", "runtime", "pnpm", "npm", "gentle-ai", "config", "tmp", "path-entry.json",
692
+ ...(windows ? [".gentle-shell-bundle"] : [])].map((name) => join(prefix, name));
693
+ write(launcher, "launcher\n");
694
+ write(join(prefix, "versions", BUNDLED_ID, ".gentle-shell-version"), `${BUNDLED_ID}\n`);
695
+ write(join(prefix, "versions", BUNDLED_ID, "node_modules", "gentle-pi", "package.json"));
696
+ write(join(prefix, "versions.history"), `${BUNDLED_ID}\n`);
697
+ if (windows) write(join(prefix, "current"), `${BUNDLED_ID}\n`);
698
+ else symlinkSync(`versions/${BUNDLED_ID}`, join(prefix, "current"));
699
+ write(node, "node");
700
+ write(join(prefix, "runtime", "pnpm-11.1.1", "package", "package.json"));
701
+ write(join(prefix, "pnpm", "store", "v10", "index"));
702
+ write(join(prefix, "npm", "cache", "index"));
703
+ write(join(prefix, "gentle-ai", "state.json"));
704
+ write(join(prefix, "config", "gentle-ai", "persona.json"));
705
+ mkdirSync(join(prefix, "tmp"));
706
+ write(join(prefix, "path-entry.json"));
707
+ if (windows) write(join(prefix, ".gentle-shell-bundle"), "gentle-shell bundled install");
708
+ const shared = { agent: join(shellHome, "agent"), config: join(shellHome, "config.json") };
709
+ write(join(shared.agent, "auth.json"));
710
+ write(shared.config, JSON.stringify({ home: "isolated" }));
711
+ // The user's own data, never touched: Pi's home, Gentle AI's config and state, Engram's memory.
712
+ const kept = [join(home, ".pi", "agent", "settings.json"), join(home, ".pi", "gentle-ai", "profiles.json"), join(home, ".gentle-ai", "state.json"),
713
+ join(home, ".engram", "engram.db")];
714
+ for (const path of kept) write(path);
715
+ const out: string[] = [];
716
+ const err: string[] = [];
717
+ const questions: string[] = [];
718
+ const pathCalls: { ownLeft: number }[] = [];
719
+ const deferred: { pid: number; files: string[]; last?: string; directories: string[] }[] = [];
720
+ const ownLeft = () => own.filter((path) => { try { lstatSync(path); return true; } catch { return false; } });
721
+ const uninstall = (args: string[], options: BundledOptions = {}) => {
722
+ const answers = [...(options.answers ?? [])];
723
+ const state = options.pathEntry ?? "recorded";
724
+ return runBundledSelfUninstall({
725
+ args,
726
+ env: options.env ?? {},
727
+ homedir: home,
728
+ platform,
729
+ interactive: options.interactive ?? false,
730
+ root: options.root ?? prefix,
731
+ pathEntry: state === "none" ? { status: "none" }
732
+ : state === "invalid" ? { status: "invalid", reason: `${join(prefix, "path-entry.json")} does not describe this install's PATH entry` }
733
+ : {
734
+ status: "recorded",
735
+ description: `the line export PATH="${join(prefix, "bin")}:$PATH" # gentle-shell bundled install in ${join(home, ".zshrc")}`,
736
+ remove: () => {
737
+ pathCalls.push({ ownLeft: ownLeft().length });
738
+ if (state === "fails") throw new Error("EACCES: permission denied");
739
+ return state === "edited" ? { removed: false, note: `${join(home, ".zshrc")} still holds export PATH="$PATH:${join(prefix, "bin")}" # gentle-shell bundled install (edited, so it was kept)` }
740
+ : { removed: true };
741
+ },
742
+ },
743
+ process: { pid: 4242, execPath: options.execPath ?? node },
744
+ deferRemoval: (request: { pid: number; files: string[]; last?: string; directories: string[] }) => { deferred.push(request); },
745
+ ask: options.ask ?? (async (question: string) => {
746
+ questions.push(question);
747
+ return answers.shift() ?? "";
748
+ }),
749
+ out: (line: string) => out.push(line),
750
+ err: (line: string) => err.push(line),
751
+ });
752
+ };
753
+ return { base, home, prefix, shellHome, node, launcher, own, shared, kept, out, err, questions, pathCalls, deferred, ownLeft, uninstall,
754
+ cleanup: () => rmSync(base, { recursive: true, force: true }) };
755
+ }
756
+
757
+ type BundledWorld = ReturnType<typeof bundledWorld>;
758
+
759
+ function bundledUntouched(w: BundledWorld) {
760
+ assert.deepEqual(w.ownLeft(), w.own);
761
+ for (const path of [...w.kept, w.shared.config, join(w.shared.agent, "auth.json")]) assert.equal(existsSync(path), true, path);
762
+ assert.deepEqual(w.pathCalls, []);
763
+ assert.deepEqual(w.deferred, []);
764
+ }
765
+
766
+ test("bundled: --dry-run lists the PATH entry, the prefix's own folders, the shared home and what is never touched, and removes nothing", posixOnly, async () => {
767
+ const w = bundledWorld();
768
+ try {
769
+ assert.equal(await w.uninstall(["--dry-run"]), 0);
770
+ const plan = w.out.join("\n");
771
+ assert.match(plan, /bundled install/);
772
+ assert.ok(section(w.out, "Remove the PATH entry:")[0]?.includes(join(w.home, ".zshrc")));
773
+ const removed = section(w.out, "Remove the bundled install:").join("\n");
774
+ for (const path of w.own) assert.ok(removed.includes(path), path);
775
+ assert.ok(removed.includes(`${w.prefix} itself`));
776
+ const shared = section(w.out, "Your Gentle Shell home").join("\n");
777
+ for (const path of Object.values(w.shared)) assert.ok(shared.includes(path), path);
778
+ const never = section(w.out, "Not touched:").join("\n");
779
+ for (const name of [join(".pi", "agent"), ".gentle-ai", ".engram"]) assert.ok(never.includes(join(w.home, name)), name);
780
+ assert.match(plan, /Dry run: nothing was removed\./);
781
+ bundledUntouched(w);
782
+ } finally { w.cleanup(); }
783
+ });
784
+
785
+ test("bundled: --yes removes the PATH entry first, then the prefix's own folders, and keeps the shared home and so the prefix", posixOnly, async () => {
786
+ const w = bundledWorld();
787
+ try {
788
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
789
+ assert.deepEqual(w.pathCalls, [{ ownLeft: w.own.length }]);
790
+ assert.deepEqual(w.ownLeft(), []);
791
+ for (const path of [...w.kept, w.shared.config, join(w.shared.agent, "auth.json")]) assert.equal(existsSync(path), true, path);
792
+ assert.deepEqual(readdirSync(w.prefix).sort(), ["agent", "config.json"]);
793
+ assert.deepEqual(w.deferred, []);
794
+ assert.match(w.out.join("\n"), /Gentle Shell was removed\./);
795
+ } finally { w.cleanup(); }
796
+ });
797
+
798
+ test("bundled: --yes --include-shared also removes the Gentle Shell home, then the empty prefix, and nothing of the user's", posixOnly, async () => {
799
+ const w = bundledWorld();
800
+ try {
801
+ assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n"));
802
+ assert.equal(existsSync(w.prefix), false);
803
+ for (const path of w.kept) assert.equal(existsSync(path), true, path);
804
+ } finally { w.cleanup(); }
805
+ });
806
+
807
+ test("bundled: anything the install did not write keeps the prefix, and links inside it are never followed", posixOnly, async () => {
808
+ const w = bundledWorld();
809
+ try {
810
+ write(join(w.prefix, "notes.txt"), "mine");
811
+ const outside = join(w.base, "outside");
812
+ write(join(outside, "keep.txt"), "mine");
813
+ rmSync(join(w.prefix, "pnpm"), { recursive: true });
814
+ symlinkSync(outside, join(w.prefix, "pnpm"));
815
+ // A link inside a removed folder (pnpm's node_modules) goes, its target stays.
816
+ symlinkSync(outside, join(w.prefix, "versions", BUNDLED_ID, "node_modules", "linked"));
817
+ assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n"));
818
+ assert.equal(existsSync(join(w.prefix, "versions")), false);
819
+ assert.equal(readFileSync(join(outside, "keep.txt"), "utf8"), "mine");
820
+ assert.deepEqual(readdirSync(w.prefix).sort(), ["notes.txt", "pnpm"]);
821
+ const kept = section(w.out, "Kept:").join("\n");
822
+ assert.ok(kept.includes(`${join(w.prefix, "pnpm")}: a symbolic link, not followed`));
823
+ assert.ok(kept.includes(`${join(w.prefix, "notes.txt")}: not written by the bundled install`));
824
+ } finally { w.cleanup(); }
825
+ });
826
+
827
+ test("bundled: without a valid receipt the PATH entry is named for the user to remove, never guessed", posixOnly, async () => {
828
+ for (const pathEntry of ["none", "invalid"] as const) {
829
+ const w = bundledWorld();
830
+ try {
831
+ assert.equal(await w.uninstall(["--yes"], { pathEntry }), 0, w.err.join("\n"));
832
+ assert.deepEqual(w.pathCalls, []);
833
+ const plan = w.out.join("\n");
834
+ assert.ok(section(w.out, "Remove the PATH entry:").join("\n").includes("(none recorded"), pathEntry);
835
+ assert.match(plan, /# gentle-shell bundled install/);
836
+ assert.ok(plan.includes(join(w.prefix, "bin")), pathEntry);
837
+ if (pathEntry === "invalid") assert.match(plan, /does not describe this install's PATH entry/);
838
+ // A5: said once, in the plan.
839
+ assert.equal(w.out.filter((line) => line.includes("If a new terminal still finds gentle-shell")).length, 1, pathEntry);
840
+ assert.deepEqual(w.ownLeft(), []);
841
+ } finally { w.cleanup(); }
842
+ }
843
+ });
844
+
845
+ // A2: a PATH line the user edited is kept and named, not silently reported as removed.
846
+ test("bundled: a PATH entry the user changed is kept, and the final message names the file and the line it still holds", posixOnly, async () => {
847
+ const w = bundledWorld();
848
+ try {
849
+ assert.equal(await w.uninstall(["--yes"], { pathEntry: "edited" }), 0, w.err.join("\n"));
850
+ const tail = w.out.slice(-2);
851
+ assert.match(tail[0], /The PATH entry was kept: .*\.zshrc still holds export PATH="\$PATH:.*# gentle-shell bundled install/);
852
+ assert.match(tail[1], /Gentle Shell was removed\./);
853
+ assert.deepEqual(w.ownLeft(), []);
854
+ } finally { w.cleanup(); }
855
+ });
856
+
857
+ test("bundled: a prefix that is not the real ~/.gentle-shell, or a Windows prefix without its marker, is refused before anything is removed", async () => {
858
+ if (process.platform !== "win32") {
859
+ const posix = bundledWorld();
860
+ try {
861
+ const elsewhere = join(posix.base, "elsewhere");
862
+ mkdirSync(elsewhere);
863
+ for (const root of [elsewhere, posix.home]) {
864
+ posix.out.length = 0;
865
+ posix.err.length = 0;
866
+ assert.equal(await posix.uninstall(["--yes"], { root }), 1, root);
867
+ assert.match(posix.err.join("\n"), /refusing to remove anything/);
868
+ assert.match(posix.err.join("\n"), /Nothing was removed\./);
869
+ }
870
+ bundledUntouched(posix);
871
+ } finally { posix.cleanup(); }
872
+ const linked = bundledWorld();
873
+ try {
874
+ const moved = join(linked.base, "moved");
875
+ const { renameSync } = await import("node:fs");
876
+ renameSync(linked.prefix, moved);
877
+ symlinkSync(moved, linked.prefix);
878
+ assert.equal(await linked.uninstall(["--yes"], { root: moved }), 1);
879
+ assert.match(linked.err.join("\n"), /refusing to remove anything/);
880
+ assert.equal(readdirSync(moved).includes("runtime"), true);
881
+ } finally { linked.cleanup(); }
882
+ }
883
+ // The Windows prefix is the user's home itself: refused even with the marker.
884
+ const homeRoot = bundledWorld({ platform: "win32" });
885
+ try {
886
+ write(join(homeRoot.home, ".gentle-shell-bundle"), "gentle-shell bundled install");
887
+ assert.equal(await homeRoot.uninstall(["--yes"], { root: homeRoot.home }), 1);
888
+ assert.match(homeRoot.err.join("\n"), /your home directory/);
889
+ bundledUntouched(homeRoot);
890
+ } finally { homeRoot.cleanup(); }
891
+ for (const marker of [undefined, "someone else's folder"]) {
892
+ const w = bundledWorld({ platform: "win32" });
893
+ try {
894
+ const path = join(w.prefix, ".gentle-shell-bundle");
895
+ if (marker === undefined) rmSync(path);
896
+ else writeFileSync(path, marker);
897
+ assert.equal(await w.uninstall(["--yes"]), 1, String(marker));
898
+ assert.match(w.err.join("\n"), /\.gentle-shell-bundle/);
899
+ assert.equal(existsSync(join(w.prefix, "runtime")), true);
900
+ assert.deepEqual([w.pathCalls, w.deferred], [[], []]);
901
+ } finally { w.cleanup(); }
902
+ }
903
+ });
904
+
905
+ test("bundled: a Pi home inside the prefix is protected, except the session's own Gentle Shell home", posixOnly, async () => {
906
+ const w = bundledWorld();
907
+ try {
908
+ assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { PI_CODING_AGENT_DIR: w.shared.agent } }), 1);
909
+ assert.match(w.err.join("\n"), /your Pi home/);
910
+ bundledUntouched(w);
911
+ const session = { PI_CODING_AGENT_DIR: w.shared.agent, GENTLE_SHELL_USER_PI_HOME: join(w.home, ".pi", "agent") };
912
+ assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: session }), 0, w.err.join("\n"));
913
+ assert.equal(existsSync(w.prefix), false);
914
+ for (const path of w.kept) assert.equal(existsSync(path), true, path);
915
+ } finally { w.cleanup(); }
916
+ });
917
+
918
+ test("bundled: in a terminal one question confirms, the shared home is asked separately and kept by default; EOF cancels and Ctrl-C aborts", posixOnly, async () => {
919
+ const cancelled = bundledWorld();
920
+ try {
921
+ assert.equal(await cancelled.uninstall([], { interactive: true, answers: [""] }), 1);
922
+ assert.match(cancelled.out.join("\n"), /Cancelled; nothing was removed\./);
923
+ bundledUntouched(cancelled);
924
+ } finally { cancelled.cleanup(); }
925
+ const confirmed = bundledWorld();
926
+ try {
927
+ assert.equal(await confirmed.uninstall([], { interactive: true, answers: ["y", ""] }), 0, confirmed.err.join("\n"));
928
+ assert.equal(confirmed.questions.length, 2);
929
+ assert.match(confirmed.questions[1], /\[y\/N\]/);
930
+ assert.deepEqual(confirmed.ownLeft(), []);
931
+ assert.equal(existsSync(join(confirmed.shared.agent, "auth.json")), true);
932
+ } finally { confirmed.cleanup(); }
933
+ for (const keys of [["\x04"], ["\x03"], ["y\r", "\x03"]]) {
934
+ const w = bundledWorld();
935
+ try {
936
+ assert.equal(await w.uninstall([], { interactive: true, ask: typing([...keys]) }), 1, JSON.stringify(keys));
937
+ assert.match([...w.out, ...w.err].join("\n"), keys.includes("\x03") ? /Interrupted; nothing was removed\./ : /Cancelled; nothing was removed\./);
938
+ bundledUntouched(w);
939
+ } finally { w.cleanup(); }
940
+ }
941
+ const script = bundledWorld();
942
+ try {
943
+ assert.equal(await script.uninstall([]), 2);
944
+ assert.match(script.err.join("\n"), /pass --yes/);
945
+ assert.equal(await script.uninstall(["--nope"]), 2);
946
+ bundledUntouched(script);
947
+ } finally { script.cleanup(); }
948
+ });
949
+
950
+ test("bundled: a PATH entry that cannot be removed stops before anything else is removed", posixOnly, async () => {
951
+ const w = bundledWorld();
952
+ try {
953
+ assert.equal(await w.uninstall(["--yes"], { pathEntry: "fails" }), 1);
954
+ assert.match(w.err.join("\n"), /EACCES/);
955
+ assert.deepEqual(w.ownLeft(), w.own);
956
+ } finally { w.cleanup(); }
957
+ });
958
+
959
+ test("bundled on Windows: the user Path entry goes first, the running node.exe, the launcher and the marker after this process exits", async () => {
960
+ const w = bundledWorld({ platform: "win32" });
961
+ try {
962
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
963
+ assert.equal(w.pathCalls.length, 1);
964
+ const marker = join(w.prefix, ".gentle-shell-bundle");
965
+ const nodeDir = dirname(w.node);
966
+ // A4: the marker goes last, only once the others are gone, so a failed removal can be retried.
967
+ assert.deepEqual(w.deferred, [{ pid: 4242, files: [w.node, w.launcher], last: marker,
968
+ directories: [nodeDir, join(w.prefix, "runtime"), join(w.prefix, "bin"), w.prefix] }]);
969
+ // Everything else is already gone; those three wait for the detached removal.
970
+ assert.deepEqual(readdirSync(w.prefix).sort(), [".gentle-shell-bundle", "bin", "runtime"]);
971
+ assert.deepEqual(readdirSync(join(w.prefix, "runtime")), ["node-24.21.0"]);
972
+ assert.deepEqual(readdirSync(nodeDir), ["node.exe"]);
973
+ assert.deepEqual(readdirSync(join(w.prefix, "bin")), ["gentle-shell.cmd"]);
974
+ assert.match(w.out.join("\n"), /after this command exits/);
975
+ // The Gentle Shell home lives outside the prefix on Windows and stays.
976
+ assert.equal(existsSync(join(w.shared.agent, "auth.json")), true);
977
+ } finally { w.cleanup(); }
978
+ });