gentle-pi 3.7.0 → 4.1.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 (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  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 +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,1308 @@
1
+ // The bundled Gentle Shell install (S2, S3, S6, S7): one private prefix with our
2
+ // own pinned Node with its npm and pnpm (and Go only when asked), every pnpm and npm folder inside it,
3
+ // one frozen install per version, an atomic `current`, a launcher and one PATH
4
+ // entry. The user's node, npm, pnpm and go are never run, read or changed.
5
+ // Shared by the web installer and `gentle-shell upgrade`; every effect outside
6
+ // plain path arithmetic goes through an injectable adapter for tests.
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { accessSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync,
9
+ rmSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
10
+ import { posix, win32 } from "node:path";
11
+ import { spawn } from "node:child_process";
12
+ import { pathToFileURL } from "node:url";
13
+ import { crc32, gunzipSync, inflateRawSync } from "node:zlib";
14
+ import { acquireGo, artifactFor, engramPinVersion, verifiedDownload } from "./installer-downloads.mjs";
15
+ import { ensureWindowsPrivateFolder, readWindowsPnpmArchive, verifyWindowsStorage } from "./installer-windows.mjs";
16
+
17
+ // The acquisition pins already verified for the installer; never a second copy.
18
+ const pins = Object.freeze({ node: artifactFor("node", "linux", "x64").version, pnpm: artifactFor("pnpm").version });
19
+ const RUNTIME_MARKER = ".gentle-shell-runtime";
20
+ const VERSION_MARKER = ".gentle-shell-version";
21
+ const WINDOWS_MARKER = Object.freeze({ marker: ".gentle-shell-bundle", text: "gentle-shell bundled install" });
22
+ const LAUNCHER_MARK = "gentle-shell bundled launcher (generated; do not edit)";
23
+ const LAUNCHER_MISSING = "gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again.";
24
+ const PROFILE_MARK = "# gentle-shell bundled install";
25
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
26
+ const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
27
+ const MAX_EXPANDED = 512 * 1024 * 1024;
28
+ const INSTALL_TIMEOUT = 15 * 60 * 1000;
29
+
30
+ const pathFor = (platform) => (platform === "win32" ? win32 : posix);
31
+ function stat(path) {
32
+ try { return lstatSync(path); }
33
+ catch (error) { if (error.code === "ENOENT" || error.code === "ENOTDIR") return null; throw error; }
34
+ }
35
+ const regular = (path) => stat(path)?.isFile() === true;
36
+ const read = (path) => (regular(path) ? readFileSync(path, "utf8") : null);
37
+ /** A value from a Windows environment, whose names are case-insensitive. */
38
+ function envValue(env, name, platform) {
39
+ const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name;
40
+ return key === undefined ? undefined : env[key];
41
+ }
42
+ /** %LOCALAPPDATA%\gentle-shell, then %USERPROFILE%\.gentle-shell-bundle, as the bootstrap claims its tools. */
43
+ function windowsCandidates(env) {
44
+ const usable = (value) => typeof value === "string" && win32.isAbsolute(value) && !value.startsWith("\\\\");
45
+ const local = envValue(env, "LOCALAPPDATA", "win32");
46
+ const profile = envValue(env, "USERPROFILE", "win32");
47
+ return [usable(local) ? win32.join(local, "gentle-shell") : null, usable(profile) ? win32.join(profile, ".gentle-shell-bundle") : null].filter(Boolean);
48
+ }
49
+
50
+ /** Every bundled path, from plain path arithmetic only. POSIX: `~/.gentle-shell`.
51
+ * Windows: `root` as claimPrefix chose it, otherwise the %LOCALAPPDATA% candidate.
52
+ * The agent home stays `<home>/.gentle-shell/agent` on every platform.
53
+ */
54
+ export function prefixLayout({ platform, env = {}, home, root }) {
55
+ const path = pathFor(platform);
56
+ if (typeof home !== "string" || !path.isAbsolute(home)) throw new Error("The bundled install needs an absolute home directory");
57
+ const windows = platform === "win32";
58
+ const base = root ?? (windows ? windowsCandidates(env)[0] : path.join(home, ".gentle-shell"));
59
+ if (typeof base !== "string" || !path.isAbsolute(base)) throw new Error("The bundled install has no private folder");
60
+ const runtime = path.join(base, "runtime");
61
+ const nodeDir = path.join(runtime, `node-${pins.node}`);
62
+ const pnpmDir = path.join(runtime, `pnpm-${pins.pnpm}`);
63
+ const pnpmHome = path.join(base, "pnpm");
64
+ const npmHome = path.join(base, "npm");
65
+ const bin = path.join(base, "bin");
66
+ return Object.freeze({
67
+ platform, root: base, agent: path.join(home, ".gentle-shell", "agent"), runtime,
68
+ nodeDir, node: windows ? path.join(nodeDir, "node.exe") : path.join(nodeDir, "bin", "node"),
69
+ // The npm bundled in the same Node archive, for Pi's own package installs.
70
+ npmCli: path.join(nodeDir, ...(windows ? [] : ["lib"]), "node_modules", "npm", "bin", "npm-cli.js"),
71
+ // The entry Pi's npmCommand runs that npm through (ensureNpmShim).
72
+ npmShim: path.join(nodeDir, "gentle-shell", "npm"),
73
+ npmHome, npmPrefix: path.join(npmHome, "prefix"), npmCache: path.join(npmHome, "cache"), npmGlobalConfig: path.join(npmHome, "npmrc"),
74
+ pnpmDir, pnpm: path.join(pnpmDir, "package", "bin", "pnpm.mjs"),
75
+ // acquireGo publishes `<goRoot>/<version>/go`.
76
+ goRoot: path.join(runtime, "go"),
77
+ // The pinned Engram (ensureEngram), given to Gentle AI and gentle-engram (S9).
78
+ engramDir: path.join(runtime, `engram-${engramPinVersion}`),
79
+ engram: path.join(runtime, `engram-${engramPinVersion}`, windows ? "engram.exe" : "engram"),
80
+ pnpmHome, store: path.join(pnpmHome, "store"), cache: path.join(pnpmHome, "cache"), state: path.join(pnpmHome, "state"),
81
+ config: path.join(pnpmHome, "config"), npmrc: path.join(pnpmHome, "config", "npmrc"), tmp: path.join(base, "tmp"),
82
+ versions: path.join(base, "versions"), history: path.join(base, "versions.history"),
83
+ // The applied PATH entry (writePathReceipt), which self-uninstall reverts exactly.
84
+ pathReceipt: path.join(base, "path-entry.json"),
85
+ current: path.join(base, "current"), currentKind: windows ? "pointer" : "symlink",
86
+ bin, launcher: path.join(bin, windows ? "gentle-shell.cmd" : "gentle-shell"),
87
+ });
88
+ }
89
+
90
+ /** POSIX: a real directory owned by this user that no one else can write. */
91
+ function privateDirectory(path) {
92
+ const info = stat(path);
93
+ if (!info?.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error(`Unsafe bundled prefix: ${path}`);
94
+ }
95
+ /** POSIX: removes group and other write access from a real directory of this
96
+ * user, through a descriptor opened without following links; anything else is
97
+ * left for privateDirectory to refuse.
98
+ */
99
+ function tightenOwnDirectory(path) {
100
+ let fd;
101
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
102
+ catch { return; }
103
+ try {
104
+ const info = fstatSync(fd);
105
+ if (info.isDirectory() && info.uid === process.getuid() && (info.mode & 0o022)) fchmodSync(fd, info.mode & 0o7755);
106
+ } finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+ function ensureDirectory(path, platform) {
111
+ try { mkdirSync(path, { mode: 0o700 }); }
112
+ catch (error) { if (error.code !== "EEXIST") throw error; }
113
+ if (platform !== "win32") privateDirectory(path);
114
+ }
115
+ /** The prefix folders and the empty npmrc pnpm reads instead of ~/.npmrc. */
116
+ function prepareFolders(layout) {
117
+ for (const path of [layout.runtime, layout.pnpmHome, layout.store, layout.cache, layout.state, layout.config, layout.npmHome, layout.npmPrefix, layout.npmCache,
118
+ layout.versions, layout.bin, layout.tmp]) {
119
+ ensureDirectory(path, layout.platform);
120
+ }
121
+ if (!stat(layout.npmrc)) writeFileSync(layout.npmrc, "", { flag: "wx", mode: 0o600 });
122
+ if (!regular(layout.npmrc)) throw new Error(`Unsafe bundled prefix: ${layout.npmrc}`);
123
+ }
124
+
125
+ /** Claims the prefix and returns its layout. POSIX: `~/.gentle-shell` is created
126
+ * 0700, or kept when it is a real directory of this user (the agent home lives
127
+ * there); group or other write access, which umask 002 leaves, is removed
128
+ * (go-w). A link, a non-folder or another owner's folder is refused. Windows: each candidate's nearest existing
129
+ * folder is walked like the bootstrap's tools (owner, ACL, reparse); only an
130
+ * untrusted owner or ACL on %LOCALAPPDATA% moves to the profile candidate. The
131
+ * chosen folder is claimed with the bootstrap's protected DACL and marked
132
+ * (ensureWindowsPrivateFolder); a non-empty unmarked folder is never adopted.
133
+ * `root`, the consented plan's folder: when the choice differs, it throws
134
+ * (check "prefix-changed") before anything is created or claimed.
135
+ */
136
+ export function claimPrefix({ platform, env = {}, home, root, adapters = {} }) {
137
+ if (platform !== "win32") {
138
+ const layout = prefixLayout({ platform, env, home });
139
+ if (root !== undefined && root !== layout.root) throw Object.assign(new Error(`The bundled folder changed since the plan: ${layout.root}`), { check: "prefix-changed" });
140
+ if (realpathSync(home) !== posix.resolve(home)) throw new Error(`Unsafe bundled prefix: ${home}`);
141
+ const info = lstatSync(home);
142
+ if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error(`Unsafe bundled prefix: ${home}`);
143
+ if (stat(layout.root)?.isDirectory()) tightenOwnDirectory(layout.root);
144
+ ensureDirectory(layout.root, platform);
145
+ (adapters.prepare ?? prepareFolders)(layout);
146
+ return layout;
147
+ }
148
+ const storage = adapters.storage ?? verifyWindowsStorage;
149
+ const claim = adapters.claim ?? ((folder) => ensureWindowsPrivateFolder(folder, env, { ...WINDOWS_MARKER, storage }));
150
+ const candidate = prefixRoot({ platform, env, adapters });
151
+ // The consented plan named a folder: a claim that would choose another one changes nothing.
152
+ if (root !== undefined && root !== candidate) throw Object.assign(new Error(`The bundled folder changed since the plan: ${candidate}`), { check: "prefix-changed" });
153
+ claim(candidate);
154
+ const layout = prefixLayout({ platform, env, home, root: candidate });
155
+ (adapters.prepare ?? prepareFolders)(layout);
156
+ return layout;
157
+ }
158
+
159
+ /** The folder claimPrefix will claim, chosen with read-only checks only, so a
160
+ * plan shows the same folder the installation uses. POSIX: undefined (the
161
+ * prefix is always `~/.gentle-shell`). Windows: the first candidate whose
162
+ * nearest existing folder passes the storage walk; an untrusted owner or ACL
163
+ * moves to the next candidate, any other failure throws.
164
+ */
165
+ export function prefixRoot({ platform, env = {}, adapters = {} }) {
166
+ if (platform !== "win32") return undefined;
167
+ const exists = adapters.exists ?? ((path) => stat(path) !== null);
168
+ const storage = adapters.storage ?? verifyWindowsStorage;
169
+ const candidates = windowsCandidates(env);
170
+ if (candidates.length === 0) throw new Error("The bundled install has no private folder");
171
+ for (const [index, candidate] of candidates.entries()) {
172
+ let target = candidate;
173
+ while (!exists(target) && win32.dirname(target) !== target) target = win32.dirname(target);
174
+ try {
175
+ storage(target, env);
176
+ } catch (error) {
177
+ if (index + 1 < candidates.length && /^(?:target|parent|ancestor)-(?:owner|acl-mask)$/.test(error?.check ?? "")) continue;
178
+ throw error;
179
+ }
180
+ return candidate;
181
+ }
182
+ }
183
+
184
+ /** The one executable a verified Node archive must contain: `<stem>/bin/node`
185
+ * from the POSIX `.tar.gz` or `<stem>/node.exe` from the Windows `.zip`, a
186
+ * regular file found exactly once. Every other member, links included, is
187
+ * skipped and never written: pnpm runs on node alone, so npm is not needed.
188
+ */
189
+ export function nodeExecutable(bytes, descriptor) {
190
+ const { file, stem } = nodeArchive(descriptor);
191
+ return file.endsWith(".zip") ? zipMember(bytes, `${stem}/node.exe`) : tarMember(bytes, `${stem}/bin/node`);
192
+ }
193
+ function nodeArchive(descriptor) {
194
+ const file = String(descriptor?.url ?? "").split("/").pop();
195
+ const stem = file.replace(/\.(?:tar\.gz|zip)$/, "");
196
+ if (stem === file || !/^node-v\d+\.\d+\.\d+-[a-z0-9]+-[a-z0-9]+$/.test(stem)) throw new Error("Node archive rejected");
197
+ return { file, stem };
198
+ }
199
+ // Our own POSIX npm and npx: our Node runs npm's entry next to it, whatever `node` is on PATH.
200
+ const npmWrapper = (entry) => ["#!/bin/sh", "# gentle-shell bundled npm (generated; do not edit)", 'basedir=$(dirname "$0")',
201
+ `exec "$basedir/node" "$basedir/../lib/node_modules/npm/bin/${entry}" "$@"`, ""].join("\n");
202
+ /** Every file runtime/node-<v>/ holds, from one verified Node archive, as
203
+ * [{ name (relative, `/`-separated), data, executable }] with `npm` set to the
204
+ * bundled npm version: the Node executable (as nodeExecutable finds it) and the
205
+ * npm bundled with it, which Pi runs to install its packages. Windows: npm's
206
+ * package under node_modules/npm and its npm.cmd and npx.cmd, which run the
207
+ * node.exe beside them. POSIX: npm's package under lib/node_modules/npm and our
208
+ * own bin/npm and bin/npx wrappers in place of the archive's links. Links,
209
+ * Corepack and every other member are skipped; a member leaving its folder, or
210
+ * an archive without npm, is rejected.
211
+ */
212
+ export function nodeRuntimeFiles(bytes, descriptor) {
213
+ const fail = () => { throw new Error("Node archive rejected"); };
214
+ const { file, stem } = nodeArchive(descriptor);
215
+ const windows = file.endsWith(".zip");
216
+ const npm = windows ? "node_modules/npm/" : "lib/node_modules/npm/";
217
+ const files = [{ name: windows ? "node.exe" : "bin/node", data: nodeExecutable(bytes, descriptor), executable: true }];
218
+ const keep = (name, data, executable) => {
219
+ const relative = name.slice(stem.length + 1);
220
+ if (!name.startsWith(`${stem}/`) || !(relative.startsWith(npm) || (windows && /^np[mx]\.cmd$/.test(relative)))) return;
221
+ if (relative.split("/").some((part) => part === "" || part === "." || part === "..") || relative.includes("\\")) fail();
222
+ files.push({ name: relative, data, executable });
223
+ };
224
+ if (windows) {
225
+ for (const entry of zipEntries(bytes, fail)) if (!entry.name.endsWith("/") && !(entry.unix && (entry.mode & 0o170000) !== 0o100000)) keep(entry.name, entry.read(), false);
226
+ } else {
227
+ for (const member of tarMembers(bytes, "Node archive rejected")) if (member.type === "0" || member.type === "\0") keep(member.name, Buffer.from(member.data), (member.mode & 0o100) !== 0);
228
+ files.push({ name: "bin/npm", data: Buffer.from(npmWrapper("npm-cli.js")), executable: true }, { name: "bin/npx", data: Buffer.from(npmWrapper("npx-cli.js")), executable: true });
229
+ }
230
+ let metadata = null;
231
+ try { metadata = JSON.parse(files.find((entry) => entry.name === `${npm}package.json`)?.data.toString("utf8") ?? "null"); }
232
+ catch { fail(); }
233
+ if (metadata?.name !== "npm" || !SEMVER.test(metadata.version ?? "") || !files.some((entry) => entry.name === `${npm}bin/npm-cli.js`)) fail();
234
+ return Object.assign(files, { npm: metadata.version });
235
+ }
236
+ function tarMember(bytes, wanted, message = "Node archive rejected") {
237
+ let found = null;
238
+ for (const member of tarMembers(bytes, message)) {
239
+ if (member.name !== wanted) continue;
240
+ if ((member.type !== "0" && member.type !== "\0") || found) throw new Error(message);
241
+ found = Buffer.from(member.data);
242
+ }
243
+ if (!found?.length) throw new Error(message);
244
+ return found;
245
+ }
246
+ /** Every member of a `.tar.gz`: { name, type, mode, data }. Headers are
247
+ * checksummed; anything malformed throws `message`.
248
+ */
249
+ function* tarMembers(bytes, message) {
250
+ const fail = () => { throw new Error(message); };
251
+ const tar = gunzipSync(bytes, { maxOutputLength: MAX_EXPANDED });
252
+ const text = (block, start, length) => block.subarray(start, start + length).toString("utf8").replace(/\0[^]*$/, "");
253
+ const number = (block, start, length) => {
254
+ const value = text(block, start, length).trim();
255
+ if (!/^[0-7]{1,12}$/.test(value)) fail();
256
+ return parseInt(value, 8);
257
+ };
258
+ let renamed = null;
259
+ for (let offset = 0; ;) {
260
+ if (offset + 512 > tar.length) fail();
261
+ const block = tar.subarray(offset, offset + 512);
262
+ if (block.every((byte) => byte === 0)) return;
263
+ let checksum = 0;
264
+ for (let index = 0; index < 512; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : block[index];
265
+ if (checksum !== number(block, 148, 8) || block.subarray(257, 262).toString("latin1") !== "ustar") fail();
266
+ const size = number(block, 124, 12);
267
+ const type = String.fromCharCode(block[156]);
268
+ const start = offset + 512;
269
+ if (start + size > tar.length) fail();
270
+ offset = start + Math.ceil(size / 512) * 512;
271
+ const data = tar.subarray(start, start + size);
272
+ // PAX `path=` records and GNU long names rename the next member only.
273
+ if (type === "x") { renamed = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString("utf8"))?.[1] ?? renamed; continue; }
274
+ if (type === "L") { renamed = data.toString("utf8").replace(/\0[^]*$/, ""); continue; }
275
+ if (type === "g" || type === "K") continue;
276
+ // Only the POSIX ustar format has a name prefix; old GNU keeps times there.
277
+ const prefix = block.subarray(257, 263).toString("latin1") === "ustar\0" ? text(block, 345, 155) : "";
278
+ const name = renamed ?? (prefix ? `${prefix}/${text(block, 0, 100)}` : text(block, 0, 100));
279
+ renamed = null;
280
+ yield { name, type, mode: number(block, 100, 8), data };
281
+ }
282
+ }
283
+ function zipMember(bytes, wanted, message = "Node archive rejected") {
284
+ const fail = () => { throw new Error(message); };
285
+ let found = null;
286
+ for (const entry of zipEntries(bytes, fail)) {
287
+ if (entry.name !== wanted) continue;
288
+ if (found || (entry.unix && (entry.mode & 0o170000) !== 0o100000)) fail();
289
+ found = entry.read();
290
+ }
291
+ if (!found?.length) fail();
292
+ return found;
293
+ }
294
+ /** Every central-directory entry of a zip: { name, unix, mode, read() }; read()
295
+ * checks the entry (no encryption or data descriptor, stored or deflated, its
296
+ * local header and CRC) and returns its bytes. Anything malformed calls `fail`.
297
+ */
298
+ function* zipEntries(bytes, fail) {
299
+ let end = -1;
300
+ for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 22 - 0xffff); at -= 1) {
301
+ if (bytes.readUInt32LE(at) === 0x06054b50 && at + 22 + bytes.readUInt16LE(at + 20) === bytes.length) { end = at; break; }
302
+ }
303
+ if (end < 0) fail();
304
+ const count = bytes.readUInt16LE(end + 10);
305
+ const directory = bytes.readUInt32LE(end + 16);
306
+ if (count === 0xffff || directory === 0xffffffff || directory + bytes.readUInt32LE(end + 12) > end) fail();
307
+ for (let at = directory, index = 0; index < count; index += 1) {
308
+ if (at + 46 > end || bytes.readUInt32LE(at) !== 0x02014b50) fail();
309
+ const [flags, method, crc, compressed, size] = [bytes.readUInt16LE(at + 8), bytes.readUInt16LE(at + 10), bytes.readUInt32LE(at + 16),
310
+ bytes.readUInt32LE(at + 20), bytes.readUInt32LE(at + 24)];
311
+ const nameLength = bytes.readUInt16LE(at + 28);
312
+ const mode = bytes.readUInt32LE(at + 38) >>> 16;
313
+ const unix = bytes[at + 5] === 3;
314
+ const local = bytes.readUInt32LE(at + 42);
315
+ const name = bytes.subarray(at + 46, at + 46 + nameLength).toString("utf8");
316
+ at += 46 + nameLength + bytes.readUInt16LE(at + 30) + bytes.readUInt16LE(at + 32);
317
+ yield { name, unix, mode, read: () => {
318
+ if ((flags & 0x41) || (method !== 0 && method !== 8) || compressed === 0xffffffff || size === 0xffffffff) fail();
319
+ if (local + 30 > directory || bytes.readUInt32LE(local) !== 0x04034b50) fail();
320
+ const localName = bytes.readUInt16LE(local + 26);
321
+ const data = local + 30 + localName + bytes.readUInt16LE(local + 28);
322
+ if (bytes.subarray(local + 30, local + 30 + localName).toString("utf8") !== name || data + compressed > directory) fail();
323
+ const raw = bytes.subarray(data, data + compressed);
324
+ const content = method === 0 ? Buffer.from(raw) : inflateRawSync(raw, { maxOutputLength: Math.max(size, 1) });
325
+ if (content.length !== size || crc32(content) !== crc) fail();
326
+ return content;
327
+ } };
328
+ }
329
+ }
330
+
331
+ /** The one executable a verified Engram release archive must contain: the
332
+ * top-level `engram` of the POSIX `.tar.gz` or `engram.exe` of the Windows
333
+ * `.zip`, a regular file found exactly once. Its tools/ scripts and anything
334
+ * else are never written.
335
+ */
336
+ export function engramExecutable(bytes, descriptor) {
337
+ const message = "Engram archive rejected";
338
+ return String(descriptor?.url ?? "").endsWith(".zip") ? zipMember(bytes, "engram.exe", message) : tarMember(bytes, "engram", message);
339
+ }
340
+
341
+ /** Runs a command without a shell; resolves { status, stdout, stderr }. */
342
+ export function runCommand(command, args, { cwd, env, timeout = 15000 }) {
343
+ return new Promise((resolve) => {
344
+ const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
345
+ const output = { stdout: "", stderr: "" };
346
+ for (const stream of ["stdout", "stderr"]) child[stream].on("data", (chunk) => { output[stream] = (output[stream] + chunk).slice(-1024 * 1024); });
347
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
348
+ child.once("error", () => { clearTimeout(timer); resolve({ status: null, ...output }); });
349
+ child.once("close", (status) => { clearTimeout(timer); resolve({ status, ...output }); });
350
+ });
351
+ }
352
+ const ownedRuntime = (directory, url, entry) => read(pathFor(process.platform).join(directory, RUNTIME_MARKER)) === `${url}\n` && regular(entry);
353
+
354
+ // Windows publication can lose a race with the node.exe just run from the
355
+ // staging folder or a real-time scanner still holding it (EPERM, EBUSY,
356
+ // EACCES). Only those codes, and only on Windows, are retried after each
357
+ // delay; a lock that never clears surfaces the original error.
358
+ const WINDOWS_RENAME_RETRY_DELAYS_MS = [200, 400, 800, 1600];
359
+ export async function renameWithWindowsRetry(from, to, { platform = process.platform, rename = renameSync, delays = WINDOWS_RENAME_RETRY_DELAYS_MS } = {}) {
360
+ if (platform !== "win32") return rename(from, to);
361
+ for (let attempt = 0; ; attempt += 1) {
362
+ try { return rename(from, to); }
363
+ catch (error) {
364
+ if (attempt >= delays.length || !["EPERM", "EBUSY", "EACCES"].includes(error?.code)) throw error;
365
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
366
+ }
367
+ }
368
+ }
369
+
370
+ /** Publishes one verified runtime as `directory`: an existing one marked with the
371
+ * same pinned URL is reused, an unmarked one is never replaced. `fill` writes and
372
+ * checks the staging folder; one rename publishes it. Renaming is safe here,
373
+ * unlike after a pnpm install: the extracted trees hold regular files and
374
+ * folders only (both archive readers reject links), so nothing points at the
375
+ * staging path.
376
+ */
377
+ async function publishRuntime(layout, name, directory, entry, { platform, arch, adapters }, fill) {
378
+ const descriptor = (adapters.artifact ?? artifactFor)(name, platform, arch);
379
+ if (ownedRuntime(directory, descriptor.url, entry)) return false;
380
+ if (stat(directory)) throw new Error(`Conflicting runtime destination: ${directory}`);
381
+ // Before downloading: a prefix that is not there fails without a download.
382
+ ensureDirectory(layout.runtime, layout.platform);
383
+ const bytes = await verifiedDownload(name, adapters, platform, arch);
384
+ let stage = mkdtempSync(pathFor(process.platform).join(layout.runtime, ".stage-"));
385
+ try {
386
+ await fill(stage, bytes, descriptor);
387
+ writeFileSync(pathFor(process.platform).join(stage, RUNTIME_MARKER), `${descriptor.url}\n`, { flag: "wx", mode: 0o600 });
388
+ if (stat(directory)) throw new Error(`Conflicting runtime destination: ${directory}`);
389
+ await renameWithWindowsRetry(stage, directory);
390
+ stage = null;
391
+ return true;
392
+ } finally {
393
+ if (stage) rmSync(stage, { recursive: true, force: true });
394
+ }
395
+ }
396
+
397
+ /** The pinned Engram in runtime/engram-<v>/ (S9): downloaded through
398
+ * verifiedDownload, only its executable written, `engram version` run from the
399
+ * staging folder, marked, then published. Gentle AI's setup uses it through
400
+ * GENTLE_AI_ENGRAM_BIN and gentle-engram through ENGRAM_BIN, so neither
401
+ * installs or finds another Engram. Returns whether it was acquired.
402
+ */
403
+ export async function ensureEngram({ layout, platform, arch, env = {}, adapters = {} }) {
404
+ const run = adapters.run ?? runCommand;
405
+ const path = pathFor(process.platform);
406
+ const childEnv = pnpmEnvironment(layout, { platform: layout.platform, env });
407
+ return publishRuntime(layout, "engram", layout.engramDir, layout.engram, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
408
+ const binary = path.join(stage, path.relative(layout.engramDir, layout.engram));
409
+ writeFileSync(binary, engramExecutable(bytes, descriptor), { flag: "wx", mode: 0o700 });
410
+ const result = await run(binary, ["version"], { cwd: layout.runtime, env: childEnv });
411
+ if (result.status !== 0 || String(result.stdout).trim() !== `engram ${descriptor.version}`) throw new Error("Bundled Engram verification failed");
412
+ });
413
+ }
414
+
415
+ /** Our pinned Node with its bundled npm, and pnpm, in runtime/ (and the pinned
416
+ * Go only when `go`, the pinned Engram only when `engram`), each downloaded
417
+ * through verifiedDownload, checked by running it from its staging folder,
418
+ * marked, then published. Returns the paths and what was acquired.
419
+ */
420
+ export async function ensureRuntime({ layout, platform, arch, go = false, engram = false, env = {}, adapters = {} }) {
421
+ const run = adapters.run ?? runCommand;
422
+ const path = pathFor(process.platform);
423
+ const childEnv = pnpmEnvironment(layout, { platform: layout.platform, env });
424
+ const acquired = [];
425
+ const node = await publishRuntime(layout, "node", layout.nodeDir, layout.node, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
426
+ const files = nodeRuntimeFiles(bytes, descriptor);
427
+ for (const file of files) {
428
+ const target = path.join(stage, ...file.name.split("/"));
429
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
430
+ writeFileSync(target, file.data, { flag: "wx", mode: file.executable ? 0o700 : 0o600 });
431
+ }
432
+ const binary = path.join(stage, path.relative(layout.nodeDir, layout.node));
433
+ const result = await run(binary, ["--version"], { cwd: layout.runtime, env: childEnv });
434
+ if (result.status !== 0 || String(result.stdout).trim() !== `v${descriptor.version}`) throw new Error("Bundled Node verification failed");
435
+ const npm = await run(binary, [path.join(stage, path.relative(layout.nodeDir, layout.npmCli)), "--version"], { cwd: layout.runtime, env: childEnv });
436
+ if (npm.status !== 0 || String(npm.stdout).trim() !== files.npm) throw new Error("Bundled npm verification failed");
437
+ });
438
+ if (node) acquired.push("node");
439
+ // Also for a runtime published before the entry existed; one from before npm
440
+ // was published (T1a) has no npm to run, and the launcher then gives Pi none.
441
+ if (regular(layout.npmCli)) ensureNpmShim(layout);
442
+ const pnpm = await publishRuntime(layout, "pnpm", layout.pnpmDir, layout.pnpm, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
443
+ // The tarball's executable bits are kept (bin/pnpm.mjs, dist/node-gyp-bin), for the owner only.
444
+ const modes = new Map([...tarMembers(bytes, "pnpm archive rejected")].map((member) => [member.name, member.mode]));
445
+ for (const entry of readWindowsPnpmArchive(bytes)) {
446
+ const target = path.join(stage, ...entry.name.replace(/\/$/, "").split("/"));
447
+ if (entry.directory) mkdirSync(target, { recursive: true, mode: 0o700 });
448
+ else {
449
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
450
+ writeFileSync(target, entry.bytes, { flag: "wx", mode: (modes.get(entry.name) ?? 0) & 0o100 ? 0o700 : 0o600 });
451
+ }
452
+ }
453
+ const metadata = JSON.parse(readFileSync(path.join(stage, "package", "package.json"), "utf8"));
454
+ const entry = path.join(stage, "package", "bin", "pnpm.mjs");
455
+ if (metadata.name !== "pnpm" || metadata.version !== descriptor.version || metadata.bin?.pnpm !== "bin/pnpm.mjs" || !regular(entry)) {
456
+ throw new Error("Bundled pnpm metadata rejected");
457
+ }
458
+ const result = await run(layout.node, [entry, "--version"], { cwd: layout.runtime, env: childEnv });
459
+ if (result.status !== 0 || String(result.stdout).trim() !== descriptor.version) throw new Error("Bundled pnpm verification failed");
460
+ });
461
+ if (pnpm) acquired.push("pnpm");
462
+ if (engram && await ensureEngram({ layout, platform, arch, env, adapters })) acquired.push("engram");
463
+ let goPath = null;
464
+ if (go) {
465
+ const result = await (adapters.acquireGo ?? acquireGo)({ root: layout.goRoot, platform, arch, adapters });
466
+ goPath = result.goPath;
467
+ if (result.acquired) acquired.push("go");
468
+ }
469
+ return { node: layout.node, pnpm: layout.pnpm, go: goPath, acquired };
470
+ }
471
+
472
+ /** The environment for our pnpm and the scripts it runs: our Node (then our Go,
473
+ * when given) first on PATH; PNPM_HOME, store, cache, state and config inside the
474
+ * prefix through the variables pnpm 11 reads (`pnpm_config_*`, XDG_*); an empty
475
+ * npmrc in place of ~/.npmrc; no switching to the pnpm a package.json above the
476
+ * folder names (pm-on-fail otherwise defaults to "download") or to a Node runtime
477
+ * a root manifest asks to download (runtime-on-fail), and no update check; on
478
+ * Windows TEMP/TMP in the private folder. Every inherited npm/pnpm/Corepack setting, NODE_OPTIONS and
479
+ * NODE_PATH is dropped, except a variable the prefix npmrc references as
480
+ * `${NAME}` (an auth token such as NPM_TOKEN, which pnpm and npm expand); the
481
+ * rest (HOME, proxies, SystemRoot) is kept.
482
+ */
483
+ export function pnpmEnvironment(layout, { platform = layout.platform, env = {}, go = null } = {}) {
484
+ const windows = platform === "win32";
485
+ const path = pathFor(platform);
486
+ const dropped = new RegExp(`^(?:npm_.*|pnpm_.*|corepack_.*|node_options|node_path|xdg_(?:config|cache|state|data)_home|path${windows ? "|temp|tmp" : ""})$`, "i");
487
+ const referenced = new Set([...(read(layout.npmrc) ?? "").matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1])
488
+ .filter((name) => !/^(?:npm_config_.*|pnpm_.*)$/i.test(name)));
489
+ const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !dropped.test(key) || referenced.has(key)));
490
+ const rest = String(envValue(env, "PATH", platform) ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
491
+ const first = [path.dirname(layout.node), ...(go ? [path.dirname(go)] : [])];
492
+ return {
493
+ ...kept,
494
+ [windows ? "Path" : "PATH"]: [...first, ...rest].join(path.delimiter),
495
+ PNPM_HOME: layout.pnpmHome,
496
+ XDG_CONFIG_HOME: layout.config, XDG_CACHE_HOME: layout.cache, XDG_STATE_HOME: layout.state,
497
+ pnpm_config_store_dir: layout.store, pnpm_config_cache_dir: layout.cache, pnpm_config_state_dir: layout.state,
498
+ pnpm_config_npmrc_auth_file: layout.npmrc,
499
+ pnpm_config_pm_on_fail: "ignore", pnpm_config_runtime_on_fail: "ignore", pnpm_config_update_notifier: "false",
500
+ ...(windows ? { TEMP: layout.tmp, TMP: layout.tmp } : {}),
501
+ };
502
+ }
503
+ /** Our npm's settings, for the children that run npm (setupEnvironment; pnpm
504
+ * never runs it): the prefix npmrc as its user config, and its global config,
505
+ * global prefix and cache inside the prefix, so nothing reads the user's npm
506
+ * settings or writes the user's npm folders. The prefix also keeps Windows'
507
+ * npm.cmd on our npm: it prefers an npm installed in the global prefix.
508
+ */
509
+ function npmEnvironment(layout) {
510
+ return { npm_config_userconfig: layout.npmrc, npm_config_globalconfig: layout.npmGlobalConfig, npm_config_prefix: layout.npmPrefix,
511
+ npm_config_cache: layout.npmCache, npm_config_update_notifier: "false" };
512
+ }
513
+ /** The environment `gentle-shell setup` runs in from the bundled install: our
514
+ * Node and its npm first on PATH, then the version's node_modules/.bin (its
515
+ * `pi`), then the user's PATH; npm's settings inside the prefix
516
+ * (npmEnvironment). Inherited npm and pnpm settings, NODE_OPTIONS, NODE_PATH
517
+ * and the wizard's GENTLE_BOOTSTRAP_ and GENTLE_INSTALL_ handoff are dropped;
518
+ * the user's XDG folders are kept, since Pi and Gentle AI read their own
519
+ * configuration through them.
520
+ */
521
+ export function setupEnvironment(layout, { platform = layout.platform, env = {}, id }) {
522
+ if (typeof id !== "string" || !VERSION_ID.test(id) || id.includes("..")) throw new Error("Unsafe version id");
523
+ const path = pathFor(platform);
524
+ const dropped = /^(?:npm_config_.*|pnpm_.*|corepack_.*|node_options|node_path|path|gentle_(?:bootstrap|install)_.*)$/i;
525
+ const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !dropped.test(key)));
526
+ const rest = String(envValue(env, "PATH", platform) ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
527
+ const bin = path.join(layout.versions, id, "node_modules", ".bin");
528
+ return { ...kept, [platform === "win32" ? "Path" : "PATH"]: [path.dirname(layout.node), bin, ...rest].join(path.delimiter), ...npmEnvironment(layout) };
529
+ }
530
+
531
+ // S12: the only npmrc keys our pnpm and npm read from the user's npmrc.
532
+ const NPMRC_KEYS = /^(?:registry|@[^\s:=]+:registry|\/\/\S+:(?:_authToken|_auth|username|_password|certfile|keyfile)|proxy|https-proxy|noproxy|no-proxy|ca|ca\[\]|cafile|strict-ssl)$/;
533
+ /** The user's npmrc reduced to its network, registry and authentication keys:
534
+ * registry, @scope:registry, per-host //host/:_authToken, _auth, username,
535
+ * _password, certfile and keyfile, proxy, https-proxy, noproxy (no-proxy), ca
536
+ * (ca[]), cafile and strict-ssl. Each kept line is copied verbatim, so `${VAR}`
537
+ * references stay for pnpm and npm to expand. Comments and every other key are
538
+ * dropped, and so is everything from the first [section] on: an ini section
539
+ * never applies at the top level.
540
+ */
541
+ export function filterNpmrc(text) {
542
+ const kept = [];
543
+ for (const raw of String(text ?? "").split(/\r\n|\r|\n/)) {
544
+ const line = raw.trim();
545
+ if (line.startsWith("[")) break;
546
+ const at = line.indexOf("=");
547
+ if (line.startsWith("#") || line.startsWith(";") || at <= 0) continue;
548
+ const key = line.slice(0, at).trim().replace(/^(["'])(.*)\1$/, "$2");
549
+ if (NPMRC_KEYS.test(key)) kept.push(line);
550
+ }
551
+ return kept.length > 0 ? `${kept.join("\n")}\n` : "";
552
+ }
553
+ /** The user's npmrc text: NPM_CONFIG_USERCONFIG (any case) when it is an
554
+ * absolute path, otherwise ~/.npmrc; empty when that file is missing. At most
555
+ * 1 MiB is read.
556
+ */
557
+ export function userNpmrc({ platform, env = {}, home }) {
558
+ const path = pathFor(platform);
559
+ const key = Object.keys(env).find((name) => name.toLowerCase() === "npm_config_userconfig");
560
+ const file = key && path.isAbsolute(env[key] ?? "") ? env[key] : path.join(home, ".npmrc");
561
+ let text;
562
+ try { text = readFileSync(file); }
563
+ catch (error) { if (error.code === "ENOENT" || error.code === "ENOTDIR") return ""; throw error; }
564
+ if (text.length > 1024 * 1024) throw new Error(`The npm user configuration is too large: ${file}`);
565
+ return text.toString("utf8");
566
+ }
567
+ /** Replaces the prefix npmrc (layout.npmrc, the file pnpm and npm read in place
568
+ * of ~/.npmrc) with filterNpmrc(text), mode 0600, with one rename.
569
+ */
570
+ export function writeNpmrcAuth(layout, text) {
571
+ const content = filterNpmrc(text);
572
+ replaceAtomically(layout.npmrc, (temp) => {
573
+ writeFileSync(temp, content, { flag: "wx", mode: 0o600 });
574
+ if (layout.platform !== "win32") chmodSync(temp, 0o600);
575
+ });
576
+ }
577
+
578
+ function versionsOf(manifest) {
579
+ const { shell, pi } = manifest ?? {};
580
+ if (!SEMVER.test(shell ?? "") || !SEMVER.test(pi ?? "")) throw new Error("Distribution manifest version rejected");
581
+ return { shell, pi, id: `${shell}-${pi}` };
582
+ }
583
+ /** The exact project the release lockfile is built from and installed with:
584
+ * gentle-pi and Pi pinned exactly, and only gentle-pi's build scripts allowed,
585
+ * as the global install allows it. Other dependencies' build scripts are
586
+ * skipped, not fatal (pnpm 11 fails on them by default).
587
+ */
588
+ export function distributionFiles(manifest) {
589
+ const { shell, pi } = versionsOf(manifest);
590
+ const project = { name: "gentle-shell-distribution", version: shell, private: true, dependencies: { "gentle-pi": shell, "@earendil-works/pi-coding-agent": pi } };
591
+ return { "package.json": `${JSON.stringify(project, null, "\t")}\n`, "pnpm-workspace.yaml": "allowBuilds:\n gentle-pi: true\nstrictDepBuilds: false\n" };
592
+ }
593
+
594
+ /** The two release assets (T1b), under stable names. */
595
+ export const DISTRIBUTION_ASSETS = Object.freeze({ distribution: "gentle-shell-distribution.json", lockfile: "gentle-shell-distribution-lock.yaml" });
596
+ const DISTRIBUTION_SCHEMA = 1;
597
+ const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
598
+ /** The distribution asset text: the exact versions, the pinned Node and pnpm
599
+ * the lockfile was resolved with, the files installVersion writes next to it
600
+ * and the lockfile's sha256. Plain data in a fixed order, so the same inputs
601
+ * always give the same bytes.
602
+ */
603
+ export function distributionAsset(manifest, lockfile) {
604
+ const { shell, pi, id } = versionsOf(manifest);
605
+ if (typeof lockfile !== "string" || lockfile.length === 0) throw new Error("Release lockfile missing");
606
+ const asset = { schema: DISTRIBUTION_SCHEMA, shell, pi, id, generatedWith: { node: pins.node, pnpm: pins.pnpm }, files: distributionFiles({ shell, pi }),
607
+ lockfile: { name: DISTRIBUTION_ASSETS.lockfile, sha256: sha256(lockfile) } };
608
+ return `${JSON.stringify(asset, null, "\t")}\n`;
609
+ }
610
+ /** Reads a downloaded asset pair: returns { manifest, lockfile } only for exact
611
+ * versions, a lockfile whose sha256 the asset records, and workspace files equal
612
+ * to the ones this module's installVersion writes (a frozen install with other
613
+ * settings was never verified). generatedWith is recorded, not compared.
614
+ */
615
+ export function readDistribution(text, lockfile) {
616
+ let asset;
617
+ try { asset = JSON.parse(text); }
618
+ catch { throw new Error("Distribution asset rejected"); }
619
+ if (asset?.schema !== DISTRIBUTION_SCHEMA) throw new Error("Distribution asset schema rejected");
620
+ const manifest = { shell: asset.shell, pi: asset.pi };
621
+ const { id } = versionsOf(manifest);
622
+ const files = distributionFiles(manifest);
623
+ const recorded = asset.files ?? {};
624
+ if (asset.id !== id || Object.keys(recorded).length !== Object.keys(files).length || Object.entries(files).some(([name, content]) => recorded[name] !== content)) {
625
+ throw new Error("Distribution asset workspace files rejected");
626
+ }
627
+ if (typeof lockfile !== "string" || asset.lockfile?.name !== DISTRIBUTION_ASSETS.lockfile || asset.lockfile?.sha256 !== sha256(lockfile)) {
628
+ throw new Error("Distribution lockfile sha256 mismatch");
629
+ }
630
+ return { manifest, lockfile };
631
+ }
632
+ function versionDirectory(layout, id) {
633
+ if (typeof id !== "string" || !VERSION_ID.test(id) || id.includes("..")) throw new Error("Unsafe version id");
634
+ return pathFor(process.platform).join(layout.versions, id);
635
+ }
636
+ /** A published version: a real directory whose marker names it. While pnpm
637
+ * runs, the marker reads `installing <id> <pid>` instead.
638
+ */
639
+ function installedVersion(layout, id) {
640
+ const directory = versionDirectory(layout, id);
641
+ return stat(directory)?.isDirectory() === true && read(pathFor(process.platform).join(directory, VERSION_MARKER)) === `${id}\n`;
642
+ }
643
+ function installedPackage(stage, name, expected, label) {
644
+ const path = pathFor(process.platform);
645
+ const directory = realpathSync(path.join(stage, "node_modules", ...name.split("/")));
646
+ if (!directory.startsWith(`${realpathSync(stage)}${path.sep}`)) throw new Error(`Installed ${label} is outside the version folder`);
647
+ const metadata = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8"));
648
+ if (metadata.name !== name || metadata.version !== expected) throw new Error(`Installed ${label} version mismatch: expected ${expected}, found ${metadata.version}`);
649
+ return directory;
650
+ }
651
+
652
+ function processAlive(pid) {
653
+ try { process.kill(pid, 0); return true; }
654
+ catch (error) { return error.code === "EPERM"; }
655
+ }
656
+ /** The pinned Go acquireGo published under runtime/go: a regular file there. */
657
+ function pinnedGo(layout, go) {
658
+ const path = pathFor(layout.platform);
659
+ if (typeof go !== "string" || !path.isAbsolute(go)) return false;
660
+ const relative = path.relative(layout.goRoot, go);
661
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && regular(go);
662
+ }
663
+
664
+ /** Installs `<shell>-<pi>` into versions/<shell>-<pi>/ in place: that folder
665
+ * is claimed and marked `installing <id> <pid>` first, the distribution files
666
+ * and the release lockfile are written, our pnpm runs `install --frozen-lockfile`
667
+ * there, the installed gentle-pi and Pi versions are checked exactly, and only
668
+ * then the marker is replaced by `<id>`. The folder is never renamed after pnpm
669
+ * ran: on Windows pnpm links packages with absolute junctions when symlinks are
670
+ * not allowed. An installed version is reused; a folder left by an interrupted
671
+ * install of this id (its process gone) is removed and installed again; any
672
+ * other folder is never replaced. On Windows the pinned Go is required, so
673
+ * gentle-pi's postinstall never builds with the user's go.exe. Adapters (run,
674
+ * and distribution for local fixture packages in place of the registry) are
675
+ * trusted local test code only.
676
+ */
677
+ export async function installVersion({ layout, manifest, lockfile, platform = layout.platform, env = {}, go = null, adapters = {} }) {
678
+ const files = (adapters.distribution ?? distributionFiles)(manifest);
679
+ const { shell, pi, id } = versionsOf(manifest);
680
+ const destination = versionDirectory(layout, id);
681
+ const path = pathFor(process.platform);
682
+ const marker = path.join(destination, VERSION_MARKER);
683
+ if (installedVersion(layout, id)) return { id, path: destination, installed: false };
684
+ if (platform === "win32" && !pinnedGo(layout, go)) throw new Error("A Windows version install needs the pinned Go from ensureRuntime({ go: true })");
685
+ if (typeof lockfile !== "string" || lockfile.length === 0) throw new Error("Release lockfile missing");
686
+ const existing = stat(destination);
687
+ if (existing) {
688
+ const pending = existing.isDirectory() ? /^installing (\S+) ([1-9]\d*)\n$/.exec(read(marker) ?? "") : null;
689
+ if (pending?.[1] !== id) throw new Error(`Conflicting version destination: ${destination}`);
690
+ if (processAlive(Number(pending[2]))) throw new Error(`An installation of ${id} is in progress (process ${pending[2]})`);
691
+ rmSync(destination, { recursive: true });
692
+ }
693
+ ensureDirectory(layout.versions, layout.platform);
694
+ // mkdir is the atomic claim: a folder created meanwhile by anyone else fails here.
695
+ mkdirSync(destination, { mode: 0o700 });
696
+ try {
697
+ writeFileSync(marker, `installing ${id} ${process.pid}\n`, { flag: "wx", mode: 0o600 });
698
+ for (const [name, text] of Object.entries({ ...files, "pnpm-lock.yaml": lockfile })) writeFileSync(path.join(destination, name), text, { flag: "wx", mode: 0o600 });
699
+ const result = await (adapters.run ?? runCommand)(layout.node, [layout.pnpm, "install", "--frozen-lockfile"],
700
+ { cwd: destination, env: pnpmEnvironment(layout, { platform, env, go }), timeout: INSTALL_TIMEOUT });
701
+ // pnpm prints install and postinstall failures on stdout: keep both tails.
702
+ if (result.status !== 0) {
703
+ throw Object.assign(new Error(`pnpm install failed (exit ${result.status})`),
704
+ { stderr: [String(result.stdout ?? "").slice(-6000), String(result.stderr ?? "").slice(-4000)].filter(Boolean).join("\n") });
705
+ }
706
+ const shellDirectory = installedPackage(destination, "gentle-pi", shell, "gentle-pi");
707
+ installedPackage(destination, "@earendil-works/pi-coding-agent", pi, "Pi");
708
+ if (!regular(path.join(shellDirectory, "bin", "gentle-shell.mjs"))) throw new Error("Installed gentle-pi has no launcher entry");
709
+ replaceAtomically(marker, (temp) => writeFileSync(temp, `${id}\n`, { flag: "wx", mode: 0o600 }));
710
+ return { id, path: destination, installed: true };
711
+ } catch (error) {
712
+ // The folder is this call's own claim: nothing else was ever in it.
713
+ rmSync(destination, { recursive: true, force: true });
714
+ throw error;
715
+ }
716
+ }
717
+
718
+ /** The active version id, or null when `current` is absent. A `current` this
719
+ * module did not write (not our symlink or pointer file) throws.
720
+ */
721
+ export function activeVersion(layout) {
722
+ const info = stat(layout.current);
723
+ if (!info) return null;
724
+ const id = layout.currentKind === "symlink"
725
+ ? (info.isSymbolicLink() ? /^versions\/([^/]+)$/.exec(readlinkSync(layout.current))?.[1] : undefined)
726
+ : (info.isFile() ? /^([^\r\n]+)\n$/.exec(readFileSync(layout.current, "utf8"))?.[1] : undefined);
727
+ if (id === undefined || !VERSION_ID.test(id)) throw new Error(`Conflicting current: ${layout.current}`);
728
+ return id;
729
+ }
730
+ function readHistory(layout) {
731
+ return (read(layout.history) ?? "").split("\n").filter((id) => VERSION_ID.test(id) && !id.includes(".."));
732
+ }
733
+ /** Replaces `path` by one rename of a freshly written sibling. */
734
+ function replaceAtomically(path, write) {
735
+ const temp = pathFor(process.platform).join(pathFor(process.platform).dirname(path), `.${pathFor(process.platform).basename(path)}-${randomBytes(6).toString("hex")}`);
736
+ try {
737
+ write(temp);
738
+ renameSync(temp, path);
739
+ } catch (error) {
740
+ if (stat(temp)) unlinkSync(temp);
741
+ throw error;
742
+ }
743
+ }
744
+
745
+ /** Switches `current` to an installed version with one rename (a relative
746
+ * symlink on POSIX, a pointer file on Windows) and records it first in the
747
+ * activation history. Returns { id, previous }.
748
+ */
749
+ export function activateVersion(layout, id) {
750
+ versionDirectory(layout, id);
751
+ if (!installedVersion(layout, id)) throw new Error(`${id} is not an installed version`);
752
+ const previous = activeVersion(layout);
753
+ replaceAtomically(layout.current, (temp) => {
754
+ if (layout.currentKind === "symlink") symlinkSync(`versions/${id}`, temp);
755
+ else writeFileSync(temp, `${id}\n`, { flag: "wx", mode: 0o600 });
756
+ });
757
+ const history = [...new Set([id, ...(previous ? [previous] : []), ...readHistory(layout)])];
758
+ replaceAtomically(layout.history, (temp) => writeFileSync(temp, `${history.join("\n")}\n`, { flag: "wx", mode: 0o600 }));
759
+ return { id, previous };
760
+ }
761
+
762
+ /** Deletes installed versions beyond the active one and the most recently
763
+ * activated others, `keep` in all. Nothing is deleted unless `current` names an
764
+ * installed version; the active one, unfinished installs and any folder
765
+ * without our marker are never deleted. Returns the deleted ids.
766
+ */
767
+ export function pruneVersions(layout, keep = 2) {
768
+ const active = activeVersion(layout);
769
+ if (!active || !installedVersion(layout, active)) return [];
770
+ const kept = new Set([active]);
771
+ for (const id of readHistory(layout)) if (kept.size < Math.max(keep, 1) && installedVersion(layout, id)) kept.add(id);
772
+ const removed = [];
773
+ for (const name of readdirSync(layout.versions).sort()) {
774
+ if (name.startsWith(".") || kept.has(name) || !VERSION_ID.test(name) || !installedVersion(layout, name)) continue;
775
+ rmSync(versionDirectory(layout, name), { recursive: true });
776
+ removed.push(name);
777
+ }
778
+ if (removed.length > 0) replaceAtomically(layout.history, (temp) => writeFileSync(temp, `${readHistory(layout).filter((id) => !removed.includes(id)).join("\n")}\n`, { flag: "wx", mode: 0o600 }));
779
+ return removed;
780
+ }
781
+
782
+ const UNPUBLISHED = Object.freeze({
783
+ missing: (version) => `release v${version} does not publish the bundled distribution yet; try again later`,
784
+ unavailable: (version) => `the bundled distribution of release v${version} could not be downloaded`,
785
+ rejected: (version) => `the bundled distribution of release v${version} did not verify`,
786
+ });
787
+ const RELEASE_EXPORTS = Object.freeze(["prefixLayout", "ensureRuntime", "ensureLauncher"]);
788
+ /** The variable that makes a version's launcher run in bundled mode for
789
+ * `--version` only, as if `current` named that version (lib/gentle-shell-bundled.ts).
790
+ */
791
+ const VERIFY_ENV = "GENTLE_SHELL_VERIFY_VERSION";
792
+
793
+ const NPM_SHIM_MARK = "gentle-shell bundled npm entry (generated; do not edit)";
794
+ /** Writes runtime/node-<v>/gentle-shell/npm, a CommonJS file that runs the npm
795
+ * bundled with that Node, and a package.json beside it that keeps it CommonJS
796
+ * under any package.json with "type": "module" above the prefix. Pi names the
797
+ * package manager from the basenames in npmCommand (getPackageManagerName in
798
+ * Pi's core/package-manager.js), so this entry is named exactly `npm`; on
799
+ * Windows it also avoids npm.cmd, which prefers an npm in the global prefix.
800
+ * Each file is replaced with one rename, only when its content differs; a link
801
+ * or anything but a regular file in its place stops it. Returns { written }.
802
+ */
803
+ export function ensureNpmShim(layout) {
804
+ const path = pathFor(layout.platform);
805
+ if (!regular(layout.npmCli)) throw new Error(`Bundled npm missing: ${layout.npmCli}`);
806
+ const folder = path.dirname(layout.npmShim);
807
+ const cli = path.relative(folder, layout.npmCli).split(path.sep).map((part) => JSON.stringify(part)).join(", ");
808
+ const files = {
809
+ "package.json": '{ "type": "commonjs" }\n',
810
+ [path.basename(layout.npmShim)]: [`// ${NPM_SHIM_MARK}`, `require(require("node:path").join(__dirname, ${cli}));`, ""].join("\n"),
811
+ };
812
+ const info = stat(folder);
813
+ if (!info) mkdirSync(folder, { mode: 0o700 });
814
+ else if (!info.isDirectory()) throw new Error(`Conflicting npm entry: ${folder}`);
815
+ let written = false;
816
+ for (const [name, text] of Object.entries(files)) {
817
+ const target = path.join(folder, name);
818
+ const existing = stat(target);
819
+ if (existing && !existing.isFile()) throw new Error(`Conflicting npm entry: ${target}`);
820
+ if (existing && readFileSync(target, "utf8") === text) continue;
821
+ replaceAtomically(target, (temp) => writeFileSync(temp, text, { flag: "wx", mode: 0o600 }));
822
+ written = true;
823
+ }
824
+ return { written };
825
+ }
826
+
827
+ /** The command Pi runs for npm in a bundled install (its `npmCommand` setting):
828
+ * our npm on our Node, through the entry named npm (ensureNpmShim), so Pi treats
829
+ * it as npm; it reads only the prefix npmrc and global config, with its cache
830
+ * inside the prefix. npm ranks command-line flags above npm_config_* variables,
831
+ * so the user's environment cannot redirect these. No --prefix: on the command
832
+ * line it also sets npm's local prefix, and Pi installs a git extension's
833
+ * dependencies with `npm install` in the clone, without a --prefix of its own
834
+ * (its npm: installs pass one). No other part's basename is npm, pnpm or bun,
835
+ * which Pi would reject as ambiguous.
836
+ */
837
+ export function bundledNpmCommand(layout) {
838
+ return [layout.node, layout.npmShim, `--userconfig=${layout.npmrc}`, `--globalconfig=${layout.npmGlobalConfig}`, `--cache=${layout.npmCache}`,
839
+ "--update-notifier=false"];
840
+ }
841
+
842
+ /** The module an installed version ships (its own pins and launcher), only from
843
+ * a regular file whose real path stays inside that version folder.
844
+ */
845
+ async function releaseModule(file, versionPath) {
846
+ if (!regular(file) || !realpathSync(file).startsWith(`${realpathSync(versionPath)}${pathFor(process.platform).sep}`)) return null;
847
+ return import(pathToFileURL(file).href);
848
+ }
849
+ /** An installed version's own scripts/bundled-install.mjs and its layout of this
850
+ * prefix; it must export prefixLayout, ensureRuntime and ensureLauncher as
851
+ * functions. `active` names the version `current` stays at otherwise.
852
+ */
853
+ async function loadRelease({ layout, id, home, platform, env, adapters, active }) {
854
+ const path = pathFor(process.platform);
855
+ const versionPath = versionDirectory(layout, id);
856
+ const file = path.join(versionPath, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs");
857
+ const module = await (adapters.load ?? releaseModule)(file, versionPath);
858
+ if (!module || RELEASE_EXPORTS.some((name) => typeof module[name] !== "function")) {
859
+ throw new Error(`Gentle Shell ${id} has no usable scripts/bundled-install.mjs (${RELEASE_EXPORTS.join(", ")}); current stays at ${active}`);
860
+ }
861
+ return { module, next: module.prefixLayout({ platform, env, home, root: layout.root }) };
862
+ }
863
+ /** Switches `current` to `id` and writes that version's launcher; when the
864
+ * launcher cannot be written, `current` goes back to `active` (one rename each).
865
+ */
866
+ function activateWithLauncher(layout, id, active, { module, next }) {
867
+ activateVersion(layout, id);
868
+ try {
869
+ module.ensureLauncher(next);
870
+ } catch (error) {
871
+ if (active) activateVersion(layout, active);
872
+ else unlinkSync(layout.current);
873
+ throw new Error(`the launcher for Gentle Shell ${id} could not be written (${error.message}); current stays at ${active}`);
874
+ }
875
+ }
876
+
877
+ /** `gentle-shell upgrade` in a bundled install (S5, S6, S8): the latest
878
+ * release's distribution assets (adapters.latest, adapters.distribution) are
879
+ * installed beside the active version with our runtime, then the new version's
880
+ * own module (loaded only from that version folder) installs its pinned runtime
881
+ * when the pins moved. Its launcher, run in bundled mode for that version, must
882
+ * print exactly the release's gentle-pi and Pi before `current` switches; the
883
+ * release's launcher is then written (a failure puts `current` back) and two
884
+ * versions are kept (a pruning failure is only a warning). An already current
885
+ * release changes nothing; any failure leaves `current` as it was and removes a
886
+ * version this call installed. Returns { id, changed, previous?, removed?, warning? }.
887
+ */
888
+ export async function upgradeBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) {
889
+ const path = pathFor(process.platform);
890
+ const latest = await adapters.latest();
891
+ const release = await adapters.distribution(latest);
892
+ if (release?.status !== "published") throw new Error((UNPUBLISHED[release?.status] ?? UNPUBLISHED.unavailable)(latest));
893
+ const { manifest, lockfile } = release;
894
+ const { shell, pi, id } = versionsOf(manifest);
895
+ const active = activeVersion(layout);
896
+ if (active === id) {
897
+ out(`Gentle Shell ${shell} with Pi ${pi} is already current.`);
898
+ return { id, changed: false };
899
+ }
900
+ const go = platform === "win32";
901
+ const runtime = await (adapters.ensureRuntime ?? ensureRuntime)({ layout, platform, arch, go, env });
902
+ const version = await installVersion({ layout, manifest, lockfile, platform, env, go: runtime.go, adapters: adapters.install ?? {} });
903
+ try {
904
+ const loaded = await loadRelease({ layout, id, home, platform, env, adapters, active });
905
+ // The release's own pins, its Engram included (an older module ignores `engram`).
906
+ await loaded.module.ensureRuntime({ layout: loaded.next, platform, arch, go, engram: true, env });
907
+ const check = await checkLauncher(loaded.next, { id, shell, pi, platform, env, run: adapters.run });
908
+ if (!check.ok) {
909
+ throw Object.assign(new Error(`Gentle Shell ${id} reported ${JSON.stringify(check.reported)}, not gentle-shell ${shell} with pi ${pi}; current stays at ${active}`),
910
+ { stderr: check.stderr });
911
+ }
912
+ activateWithLauncher(layout, id, active, loaded);
913
+ } catch (error) {
914
+ if (version.installed && activeVersion(layout) !== id) rmSync(version.path, { recursive: true, force: true });
915
+ throw error;
916
+ }
917
+ out(`Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.`);
918
+ try {
919
+ return { id, changed: true, previous: active, removed: (adapters.prune ?? pruneVersions)(layout, 2) };
920
+ } catch (error) {
921
+ const warning = `older versions were not removed (${error.message})`;
922
+ out(`warning: ${warning}`);
923
+ return { id, changed: true, previous: active, removed: [], warning };
924
+ }
925
+ }
926
+
927
+ /** Whether version `id` reports exactly `gentle-shell <shell>` and `pi <pi>` to
928
+ * `--version`: its entry run by our Node, or with `launcher` on POSIX the
929
+ * bin/gentle-shell launcher itself (it runs the version `current` names).
930
+ * The environment is the one our pnpm ran in, without any Pi or Gentle override
931
+ * that could point the launcher at another Pi or agent home.
932
+ * Returns { ok, reported, stderr }.
933
+ */
934
+ export async function checkLauncher(layout, { id, shell, pi, platform = layout.platform, env = {}, run = runCommand, launcher = false }) {
935
+ // The environment our pnpm ran in, without any Pi or Gentle override that
936
+ // could point the launcher at another Pi or agent home, plus the one input
937
+ // that runs this version in bundled mode for its --version (exact Pi).
938
+ const launchEnv = { ...Object.fromEntries(Object.entries(pnpmEnvironment(layout, { platform, env })).filter(([key]) => !/^(?:gentle_|pi_)/i.test(key))),
939
+ [VERIFY_ENV]: id };
940
+ const entry = pathFor(platform).join(layout.versions, id, "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
941
+ const [command, args] = launcher && platform !== "win32" ? [layout.launcher, ["--version"]] : [layout.node, [entry, "--version"]];
942
+ const result = await (run ?? runCommand)(command, args, { cwd: layout.root, env: launchEnv, timeout: 120000 });
943
+ const reported = String(result.stdout ?? "").trim();
944
+ const [shellLine, piLine] = reported.split(/\r?\n/);
945
+ return { ok: result.status === 0 && shellLine === `gentle-shell ${shell}` && piLine === `pi ${pi}`, reported, stderr: String(result.stderr ?? "").slice(-4000) };
946
+ }
947
+
948
+ /** `gentle-shell upgrade --rollback`: `current` switches back to the most
949
+ * recently active other version still installed, with that version's own
950
+ * launcher and runtime pins (its own scripts/bundled-install.mjs, loaded like
951
+ * upgradeBundled loads it). Without a usable module nothing changes; when its
952
+ * launcher cannot be written `current` goes back. Returns { from, to }.
953
+ */
954
+ export async function rollbackBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters = {} }) {
955
+ const active = activeVersion(layout);
956
+ const previous = readHistory(layout).find((id) => id !== active && installedVersion(layout, id));
957
+ if (!previous) throw new Error("No previous Gentle Shell version is kept to roll back to");
958
+ const loaded = await loadRelease({ layout, id: previous, home, platform, env, adapters, active });
959
+ await loaded.module.ensureRuntime({ layout: loaded.next, platform, arch, go: platform === "win32", engram: true, env });
960
+ activateWithLauncher(layout, previous, active, loaded);
961
+ return { from: active, to: previous };
962
+ }
963
+
964
+ /** The launcher text: our Node running the current gentle-pi's
965
+ * bin/gentle-shell.mjs, passing every argument and the exit code. Without an
966
+ * active version it says so and exits 1. The Windows one reads the pointer file
967
+ * with delayed expansion off, so `!`, `%`, `^` or `&` in the prefix path stay
968
+ * literal inside its quotes.
969
+ */
970
+ export function launcherText(layout) {
971
+ if (layout.platform === "win32") {
972
+ const node = win32.relative(layout.root, layout.node);
973
+ const entry = "%~dp0..\\versions\\%GENTLE_SHELL_CURRENT%\\node_modules\\gentle-pi\\bin\\gentle-shell.mjs";
974
+ return ["@echo off", `rem ${LAUNCHER_MARK}`, "setlocal DisableDelayedExpansion", 'set "GENTLE_SHELL_CURRENT="',
975
+ 'if exist "%~dp0..\\current" set /p GENTLE_SHELL_CURRENT=<"%~dp0..\\current"', "if not defined GENTLE_SHELL_CURRENT goto missing",
976
+ `if not exist "${entry}" goto missing`, `"%~dp0..\\${node}" "${entry}" %*`, "exit /b %errorlevel%", ":missing",
977
+ `>&2 echo ${LAUNCHER_MISSING}`, "exit /b 1", ""].join("\r\n");
978
+ }
979
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
980
+ const entry = posix.join(layout.current, "node_modules/gentle-pi/bin/gentle-shell.mjs");
981
+ return ["#!/bin/sh", `# ${LAUNCHER_MARK}`, `if [ ! -f ${quote(entry)} ]; then`, `\techo '${LAUNCHER_MISSING}' >&2`, "\texit 1", "fi",
982
+ `exec ${quote(layout.node)} ${quote(entry)} "$@"`, ""].join("\n");
983
+ }
984
+ /** Writes bin/gentle-shell (bin/gentle-shell.cmd on Windows). A launcher this
985
+ * module wrote is refreshed; any other file there is never replaced.
986
+ */
987
+ export function ensureLauncher(layout) {
988
+ const text = launcherText(layout);
989
+ const existing = stat(layout.launcher);
990
+ if (existing && (!existing.isFile() || !readFileSync(layout.launcher, "utf8").split(/\r?\n/)[1]?.endsWith(LAUNCHER_MARK))) {
991
+ throw new Error(`Conflicting launcher: ${layout.launcher}`);
992
+ }
993
+ if (existing && readFileSync(layout.launcher, "utf8") === text) return { written: false };
994
+ ensureDirectory(layout.bin, layout.platform);
995
+ replaceAtomically(layout.launcher, (temp) => {
996
+ writeFileSync(temp, text, { flag: "wx", mode: 0o755 });
997
+ if (layout.platform !== "win32") chmodSync(temp, 0o755);
998
+ });
999
+ return { written: true };
1000
+ }
1001
+
1002
+ /** The startup file that shell reads for the terminals users open:
1003
+ * - zsh: $ZDOTDIR/.zshrc (interactive shells);
1004
+ * - bash on macOS, where terminals start login shells: the first of
1005
+ * ~/.bash_profile, ~/.bash_login and ~/.profile that exists, as login bash
1006
+ * reads exactly one of them ("INVOCATION" in bash(1)); ~/.bash_profile only
1007
+ * when none exists, since creating it would hide the user's ~/.profile;
1008
+ * - bash elsewhere: ~/.bashrc, what an interactive non-login bash (a Linux
1009
+ * terminal) reads; distributions' ~/.profile sources it for login shells;
1010
+ * - fish: our own conf.d file; anything else: ~/.profile.
1011
+ */
1012
+ function profileFor(platform, env, home) {
1013
+ const files = profileFiles(platform, env, home);
1014
+ const shell = posix.basename(String(env.SHELL ?? ""));
1015
+ if (shell === "bash" && platform === "darwin") return files.bash.find((path) => stat(path)) ?? files.bash[0];
1016
+ return (files[shell] ?? files.other)[0];
1017
+ }
1018
+ /** Every startup file profileFor can choose on `platform`, per shell, in its
1019
+ * order of preference: the one source for planning a line and for trusting a
1020
+ * receipt (readPathReceipt).
1021
+ */
1022
+ function profileFiles(platform, env, home) {
1023
+ const config = posix.isAbsolute(env.XDG_CONFIG_HOME ?? "") ? env.XDG_CONFIG_HOME : posix.join(home, ".config");
1024
+ return {
1025
+ zsh: [posix.join(posix.isAbsolute(env.ZDOTDIR ?? "") ? env.ZDOTDIR : home, ".zshrc")],
1026
+ bash: platform === "darwin" ? [".bash_profile", ".bash_login", ".profile"].map((name) => posix.join(home, name)) : [posix.join(home, ".bashrc")],
1027
+ fish: [posix.join(config, "fish", "conf.d", "gentle-shell.fish")],
1028
+ other: [posix.join(home, ".profile")],
1029
+ };
1030
+ }
1031
+ /** Whether the installer may edit `path`: "file" (a regular file this user can
1032
+ * write, in real folders) or "absent" (creatable inside real, writable folders);
1033
+ * otherwise why not: "symlink" (the file or a folder above it under home is a
1034
+ * link, as stow, chezmoi and home-manager lay them out), "not-a-file" or
1035
+ * "not-writable".
1036
+ */
1037
+ function profileState(path, home) {
1038
+ const realHome = realpathSync(home);
1039
+ const realFolder = (folder) => {
1040
+ const relative = posix.relative(home, folder);
1041
+ return relative.startsWith("..") || posix.isAbsolute(relative) || realpathSync(folder) === posix.join(realHome, relative);
1042
+ };
1043
+ const writable = (target) => {
1044
+ try { accessSync(target, constants.W_OK); return true; }
1045
+ catch { return false; }
1046
+ };
1047
+ const info = stat(path);
1048
+ if (info) {
1049
+ if (info.isSymbolicLink()) return "symlink";
1050
+ if (!info.isFile()) return "not-a-file";
1051
+ if (!realFolder(posix.dirname(path))) return "symlink";
1052
+ return writable(path) ? "file" : "not-writable";
1053
+ }
1054
+ let folder = posix.dirname(path);
1055
+ while (!stat(folder)) {
1056
+ if (posix.dirname(folder) === folder) return "not-a-file";
1057
+ folder = posix.dirname(folder);
1058
+ }
1059
+ const existing = stat(folder);
1060
+ if (existing.isSymbolicLink()) return "symlink";
1061
+ if (!existing.isDirectory()) return "not-a-file";
1062
+ if (!realFolder(folder)) return "symlink";
1063
+ return writable(folder) ? "absent" : "not-writable";
1064
+ }
1065
+ /** The marked line that puts `bin` first on PATH, for fish and for sh-like shells. */
1066
+ function profileLines(bin) {
1067
+ return { fish: `set -gx PATH '${bin}' $PATH ${PROFILE_MARK}`, sh: `export PATH="${bin}:$PATH" ${PROFILE_MARK}` };
1068
+ }
1069
+ /** Whether `path`, followed through links, already holds `line`. */
1070
+ function holdsLine(path, line) {
1071
+ try { return readFileSync(path, "utf8").split("\n").includes(line); }
1072
+ catch { return false; }
1073
+ }
1074
+ /** The single PATH change, planned and not applied:
1075
+ * - { kind: "none" } when bin/ or our ~/.local/bin link is already on PATH, or
1076
+ * the profile (even through a link) already holds our line;
1077
+ * - POSIX { kind: "symlink", path, target }: ~/.local/bin/gentle-shell when
1078
+ * ~/.local/bin is on PATH and that name is free;
1079
+ * - POSIX { kind: "profile", path, line, create }: otherwise one marked line in
1080
+ * the shell's startup file (profileFor), only when profileState allows it;
1081
+ * - POSIX { kind: "manual", path, line, reason }: that file may not be edited;
1082
+ * the user adds `line` and nothing is written;
1083
+ * - Windows { kind: "registry", key, name, entry }: bin/ in the HKCU user Path.
1084
+ */
1085
+ export function pathEntryPlan(layout, { platform = layout.platform, env = {}, home }) {
1086
+ if (platform === "win32") {
1087
+ const normal = (value) => win32.normalize(value).replace(/(.)\\+$/, "$1").toLowerCase();
1088
+ const entries = String(envValue(env, "PATH", platform) ?? "").split(";").filter(Boolean).map(normal);
1089
+ if (entries.includes(normal(layout.bin))) return { kind: "none" };
1090
+ return { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: layout.bin };
1091
+ }
1092
+ const entries = String(env.PATH ?? "").split(":").filter(Boolean).map((entry) => posix.normalize(entry).replace(/(.)\/+$/, "$1"));
1093
+ if (entries.includes(layout.bin)) return { kind: "none" };
1094
+ const localBin = posix.join(home, ".local", "bin");
1095
+ if (entries.includes(localBin) && stat(localBin)?.isDirectory()) {
1096
+ const link = posix.join(localBin, "gentle-shell");
1097
+ const existing = stat(link);
1098
+ if (!existing) return { kind: "symlink", path: link, target: layout.launcher };
1099
+ if (existing.isSymbolicLink() && readlinkSync(link) === layout.launcher) return { kind: "none" };
1100
+ }
1101
+ const profile = profileFor(platform, env, home);
1102
+ const fish = profile.endsWith(".fish");
1103
+ if (fish ? /['\\\n]/.test(layout.bin) : /["\\$`\n]/.test(layout.bin)) throw new Error(`Unsafe bundled bin path for a shell profile: ${layout.bin}`);
1104
+ const line = profileLines(layout.bin)[fish ? "fish" : "sh"];
1105
+ if (holdsLine(profile, line)) return { kind: "none" };
1106
+ const state = profileState(profile, home);
1107
+ if (state === "file" || state === "absent") return { kind: "profile", path: profile, line, create: state === "absent" };
1108
+ return { kind: "manual", path: profile, line, reason: state };
1109
+ }
1110
+
1111
+ /** Applies a pathEntryPlan and returns its receipt ({ ...plan, applied }, plus
1112
+ * `separator` for a profile line), which removePathEntry needs to revert it
1113
+ * exactly. Nothing existing is replaced and nothing is written for "none" or
1114
+ * "manual". A profile that changed since the plan (now a link, or created
1115
+ * meanwhile) throws. Windows needs an injected `registry` adapter
1116
+ * ({ add(entry), remove(entry) }); an add that reports "present" (the user Path
1117
+ * already held the entry) applied nothing.
1118
+ */
1119
+ export function applyPathEntry(plan, { registry } = {}) {
1120
+ if (plan.kind === "symlink") symlinkSync(plan.target, plan.path);
1121
+ else if (plan.kind === "profile") {
1122
+ const changed = () => new Error(`The shell profile changed since the PATH plan: ${plan.path}`);
1123
+ if (plan.create) {
1124
+ let folder = posix.dirname(plan.path);
1125
+ while (!stat(folder)) folder = posix.dirname(folder);
1126
+ if (!stat(folder).isDirectory()) throw changed();
1127
+ mkdirSync(posix.dirname(plan.path), { recursive: true });
1128
+ // wx: never through a file or link created meanwhile.
1129
+ try { writeFileSync(plan.path, `${plan.line}\n`, { flag: "wx", mode: 0o644 }); }
1130
+ catch (error) { if (error.code === "EEXIST") throw changed(); throw error; }
1131
+ return { ...plan, applied: true, separator: false };
1132
+ }
1133
+ if (!stat(plan.path)?.isFile()) throw changed();
1134
+ const text = readFileSync(plan.path, "utf8");
1135
+ if (text.split("\n").includes(plan.line)) return { ...plan, applied: false };
1136
+ const separator = text.length > 0 && !text.endsWith("\n");
1137
+ // O_NOFOLLOW: a link swapped in after the check above is refused, not followed.
1138
+ const fd = openSync(plan.path, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
1139
+ try { writeSync(fd, `${separator ? "\n" : ""}${plan.line}\n`); }
1140
+ finally { closeSync(fd); }
1141
+ return { ...plan, applied: true, separator };
1142
+ } else if (plan.kind === "registry") {
1143
+ if (typeof registry?.add !== "function") throw new Error("The Windows PATH change needs a registry adapter");
1144
+ if (registry.add(plan.entry) === "present") return { ...plan, applied: false };
1145
+ } else return { ...plan, applied: false };
1146
+ return { ...plan, applied: true };
1147
+ }
1148
+ /** Reverts an applied receipt: only our symlink, only our exact marked line
1149
+ * (with the newline added before it, so the file reads as before), or only our
1150
+ * registry entry is removed. A profile applyPathEntry created is deleted when
1151
+ * nothing else is left in it. A profile that is no longer a regular file is
1152
+ * left alone, and so is everything a receipt with `applied: false` names: it
1153
+ * was there before. Returns { removed: true }, or { removed: false, note }
1154
+ * saying what was kept and why (a line the user edited, a link replaced).
1155
+ */
1156
+ export function removePathEntry(plan, { registry } = {}) {
1157
+ const removed = { removed: true };
1158
+ if (plan.applied === false) return { removed: false, note: "it was there before the install" };
1159
+ if (plan.kind === "symlink") {
1160
+ const info = stat(plan.path);
1161
+ if (!info) return removed;
1162
+ if (!info.isSymbolicLink() || readlinkSync(plan.path) !== plan.target) return { removed: false, note: `${plan.path} no longer links to ${plan.target}, so it was kept` };
1163
+ unlinkSync(plan.path);
1164
+ } else if (plan.kind === "profile") {
1165
+ const info = stat(plan.path);
1166
+ if (!info) return removed;
1167
+ if (!info.isFile() || !plan.line.endsWith(PROFILE_MARK)) return { removed: false, note: `${plan.path} is no longer a regular file, so it was kept` };
1168
+ const lines = readFileSync(plan.path, "utf8").split("\n");
1169
+ const index = lines.indexOf(plan.line);
1170
+ if (index < 0) {
1171
+ // Edited by the user: nothing is rewritten; the marked lines left there are named.
1172
+ // Without any, the user already took the line out.
1173
+ const marked = lines.filter((line) => line.includes(PROFILE_MARK));
1174
+ return marked.length > 0 ? { removed: false, note: `${plan.path} still holds ${marked.join(" and ")} (edited, so it was kept)` } : removed;
1175
+ }
1176
+ lines.splice(index, 1);
1177
+ let text = lines.join("\n");
1178
+ if (plan.separator && index === lines.length - 1 && text.endsWith("\n")) text = text.slice(0, -1);
1179
+ if (plan.create && text === "") {
1180
+ unlinkSync(plan.path);
1181
+ return removed;
1182
+ }
1183
+ const mode = info.mode & 0o777;
1184
+ replaceAtomically(plan.path, (temp) => {
1185
+ writeFileSync(temp, text, { flag: "wx", mode });
1186
+ chmodSync(temp, mode);
1187
+ });
1188
+ } else if (plan.kind === "registry") {
1189
+ if (typeof registry?.remove !== "function") throw new Error("The Windows PATH change needs a registry adapter");
1190
+ registry.remove(plan.entry);
1191
+ }
1192
+ return removed;
1193
+ }
1194
+
1195
+ // The PATH receipt (S7): the one PATH change applyPathEntry made, kept in the
1196
+ // prefix so `gentle-shell self-uninstall` reverts exactly that change.
1197
+ const RECEIPT_SCHEMA = 1;
1198
+ const RECEIPT_FIELDS = Object.freeze({ symlink: ["path", "target"], profile: ["path", "line", "create", "separator"], registry: ["key", "name", "entry"] });
1199
+ /** Writes an applied receipt to layout.pathReceipt (mode 0600, replaced with
1200
+ * one rename) and returns true; a receipt that applied nothing writes nothing
1201
+ * and returns false, so an earlier receipt stays.
1202
+ */
1203
+ export function writePathReceipt(layout, receipt) {
1204
+ if (receipt?.applied !== true) return false;
1205
+ const fields = RECEIPT_FIELDS[receipt.kind];
1206
+ if (!fields) throw new Error(`Unknown PATH entry kind: ${receipt.kind}`);
1207
+ const text = `${JSON.stringify({ schema: RECEIPT_SCHEMA, kind: receipt.kind, ...Object.fromEntries(fields.map((field) => [field, receipt[field]])) }, null, "\t")}\n`;
1208
+ replaceAtomically(layout.pathReceipt, (temp) => {
1209
+ writeFileSync(temp, text, { flag: "wx", mode: 0o600 });
1210
+ if (layout.platform !== "win32") chmodSync(temp, 0o600);
1211
+ });
1212
+ return true;
1213
+ }
1214
+ /** Whether a receipt names only what this prefix writes: our launcher linked as
1215
+ * ~/.local/bin/gentle-shell, our exact marked line for bin/ in a startup file
1216
+ * pathEntryPlan can choose for this user (profileFiles, with the same ZDOTDIR
1217
+ * and XDG_CONFIG_HOME rules), or bin/ in the HKCU user Path.
1218
+ */
1219
+ function ownPathEntry(layout, receipt, { env, home }) {
1220
+ if (layout.platform === "win32") return receipt.kind === "registry" && receipt.key === "HKCU\\Environment" && receipt.name === "Path" && receipt.entry === layout.bin;
1221
+ if (typeof home !== "string" || !posix.isAbsolute(home)) return false;
1222
+ if (receipt.kind === "symlink") return receipt.path === posix.join(home, ".local", "bin", "gentle-shell") && receipt.target === layout.launcher;
1223
+ if (receipt.kind !== "profile" || typeof receipt.create !== "boolean" || typeof receipt.separator !== "boolean") return false;
1224
+ // Only a file pathEntryPlan can choose here, holding the line it writes there.
1225
+ const lines = profileLines(layout.bin);
1226
+ const files = profileFiles(layout.platform, env, home);
1227
+ return Object.entries(files).some(([shell, paths]) => paths.includes(receipt.path) && receipt.line === lines[shell === "fish" ? "fish" : "sh"]);
1228
+ }
1229
+ /** The persisted receipt: { status: "recorded", receipt } (applied: true, as
1230
+ * removePathEntry takes it), { status: "none" } without one, or
1231
+ * { status: "invalid", reason } for anything that is not a regular file this
1232
+ * prefix wrote (never acted on). `env` and `home` are the user's, as
1233
+ * pathEntryPlan read them.
1234
+ */
1235
+ export function readPathReceipt(layout, { env = {}, home } = {}) {
1236
+ const info = stat(layout.pathReceipt);
1237
+ if (!info) return { status: "none" };
1238
+ const invalid = (reason) => ({ status: "invalid", reason: `${layout.pathReceipt} ${reason}` });
1239
+ if (!info.isFile()) return invalid("is not a regular file");
1240
+ if (info.size > 64 * 1024) return invalid("is too large");
1241
+ let data;
1242
+ try { data = JSON.parse(readFileSync(layout.pathReceipt, "utf8")); }
1243
+ catch { return invalid("is not valid JSON"); }
1244
+ if (data === null || typeof data !== "object" || Array.isArray(data) || data.schema !== RECEIPT_SCHEMA || !Object.hasOwn(RECEIPT_FIELDS, data.kind)) {
1245
+ return invalid("has an unknown format");
1246
+ }
1247
+ const receipt = { kind: data.kind, ...Object.fromEntries(RECEIPT_FIELDS[data.kind].map((field) => [field, data[field]])), applied: true };
1248
+ return ownPathEntry(layout, receipt, { env, home }) ? { status: "recorded", receipt } : invalid("does not describe this install's PATH entry");
1249
+ }
1250
+ /** What a recorded receipt changed, for the uninstall plan. */
1251
+ export function describePathReceipt(receipt) {
1252
+ if (receipt.kind === "symlink") return `the link ${receipt.path} to ${receipt.target}`;
1253
+ if (receipt.kind === "profile") return `the line ${receipt.line} in ${receipt.path}${receipt.create ? " (and that file, when nothing else is left in it)" : ""}`;
1254
+ return `${receipt.entry} in your user Path (${receipt.key})`;
1255
+ }
1256
+
1257
+ // Windows: once process GENTLE_SHELL_REMOVE_AFTER_PID exits, deletes each file
1258
+ // in GENTLE_SHELL_REMOVE_FILES (retrying while it is still in use), then
1259
+ // GENTLE_SHELL_REMOVE_LAST (the prefix marker) only when nothing is left at any
1260
+ // of those paths, so a failed removal can be retried with self-uninstall, then each
1261
+ // folder in GENTLE_SHELL_REMOVE_DIRECTORIES only when it is empty (never
1262
+ // recursively, so no link is followed). Every path is data, never script text.
1263
+ const deferredRemovalScript = `$ErrorActionPreference = 'SilentlyContinue'
1264
+ $waitFor = [int]$env:GENTLE_SHELL_REMOVE_AFTER_PID
1265
+ try { Wait-Process -Id $waitFor -Timeout 600 -ErrorAction Stop } catch {}
1266
+ Start-Sleep -Seconds 2
1267
+ $files = @($env:GENTLE_SHELL_REMOVE_FILES -split "\`n" | Where-Object { $_ -ne '' })
1268
+ foreach ($file in $files) {
1269
+ for ($try = 0; $try -lt 60; $try++) {
1270
+ try { if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) }; break } catch { Start-Sleep -Milliseconds 500 }
1271
+ }
1272
+ }
1273
+ $last = $env:GENTLE_SHELL_REMOVE_LAST
1274
+ if ($last -and @($files | Where-Object { Test-Path -LiteralPath $_ }).Count -eq 0) { try { [IO.File]::Delete($last) } catch {} }
1275
+ foreach ($folder in @($env:GENTLE_SHELL_REMOVE_DIRECTORIES -split "\`n" | Where-Object { $_ -ne '' })) {
1276
+ try { [IO.Directory]::Delete($folder, $false) } catch {}
1277
+ }
1278
+ exit 0`;
1279
+ /** Windows self-uninstall: the running node.exe and the .cmd launcher cannot
1280
+ * delete themselves, so one detached, hidden Windows PowerShell removes `files`,
1281
+ * then `last` once nothing is left at those paths, then the empty
1282
+ * `directories` (deepest first), after process `pid` exits.
1283
+ * cmd.exe (detached) starts it with `start`, so it outlives the launcher; the
1284
+ * script goes as -EncodedCommand (UTF-16LE base64). Absolute local
1285
+ * paths only, passed in the environment; `spawn` is injectable for tests.
1286
+ */
1287
+ export function scheduleWindowsRemoval({ pid, files, last, directories, env = {}, spawn: start = spawn }) {
1288
+ const systemRoot = envValue(env, "SYSTEMROOT", "win32");
1289
+ const safe = (path) => typeof path === "string" && win32.isAbsolute(path) && !path.startsWith("\\\\") && !/[\r\n]/.test(path);
1290
+ // SystemRoot is part of cmd's command line (the PowerShell path): no cmd metacharacter.
1291
+ const cmdSafe = (path) => safe(path) && !/[&|<>^%!"]/.test(path);
1292
+ if (!Number.isInteger(pid) || pid <= 0 || !cmdSafe(systemRoot) || ![...files, ...directories, ...(last === undefined ? [] : [last])].every(safe)) {
1293
+ throw new Error("Unsafe deferred removal request");
1294
+ }
1295
+ // A detached PowerShell never ran on the Windows runner, and a non-detached one
1296
+ // sits in Node's job object and dies with the exiting launcher. A detached
1297
+ // cmd.exe leaves that job and `start` gives PowerShell its own console, so it
1298
+ // outlives the launcher. Only fixed tokens and base64 reach cmd's command line.
1299
+ const powershell = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
1300
+ const encoded = Buffer.from(deferredRemovalScript, "utf16le").toString("base64");
1301
+ const child = start(win32.join(systemRoot, "System32", "cmd.exe"), ["/d", "/c", "start", "\"\"", "/min", powershell,
1302
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand", encoded], {
1303
+ cwd: systemRoot, detached: true, stdio: "ignore", windowsHide: true,
1304
+ env: { ...env, GENTLE_SHELL_REMOVE_AFTER_PID: String(pid), GENTLE_SHELL_REMOVE_FILES: files.join("\n"), GENTLE_SHELL_REMOVE_DIRECTORIES: directories.join("\n"),
1305
+ GENTLE_SHELL_REMOVE_LAST: last ?? "" },
1306
+ });
1307
+ child.unref();
1308
+ }