gentle-pi 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,1102 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { posix, win32 } from "node:path";
3
+ import {
4
+ gentleAiBinaryPath,
5
+ gentleAiDevBinaryOverrideConfigured,
6
+ resolveGentleAiBinary,
7
+ } from "../runtime/gentle-ai-binary.mjs";
8
+ import { PI_INSTALL_VERSION, goAcquisition, persistencePins, pnpmGlobalBin, requirements } from "./installer-preflight.mjs";
9
+ import { GENTLE_AI_REPOSITORY, MainChannelError, SHELL_REPOSITORY, installOwner, mainVersion } from "./main-channel.mjs";
10
+ import { windowsShim } from "./installer-windows.mjs";
11
+
12
+ // Standard installation runner: one fixed, consented global pnpm installation
13
+ // of Pi plus gentle-pi, then the public `gentle-shell setup`. Every adapter is
14
+ // injected by trusted local code; requests carry no commands, URLs, roots or env.
15
+ // When Node is bootstrap-only or no usable npm resolves, fixed steps first persist
16
+ // node, npm and pnpm under PNPM_HOME through pnpm itself. When an earlier run
17
+ // installed the pinned stack but stopped in setup, a fixed recovery re-verifies
18
+ // that stack and reruns only `gentle-shell setup` (and `pnpm setup`), never `add -g`.
19
+ // An older Pi that pnpm or npm owns is first updated with that package manager.
20
+ // When a build needs Go and the user's is missing, older or unchecked, the installer's pinned
21
+ // Go is acquired first and given only to the build children, first on PATH.
22
+
23
+ /** Pi version installed next to gentle-pi (optional peer, resolved in one add). */
24
+ export { PI_INSTALL_VERSION };
25
+ export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
26
+ export const SHELL_PACKAGE = "gentle-pi";
27
+
28
+ /** Every `reason` a `blocked` outcome can carry (for host guidance; no behavior). */
29
+ export const blockedReasons = Object.freeze([
30
+ "invalid-request",
31
+ "consent-required",
32
+ "preflight-blocked",
33
+ "go-required",
34
+ "unsupported-plan",
35
+ "pnpm-home-unknown",
36
+ "pnpm-home-changed",
37
+ "node-unavailable",
38
+ "pnpm-unavailable",
39
+ "npm-unavailable",
40
+ "npm-shadowed",
41
+ "global-bin-mismatch",
42
+ "global-list-unavailable",
43
+ "existing-stack",
44
+ "existing-stack-unverified",
45
+ ]);
46
+ /** Every `failedStep` a `failed` outcome can carry (for host guidance; no behavior). */
47
+ export const failedSteps = Object.freeze([
48
+ "prepare-pnpm-home",
49
+ "persist-node",
50
+ "persist-package-managers",
51
+ "persist-npm",
52
+ "persist-pnpm",
53
+ "verify-persistent-runtime",
54
+ "verify-persistent-pnpm",
55
+ "check-npm",
56
+ "configure-npm-prefix",
57
+ "acquire-go",
58
+ "verify-go",
59
+ "install-global",
60
+ "verify-global-list",
61
+ "verify-shell-bin",
62
+ "verify-gentle-ai",
63
+ "shell-setup",
64
+ "persist-path",
65
+ "build-gentle-ai-main",
66
+ "install-shell-main",
67
+ "record-channel",
68
+ "install-pi",
69
+ "update-shell",
70
+ "verify-updated-shell",
71
+ "update-pi",
72
+ "verify-updated-pi",
73
+ "verify-installed-pi",
74
+ ]);
75
+
76
+ const SECOND = 1000;
77
+ const MINUTE = 60 * SECOND;
78
+ const deadlines = Object.freeze({ probe: 30 * SECOND, install: 20 * MINUTE, setup: 20 * MINUTE });
79
+
80
+ // Exact descriptors planPreflight can emit; anything else is a foreign request.
81
+ const knownActions = Object.freeze({
82
+ "acquire-node": { kind: "acquire", target: "node", version: requirements.node },
83
+ "verify-node": { kind: "verify", target: "node" },
84
+ "acquire-pnpm": { kind: "acquire", target: "pnpm", version: requirements.pnpm },
85
+ "verify-pnpm": { kind: "verify", target: "pnpm" },
86
+ "setup-global-bin": { kind: "setup", target: "globalBin" },
87
+ "persist-node": { kind: "persist-runtime", target: "node", version: persistencePins.node },
88
+ "persist-package-managers": { kind: "install-global", target: "package-managers" },
89
+ "configure-npm-prefix": { kind: "configure", target: "npm-prefix" },
90
+ "persist-npm": { kind: "install-global", target: "npm", version: persistencePins.npm },
91
+ "persist-pnpm": { kind: "install-global", target: "pnpm", version: persistencePins.pnpm },
92
+ "acquire-go": { kind: "acquire", target: "go", version: goAcquisition.version },
93
+ "verify-go": { kind: "verify", target: "go" },
94
+ "install-pi": { kind: "install-global", target: "pi", version: requirements.pi },
95
+ "install-shell": { kind: "install-global", target: "shell", version: requirements.shell },
96
+ "provision-native": { kind: "existing-installer", target: "gentleAi", version: requirements.gentleAi },
97
+ "setup-shell": { kind: "normal-setup", target: "shell" },
98
+ "verify-readiness": { kind: "verify", target: "stack" },
99
+ "build-gentle-ai-main": { kind: "build-native-main", target: "gentleAi" },
100
+ "install-shell-main": { kind: "install-global", target: "shell" },
101
+ "record-channel": { kind: "configure", target: "channel" },
102
+ "update-shell-release": { kind: "upgrade", target: "shell" },
103
+ "update-shell-main": { kind: "upgrade", target: "shell" },
104
+ "update-pi": { kind: "upgrade", target: "pi", version: PI_INSTALL_VERSION },
105
+ });
106
+ // The clean-stack path: both global packages are missing.
107
+ const requiredActions = ["install-pi", "install-shell", "setup-shell", "verify-readiness"];
108
+ const optionalActions = ["setup-global-bin"];
109
+ // Pi already installed: only Gentle Shell is added (never Pi).
110
+ const shellOnlyActions = ["install-shell", "setup-shell", "verify-readiness"];
111
+ // An existing Gentle Shell: exactly one update, then setup; a missing Pi is installed first.
112
+ const updateActions = ["update-shell-release", "update-shell-main"];
113
+ // Pi alone (a current Gentle Shell): an older owned Pi's update, or the installer's
114
+ // Pi added next to an older one neither pnpm nor npm owns; then readiness.
115
+ const piOnlyVariants = Object.freeze([["update-pi", "verify-readiness"], ["install-pi", "verify-readiness"]]);
116
+ // The main channel overlay: all three after a release installation, or none.
117
+ const mainActions = ["build-gentle-ai-main", "install-shell-main", "record-channel"];
118
+ // The pinned Go: both, only when preflight recorded a missing, older or unchecked Go, or none.
119
+ const goActions = ["acquire-go", "verify-go"];
120
+ // Setup recovery: the pinned stack this pnpm installed is present (planPreflight
121
+ // saw a recoverable setup), so only setup and the optional PATH step remain.
122
+ const recoveryActions = ["setup-shell", "verify-readiness"];
123
+ // Runtime persistence is one of these exact sets planPreflight emits, or nothing:
124
+ // bootstrap-only Node (with pnpm, or only npm next to a persistent pnpm), or a
125
+ // persistent Node missing npm and/or pnpm (one add -g).
126
+ const persistenceVariants = Object.freeze([
127
+ ["persist-node", "persist-package-managers", "configure-npm-prefix"],
128
+ ["persist-node", "persist-npm", "configure-npm-prefix"],
129
+ ["persist-package-managers"],
130
+ ["persist-npm"],
131
+ ["persist-pnpm"],
132
+ ]);
133
+ const persistenceActions = [...new Set(persistenceVariants.flat())];
134
+
135
+ const MAIN_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-main\.[0-9a-f]{12}$/;
136
+ function stable(version) {
137
+ const match = typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
138
+ return match ? match.slice(1).map(Number) : null;
139
+ }
140
+ function atLeast(version, minimum) {
141
+ const [left, right] = [stable(version), stable(minimum)];
142
+ if (!left || !right) return false;
143
+ for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] > right[i];
144
+ return true;
145
+ }
146
+ if (!atLeast(PI_INSTALL_VERSION, requirements.pi)) throw new Error("Pi install pin is below the peer minimum");
147
+
148
+ function plainObject(value) {
149
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
150
+ }
151
+ function onlyKeys(value, allowed) {
152
+ return plainObject(value) && Object.keys(value).every((key) => allowed.includes(key));
153
+ }
154
+
155
+ /** Accept only `{ plan, consent }` where plan is an unmodified planPreflight result. */
156
+ function validRequest(request) {
157
+ if (!onlyKeys(request, ["plan", "consent"]) || !onlyKeys(request.plan, ["tools", "blockers", "actions", "ready"])) return false;
158
+ const { tools, blockers, actions, ready } = request.plan;
159
+ if (!plainObject(tools) || !Array.isArray(blockers) || !Array.isArray(actions) || typeof ready !== "boolean") return false;
160
+ const ids = new Set();
161
+ for (const action of actions) {
162
+ const expected = knownActions[action?.id];
163
+ if (!expected || ids.has(action.id) || !onlyKeys(action, ["id", "kind", "target", "version"])) return false;
164
+ if (Object.keys(action).length !== Object.keys(expected).length + 1) return false;
165
+ if (Object.entries(expected).some(([key, value]) => action[key] !== value)) return false;
166
+ ids.add(action.id);
167
+ }
168
+ return true;
169
+ }
170
+
171
+ /** Gates that need no process: returns a blocked reason or null. */
172
+ function planGate(plan, platform) {
173
+ if (plan.blockers.length > 0) return "preflight-blocked";
174
+ const all = plan.actions.map((action) => action.id);
175
+ const acquiringGo = goActions.filter((id) => all.includes(id)).length;
176
+ if (acquiringGo === 1 || (acquiringGo === 2 && plan.tools.go?.status !== "needs-acquire")) return "unsupported-plan";
177
+ const goReady = plan.tools.go?.status === "reusable" || acquiringGo === 2;
178
+ const overlay = mainActions.filter((id) => all.includes(id));
179
+ if (overlay.length > 0 && (overlay.length !== mainActions.length || !goReady)) return "unsupported-plan";
180
+ const ids = all.filter((id) => !mainActions.includes(id) && !goActions.includes(id));
181
+ const only = (allowed) => ids.every((id) => allowed.includes(id));
182
+ // All or nothing, like the persistence variants: never part of an installation.
183
+ const recovery = recoveryActions.every((id) => ids.includes(id)) && only([...recoveryActions, ...optionalActions]);
184
+ // A Pi update needs the older, owned Pi preflight recorded, and never installs Pi as well.
185
+ const updatingPi = ids.includes("update-pi");
186
+ if (updatingPi && (ids.includes("install-pi") || plan.tools.pi?.status !== "needs-update")) return "unsupported-plan";
187
+ const piOnly = overlay.length === 0 && plan.tools.shell?.status === "reusable" &&
188
+ piOnlyVariants.some((variant) => variant.every((id) => ids.includes(id)) && only(variant)) &&
189
+ (updatingPi || ["unavailable", "needs-install"].includes(plan.tools.pi?.status));
190
+ // gentle-pi's postinstall may build Gentle AI from source on Windows. A recovery
191
+ // runs no postinstall: its binary is verified. A Pi-only update touches no
192
+ // Gentle Shell package either. Go is acquired only for a build that uses it.
193
+ const windowsBuild = platform === "win32" && !recovery && !piOnly;
194
+ if (acquiringGo === 2 && overlay.length === 0 && !ids.includes("update-shell-main") && !windowsBuild) return "unsupported-plan";
195
+ if (windowsBuild && !goReady) return "go-required";
196
+ const persisting = persistenceActions.filter((id) => ids.includes(id));
197
+ if (persisting.length > 0 && !persistenceVariants.some((variant) =>
198
+ variant.length === persisting.length && variant.every((id) => persisting.includes(id)))) return "unsupported-plan";
199
+ const updating = updateActions.filter((id) => ids.includes(id));
200
+ if (updating.length > 0) {
201
+ const valid = updating.length === 1 && overlay.length === 0 && ["setup-shell", "verify-readiness"].every((id) => ids.includes(id)) &&
202
+ only([...updating, "install-pi", "update-pi", "setup-shell", "verify-readiness"]) &&
203
+ (updating[0] !== "update-shell-main" || goReady);
204
+ return valid ? null : "unsupported-plan";
205
+ }
206
+ const clean = requiredActions.every((id) => ids.includes(id)) && only([...requiredActions, ...optionalActions, ...persistenceActions]);
207
+ // The same actions as a forged recovery plus install-shell: the tools must say Shell
208
+ // is missing and Pi reusable, or older and updated first.
209
+ const shellOnly = shellOnlyActions.every((id) => ids.includes(id)) && !ids.includes("install-pi") &&
210
+ plan.tools.shell?.status === "unavailable" && plan.tools.pi?.status === (updatingPi ? "needs-update" : "reusable") &&
211
+ only([...shellOnlyActions, "update-pi", ...optionalActions, ...persistenceActions]);
212
+ return clean || shellOnly || recovery || piOnly ? null : "unsupported-plan";
213
+ }
214
+
215
+ function pathKeyOf(env, platform) {
216
+ return platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "Path" : "PATH";
217
+ }
218
+ function envValue(env, name, platform) {
219
+ const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name;
220
+ return key === undefined ? undefined : env[key];
221
+ }
222
+
223
+ /** Child env: the user's env plus PNPM_HOME and `$PNPM_HOME/bin` first on PATH.
224
+ * With a private Windows PNPM_HOME (S6), pnpm's config, cache and state move into
225
+ * it too, unless the user set those folders: pnpm otherwise keeps them under
226
+ * %LOCALAPPDATA%, which another principal may write, and trusts its cached registry
227
+ * metadata for exact versions. TEMP and TMP always move to its `tmp` folder
228
+ * (created by prepare-pnpm-home), so a postinstall's os.tmpdir(), such as the
229
+ * Gentle AI source build, stays private. Only these children get them; nothing
230
+ * persists them.
231
+ */
232
+ export function childEnvironment(env, platform, globalBin, { privateHome = false } = {}) {
233
+ const path = platform === "win32" ? win32 : posix;
234
+ const key = pathKeyOf(env, platform);
235
+ const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
236
+ const isolate = platform === "win32" && privateHome;
237
+ const xdg = isolate ? Object.fromEntries([["XDG_CONFIG_HOME", ".config"], ["XDG_CACHE_HOME", ".cache"], ["XDG_STATE_HOME", ".state"]]
238
+ .filter(([name]) => envValue(env, name, platform) === undefined).map(([name, folder]) => [name, win32.join(globalBin.pnpmHome, folder)])) : {};
239
+ const temp = isolate ? win32.join(globalBin.pnpmHome, "tmp") : null;
240
+ const kept = isolate ? Object.fromEntries(Object.entries(env).filter(([name]) => !/^(?:TEMP|TMP)$/i.test(name))) : env;
241
+ return { ...kept, PNPM_HOME: globalBin.pnpmHome, ...xdg, ...(temp ? { TEMP: temp, TMP: temp } : {}), [key]: [globalBin.path, ...rest].join(path.delimiter) };
242
+ }
243
+
244
+ /** A build child's env: `env` with the pinned Go's bin directory first on PATH. */
245
+ export function goFirstEnvironment(env, platform, goPath) {
246
+ const path = platform === "win32" ? win32 : posix;
247
+ const key = pathKeyOf(env, platform);
248
+ const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
249
+ return { ...env, [key]: [path.dirname(goPath), ...rest].join(path.delimiter) };
250
+ }
251
+
252
+ /** gentle-shell setup gets no bootstrap handoff keys (case-insensitive on Windows). */
253
+ function setupEnvironment(child, platform) {
254
+ const bootstrap = platform === "win32" ? /^GENTLE_(BOOTSTRAP|INSTALL)_/i : /^GENTLE_(BOOTSTRAP|INSTALL)_/;
255
+ return Object.fromEntries(Object.entries(child).filter(([key]) => !bootstrap.test(key)));
256
+ }
257
+
258
+ /** pnpm comes from the bootstrap handoff, or a PATH executable on POSIX only. */
259
+ export async function pnpmInvocation(env, platform, fs) {
260
+ const path = platform === "win32" ? win32 : posix;
261
+ const node = env.GENTLE_INSTALL_PNPM_NODE;
262
+ const entry = env.GENTLE_INSTALL_PNPM_ENTRY;
263
+ if (node && entry) {
264
+ return path.isAbsolute(node) && path.isAbsolute(entry) ? { command: node, prefix: [entry] } : null;
265
+ }
266
+ // A Windows .cmd shim cannot be spawned with shell:false; require the direct
267
+ // handoff: Node plus pnpm's JS entry, or a native pnpm.exe on its own.
268
+ if (platform === "win32") {
269
+ const command = env.GENTLE_INSTALL_PNPM_COMMAND;
270
+ return command && win32.isAbsolute(command) && win32.extname(command).toLowerCase() === ".exe" ? { command, prefix: [] } : null;
271
+ }
272
+ for (const directory of String(env.PATH ?? "").split(path.delimiter)) {
273
+ if (!path.isAbsolute(directory)) continue;
274
+ const candidate = path.join(directory, "pnpm");
275
+ if (await fs.isFile(candidate)) return { command: candidate, prefix: [] };
276
+ }
277
+ return null;
278
+ }
279
+
280
+ /** Windows PATHEXT extensions in order: lowercased, dot-prefixed, default .com/.exe/.bat/.cmd. */
281
+ function pathExtensions(env) {
282
+ return String(envValue(env, "PATHEXT", "win32") || ".com;.exe;.bat;.cmd").toLowerCase().split(";")
283
+ .filter((extension) => extension.length > 0).map((extension) => extension.startsWith(".") ? extension : `.${extension}`);
284
+ }
285
+
286
+ /** A path on a local drive (`C:\...`). UNC, `\\?\`, `\\.\` and drive-less rooted
287
+ * paths are not: even looking one up can reach a remote share. */
288
+ const LOCAL_DRIVE = /^[A-Za-z]:\\/;
289
+
290
+ /** First `name` the way Go's exec.LookPath (used by Gentle AI) finds it: each
291
+ * absolute PATH directory in order and, on Windows, every PATHEXT extension in
292
+ * PATHEXT order (lowercased, dot-prefixed, default .com/.exe/.bat/.cmd). On
293
+ * Windows only local drive directories are searched; any other one is skipped,
294
+ * like a relative one, and never touched.
295
+ */
296
+ export async function lookPath(name, env, platform, fs) {
297
+ const path = platform === "win32" ? win32 : posix;
298
+ const extensions = platform === "win32" ? pathExtensions(env) : [""];
299
+ for (const directory of String(env[pathKeyOf(env, platform)] ?? "").split(path.delimiter)) {
300
+ if (!path.isAbsolute(directory) || (platform === "win32" && !LOCAL_DRIVE.test(directory))) continue;
301
+ for (const extension of extensions) {
302
+ const candidate = path.join(directory, `${name}${extension}`);
303
+ if (await fs.isFile(candidate)) return candidate;
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
309
+ /** A Windows `.cmd`/`.bat` shim cannot run with shell:false; executables can. */
310
+ export function spawnable(file, platform) {
311
+ return platform !== "win32" || [".exe", ".com"].includes(win32.extname(file).toLowerCase());
312
+ }
313
+
314
+ /** How a Windows command runs with shell:false, never through cmd.exe: an .exe
315
+ * as it is, or what a known shim (windowsShim) runs, its native target or its
316
+ * JS entry with the Node it selects (an absolute node.exe it names, its sibling
317
+ * node.exe, else the first node on PATH, which must be an .exe). A native target
318
+ * without an extension resolves like CMD resolves it, through PATHEXT in order,
319
+ * and runs only when that first match is an .exe. A `pnpm.cmd` runs only pnpm's
320
+ * own `node_modules\pnpm\bin\pnpm.[cm]js` entry (never Corepack's). npm's own
321
+ * npm.cmd keeps its redirect: it asks npm for the global prefix (from the drive
322
+ * root, like any probe) and runs the npm-cli.js installed there, when there is
323
+ * one. The command, the Node and npm's prefix must be local drive paths.
324
+ * Returns { command, prefix } or null when the command is anything else.
325
+ */
326
+ export async function windowsInvocation(file, env, adapters) {
327
+ const { fs } = adapters;
328
+ if (!LOCAL_DRIVE.test(file)) return null;
329
+ const extension = win32.extname(file).toLowerCase();
330
+ if (extension === ".exe") return (await fs.isFile(file)) ? { command: file, prefix: [] } : null;
331
+ if (extension !== ".cmd") return null;
332
+ const shim = windowsShim(await fs.readText(file).catch(() => null));
333
+ if (!shim) return null;
334
+ const directory = win32.dirname(file);
335
+ if (shim.exe !== undefined) {
336
+ let exe = win32.resolve(directory, shim.exe);
337
+ // @pnpm/exe hard-links its binary under both names; the shim may name either.
338
+ if (win32.extname(exe) === "") {
339
+ let first = null;
340
+ for (const candidate of pathExtensions(env)) if (first === null && (await fs.isFile(`${exe}${candidate}`))) first = `${exe}${candidate}`;
341
+ if (first === null) return null;
342
+ exe = first;
343
+ }
344
+ return win32.extname(exe).toLowerCase() === ".exe" && (await fs.isFile(exe)) ? { command: exe, prefix: [] } : null;
345
+ }
346
+ const sibling = win32.join(directory, "node.exe");
347
+ const node = shim.node ?? ((await fs.isFile(sibling)) ? sibling : await lookPath("node", env, "win32", fs));
348
+ if (!node || !LOCAL_DRIVE.test(node) || !spawnable(node, "win32") || !(await fs.isFile(node))) return null;
349
+ const npmCli = (root) => win32.join(root, "node_modules", "npm", "bin", "npm-cli.js");
350
+ let entry = shim.npm ? npmCli(directory) : win32.resolve(directory, shim.entry);
351
+ const pnpmEntry = (path) => entryShape(path, win32, "pnpm", "pnpm.cjs") || entryShape(path, win32, "pnpm", "pnpm.mjs");
352
+ if (win32.basename(file).toLowerCase() === "pnpm.cmd" && (shim.npm || !pnpmEntry(entry))) return null;
353
+ if (shim.npm) {
354
+ const query = shim.npm === "prefix-js" ? [win32.join(directory, "node_modules", "npm", "bin", "npm-prefix.js")] : [entry, "prefix", "-g"];
355
+ // Without npm-prefix.js, npm.cmd's FOR /F reads no line and keeps the bundled npm.
356
+ if (await fs.isFile(query[0])) {
357
+ const result = await adapters.run(node, query, { env, cwd: win32.parse(node).root, deadlineMs: deadlines.probe });
358
+ const prefix = succeeded(result) && result.truncated !== true
359
+ ? String(result.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) : undefined;
360
+ if (prefix && LOCAL_DRIVE.test(prefix) && (await fs.isFile(npmCli(prefix)))) entry = npmCli(prefix);
361
+ }
362
+ }
363
+ return (await fs.isFile(entry)) ? { command: node, prefix: [entry] } : null;
364
+ }
365
+
366
+ /** The user's npm as { command, prefix }: the first npm on PATH, run through
367
+ * windowsInvocation on Windows. Null when none resolves.
368
+ */
369
+ export async function npmInvocation(env, platform, adapters) {
370
+ const npm = await lookPath("npm", env, platform, adapters.fs);
371
+ if (!npm) return null;
372
+ return platform === "win32" ? windowsInvocation(npm, env, adapters).catch(() => null) : { command: npm, prefix: [] };
373
+ }
374
+
375
+ /** runUpgrade's `invocation` adapter on Windows: npm through npmInvocation, any
376
+ * other command (pnpm) through windowsInvocation of the first one on PATH, or,
377
+ * with `handoff` (the wizard), pnpm from the bootstrap handoff (pnpmInvocation).
378
+ * Never a .cmd with shell:false. Undefined on POSIX, where the command `which`
379
+ * finds runs as it is.
380
+ */
381
+ export function upgradeInvocation({ platform, env, run, fs, handoff = false }) {
382
+ if (platform !== "win32") return undefined;
383
+ const adapters = { run, fs };
384
+ return async (name) => {
385
+ if (name === "npm") return npmInvocation(env, platform, adapters);
386
+ if (name === "pnpm" && handoff) return pnpmInvocation(env, platform, fs);
387
+ const file = await lookPath(name, env, platform, fs);
388
+ return file ? windowsInvocation(file, env, adapters).catch(() => null) : null;
389
+ };
390
+ }
391
+
392
+ /** `.../node_modules/<name>/bin/<file>`: a package's own bin entry. */
393
+ function entryShape(entry, path, name, file) {
394
+ const packageDir = path.dirname(path.dirname(entry));
395
+ return path.basename(entry) === file && path.basename(path.dirname(entry)) === "bin" &&
396
+ path.basename(packageDir) === name && path.basename(path.dirname(packageDir)) === "node_modules";
397
+ }
398
+
399
+ function escapeRegExp(text) {
400
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
401
+ }
402
+
403
+ /** The single `node_modules/<name>/bin/<file>` target a pnpm shim names, absolute
404
+ * or relative to the shim directory (`$basedir/` on POSIX, `%~dp0\` or `%dp0%\`
405
+ * on Windows).
406
+ */
407
+ function shimTarget(text, shim, platform, name, file) {
408
+ const path = platform === "win32" ? win32 : posix;
409
+ const tail = `node_modules${path.sep}${name}${path.sep}bin${path.sep}${file}`;
410
+ const pattern = platform === "win32"
411
+ ? new RegExp(`"(%~?dp0%?\\\\)?([^"%]*\\\\${escapeRegExp(tail)})"`, "gi")
412
+ : new RegExp(`"(\\$basedir\\/)?([^"$]*\\/${escapeRegExp(tail)})"`, "g");
413
+ const targets = new Set();
414
+ for (const [, relative, target] of String(text).matchAll(pattern)) {
415
+ const resolved = relative ? path.join(path.dirname(shim), target) : target;
416
+ if (!path.isAbsolute(resolved)) return null;
417
+ targets.add(path.normalize(resolved));
418
+ }
419
+ return targets.size === 1 ? [...targets][0] : null;
420
+ }
421
+
422
+ /** The package entry a pnpm global shim in `$PNPM_HOME/bin` runs: a POSIX
423
+ * symlink to it, or the single target named in the shim text. Either way its
424
+ * realpath must be a `<name>` bin entry inside PNPM_HOME. Returns it or null.
425
+ */
426
+ async function globalShimEntry(shim, name, file, platform, globalBin, fs) {
427
+ const path = platform === "win32" ? win32 : posix;
428
+ let entry = platform === "win32" ? null : await fs.realpath(shim);
429
+ if (entry === null || !entryShape(entry, path, name, file)) {
430
+ const target = shimTarget(await fs.readText(shim), shim, platform, name, file);
431
+ if (!target) return null;
432
+ entry = await fs.realpath(target);
433
+ }
434
+ if (!entryShape(entry, path, name, file)) return null;
435
+ return contains(await fs.realpath(globalBin.pnpmHome), entry, platform) ? entry : null;
436
+ }
437
+
438
+ /** The entry's package.json names `name` at `version` (any stable version when
439
+ * null), and `node <entry> --version` prints that version.
440
+ */
441
+ async function runsAsPackage(entry, name, version, nodePath, child, platform, adapters) {
442
+ const path = platform === "win32" ? win32 : posix;
443
+ if (!(await adapters.fs.isFile(entry))) return false;
444
+ const metadata = JSON.parse(await adapters.fs.readText(path.join(path.dirname(path.dirname(entry)), "package.json")));
445
+ if (metadata?.name !== name || !stable(metadata.version) || (version !== null && metadata.version !== version)) return false;
446
+ const result = await adapters.run(nodePath, [entry, "--version"], { env: child, deadlineMs: deadlines.probe });
447
+ return succeeded(result) && String(result.stdout ?? "").trim() === metadata.version;
448
+ }
449
+
450
+ function inGlobalBin(file, platform, globalBin) {
451
+ const path = platform === "win32" ? win32 : posix;
452
+ return globalBin !== null && samePath(path.dirname(file), globalBin.path, platform);
453
+ }
454
+
455
+ /** The npm the user runs works in the user's environment: run from the drive
456
+ * root (`/` on POSIX) with the child env, `npm --version` prints a stable version
457
+ * and `npm config get prefix` one absolute path. Whatever installed it (Node,
458
+ * Homebrew, nvm, fnm, or a mise, asdf or Volta shim), this is how Gentle AI's
459
+ * Engram step will run it. `npm` is { command, prefix }.
460
+ */
461
+ async function worksAsNpm(npm, child, adapters, platform) {
462
+ const path = platform === "win32" ? win32 : posix;
463
+ const output = async (args) => {
464
+ const result = await adapters.run(npm.command, [...npm.prefix, ...args], { env: child, cwd: path.parse(npm.command).root, deadlineMs: deadlines.probe });
465
+ return succeeded(result) && result.truncated !== true ? String(result.stdout ?? "").trim() : null;
466
+ };
467
+ if (stable(await output(["--version"])) === null) return false;
468
+ const prefix = await output(["config", "get", "prefix"]);
469
+ return prefix !== null && path.isAbsolute(prefix) && !/[\r\n]/.test(prefix);
470
+ }
471
+
472
+ /** A usable npm: the first npm resolved in the child env.
473
+ * Any npm outside `$PNPM_HOME/bin` that works there (worksAsNpm) is accepted as
474
+ * { command, prefix }: on POSIX the command itself, on Windows an npm.exe (a
475
+ * Volta or mise shim) or what a known npm.cmd runs (windowsInvocation: Node.js's
476
+ * own npm.cmd, also behind nvm-windows or fnm, or an npm cmd-shim). For any npm
477
+ * in `$PNPM_HOME/bin` (shim or symlink), pnpm's global npm must resolve inside
478
+ * PNPM_HOME at the persistence pin and run, returned as { cli }.
479
+ * Returns { command, prefix }, { cli }, "npm-shadowed" (Windows: an earlier npm
480
+ * that is neither .cmd nor .exe wins) or false.
481
+ */
482
+ export async function genuineNpm(child, platform, nodePath, adapters, globalBin = null) {
483
+ const path = platform === "win32" ? win32 : posix;
484
+ const { fs } = adapters;
485
+ const first = await lookPath("npm", child, platform, fs);
486
+ if (!first) return false;
487
+ const extension = path.extname(first).toLowerCase();
488
+ if (inGlobalBin(first, platform, globalBin)) {
489
+ if (platform === "win32" && extension !== ".cmd") return "npm-shadowed";
490
+ const cli = await globalShimEntry(first, "npm", "npm-cli.js", platform, globalBin, fs);
491
+ if (!cli || !entryShape(cli, path, "npm", "npm-cli.js")) return false;
492
+ return (await runsAsPackage(cli, "npm", persistencePins.npm, nodePath, child, platform, adapters)) ? { cli } : false;
493
+ }
494
+ if (platform === "win32" && ![".cmd", ".exe"].includes(extension)) return "npm-shadowed";
495
+ const npm = platform === "win32" ? await windowsInvocation(first, child, adapters) : { command: first, prefix: [] };
496
+ return npm !== null && (await worksAsNpm(npm, child, adapters, platform)) ? npm : false;
497
+ }
498
+
499
+ /** After adding pnpm, the first pnpm in the child env is pnpm's global shim in
500
+ * `$PNPM_HOME/bin` for pnpm at the persistence pin, which then runs.
501
+ */
502
+ async function persistentPnpm(child, platform, nodePath, globalBin, adapters) {
503
+ const path = platform === "win32" ? win32 : posix;
504
+ const first = await lookPath("pnpm", child, platform, adapters.fs);
505
+ if (!first || !inGlobalBin(first, platform, globalBin)) return false;
506
+ if (platform === "win32" && path.extname(first).toLowerCase() !== ".cmd") return false;
507
+ const entry = await globalShimEntry(first, "pnpm", "pnpm.mjs", platform, globalBin, adapters.fs);
508
+ return entry !== null && runsAsPackage(entry, "pnpm", persistencePins.pnpm, nodePath, child, platform, adapters);
509
+ }
510
+
511
+ export function succeeded(result) {
512
+ return result?.code === 0 && !result.signal && result.timedOut !== true;
513
+ }
514
+
515
+ export function samePath(left, right, platform) {
516
+ const path = platform === "win32" ? win32 : posix;
517
+ const normal = (value) => path.normalize(value).replace(/(.)[\\/]+$/, "$1");
518
+ const [a, b] = [normal(left), normal(right)];
519
+ return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
520
+ }
521
+
522
+ /** True when `child` is strictly inside `parent` (case-insensitive on Windows). */
523
+ export function contains(parent, child, platform) {
524
+ const path = platform === "win32" ? win32 : posix;
525
+ const inside = path.relative(parent, child);
526
+ return inside.length > 0 && !inside.startsWith("..") && !path.isAbsolute(inside);
527
+ }
528
+
529
+ /** How many times Pi and gentle-pi are listed across every project and
530
+ * dependency field of a `list -g --json` output, or null when the shape is unknown.
531
+ */
532
+ function stackListings(stdout) {
533
+ const projects = JSON.parse(stdout);
534
+ if (!Array.isArray(projects)) return null;
535
+ let listed = 0;
536
+ for (const project of projects) {
537
+ if (!plainObject(project)) return null;
538
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
539
+ const dependencies = project[field];
540
+ if (dependencies === undefined) continue;
541
+ if (!plainObject(dependencies)) return null;
542
+ listed += [PI_PACKAGE, SHELL_PACKAGE].filter((name) => Object.hasOwn(dependencies, name)).length;
543
+ }
544
+ }
545
+ return listed;
546
+ }
547
+
548
+ /** Shell-only (or Pi-only) pre-install `list -g --json`: true when no project
549
+ * lists the package, "existing-stack" when one does, false when the shape is unknown.
550
+ */
551
+ function notListed(stdout, name) {
552
+ const projects = JSON.parse(stdout);
553
+ if (!Array.isArray(projects) || !projects.every(plainObject)) return false;
554
+ return projects.some((project) => ["dependencies", "devDependencies", "optionalDependencies"]
555
+ .some((field) => plainObject(project[field]) && Object.hasOwn(project[field], name))) ? "existing-stack" : true;
556
+ }
557
+
558
+ /** The version of the Pi the launcher runs from gentle-pi at `root`: Node's
559
+ * resolution from gentle-pi, nested in its node_modules first, then beside it
560
+ * (where pnpm 11 links its peer). Null when neither is a readable Pi package.
561
+ */
562
+ export async function adjacentPiVersion(root, platform, fs) {
563
+ const path = platform === "win32" ? win32 : posix;
564
+ const nested = path.join(root, "node_modules", PI_PACKAGE, "package.json");
565
+ const beside = path.join(path.dirname(root), PI_PACKAGE, "package.json");
566
+ for (const file of [nested, beside]) {
567
+ const text = await fs.readText(file).catch(() => null);
568
+ if (text === null) continue;
569
+ try {
570
+ const manifest = JSON.parse(text);
571
+ return manifest?.name === PI_PACKAGE && typeof manifest.version === "string" ? manifest.version : null;
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+ return null;
577
+ }
578
+
579
+ /** That Pi's version when Gentle Shell can run it: a stable version at
580
+ * requirements.pi or newer. pnpm 11 installs gentle-pi's optional peer Pi in
581
+ * gentle-pi's own global group, at its latest version, so it may be newer than
582
+ * PI_INSTALL_VERSION. Null otherwise (missing, unreadable, prerelease or older).
583
+ */
584
+ async function runnablePi(root, platform, fs) {
585
+ const version = await adjacentPiVersion(root, platform, fs);
586
+ return stable(version) !== null && atLeast(version, requirements.pi) ? version : null;
587
+ }
588
+
589
+ /** Pre-install `list -g --json`: true when neither package is listed in any
590
+ * project, "existing-stack" when one is, false when the shape is unknown.
591
+ */
592
+ function noExistingStack(stdout) {
593
+ const listed = stackListings(stdout);
594
+ if (listed === null) return false;
595
+ return listed === 0 ? true : "existing-stack";
596
+ }
597
+
598
+ /** Installed gentle-pi root from `list -g --json`, confined under PNPM_HOME, with
599
+ * the version of the Pi the launcher runs from it ({ root, pi }), or null.
600
+ * Exactly one listed project may own gentle-pi and list Pi at the pin (unless
601
+ * only Gentle Shell was added), and that runnable Pi (runnablePi) must exist;
602
+ * other projects (such as the persisted npm and pnpm) are ignored.
603
+ */
604
+ async function verifiedPackageRoot(stdout, pnpmHome, platform, fs, shellVersion = requirements.shell, requirePi = true) {
605
+ const path = platform === "win32" ? win32 : posix;
606
+ const projects = JSON.parse(stdout);
607
+ if (!Array.isArray(projects)) return null;
608
+ const owners = projects.filter((project) => plainObject(project?.dependencies) && Object.hasOwn(project.dependencies, SHELL_PACKAGE));
609
+ const dependencies = owners.length === 1 ? owners[0].dependencies : null;
610
+ if (!plainObject(dependencies)) return null;
611
+ const pi = dependencies[PI_PACKAGE];
612
+ const shell = dependencies[SHELL_PACKAGE];
613
+ if ((requirePi && pi?.version !== PI_INSTALL_VERSION) || shell?.version !== shellVersion) return null;
614
+ if (typeof shell.path !== "string" || !path.isAbsolute(shell.path)) return null;
615
+ const [root, home] = [await fs.realpath(shell.path), await fs.realpath(pnpmHome)];
616
+ if (!contains(home, root, platform)) return null;
617
+ const runs = await runnablePi(root, platform, fs);
618
+ return runs === null ? null : { root, pi: runs };
619
+ }
620
+
621
+ /** A stack this pnpm installed whose setup may only be rerun (setup recovery):
622
+ * Pi and gentle-pi are each listed exactly once, in the single project that
623
+ * verifiedPackageRoot accepts (Pi at PI_INSTALL_VERSION, gentle-pi at this
624
+ * package version, realpath confined under PNPM_HOME). Returns the root or null.
625
+ */
626
+ export async function recoverableStackRoot(stdout, pnpmHome, platform, fs) {
627
+ return stackListings(stdout) === 2 ? (await verifiedPackageRoot(stdout, pnpmHome, platform, fs))?.root ?? null : null;
628
+ }
629
+
630
+ /** After persistence, node and npm must resolve from `$PNPM_HOME/bin` in the
631
+ * child env and that node must report the pinned version. Returns its path.
632
+ */
633
+ async function persistentRuntime(child, platform, globalBin, adapters) {
634
+ const path = platform === "win32" ? win32 : posix;
635
+ const node = await lookPath("node", child, platform, adapters.fs);
636
+ const npm = await lookPath("npm", child, platform, adapters.fs);
637
+ const fromBin = (file) => file !== null && samePath(path.dirname(file), globalBin.path, platform);
638
+ if (!fromBin(node) || !fromBin(npm) || !spawnable(node, platform)) return null;
639
+ const result = await adapters.run(node, ["--version"], { env: child, deadlineMs: deadlines.probe });
640
+ return succeeded(result) && String(result.stdout ?? "").trim() === `v${persistencePins.node}` ? node : null;
641
+ }
642
+
643
+ /** npm's prefix setting from the environment, which npm reads case-insensitively. */
644
+ function explicitNpmPrefix(env, platform) {
645
+ return Object.keys(env).some((key) => key.toLowerCase() === "npm_config_prefix" ||
646
+ (platform === "win32" ? key.toUpperCase() === "PREFIX" : key === "PREFIX"));
647
+ }
648
+
649
+ /** With a pnpm-managed node, npm's default prefix is derived from a path inside
650
+ * pnpm's store, so `npm install -g` would write there. Only then, and only when
651
+ * the user has no explicit prefix (otherwise npm would report it), set the
652
+ * user-level prefix to PNPM_HOME. The store is the one `pnpm store path`
653
+ * reports; when it is unknown the step fails. Returns "configured",
654
+ * "unchanged" or null.
655
+ */
656
+ async function configureNpmPrefix({ node, cli, child, platform, globalBin, adapters, storePath }) {
657
+ const path = platform === "win32" ? win32 : posix;
658
+ // Only the persisted npm's { cli } reaches this step ($PNPM_HOME/bin is first); anything else fails closed.
659
+ if (typeof cli !== "string") return null;
660
+ const npm = (args) => adapters.run(node, [cli, ...args], { env: child, deadlineMs: deadlines.probe });
661
+ const effective = async () => {
662
+ const result = await npm(["config", "get", "prefix"]);
663
+ const value = String(result.stdout ?? "").trim();
664
+ return succeeded(result) && path.isAbsolute(value) ? value : null;
665
+ };
666
+ const current = await effective();
667
+ if (current === null) return null;
668
+ if (explicitNpmPrefix(child, platform)) return "unchanged";
669
+ const reported = await storePath();
670
+ const value = String(reported?.stdout ?? "").trim();
671
+ if (!succeeded(reported) || !path.isAbsolute(value) || /[\r\n]/.test(value)) return null;
672
+ const store = await adapters.fs.realpath(value).catch(() => null);
673
+ if (store === null) return null;
674
+ const resolved = await adapters.fs.realpath(current).catch(() => current);
675
+ if (!contains(store, resolved, platform)) return "unchanged";
676
+ if (!succeeded(await npm(["config", "set", "prefix", globalBin.pnpmHome, "--location=user"]))) return null;
677
+ const after = await effective();
678
+ return after !== null && samePath(after, globalBin.pnpmHome, platform) ? "configured" : null;
679
+ }
680
+
681
+ /** Default integrity adapter: the package-local resolver, never a dev override.
682
+ * `resolve` is injectable only so trusted tests can stand in for a pinned binary.
683
+ */
684
+ export async function packageNativeGentleAi({ packageRoot, platform, env, home }, resolve = resolveGentleAiBinary) {
685
+ const environment = { env, home };
686
+ if (gentleAiDevBinaryOverrideConfigured(environment)) return { ok: false, reason: "development-override" };
687
+ try {
688
+ const binary = resolve(packageRoot, platform, readFileSync, environment);
689
+ if (binary === gentleAiBinaryPath(packageRoot, platform)) return { ok: true };
690
+ } catch {
691
+ // Resolver errors name local paths; report only the classification.
692
+ }
693
+ return { ok: false, reason: "package-native-unverified" };
694
+ }
695
+
696
+ /** The failed steps whose sanitized detail line the outcome may carry. */
697
+ const detailSteps = Object.freeze(["shell-setup", "persist-path", "acquire-go", "install-global", "install-shell-main", "build-gentle-ai-main", "update-shell"]);
698
+
699
+ /** One displayable line from the bounded output of a failed `gentle-shell
700
+ * setup` or `pnpm setup`: the last error line (`Error:` or a pnpm `ERR_` code),
701
+ * else the last non-empty line.
702
+ * Terminal escapes, control and bidi characters are removed, the user's home
703
+ * becomes `~`, and the result has at most 300 characters. Null when nothing
704
+ * remains. Untrusted output: the host still shows it only as text.
705
+ */
706
+ export function setupErrorDetail(text, home, platform) {
707
+ if (typeof text !== "string") return null;
708
+ const lines = text.split(/\r\n|\r|\n/).map((line) => line
709
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
710
+ .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
711
+ .replace(/\t/g, " ")
712
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "")
713
+ .trim()).filter((line) => line.length > 0);
714
+ let line = lines.findLast((candidate) => /Error:|\bERR_[A-Z0-9_]+/.test(candidate)) ?? lines.at(-1);
715
+ if (line === undefined) return null;
716
+ const path = platform === "win32" ? win32 : posix;
717
+ const root = typeof home === "string" && path.isAbsolute(home) ? home.replace(/[\\/]+$/, "") : "";
718
+ if (root.length > 1) {
719
+ // Only the whole home path, not a longer sibling such as /home/user for /home/u.
720
+ line = line.replace(new RegExp(`${escapeRegExp(root)}(?=$|[\\\\/\\s"'():,;\\]])`, platform === "win32" ? "gi" : "g"), "~");
721
+ }
722
+ return Array.from(line).slice(0, 300).join("");
723
+ }
724
+
725
+ /**
726
+ * runStandardInstall({ plan, consent }, adapters) -> { outcome, ... }
727
+ * Outcomes: blocked (nothing installed), failed (stopped after `completed`),
728
+ * terminal-action-required (installed; user PATH persisted, open a new
729
+ * terminal) or ready. A failed `shell-setup`, `persist-path`, `install-global`,
730
+ * `install-shell-main`, `build-gentle-ai-main` or `update-shell` may add `detail`, one
731
+ * sanitized line (setupErrorDetail) of the failed command's output or of the main
732
+ * channel's error, and a failed `acquire-go` the Go folder in its way, with the home
733
+ * as ~; no other output is kept. When the plan persists the Node runtime, successful
734
+ * outcomes also report npmPrefix: "configured" or "unchanged". When the Pi beside a pnpm
735
+ * gentle-pi (the one Gentle Shell runs) is not PI_INSTALL_VERSION, they also report its
736
+ * piVersion (pnpm 11 may install a newer peer Pi, at least requirements.pi). Adapters: platform, nodePath, env (user env), home?,
737
+ * run(command, argv, { env, deadlineMs, stderrTail? }) with shell:false semantics returning
738
+ * { code, signal, timedOut, stdout, stderrTail? }, fs { isFile, realpath, readText },
739
+ * verifyGentleAi({ packageRoot, platform, env, home }) and log({ step, status }).
740
+ * An update plan also uses locateShell() and upgradeShell({ channel, packageRoot,
741
+ * currentVersion, goPath? }); a plan that updates an older Pi uses locatePi(), which returns
742
+ * the single installed Pi as { root, version, owner } or null. A plan that acquires
743
+ * Go uses acquireGo(), which returns the published pinned Go as { goPath }. A plan
744
+ * with a private Windows PNPM_HOME (tools.pnpmHome, S6) uses preparePnpmHome(home)
745
+ * before any command, then persists it with `pnpm setup`.
746
+ * Nothing is ever deleted; no provisioning marker is written. A setup-recovery
747
+ * plan replaces check-existing-stack with check-recoverable-stack and skips
748
+ * install-global; every later step and outcome rule is the same.
749
+ */
750
+ export async function runStandardInstall(request, adapters) {
751
+ const { platform, env, log = () => {} } = adapters;
752
+ const completed = [];
753
+ const blocked = (reason) => {
754
+ log({ step: "gate", status: "blocked", reason });
755
+ return { outcome: "blocked", reason, completed };
756
+ };
757
+ if (!validRequest(request)) return blocked("invalid-request");
758
+ if (request.consent !== true) return blocked("consent-required");
759
+ const gate = planGate(request.plan, platform);
760
+ if (gate) return blocked(gate);
761
+
762
+ const path = platform === "win32" ? win32 : posix;
763
+ const globalBin = pnpmGlobalBin({ platform, env });
764
+ if (!globalBin) return blocked("pnpm-home-unknown");
765
+ // A private Windows PNPM_HOME (S6): exactly the folder preflight recorded.
766
+ const privateHome = platform === "win32" && request.plan.tools.pnpmHome?.status === "private";
767
+ if (privateHome && !samePath(String(request.plan.tools.pnpmHome.path ?? ""), globalBin.pnpmHome, platform)) return blocked("pnpm-home-changed");
768
+ if (!path.isAbsolute(adapters.nodePath ?? "")) return blocked("node-unavailable");
769
+ const child = childEnvironment(env, platform, globalBin, { privateHome });
770
+ const pnpm = await pnpmInvocation(env, platform, adapters.fs).catch(() => null);
771
+ if (!pnpm) return blocked("pnpm-unavailable");
772
+ const runPnpm = (args, deadlineMs, environment = child, options = {}) => adapters.run(pnpm.command, [...pnpm.prefix, ...args], { env: environment, deadlineMs, ...options });
773
+ const home = adapters.home ?? (platform === "win32" ? env.USERPROFILE : env.HOME);
774
+ let setupDetail = null;
775
+ // pnpm prints its own errors, such as ERR_PNPM_UNKNOWN_SHELL or a failed postinstall, on stdout.
776
+ const outputDetail = (result) => setupErrorDetail(result?.stderrTail, home, platform) ?? setupErrorDetail(result?.stdout, home, platform);
777
+ // Only the main channel's own errors: their message, then the failed command's stderr tail.
778
+ const mainChannelDetail = (error) => (error instanceof MainChannelError
779
+ ? setupErrorDetail([error.message, error.cause?.message].filter((text) => typeof text === "string").join("\n"), home, platform) : null);
780
+ // A pnpm global add whose failure keeps pnpm's last error line as the detail.
781
+ const addGlobal = async (args, environment) => {
782
+ const result = await runPnpm(["add", "-g", ...args], deadlines.install, environment, { stderrTail: 4096 });
783
+ if (succeeded(result)) return true;
784
+ setupDetail = outputDetail(result);
785
+ return false;
786
+ };
787
+ // A main-channel call whose MainChannelError becomes the detail; any other error stays hidden.
788
+ const withMainDetail = (step) => async () => {
789
+ try {
790
+ return await step();
791
+ } catch (error) {
792
+ setupDetail = mainChannelDetail(error);
793
+ return false;
794
+ }
795
+ };
796
+
797
+ const list = () => runPnpm(["list", "-g", "--depth", "0", "--json"], deadlines.probe);
798
+ const ids = request.plan.actions.map((action) => action.id);
799
+ // planGate accepted either the clean-stack path or the fixed setup recovery.
800
+ const update = ids.find((id) => updateActions.includes(id));
801
+ // An older Pi is updated first; alone, nothing else runs (planGate: piOnlyVariants).
802
+ const updatingPi = ids.includes("update-pi");
803
+ const piOnly = (updatingPi || ids.includes("install-pi")) && !ids.includes("install-shell") && !update;
804
+ const recovering = !ids.includes("install-shell") && !update && !piOnly;
805
+ const shellOnly = ids.includes("install-shell") && !ids.includes("install-pi");
806
+ const main = ids.includes("install-shell-main");
807
+ // Fixed variants (planGate): runtime + both managers, or one add of the missing ones.
808
+ const persistRuntime = ids.includes("persist-node");
809
+ const addNpm = ids.includes("persist-package-managers") || ids.includes("persist-npm");
810
+ const addPnpm = ids.includes("persist-package-managers") || ids.includes("persist-pnpm");
811
+ let npmCli = null;
812
+ const npmCheck = async () => {
813
+ const verdict = await genuineNpm(child, platform, adapters.nodePath, adapters, globalBin);
814
+ if (typeof verdict !== "object" || verdict === null) return verdict;
815
+ npmCli = verdict.cli ?? null;
816
+ return true;
817
+ };
818
+
819
+ // Pre-install gates: a false check or adapter exception blocks before mutation;
820
+ // a check may return a more specific blocked reason instead of false.
821
+ // An npm that is about to be persisted is checked only after it has been added.
822
+ let installed = null;
823
+ let installedPi = null;
824
+ let npmCommand = null;
825
+ // The Pi to update is still exactly the older one preflight recorded, from the
826
+ // same owner: never a reinstall or downgrade. For npm, the npm on PATH must
827
+ // install into the global root that holds it.
828
+ const checkInstalledPi = async () => {
829
+ const recorded = request.plan.tools.pi;
830
+ const found = await adapters.locatePi?.();
831
+ if (!found || !["pnpm", "npm"].includes(found.owner) || found.owner !== recorded?.owner || found.version !== recorded?.version) return false;
832
+ if (stable(found.version) === null || atLeast(found.version, requirements.pi)) return false;
833
+ if (typeof found.root !== "string" || !path.isAbsolute(found.root)) return false;
834
+ if (found.owner === "npm") {
835
+ // On Windows, what npm.cmd runs (windowsInvocation), never cmd.exe.
836
+ const npm = await npmInvocation(env, platform, adapters);
837
+ if (!npm) return false;
838
+ const result = await adapters.run(npm.command, [...npm.prefix, "root", "-g"], { env, deadlineMs: deadlines.probe });
839
+ const reported = succeeded(result) ? String(result.stdout ?? "").trim().split(/\r?\n/).at(-1).trim() : "";
840
+ const npmRoot = path.isAbsolute(reported) ? await adapters.fs.realpath(reported) : null;
841
+ if (installOwner({ packageRoot: found.root, pnpmHome: null, npmRoot, name: PI_PACKAGE, platform }) !== "npm") return false;
842
+ npmCommand = npm;
843
+ }
844
+ installedPi = found;
845
+ return true;
846
+ };
847
+ const checks = [
848
+ ...(addNpm ? [] : [["check-npm", "npm-unavailable", npmCheck]]),
849
+ ["check-global-bin", "global-bin-mismatch", async () => {
850
+ const result = await runPnpm(["bin", "-g"], deadlines.probe);
851
+ const reported = String(result.stdout ?? "").trim();
852
+ return succeeded(result) && path.isAbsolute(reported) && samePath(reported, globalBin.path, platform);
853
+ }],
854
+ // Never rely on the caller's plan alone: an existing Pi or gentle-pi is not overwritten,
855
+ // and a recovery still finds exactly the pinned stack this pnpm installed.
856
+ ...(piOnly ? [] : [update
857
+ ? ["check-installed-shell", "existing-stack-unverified", async () => {
858
+ installed = await adapters.locateShell?.();
859
+ return ["pnpm", "npm"].includes(installed?.owner) && typeof installed.root === "string" && path.isAbsolute(installed.root);
860
+ }]
861
+ : shellOnly
862
+ ? ["check-existing-shell", "global-list-unavailable", async () => {
863
+ const result = await list();
864
+ return succeeded(result) && notListed(String(result.stdout ?? ""), SHELL_PACKAGE);
865
+ }]
866
+ : recovering
867
+ ? ["check-recoverable-stack", "global-list-unavailable", async () => {
868
+ const result = await list();
869
+ if (!succeeded(result)) return false;
870
+ const root = await recoverableStackRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs)
871
+ .catch(() => null);
872
+ return root !== null || "existing-stack-unverified";
873
+ }]
874
+ : ["check-existing-stack", "global-list-unavailable", async () => {
875
+ const result = await list();
876
+ return succeeded(result) && noExistingStack(String(result.stdout ?? ""));
877
+ }]]),
878
+ ...(updatingPi ? [["check-installed-pi", "existing-stack-unverified", checkInstalledPi]] : []),
879
+ // The installer's Pi added on its own: pnpm must not list any Pi yet.
880
+ ...(piOnly && !updatingPi ? [["check-existing-pi", "global-list-unavailable", async () => {
881
+ const result = await list();
882
+ return succeeded(result) && notListed(String(result.stdout ?? ""), PI_PACKAGE);
883
+ }]] : []),
884
+ ];
885
+ // The private PNPM_HOME is claimed (or the one this flow created is kept) before
886
+ // any pnpm command could create it with inherited permissions.
887
+ if (privateHome) {
888
+ const claimed = await Promise.resolve().then(() => adapters.preparePnpmHome(globalBin.pnpmHome)).then(() => true, () => false);
889
+ if (!claimed) {
890
+ log({ step: "prepare-pnpm-home", status: "failed" });
891
+ return { outcome: "failed", failedStep: "prepare-pnpm-home", completed };
892
+ }
893
+ completed.push("prepare-pnpm-home");
894
+ log({ step: "prepare-pnpm-home", status: "done" });
895
+ }
896
+ for (const [step, reason, check] of checks) {
897
+ const verdict = await check().catch(() => false);
898
+ if (verdict !== true) return blocked(typeof verdict === "string" ? verdict : reason);
899
+ completed.push(step);
900
+ log({ step, status: "done" });
901
+ }
902
+
903
+ // Mutating and post-install steps: a false result or exception is a failure.
904
+ let packageRoot = null;
905
+ // The Pi the launcher runs beside a pnpm gentle-pi, once verified.
906
+ let runsPi = null;
907
+ let persistentNode = null;
908
+ let npmPrefix = null;
909
+ // One add of only the missing managers (fixed versions only: never a blanket build approval).
910
+ const addOnly = ids.find((id) => ["persist-package-managers", "persist-npm", "persist-pnpm"].includes(id));
911
+ const addManagers = [addOnly, async () => succeeded(await runPnpm(["add", "-g", ...(addNpm ? [`npm@${persistencePins.npm}`] : []),
912
+ ...(addPnpm ? [`pnpm@${persistencePins.pnpm}`] : [])], deadlines.install))];
913
+ const persistence = [
914
+ ["persist-node", async () => succeeded(await runPnpm(["runtime", "set", "node", persistencePins.node, "-g"], deadlines.install))],
915
+ // npm, plus pnpm unless a persistent pnpm (never downgraded) is already there.
916
+ addManagers,
917
+ ["verify-persistent-runtime", async () => (persistentNode = await persistentRuntime(child, platform, globalBin, adapters)) !== null],
918
+ ["check-npm", async () => (await npmCheck()) === true],
919
+ ["configure-npm-prefix", async () => (npmPrefix = await configureNpmPrefix({ node: persistentNode, cli: npmCli, child,
920
+ platform, globalBin, adapters, storePath: () => runPnpm(["store", "path"], deadlines.probe) })) !== null],
921
+ ];
922
+ // A persistent Node is never replaced: one add of only the missing managers.
923
+ const packageManagers = [
924
+ addManagers,
925
+ ...(addNpm ? [["check-npm", async () => (await npmCheck()) === true]] : []),
926
+ ...(addPnpm ? [["verify-persistent-pnpm", () => persistentPnpm(child, platform, adapters.nodePath, globalBin, adapters)]] : []),
927
+ ];
928
+ // A recovery never runs `add -g`: the installed packages are verified as they are.
929
+ // The older Pi, updated with the package manager that owns it, then found again:
930
+ // the next probe must see a single Pi (one listed twice is located as null)
931
+ // from the same owner at the target version, never a downgrade.
932
+ const piSteps = updatingPi ? [
933
+ ["update-pi", async () => {
934
+ const spec = `${PI_PACKAGE}@${PI_INSTALL_VERSION}`;
935
+ const result = installedPi.owner === "npm"
936
+ ? await adapters.run(npmCommand.command, [...npmCommand.prefix, "install", "-g", spec], { env, deadlineMs: deadlines.install })
937
+ : await runPnpm(["add", "-g", spec], deadlines.install);
938
+ return succeeded(result);
939
+ }],
940
+ ["verify-updated-pi", async () => {
941
+ const after = await adapters.locatePi();
942
+ if (!after || after.owner !== installedPi.owner || typeof after.root !== "string" || !path.isAbsolute(after.root)) return false;
943
+ if (stable(after.version) === null || !atLeast(after.version, PI_INSTALL_VERSION)) return false;
944
+ // npm replaces the package in place; pnpm 11 moves it to a new global directory.
945
+ return after.owner === "pnpm" || samePath(after.root, installedPi.root, platform);
946
+ }],
947
+ ] : [];
948
+ // The installer's pinned Go, when acquired: never on the user's PATH, only first
949
+ // on the PATH of the children that may build Gentle AI (gentle-pi's Windows
950
+ // postinstall finds go.exe there) and passed by path to the main build.
951
+ let pinnedGo = null;
952
+ const buildEnv = () => (pinnedGo ? goFirstEnvironment(child, platform, pinnedGo) : child);
953
+ const goSteps = ids.includes("acquire-go") ? [
954
+ ["acquire-go", async () => {
955
+ try {
956
+ pinnedGo = (await adapters.acquireGo())?.goPath ?? null;
957
+ } catch (error) {
958
+ // Only a folder in the way is reported, by its path with the home as ~.
959
+ const cause = String(error?.cause?.message ?? "");
960
+ if (cause.startsWith("Conflicting Go destination: ")) setupDetail = setupErrorDetail(cause, home, platform);
961
+ return false;
962
+ }
963
+ return typeof pinnedGo === "string";
964
+ }],
965
+ // It runs and reports exactly the pinned version; GOTOOLCHAIN=local keeps it from switching toolchains.
966
+ ["verify-go", async () => {
967
+ if (!path.isAbsolute(pinnedGo) || !spawnable(pinnedGo, platform)) return false;
968
+ const result = await adapters.run(pinnedGo, ["version"], { env: { ...child, GOTOOLCHAIN: "local" }, cwd: path.dirname(pinnedGo),
969
+ deadlineMs: deadlines.probe });
970
+ return succeeded(result) && new RegExp(`^go version go${escapeRegExp(goAcquisition.version)} \\S+$`).test(String(result.stdout ?? "").trim());
971
+ }],
972
+ ] : [];
973
+ // Pi and gentle-pi stay separate pnpm groups, so `pi update` (which replaces Pi's
974
+ // own group) never removes gentle-pi; gentle-pi gets pnpm's peer Pi beside it.
975
+ const install = ["install-global", () => addGlobal([...(shellOnly ? [] : [`${PI_PACKAGE}@${PI_INSTALL_VERSION}`]),
976
+ `${SHELL_PACKAGE}@${requirements.shell}`, `--allow-build=${SHELL_PACKAGE}`], buildEnv())];
977
+ function mainSteps() {
978
+ const channel = adapters.mainChannel;
979
+ const ctx = { env, home };
980
+ const runIn = (command, argv, options = {}) => adapters.run(command, argv, { env: options.env ?? child, cwd: options.cwd, deadlineMs: options.deadlineMs ?? deadlines.install,
981
+ ...(options.stderrTail === undefined ? {} : { stderrTail: options.stderrTail }) });
982
+ let gentleAiCommit = null;
983
+ let shellCommit = null;
984
+ return [
985
+ ["build-gentle-ai-main", withMainDetail(async () => {
986
+ const goPath = pinnedGo ?? await lookPath("go", env, platform, adapters.fs);
987
+ if (!goPath || !channel) return false;
988
+ gentleAiCommit = await channel.resolveCommit(GENTLE_AI_REPOSITORY);
989
+ await channel.buildGentleAi({ commit: gentleAiCommit, goPath, platform, ctx, run: runIn });
990
+ return true;
991
+ })],
992
+ ["install-shell-main", withMainDetail(async () => {
993
+ shellCommit = await channel.resolveCommit(SHELL_REPOSITORY);
994
+ const tgz = await channel.packShell({ commit: shellCommit, ctx, run: runIn, pnpm, platform });
995
+ if (!(await addGlobal([tgz, `--allow-build=${SHELL_PACKAGE}`], buildEnv()))) return false;
996
+ const result = await list();
997
+ if (!succeeded(result)) return false;
998
+ const verified = await verifiedPackageRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs,
999
+ mainVersion(requirements.shell, shellCommit), !shellOnly);
1000
+ if (verified === null) return false;
1001
+ ({ root: packageRoot, pi: runsPi } = verified);
1002
+ return true;
1003
+ })],
1004
+ ["record-channel", async () => {
1005
+ await channel.writeChannel(ctx, { channel: "main", shellCommit, gentleAiCommit });
1006
+ return true;
1007
+ }],
1008
+ ];
1009
+ }
1010
+ const steps = [
1011
+ // The pinned Go first: a failed download leaves nothing persisted or installed.
1012
+ ...goSteps,
1013
+ ...(persistRuntime ? persistence : addOnly ? packageManagers : []),
1014
+ ...piSteps,
1015
+ ...(recovering ? [] : [install]),
1016
+ ["verify-global-list", async () => {
1017
+ const result = await list();
1018
+ if (!succeeded(result)) return false;
1019
+ const verified = await verifiedPackageRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs, requirements.shell, !shellOnly);
1020
+ if (verified === null) return false;
1021
+ ({ root: packageRoot, pi: runsPi } = verified);
1022
+ return true;
1023
+ }],
1024
+ ["verify-shell-bin", () => adapters.fs.isFile(path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell"))],
1025
+ ["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true],
1026
+ // Main overlays the verified release stack: Gentle AI built from the latest
1027
+ // main commit (registered as the override), then Gentle Shell packed from
1028
+ // it. Setup below then runs from the main package.
1029
+ ...(main ? mainSteps() : []),
1030
+ // The setup commands are the only ones whose stderr tail is requested.
1031
+ ["shell-setup", async () => {
1032
+ const result = await adapters.run(adapters.nodePath, [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"],
1033
+ { env: setupEnvironment(child, platform), deadlineMs: deadlines.setup, stderrTail: 4096 });
1034
+ if (succeeded(result)) return true;
1035
+ setupDetail = setupErrorDetail(result?.stderrTail, home, platform);
1036
+ return false;
1037
+ }],
1038
+ ];
1039
+ if (update) {
1040
+ // Update an existing Gentle Shell with its owner's package manager (the same
1041
+ // logic as `gentle-shell upgrade`), confirm the result, then set it up.
1042
+ const channel = update === "update-shell-main" ? "main" : "release";
1043
+ const setupStep = steps.find(([name]) => name === "shell-setup");
1044
+ steps.splice(0, steps.length,
1045
+ ...goSteps,
1046
+ ...(ids.includes("install-pi") ? [["install-pi", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`], deadlines.install))]] : []),
1047
+ ...piSteps,
1048
+ ["update-shell", withMainDetail(async () => (await adapters.upgradeShell({ channel, packageRoot: installed.root, currentVersion: installed.version,
1049
+ ...(pinnedGo ? { goPath: pinnedGo } : {}) })) === true)],
1050
+ ["verify-updated-shell", async () => {
1051
+ const after = await adapters.locateShell();
1052
+ const version = String(after?.version ?? "");
1053
+ const expected = channel === "main" ? MAIN_VERSION.test(version)
1054
+ : stable(version) !== null && (stable(installed.version) === null || atLeast(version, installed.version));
1055
+ if (!expected || after.owner !== installed.owner || !path.isAbsolute(after.root)) return false;
1056
+ // pnpm gives the updated gentle-pi its peer Pi beside it: the one Gentle Shell runs.
1057
+ if (after.owner === "pnpm" && (runsPi = await runnablePi(after.root, platform, adapters.fs)) === null) return false;
1058
+ packageRoot = after.root;
1059
+ return true;
1060
+ }],
1061
+ ...(channel === "release" ? [["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true]] : []),
1062
+ setupStep);
1063
+ }
1064
+ // A child PATH never proves a fresh terminal; persist it with pnpm's own setup.
1065
+ // globalBin.onPath was computed from the user's own PATH, not the child env.
1066
+ // pnpm setup installs @pnpm/exe over the network, so it gets the setup deadline.
1067
+ // An update keeps the PATH its existing installation already uses, except in a
1068
+ // new private PNPM_HOME, which pnpm setup must persist for new terminals.
1069
+ if (piOnly) {
1070
+ // The installer's Pi alone: the same `add -g` as install-pi, then found in pnpm's list.
1071
+ steps.splice(0, steps.length, ...(updatingPi ? piSteps : [
1072
+ ["install-pi", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`], deadlines.install))],
1073
+ ["verify-installed-pi", async () => {
1074
+ const after = await adapters.locatePi();
1075
+ return after?.owner === "pnpm" && typeof after.root === "string" && path.isAbsolute(after.root) &&
1076
+ stable(after.version) !== null && atLeast(after.version, PI_INSTALL_VERSION);
1077
+ }],
1078
+ ]));
1079
+ }
1080
+ const persistPath = !globalBin.onPath && (privateHome || (!update && !piOnly));
1081
+ if (persistPath) {
1082
+ steps.push(["persist-path", async () => {
1083
+ const result = await adapters.run(pnpm.command, [...pnpm.prefix, "setup"], { env: child, deadlineMs: deadlines.setup, stderrTail: 4096 });
1084
+ if (succeeded(result)) return true;
1085
+ setupDetail = outputDetail(result);
1086
+ return false;
1087
+ }]);
1088
+ }
1089
+ for (const [step, run] of steps) {
1090
+ if (!(await Promise.resolve().then(run).catch(() => false))) {
1091
+ log({ step, status: "failed" });
1092
+ const detail = detailSteps.includes(step) && setupDetail ? { detail: setupDetail } : {};
1093
+ return { outcome: "failed", failedStep: step, completed, ...detail };
1094
+ }
1095
+ completed.push(step);
1096
+ log({ step, status: "done" });
1097
+ }
1098
+ // Reported only when Gentle Shell runs a Pi other than the installer's.
1099
+ const prefix = { ...(persistRuntime ? { npmPrefix } : {}), ...(runsPi !== null && runsPi !== PI_INSTALL_VERSION ? { piVersion: runsPi } : {}) };
1100
+ if (persistPath) return { outcome: "terminal-action-required", action: "open-new-terminal", completed, ...prefix };
1101
+ return { outcome: "ready", completed, ...prefix };
1102
+ }