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,1106 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs";
7
+ import { collectInventory, planPreflight, requirements } from "../scripts/installer-preflight.mjs";
8
+ import { bootstrapRoots, createProbes, hostAdapters, userEnvironment } from "../scripts/installer-probes.mjs";
9
+ import { PI_INSTALL_VERSION } from "../scripts/installer-runner.mjs";
10
+ import { verifyWindowsStorageMany } from "../scripts/installer-windows.mjs";
11
+
12
+ const HOME = "/home/u";
13
+ const PNPM_HOME = "/home/u/.local/share/pnpm";
14
+ const BIN = `${PNPM_HOME}/bin`;
15
+ const TOOLS = "/home/u/.gentle-shell-bootstrap-tools.Ab12Cd34";
16
+ const TOOLS_NODE = `${TOOLS}/node/bin/node`;
17
+ const TOOLS_PNPM = `${TOOLS}/pnpm/bin/pnpm`;
18
+ const USER_NODE = "/usr/bin/node";
19
+ const USER_NPM_CLI = "/usr/lib/node_modules/npm/bin/npm-cli.js";
20
+ const TOOLS_NPM_CLI = `${TOOLS}/node/lib/node_modules/npm/bin/npm-cli.js`;
21
+ const SHELL_ROOT = `${PNPM_HOME}/global/v11/def/node_modules/gentle-pi`;
22
+ // pnpm 11 links gentle-pi's peer Pi beside it in its global group: the Pi the launcher runs.
23
+ const BESIDE_PI = `${PNPM_HOME}/global/v11/def/node_modules/@earendil-works/pi-coding-agent/package.json`;
24
+ const besidePi = (version: string) => ({ [BESIDE_PI]: JSON.stringify({ name: "@earendil-works/pi-coding-agent", version }) });
25
+ const LIST = "list -g --depth 0 --json";
26
+ const npmPackage = JSON.stringify({ name: "npm", version: "11.19.0" });
27
+
28
+ type Result = { code: number | null; signal?: string | null; timedOut?: boolean; truncated?: boolean; stdout?: string };
29
+ type Call = { command: string; args: string[]; env: Record<string, string>; deadlineMs: number; cwd?: string };
30
+
31
+ // A bootstrap-acquired Node and pnpm in the temporary tools directory, ahead of the user's PATH.
32
+ const bootstrapEnv = { HOME, PATH: `${TOOLS}/node/bin:${TOOLS}/pnpm/bin:/usr/bin:/bin` };
33
+
34
+ function probes({ env = bootstrapEnv as Record<string, string>, files = [] as string[], dirs = [HOME] as string[],
35
+ writable = [HOME] as string[], others = [] as string[], realpaths = {} as Record<string, string>,
36
+ texts = {} as Record<string, string>, results = {} as Record<string, Result | (() => Result)>,
37
+ integrity = { ok: true } as object, platform = "linux", storage = (() => {}) as (file: string) => void, pnpmHome = undefined as object | undefined } = {}) {
38
+ const calls: Call[] = [];
39
+ const unexpected: string[] = [];
40
+ const fileSet = new Set(files);
41
+ const dirSet = new Set(dirs);
42
+ const integrityCalls: object[] = [];
43
+ const instance = createProbes({
44
+ platform,
45
+ env,
46
+ storage,
47
+ pnpmHome,
48
+ run: async (command: string, args: string[], options: { env: Record<string, string>; deadlineMs: number; cwd?: string }) => {
49
+ calls.push({ command, args, env: options.env, deadlineMs: options.deadlineMs, ...(options.cwd === undefined ? {} : { cwd: options.cwd }) });
50
+ const key = [command, ...args].join(" ");
51
+ const entry = results[key];
52
+ if (!entry) {
53
+ unexpected.push(key);
54
+ return { code: 127, signal: null, timedOut: false, stdout: "" };
55
+ }
56
+ return { signal: null, timedOut: false, stdout: "", ...(typeof entry === "function" ? entry() : entry) };
57
+ },
58
+ fs: {
59
+ isFile: async (path: string) => fileSet.has(path),
60
+ isDirectory: async (path: string) => dirSet.has(path),
61
+ exists: async (path: string) => fileSet.has(path) || dirSet.has(path) || others.includes(path),
62
+ realpath: async (path: string) => {
63
+ if (path in realpaths) return realpaths[path];
64
+ if (fileSet.has(path) || dirSet.has(path)) return path;
65
+ throw new Error(`ENOENT ${path}`);
66
+ },
67
+ readText: async (path: string) => {
68
+ if (!(path in texts)) throw new Error(`ENOENT ${path}`);
69
+ return texts[path];
70
+ },
71
+ writable: async (path: string) => writable.includes(path),
72
+ },
73
+ verifyGentleAi: async (request: object) => {
74
+ integrityCalls.push(request);
75
+ return integrity;
76
+ },
77
+ });
78
+ return { probes: instance, calls, unexpected, integrityCalls };
79
+ }
80
+
81
+ const bootstrapNode = {
82
+ files: [TOOLS_NODE, `${TOOLS}/node/bin/npm`, TOOLS_NPM_CLI, TOOLS_PNPM],
83
+ realpaths: { [`${TOOLS}/node/bin/npm`]: TOOLS_NPM_CLI },
84
+ texts: { [`${TOOLS}/node/lib/node_modules/npm/package.json`]: npmPackage },
85
+ results: { [`${TOOLS_NODE} --version`]: { code: 0, stdout: "v24.18.0\n" } } as Record<string, Result>,
86
+ };
87
+ const userNode = {
88
+ files: [USER_NODE, "/usr/bin/npm", USER_NPM_CLI],
89
+ realpaths: { "/usr/bin/npm": USER_NPM_CLI },
90
+ texts: { "/usr/lib/node_modules/npm/package.json": JSON.stringify({ name: "npm", version: "10.9.2" }) },
91
+ results: { [`${USER_NODE} --version`]: { code: 0, stdout: "v24.18.0\n" },
92
+ "/usr/bin/npm --version": { code: 0, stdout: "10.9.2\n" },
93
+ "/usr/bin/npm config get prefix": { code: 0, stdout: "/usr\n" } } as Record<string, Result>,
94
+ };
95
+ // npm from a version manager: a mise shim (a symlink to the mise binary) or a Volta-like shim script.
96
+ const MISE_SHIMS = "/home/u/.local/share/mise/shims";
97
+ const managedNpm = (directory: string, results: Record<string, Result> = {}) => probes({
98
+ env: { HOME, PATH: `${TOOLS}/pnpm/bin:${directory}:/usr/bin` },
99
+ files: [`${directory}/node`, `${directory}/npm`, TOOLS_PNPM],
100
+ realpaths: { [`${directory}/npm`]: "/home/u/.local/bin/mise" },
101
+ results: { [`${directory}/node --version`]: { code: 0, stdout: "v24.18.0\n" },
102
+ [`${directory}/npm --version`]: { code: 0, stdout: "10.9.2\n" },
103
+ [`${directory}/npm config get prefix`]: { code: 0, stdout: "/home/u/.local/share/mise/installs/node/24.18.0\n" }, ...results },
104
+ });
105
+ // A clean installation plan for that Node; everything else is ready to install.
106
+ const npmPlan = async (h: ReturnType<typeof probes>) => {
107
+ const plan = planPreflight({ platform: "linux", arch: "x64", node: await h.probes.node(),
108
+ pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true }, pi: { available: false },
109
+ shell: { available: false }, gentleAi: { available: false }, go: { available: false },
110
+ globalBin: { available: true, path: BIN, writable: true, onPath: true }, setup: false });
111
+ assert.deepEqual(plan.blockers, []);
112
+ return plan.actions.map((action: { id: string }) => action.id);
113
+ };
114
+
115
+ test("bootstrap tool roots come from GENTLE_BOOTSTRAP_TOOLS and PATH segments, and are removed from the user PATH", () => {
116
+ assert.deepEqual(bootstrapRoots({ platform: "linux", env: bootstrapEnv }), [TOOLS]);
117
+ assert.deepEqual(userEnvironment({ platform: "linux", env: bootstrapEnv }), { HOME, PATH: "/usr/bin:/bin" });
118
+ const tools = "C:\\Users\\u\\AppData\\Local\\.gentle-shell-bootstrap-tools.1.2.3";
119
+ const env = { GENTLE_BOOTSTRAP_TOOLS: tools, Path: `${tools}\\pnpm;C:\\Users\\U\\APPDATA\\Local\\.GENTLE-SHELL-BOOTSTRAP-TOOLS.9\\node;C:\\Windows` };
120
+ assert.deepEqual(bootstrapRoots({ platform: "win32", env }), [tools, "C:\\Users\\U\\APPDATA\\Local\\.GENTLE-SHELL-BOOTSTRAP-TOOLS.9"]);
121
+ assert.equal(userEnvironment({ platform: "win32", env }).Path, "C:\\Windows");
122
+ assert.deepEqual(bootstrapRoots({ platform: "linux", env: { PATH: "/usr/bin" } }), []);
123
+ assert.equal(userEnvironment({ platform: "linux", env: { PATH: `${TOOLS}/:/usr/bin` } }).PATH, "/usr/bin");
124
+ });
125
+
126
+ test("node on the user's real PATH is persistent with genuine npm evidence", async () => {
127
+ const h = probes({ env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/bin` }, ...userNode });
128
+ assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: true, npm: true });
129
+ assert.deepEqual(h.unexpected, []);
130
+ for (const call of h.calls) assert.ok(call.deadlineMs > 0);
131
+ });
132
+
133
+ test("a working npm from a version manager is usable: no npm is persisted for it", async () => {
134
+ for (const directory of [MISE_SHIMS, "/home/u/.volta/bin"]) {
135
+ const h = managedNpm(directory);
136
+ assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: true, npm: true });
137
+ assert.deepEqual(h.unexpected, []);
138
+ // That npm itself runs from `/` in the user's env with $PNPM_HOME/bin first, never npm-cli.js with another Node.
139
+ const runs = h.calls.filter((call) => call.command === `${directory}/npm`);
140
+ assert.deepEqual(runs.map((call) => call.args.join(" ")), ["--version", "config get prefix"]);
141
+ for (const call of runs) {
142
+ assert.equal(call.cwd, "/");
143
+ assert.equal(call.env.PATH, `${BIN}:${directory}:/usr/bin`);
144
+ }
145
+ assert.deepEqual(await npmPlan(managedNpm(directory)), ["install-pi", "install-shell", "setup-shell", "verify-readiness"]);
146
+ }
147
+ });
148
+
149
+ test("an npm that fails, prints no stable version or no absolute prefix is not usable: persist-npm is planned", async () => {
150
+ const failures: Record<string, Result>[] = [
151
+ { [`${MISE_SHIMS}/npm --version`]: { code: 1, stdout: "" } },
152
+ { [`${MISE_SHIMS}/npm --version`]: { code: 0, stdout: "11.0.0-pre.1\n" } },
153
+ { [`${MISE_SHIMS}/npm --version`]: { code: 0, truncated: true, stdout: "10.9.2" } },
154
+ { [`${MISE_SHIMS}/npm config get prefix`]: { code: 0, stdout: "relative/prefix\n" } },
155
+ { [`${MISE_SHIMS}/npm config get prefix`]: { code: 0, timedOut: true, stdout: "/opt/node\n" } },
156
+ ];
157
+ for (const results of failures) {
158
+ assert.equal((await managedNpm(MISE_SHIMS, results).probes.node()).npm, false, JSON.stringify(results));
159
+ assert.ok((await npmPlan(managedNpm(MISE_SHIMS, results))).includes("persist-npm"));
160
+ }
161
+ });
162
+
163
+ test("a mise-like npm shim runs for real: a symlink to a non-npm executable that behaves like npm", async (t) => {
164
+ if (process.platform === "win32") return t.skip("POSIX shims");
165
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-probe-npm-")));
166
+ try {
167
+ const shims = join(root, "shims");
168
+ mkdirSync(shims);
169
+ // Like mise, one executable dispatches on the name it was run as, and reads the global config only from `/`.
170
+ const mise = join(root, "mise");
171
+ writeFileSync(mise, `#!/bin/sh\n[ "$(basename "$0")" = npm ] || exit 9\n[ "$(pwd)" = / ] || exit 8\n` +
172
+ `case "$*" in\n --version) echo 10.9.2 ;;\n "config get prefix") echo /opt/mise/node ;;\n *) exit 7 ;;\nesac\n`);
173
+ chmodSync(mise, 0o755);
174
+ symlinkSync(mise, join(shims, "npm"));
175
+ symlinkSync(process.execPath, join(shims, "node"));
176
+ const { run, fs } = hostAdapters();
177
+ const env = { HOME: root, PNPM_HOME: join(root, "pnpm"), PATH: `${shims}:/usr/bin:/bin` };
178
+ const node = await createProbes({ platform: process.platform, env, run, fs }).node();
179
+ assert.equal(node.npm, true);
180
+ assert.equal(node.persistent, true);
181
+ // The same shim answering a relative prefix is not usable.
182
+ writeFileSync(mise, readFileSync(mise, "utf8").replace("echo /opt/mise/node", "echo opt/mise/node"));
183
+ assert.equal((await createProbes({ platform: process.platform, env, run, fs }).node()).npm, false);
184
+ } finally {
185
+ rmSync(root, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ test("a node only inside the bootstrap tools directory is bootstrap-only and its npm does not count", async () => {
190
+ const h = probes(bootstrapNode);
191
+ assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: false, npm: false });
192
+ assert.deepEqual(h.unexpected, []);
193
+ assert.equal(h.calls.some((call) => call.args[0] === TOOLS_NPM_CLI), false);
194
+ });
195
+
196
+ test("an older Node on the user's PATH is left as it is: the bootstrap's Node is reported with the older version found", async () => {
197
+ const older = { ...userNode.results, [`${USER_NODE} --version`]: { code: 0, stdout: "v22.18.0\n" } };
198
+ const h = probes({ files: [...userNode.files, ...bootstrapNode.files], realpaths: { ...userNode.realpaths, ...bootstrapNode.realpaths },
199
+ texts: { ...userNode.texts, ...bootstrapNode.texts }, results: { ...older, [`${TOOLS_NODE} --version`]: { code: 0, stdout: "v24.21.0\n" },
200
+ [`${TOOLS_NODE} ${USER_NPM_CLI} --version`]: { code: 0, stdout: "10.9.2\n" } } });
201
+ const node = await h.probes.node();
202
+ assert.equal(node.version, "24.21.0");
203
+ assert.equal(node.persistent, false);
204
+ assert.equal(node.found, "22.18.0");
205
+ // A current user Node is reused as before, and an older one without a bootstrap copy is reported as it is.
206
+ const current = probes({ files: [...userNode.files, ...bootstrapNode.files], realpaths: userNode.realpaths, texts: userNode.texts,
207
+ results: { ...userNode.results, [`${TOOLS_NODE} --version`]: { code: 0, stdout: "v24.21.0\n" } } });
208
+ assert.deepEqual(await current.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: true, npm: true });
209
+ const alone = probes({ ...userNode, env: { HOME, PATH: "/usr/bin" }, results: older });
210
+ assert.deepEqual(await alone.probes.node(), { available: true, version: "22.18.0", usable: true, persistent: true, npm: true });
211
+ // A bootstrap copy that is itself not newer is no replacement.
212
+ const stale = probes({ files: [...userNode.files, ...bootstrapNode.files], realpaths: userNode.realpaths, texts: userNode.texts,
213
+ results: { ...older, [`${TOOLS_NODE} --version`]: { code: 0, stdout: "v22.0.0\n" } } });
214
+ assert.equal((await stale.probes.node()).found, undefined);
215
+ });
216
+
217
+ test("node absent, unparseable, failing, timed out or truncated", async () => {
218
+ assert.deepEqual(await probes({ env: { HOME, PATH: "/usr/bin" } }).probes.node(), { available: false });
219
+ for (const result of [{ code: 0, stdout: "v24\n" }, { code: 0, stdout: "v24.18.0-nightly\n" }, { code: 1, stdout: "v24.18.0" },
220
+ { code: 0, timedOut: true, stdout: "v24.18.0" }, { code: null, signal: "SIGKILL" }, { code: 0, truncated: true, stdout: "v24.18.0" }]) {
221
+ const h = probes({ ...userNode, env: { HOME, PATH: "/usr/bin" }, results: { [`${USER_NODE} --version`]: result } });
222
+ assert.deepEqual(await h.probes.node(), { available: null });
223
+ }
224
+ // An adapter exception becomes the unknown shape and never leaks its message.
225
+ const instance = createProbes({ platform: "linux", env: { HOME, PATH: "/usr/bin" }, run: async () => { throw new Error("private /home/u/secret"); },
226
+ fs: { isFile: async () => true, isDirectory: async () => false, exists: async () => true, realpath: async (p: string) => p,
227
+ readText: async () => "", writable: async () => false } });
228
+ for (const name of ["node", "pnpm", "pi", "shell", "gentleAi", "go", "setup"] as const) {
229
+ assert.deepEqual(await instance[name](), { available: null }, name);
230
+ }
231
+ });
232
+
233
+ test("pnpm from the bootstrap is bootstrap-only; one on the user's PATH is persistent", async () => {
234
+ const results = { [`${TOOLS_PNPM} --version`]: { code: 0, stdout: "11.1.1\n" } };
235
+ const h = probes({ files: [TOOLS_PNPM], results });
236
+ assert.deepEqual(await h.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false });
237
+ assert.equal(h.calls[0].env.PNPM_HOME, PNPM_HOME);
238
+ assert.equal(h.calls[0].env.PATH.split(":")[0], BIN);
239
+ const persistent = probes({ env: { HOME, PATH: "/usr/bin" }, files: ["/usr/bin/pnpm"],
240
+ results: { "/usr/bin/pnpm --version": { code: 0, stdout: "11.4.0\n" } } });
241
+ assert.deepEqual(await persistent.probes.pnpm(), { available: true, version: "11.4.0", usable: true, compatible: true, persistent: true });
242
+ });
243
+
244
+ test("pnpm older than the pin, unparseable, timed out or absent; the pin or newer of any major is compatible", async () => {
245
+ for (const [stdout, compatible] of [["10.30.0", false], ["12.0.0", true], ["11.0.9", false], ["11.1.1", true]] as const) {
246
+ const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} --version`]: { code: 0, stdout } } });
247
+ assert.equal((await h.probes.pnpm()).compatible, compatible);
248
+ }
249
+ for (const result of [{ code: 0, stdout: "pnpm 11" }, { code: 0, timedOut: true, stdout: "11.1.1" }, { code: 2 }]) {
250
+ const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} --version`]: result } });
251
+ assert.deepEqual(await h.probes.pnpm(), { available: null });
252
+ }
253
+ assert.deepEqual(await probes({ env: { HOME, PATH: "/usr/bin" } }).probes.pnpm(), { available: false });
254
+ });
255
+
256
+ test("the bootstrap's pnpm next to an incompatible pnpm on the user's PATH is bootstrap-only and reports the version found", async () => {
257
+ const results = { [`${TOOLS_PNPM} --version`]: { code: 0, stdout: "11.1.1\n" } };
258
+ // The bootstraps run only the pinned major, so a pnpm of another major there is reported as found
259
+ // (the plan says the pinned pnpm is installed and that one is left unchanged); a newer 11.x is not.
260
+ for (const [own, found] of [["10.27.0", "10.27.0"], ["12.0.0", "12.0.0"], ["13.0.0", "13.0.0"], ["11.5.0", undefined], ["11.1.1", undefined], ["not a version", undefined]] as const) {
261
+ const h = probes({ files: [TOOLS_PNPM, "/usr/bin/pnpm"], results: { ...results, "/usr/bin/pnpm --version": { code: 0, stdout: `${own}\n` } } });
262
+ assert.deepEqual(await h.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false,
263
+ ...(found ? { found } : {}) }, own);
264
+ // The user's pnpm runs only for its version, with the user's own environment.
265
+ const own_ = h.calls.find((call) => call.command === "/usr/bin/pnpm");
266
+ assert.deepEqual(own_?.args, ["--version"]);
267
+ assert.equal(own_?.env.PATH, "/usr/bin:/bin");
268
+ }
269
+ // The Windows handoff from the bootstrap tools is bootstrap-only even with a pnpm.cmd on the user's Path.
270
+ const local = "C:\\Users\\u\\AppData\\Local";
271
+ const tools = `${local}\\.gentle-shell-bootstrap-tools.1`;
272
+ const node = `${tools}\\node\\node.exe`;
273
+ const entry = `${tools}\\pnpm\\package\\bin\\pnpm.mjs`;
274
+ const env = { LOCALAPPDATA: local, USERPROFILE: "C:\\Users\\u", Path: "C:\\Tools", GENTLE_BOOTSTRAP_TOOLS: tools,
275
+ GENTLE_INSTALL_PNPM_NODE: node, GENTLE_INSTALL_PNPM_ENTRY: entry };
276
+ const windows = probes({ platform: "win32", env, files: ["C:\\Tools\\pnpm.cmd"], results: { [`${node} ${entry} --version`]: { code: 0, stdout: "11.1.1\r\n" } } });
277
+ assert.deepEqual(await windows.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false });
278
+ });
279
+
280
+ test("a user's pnpm in $PNPM_HOME/bin is reported as it is next to the bootstrap's pnpm, never as replaced", async () => {
281
+ // Persisting pnpm writes $PNPM_HOME/bin/pnpm: the user's own pnpm there is never replaced or downgraded.
282
+ const env = { HOME, PATH: `${TOOLS}/node/bin:${TOOLS}/pnpm/bin:${BIN}:/usr/bin:/bin` };
283
+ const results = { [`${TOOLS_PNPM} --version`]: { code: 0, stdout: "11.1.1\n" } };
284
+ // Compatible means the pin or newer, of any major: pnpm 12 runs the installer's commands (measured).
285
+ for (const [own, compatible] of [["11.0.5", false], ["10.27.0", false], ["12.0.0", true], ["12.11.2", true], ["13.0.0", true]] as const) {
286
+ const h = probes({ env, files: [TOOLS_PNPM, `${BIN}/pnpm`], results: { ...results, [`${BIN}/pnpm --version`]: { code: 0, stdout: `${own}\n` } } });
287
+ assert.deepEqual(await h.probes.pnpm(), { available: true, version: own, usable: true, compatible, persistent: true, inGlobalBin: true }, own);
288
+ }
289
+ // Without a stable version it is left as it is and never replaced: the bootstrap's
290
+ // pnpm is reported in its place, as persistent, so nothing is persisted over it.
291
+ const unchecked = { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true, inGlobalBin: true, unchecked: true };
292
+ for (const read of [{ code: 2 }, { code: 0, stdout: "11.2.0-rc.1\n" }, { code: 0, stdout: "" }, { code: null, timedOut: true }]) {
293
+ const unread = probes({ env, files: [TOOLS_PNPM, `${BIN}/pnpm`], results: { ...results, [`${BIN}/pnpm --version`]: read } });
294
+ assert.deepEqual(await unread.probes.pnpm(), unchecked, JSON.stringify(read));
295
+ }
296
+ // A compatible one there needs nothing persisted over it.
297
+ const current = probes({ env, files: [TOOLS_PNPM, `${BIN}/pnpm`], results: { ...results, [`${BIN}/pnpm --version`]: { code: 0, stdout: "11.5.0\n" } } });
298
+ assert.deepEqual(await current.probes.pnpm(), { available: true, version: "11.5.0", usable: true, compatible: true, persistent: true, inGlobalBin: true });
299
+ // Through the planner: an older one blocks before consent; a newer major does not; nothing is persisted over either.
300
+ for (const own of ["11.0.5", "12.11.2"]) {
301
+ const h = probes({ env, files: [TOOLS_PNPM, `${BIN}/pnpm`], results: { ...results, [`${BIN}/pnpm --version`]: { code: 0, stdout: `${own}\n` } } });
302
+ const inventory = await collectInventory({ platform: "linux", arch: "x64", probes: { pnpm: h.probes.pnpm,
303
+ node: async () => ({ available: true, version: "24.18.0", usable: true, persistent: true, npm: true }),
304
+ pi: async () => ({ available: false }), shell: async () => ({ available: false }), gentleAi: async () => ({ available: false }),
305
+ go: async () => ({ available: false }), globalBin: async () => ({ available: true, path: BIN, writable: true, onPath: true }),
306
+ setup: async () => false } });
307
+ const plan = planPreflight(inventory);
308
+ const older = own === "11.0.5";
309
+ assert.deepEqual(plan.blockers, older ? [{ code: "incompatible-tool", tool: "pnpm" }] : [], own);
310
+ if (older) assert.deepEqual(plan.actions, []);
311
+ assert.equal(plan.actions.some((action: { id: string }) => ["persist-pnpm", "persist-package-managers"].includes(action.id) &&
312
+ JSON.stringify(action).includes("pnpm@")), false, own);
313
+ }
314
+ // The same user pnpm outside $PNPM_HOME/bin keeps the pinned copy alongside.
315
+ const outside = probes({ files: [TOOLS_PNPM, "/usr/bin/pnpm"], results: { ...results, "/usr/bin/pnpm --version": { code: 0, stdout: "11.0.5\n" } } });
316
+ assert.deepEqual(await outside.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false, found: "11.0.5" });
317
+ // A pnpm.cmd in $PNPM_HOME\bin whose shim is not recognized is never run, and left as it is.
318
+ const local = "C:\\Users\\u\\AppData\\Local";
319
+ const tools = `${local}\\.gentle-shell-bootstrap-tools.1`;
320
+ const node = `${tools}\\node\\node.exe`;
321
+ const entry = `${tools}\\pnpm\\package\\bin\\pnpm.mjs`;
322
+ const windowsEnv = { LOCALAPPDATA: local, USERPROFILE: "C:\\Users\\u", Path: `${local}\\pnpm\\bin`, GENTLE_BOOTSTRAP_TOOLS: tools,
323
+ GENTLE_INSTALL_PNPM_NODE: node, GENTLE_INSTALL_PNPM_ENTRY: entry };
324
+ const windows = probes({ platform: "win32", env: windowsEnv, files: [`${local}\\pnpm\\bin\\pnpm.cmd`],
325
+ results: { [`${node} ${entry} --version`]: { code: 0, stdout: "11.1.1\r\n" } } });
326
+ assert.deepEqual(await windows.probes.pnpm(), unchecked);
327
+ assert.deepEqual(windows.calls.map((call) => call.command), [node]);
328
+ });
329
+
330
+ test("Windows pnpm uses the direct bootstrap handoff; a .cmd shim alone is unknown", async () => {
331
+ const local = "C:\\Users\\u\\AppData\\Local";
332
+ const node = `${local}\\.gentle-shell-bootstrap-tools.1\\node\\node.exe`;
333
+ const entry = `${local}\\.gentle-shell-bootstrap-tools.1\\pnpm\\package\\bin\\pnpm.mjs`;
334
+ const env = { LOCALAPPDATA: local, USERPROFILE: "C:\\Users\\u", Path: "C:\\Windows", GENTLE_INSTALL_PNPM_NODE: node, GENTLE_INSTALL_PNPM_ENTRY: entry };
335
+ const h = probes({ platform: "win32", env, results: { [`${node} ${entry} --version`]: { code: 0, stdout: "11.1.1\r\n" } } });
336
+ assert.deepEqual(await h.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false });
337
+ assert.equal(h.calls[0].env.Path.split(";")[0], `${local}\\pnpm\\bin`);
338
+ const shim = probes({ platform: "win32", env: { LOCALAPPDATA: local, Path: "C:\\Tools" }, files: ["C:\\Tools\\pnpm.cmd"] });
339
+ assert.deepEqual(await shim.probes.pnpm(), { available: null });
340
+ assert.deepEqual(shim.calls, []);
341
+ });
342
+
343
+ // Windows layouts, verbatim shim text (see installer-windows-bootstrap.test.ts for sources).
344
+ const W_LOCAL = "C:\\Users\\u\\AppData\\Local";
345
+ const W_TOOLS = `${W_LOCAL}\\.gentle-shell-bootstrap-tools.1`;
346
+ const W_TOOLS_NODE = `${W_TOOLS}\\node\\node.exe`;
347
+ const W_TOOLS_ENTRY = `${W_TOOLS}\\pnpm\\package\\bin\\pnpm.mjs`;
348
+ const W_APPDATA_NPM = "C:\\Users\\u\\AppData\\Roaming\\npm";
349
+ const W_NPM_ROOT = `${W_APPDATA_NPM}\\node_modules`;
350
+ const W_NODE_DIR = "C:\\Program Files\\nodejs";
351
+ const W_NODE = `${W_NODE_DIR}\\node.exe`;
352
+ const W_NPM_CLI = `${W_NODE_DIR}\\node_modules\\npm\\bin\\npm-cli.js`;
353
+ const cmdShimHead = "@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n";
354
+ const cmdShim = (target: string) => `${cmdShimHead}\r\nIF EXIST "%dp0%\\node.exe" (\r\n SET "_prog=%dp0%\\node.exe"\r\n) ELSE (\r\n SET "_prog=node"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${target}" %*\r\n`;
355
+ const nodeNpmCmd = ":: Created by npm, please don't edit manually.\r\n@ECHO OFF\r\n\r\nSETLOCAL\r\n\r\nSET \"NODE_EXE=%~dp0\\node.exe\"\r\nIF NOT EXIST \"%NODE_EXE%\" (\r\n SET \"NODE_EXE=node\"\r\n)\r\n\r\nSET \"NPM_PREFIX_JS=%~dp0\\node_modules\\npm\\bin\\npm-prefix.js\"\r\nSET \"NPM_CLI_JS=%~dp0\\node_modules\\npm\\bin\\npm-cli.js\"\r\nFOR /F \"delims=\" %%F IN ('CALL \"%NODE_EXE%\" \"%NPM_PREFIX_JS%\"') DO (\r\n SET \"NPM_PREFIX_NPM_CLI_JS=%%F\\node_modules\\npm\\bin\\npm-cli.js\"\r\n)\r\nIF EXIST \"%NPM_PREFIX_NPM_CLI_JS%\" (\r\n SET \"NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%\"\r\n)\r\n\r\n\"%NODE_EXE%\" \"%NPM_CLI_JS%\" %*\r\n";
356
+ const W_PI = `${W_NPM_ROOT}\\@earendil-works\\pi-coding-agent`;
357
+ const W_PI_ENTRY = `${W_PI}\\dist\\bundle\\cli.js`;
358
+ // The wizard on Windows: the bootstrap's Node and pnpm handed off, Node.js on the user's Path.
359
+ const windowsPi = (version: string, { shim = cmdShim("node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js"), root = W_NPM_ROOT } = {}) => probes({
360
+ platform: "win32",
361
+ env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: `${W_TOOLS}\\node;${W_APPDATA_NPM};${W_NODE_DIR}`, PATHEXT: ".COM;.EXE;.BAT;.CMD",
362
+ GENTLE_BOOTSTRAP_TOOLS: W_TOOLS, GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
363
+ files: [`${W_APPDATA_NPM}\\pi.cmd`, `${W_APPDATA_NPM}\\pi`, W_PI_ENTRY, W_NODE, `${W_NODE_DIR}\\npm.cmd`, W_NPM_CLI],
364
+ dirs: [W_NPM_ROOT, root],
365
+ texts: { [`${W_APPDATA_NPM}\\pi.cmd`]: shim, [`${W_NODE_DIR}\\npm.cmd`]: nodeNpmCmd,
366
+ [`${W_PI}\\package.json`]: JSON.stringify({ name: "@earendil-works/pi-coding-agent", version }) },
367
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} ${LIST}`]: { code: 0, stdout: "[]" },
368
+ [`${W_NODE} ${W_PI_ENTRY} --version`]: { code: 0, stdout: `${version}\r\n` },
369
+ [`${W_NODE} ${W_NPM_CLI} root -g`]: { code: 0, stdout: `${root}\r\n` } },
370
+ });
371
+ test("Windows: an older Pi installed by npm is run through its shim and attributed to npm, as on POSIX", async () => {
372
+ const npm = windowsPi("0.87.1");
373
+ assert.deepEqual(await npm.probes.pi(), { available: true, version: "0.87.1", usable: true, external: true, owner: "npm" });
374
+ assert.deepEqual(await npm.probes.locatePi(), { root: W_PI, version: "0.87.1", owner: "npm" });
375
+ assert.deepEqual(npm.unexpected, []);
376
+ assert.equal(npm.calls.some((call) => /\.cmd$/i.test(call.command)), false, "no shim is ever spawned");
377
+ // npm's global root elsewhere: the Pi is reused or replaced alongside, never attributed.
378
+ const elsewhere = windowsPi("0.87.1", { root: "C:\\Other\\node_modules" });
379
+ assert.deepEqual(await elsewhere.probes.pi(), { available: true, version: "0.87.1", usable: true, external: true });
380
+ // A pi.cmd no structure resolves is never run: unknown, outside pnpm.
381
+ const mise = windowsPi("0.87.1", { shim: "@echo off\r\nsetlocal\r\nmise x -- %*\r\n" });
382
+ assert.deepEqual(await mise.probes.pi(), { available: null, outsidePnpm: true });
383
+ assert.equal(mise.calls.some((call) => call.args.includes("--version")), false);
384
+ });
385
+
386
+ test("Windows: a user's pnpm in $PNPM_HOME\\bin is run through its shim for the version it reports, never replaced", async () => {
387
+ const bin = `${W_LOCAL}\\pnpm\\bin`;
388
+ const exe = `${W_LOCAL}\\pnpm\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe`;
389
+ const h = probes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: bin, GENTLE_BOOTSTRAP_TOOLS: W_TOOLS,
390
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
391
+ files: [`${bin}\\pnpm.cmd`, `${bin}\\pnpm`, exe],
392
+ texts: { [`${bin}\\pnpm.cmd`]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe\" %*\r\n" },
393
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} --version`]: { code: 0, stdout: "11.1.1\r\n" }, [`${exe} --version`]: { code: 0, stdout: "11.0.5\r\n" } } });
394
+ assert.deepEqual(await h.probes.pnpm(), { available: true, version: "11.0.5", usable: true, compatible: false, persistent: true, inGlobalBin: true });
395
+ assert.deepEqual(h.calls.find((call) => call.command === exe)?.cwd, "C:\\");
396
+ // One whose version cannot be read is left as it is: the bootstrap's pnpm is reported in its place.
397
+ const unread = probes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: bin, GENTLE_BOOTSTRAP_TOOLS: W_TOOLS,
398
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
399
+ files: [`${bin}\\pnpm.cmd`, `${bin}\\pnpm`, exe],
400
+ texts: { [`${bin}\\pnpm.cmd`]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe\" %*\r\n" },
401
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} --version`]: { code: 0, stdout: "11.1.1\r\n" }, [`${exe} --version`]: { code: null, timedOut: true } } });
402
+ assert.deepEqual(await unread.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true, inGlobalBin: true, unchecked: true });
403
+ assert.equal(unread.calls.filter((call) => call.command === exe).length, 1);
404
+ });
405
+
406
+ // S8: an npm-owned Gentle Shell on Windows is found through what its shim runs, so the
407
+ // wizard can update it with npm (run through npm.cmd's own invocation, never the .cmd).
408
+ test("Windows: a Gentle Shell installed by npm is followed through its shim and attributed to npm", async () => {
409
+ const shellCmd = `${W_APPDATA_NPM}\\gentle-shell.cmd`;
410
+ const shellRoot = `${W_NPM_ROOT}\\gentle-pi`;
411
+ const shellEntry = `${shellRoot}\\bin\\gentle-shell.mjs`;
412
+ const layout = (root = W_NPM_ROOT) => probes({ platform: "win32",
413
+ env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: `${W_TOOLS}\\node;${W_APPDATA_NPM};${W_NODE_DIR}`, PATHEXT: ".COM;.EXE;.BAT;.CMD",
414
+ GENTLE_BOOTSTRAP_TOOLS: W_TOOLS, GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
415
+ files: [shellCmd, shellEntry, W_NODE, `${W_NODE_DIR}\\npm.cmd`, W_NPM_CLI], dirs: [W_NPM_ROOT, root],
416
+ texts: { [shellCmd]: cmdShim("node_modules\\gentle-pi\\bin\\gentle-shell.mjs"), [`${W_NODE_DIR}\\npm.cmd`]: nodeNpmCmd,
417
+ [`${shellRoot}\\package.json`]: JSON.stringify({ name: "gentle-pi", version: "0.9.0" }) },
418
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} ${LIST}`]: { code: 0, stdout: "[]" }, [`${W_NODE} ${W_NPM_CLI} root -g`]: { code: 0, stdout: `${root}\r\n` } } });
419
+ const npm = layout();
420
+ assert.deepEqual(await npm.probes.shell(), { available: true, version: "0.9.0", usable: true, global: true, owner: "npm" });
421
+ assert.deepEqual(await npm.probes.locateShell(), { root: shellRoot, version: "0.9.0", owner: "npm" });
422
+ assert.equal(npm.calls.some((call) => /\.cmd$/i.test(call.command)), false, "no shim is ever spawned");
423
+ // npm's global root elsewhere: not npm's, so it stays unknown and is never updated.
424
+ const elsewhere = layout("C:\\Other\\node_modules");
425
+ assert.deepEqual(await elsewhere.probes.shell(), { available: null, outsidePnpm: true });
426
+ assert.deepEqual(await elsewhere.probes.locateShell(), { root: shellRoot, version: "0.9.0", owner: null });
427
+ });
428
+
429
+ // S7: one that fails the walk (a reparse point, a folder another account can change)
430
+ // never runs and is never replaced: the bootstrap's verified pnpm runs the installer.
431
+ test("Windows: a user's pnpm in $PNPM_HOME\\bin that fails the walk is not run, not blocked and never replaced", async () => {
432
+ const bin = `${W_LOCAL}\\pnpm\\bin`;
433
+ const exe = `${W_LOCAL}\\pnpm\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe`;
434
+ const walked: string[] = [];
435
+ const h = probes({ platform: "win32", storage: (file) => {
436
+ walked.push(file);
437
+ if (file === exe) throw Object.assign(new Error("Windows ACL evidence rejected"), { check: "target-reparse" });
438
+ }, env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: `${bin};${W_NODE_DIR}`, GENTLE_BOOTSTRAP_TOOLS: W_TOOLS,
439
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
440
+ files: [`${bin}\\pnpm.cmd`, exe],
441
+ texts: { [`${bin}\\pnpm.cmd`]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe\" %*\r\n" },
442
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} --version`]: { code: 0, stdout: "11.1.1\r\n" }, [`${exe} --version`]: { code: 0, stdout: "11.0.5\r\n" } } });
443
+ const pnpm = await h.probes.pnpm();
444
+ assert.deepEqual(pnpm, { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true, inGlobalBin: true, untrusted: true });
445
+ assert.equal(h.calls.some((call) => call.command === exe), false, "the user's pnpm never runs");
446
+ assert.deepEqual(walked, [`${bin}\\pnpm.cmd`, exe]);
447
+ // The plan reuses the bootstrap's pnpm and persists nothing over the user's.
448
+ const plan = planPreflight({ platform: "win32", arch: "x64", node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, pnpm,
449
+ pi: { available: false }, shell: { available: false }, gentleAi: { available: false }, go: { available: true, version: "1.26.0", usable: true },
450
+ globalBin: { available: true, path: bin, writable: true, onPath: true }, setup: false });
451
+ assert.deepEqual(plan.blockers, []);
452
+ assert.equal(plan.tools.pnpm.status, "reusable");
453
+ assert.equal(plan.actions.some((action: { id: string }) => action.id.startsWith("persist-")), false);
454
+ // A shim no structure resolves there is never run either: the bootstrap's pnpm is reported in its place.
455
+ const unresolved = probes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, Path: bin, GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY,
456
+ GENTLE_BOOTSTRAP_TOOLS: W_TOOLS }, files: [`${bin}\\pnpm.cmd`], texts: { [`${bin}\\pnpm.cmd`]: "@echo off\r\nvolta run %~n0 %*\r\n" },
457
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} --version`]: { code: 0, stdout: "11.1.1\r\n" } } });
458
+ assert.deepEqual(await unresolved.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true, inGlobalBin: true, unchecked: true });
459
+ assert.equal(unresolved.calls.some((call) => call.command !== W_TOOLS_NODE), false, "the user's pnpm never runs");
460
+ });
461
+
462
+ // S6 notice: every reused tool's command, and what it runs, walked in one launch.
463
+ test("Windows: the folders probe walks every tool found on the user's PATH in one launch and reports only what fails", async () => {
464
+ const goExe = "C:\\Program Files\\Go\\bin\\go.exe";
465
+ const shellCmd = `${W_APPDATA_NPM}\\gentle-shell.cmd`;
466
+ const shellEntry = `${W_NPM_ROOT}\\gentle-pi\\bin\\gentle-shell.mjs`;
467
+ const launches: string[][] = [];
468
+ const weak = { check: "parent-acl-mask", at: W_APPDATA_NPM, sid: "S-1-5-21-1-2-3-1002", account: "PC\\other", rights: "0x001301BF" };
469
+ const layout = (storageMany: (paths: string[]) => unknown) => createProbes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: `${W_TOOLS}\\node;${W_APPDATA_NPM};${W_NODE_DIR};C:\\Program Files\\Go\\bin`,
470
+ PATHEXT: ".COM;.EXE;.BAT;.CMD", GENTLE_BOOTSTRAP_TOOLS: W_TOOLS, GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
471
+ run: async () => ({ code: 1, signal: null, timedOut: false, stdout: "" }), storage: () => {}, storageMany,
472
+ fs: { isFile: async (path: string) => [`${W_APPDATA_NPM}\\pi.cmd`, W_PI_ENTRY, W_NODE, `${W_NODE_DIR}\\npm.cmd`, W_NPM_CLI, goExe, shellCmd, shellEntry].includes(path),
473
+ isDirectory: async () => false, exists: async () => false, realpath: async (path: string) => path, writable: async () => false,
474
+ readText: async (path: string) => ({ [`${W_APPDATA_NPM}\\pi.cmd`]: cmdShim("node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js"),
475
+ [`${W_NODE_DIR}\\npm.cmd`]: nodeNpmCmd, [shellCmd]: cmdShim("node_modules\\gentle-pi\\bin\\gentle-shell.mjs") } as Record<string, string>)[path] ?? Promise.reject(new Error("ENOENT")) } });
476
+ const paths = [W_NODE, `${W_NODE_DIR}\\npm.cmd`, W_NPM_CLI, goExe, `${W_APPDATA_NPM}\\pi.cmd`, W_PI_ENTRY, shellCmd, shellEntry];
477
+ const probesWith = layout((list) => { launches.push(list); return list.map((path) => (path.startsWith(W_APPDATA_NPM) ? weak : null)); });
478
+ assert.deepEqual(await probesWith.folders(), { pi: weak, shell: weak });
479
+ assert.deepEqual(launches, [paths], "one launch, each path once, never a bootstrap tool");
480
+ // Nothing weak, a policy denial or a walk that cannot finish: no notice, never a blocker.
481
+ assert.equal(await layout((list) => list.map(() => null)).folders(), null);
482
+ assert.equal(await layout(() => { throw new Error("Windows ACL evidence rejected"); }).folders(), null);
483
+ // Nothing found: nothing walked.
484
+ const none: string[][] = [];
485
+ const empty = createProbes({ platform: "win32", env: { Path: "C:\\Windows", PATHEXT: ".EXE" }, run: async () => ({ code: 1 }), storageMany: (list: string[]) => { none.push(list); return []; },
486
+ fs: { isFile: async () => false, isDirectory: async () => false, exists: async () => false, realpath: async (path: string) => path, readText: async () => "", writable: async () => false } });
487
+ assert.equal(await empty.folders(), null);
488
+ assert.deepEqual(none, []);
489
+ // POSIX has no such walk.
490
+ assert.equal(await probes({ ...userNode }).probes.folders(), null);
491
+ });
492
+
493
+ // S6 notice, pnpm's own layout: a global package is reached through pnpm's link
494
+ // global\v11\<hash>\node_modules\<pkg> (a junction into the store), and `pnpm runtime`
495
+ // links node.exe. The walk sees each file where it really is, plus the folder that
496
+ // holds each link, and never reports pnpm's link itself.
497
+ const W_PNPM = `${W_LOCAL}\\pnpm`;
498
+ const W_PNPM_BIN = `${W_PNPM}\\bin`;
499
+ const W_SCOPE = `${W_PNPM}\\global\\v11\\5f1a\\node_modules\\@earendil-works`;
500
+ const W_JUNCTION = `${W_SCOPE}\\pi-coding-agent`;
501
+ const W_STORE = `${W_PNPM}\\store`;
502
+ const W_STORE_PI = `${W_STORE}\\v11\\links\\@earendil-works\\pi-coding-agent\\1.0.0\\abc\\node_modules\\@earendil-works\\pi-coding-agent`;
503
+ const W_RUNTIME_NODE = `${W_PNPM}\\nodejs\\24.21.0\\node.exe`;
504
+ const pnpmJsShim = (target: string) => `@SETLOCAL\r\n@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "%~dp0\\${target}" %*\r\n) ELSE (\r\n @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n node "%~dp0\\${target}" %*\r\n)\r\n`;
505
+ type Finding = { check: string; at?: string } | null;
506
+ /** The walk as Windows runs it: a path through the junction stops at it; a weak folder rejects what is below it. */
507
+ function pnpmLayout({ weak = [] as string[], unreadable = [] as string[] } = {}) {
508
+ const launches: string[][] = [];
509
+ const lower = (path: string) => path.toLowerCase();
510
+ const under = (path: string, root: string) => lower(path) === lower(root) || lower(path).startsWith(`${lower(root)}\\`);
511
+ const walk = (path: string): Finding => {
512
+ if (under(path, W_JUNCTION)) return { check: "ancestor-reparse", at: W_JUNCTION };
513
+ const folder = weak.find((root) => under(path, root));
514
+ return folder ? { check: "ancestor-acl-mask", at: folder } : null;
515
+ };
516
+ const entry = `${W_JUNCTION}\\dist\\cli.js`;
517
+ const instance = createProbes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: W_PNPM_BIN, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
518
+ run: async () => ({ code: 1, signal: null, timedOut: false, stdout: "" }), storage: () => {},
519
+ storageMany: (list: string[]) => { launches.push(list); return list.map(walk); },
520
+ fs: { isFile: async (path: string) => [`${W_PNPM_BIN}\\pi.cmd`, `${W_PNPM_BIN}\\node.exe`, entry].includes(path),
521
+ isDirectory: async () => false, exists: async () => false, writable: async () => false,
522
+ isLink: async (path: string) => [lower(W_JUNCTION), lower(`${W_PNPM_BIN}\\node.exe`)].includes(lower(path)),
523
+ realpath: async (path: string) => {
524
+ if (unreadable.includes(path)) throw new Error("EACCES");
525
+ if (lower(path) === lower(`${W_PNPM_BIN}\\node.exe`)) return W_RUNTIME_NODE;
526
+ return under(path, W_JUNCTION) ? W_STORE_PI + path.slice(W_JUNCTION.length) : path;
527
+ },
528
+ readText: async (path: string) => (path === `${W_PNPM_BIN}\\pi.cmd` ? pnpmJsShim("..\\global\\v11\\5f1a\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js")
529
+ : Promise.reject(new Error("ENOENT"))) } });
530
+ return { instance, launches, entry };
531
+ }
532
+ test("Windows: pnpm's own global and runtime links are walked where they lead, so the installer's own Pi raises no notice", async () => {
533
+ const clean = pnpmLayout();
534
+ assert.equal(await clean.instance.folders(), null, "pnpm's junction is not a finding");
535
+ assert.deepEqual(clean.launches, [[W_RUNTIME_NODE, W_PNPM_BIN, `${W_PNPM_BIN}\\pi.cmd`, `${W_STORE_PI}\\dist\\cli.js`, W_SCOPE]],
536
+ "each file's real location and the folder holding each link, in one launch, never a path through the link");
537
+ // A weak ACL on the resolved folders or their ancestors is still reported.
538
+ assert.deepEqual(await pnpmLayout({ weak: [W_STORE] }).instance.folders(), { pi: { check: "ancestor-acl-mask", at: W_STORE } });
539
+ assert.deepEqual(await pnpmLayout({ weak: [`${W_PNPM}\\nodejs`] }).instance.folders(),
540
+ { node: { check: "ancestor-acl-mask", at: `${W_PNPM}\\nodejs` }, pi: { check: "ancestor-acl-mask", at: `${W_PNPM}\\nodejs` } }, "Pi runs that node.exe too");
541
+ // So is a weak folder holding the link: another account could point it elsewhere.
542
+ assert.deepEqual(await pnpmLayout({ weak: [W_SCOPE] }).instance.folders(), { pi: { check: "ancestor-acl-mask", at: W_SCOPE } });
543
+ // A file whose real location cannot be read could not be checked.
544
+ const hidden = pnpmLayout({ unreadable: [`${W_JUNCTION}\\dist\\cli.js`] });
545
+ assert.deepEqual(await hidden.instance.folders(), { pi: { check: "unchecked", at: hidden.entry } });
546
+ });
547
+
548
+ // CI on Windows: the runner's %TEMP% is C:\Users\RUNNER~1\..., and realpath gives the
549
+ // long name. An 8.3 alias is not a link: only lstat says what a link is, so no
550
+ // folder is walked as a link holder because of it (C:\Users would put C:\ at depth 1).
551
+ test("Windows: an 8.3 short-name alias is not a link, so no extra folder is walked", async () => {
552
+ const short = "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\fixture";
553
+ const long = "C:\\Users\\runneradmin\\AppData\\Local\\Temp\\fixture";
554
+ const goExe = `${short}\\linked-go\\go.exe`;
555
+ const realGo = `${long}\\store\\go\\go.exe`;
556
+ const launches: string[][] = [];
557
+ const lower = (path: string) => path.toLowerCase();
558
+ const layout = (links: string[]) => createProbes({ platform: "win32", env: { Path: `${short}\\linked-go`, PATHEXT: ".EXE" }, run: async () => ({ code: 1 }), storage: () => {},
559
+ storageMany: (list: string[]) => { launches.push(list); return list.map(() => null); },
560
+ fs: { isFile: async (path: string) => path === goExe, isDirectory: async () => false, exists: async () => false, writable: async () => false, readText: async () => "",
561
+ isLink: async (path: string) => links.map(lower).includes(lower(path)),
562
+ realpath: async (path: string) => {
563
+ const expanded = path.replace(/^C:\\Users\\RUNNER~1/i, "C:\\Users\\runneradmin");
564
+ return expanded.replace(/\\linked-go(?=\\|$)/i, "\\store\\go");
565
+ } } });
566
+ // The tests' layout: only `linked-go` is a junction; RUNNER~1 is a short name.
567
+ assert.equal(await layout([`${short}\\linked-go`]).folders(), null);
568
+ assert.deepEqual(launches.at(-1), [realGo, long], "the real file and the real folder holding the junction, never C:\\Users");
569
+ // Without any link, only the real file is walked.
570
+ await layout([]).folders();
571
+ assert.deepEqual(launches.at(-1), [realGo]);
572
+ });
573
+
574
+ // B3: a real location that is not on a local drive (a mapped network drive, or a
575
+ // symlink to a UNC share) is never sent to PowerShell: that tool could not be
576
+ // checked, and the rest of the batch is still walked in its one launch.
577
+ test("Windows: a tool whose real location is a network share is unchecked, and other tools are still walked", async () => {
578
+ const node = "Z:\\node\\node.exe";
579
+ const piCmd = `${W_APPDATA_NPM}\\pi.cmd`;
580
+ const entry = `${W_NPM_ROOT}\\@earendil-works\\pi-coding-agent\\dist\\cli.js`;
581
+ const env = { SystemRoot: "C:\\Windows", Path: `Z:\\node;${W_APPDATA_NPM}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
582
+ const launches: string[][] = [];
583
+ // The production parser, with PowerShell answering per path: the weak %APPDATA%\npm fails.
584
+ const processAdapter = (_command: string, _args: string[], childEnv: Record<string, string>) => {
585
+ const paths = childEnv.GENTLE_WINDOWS_CHECKS.split("|");
586
+ launches.push(paths);
587
+ return paths.map((path) => (path.startsWith(W_APPDATA_NPM) ? "unsafe:parent-acl-mask" : "safe")).join("\r\n");
588
+ };
589
+ const share = (path: string) => path.replace(/^Z:/i, "\\\\srv\\share");
590
+ const instance = createProbes({ platform: "win32", env, run: async () => ({ code: 1 }), storage: () => {},
591
+ storageMany: (files: string[]) => verifyWindowsStorageMany(files, env, { processAdapter, platform: "win32" }),
592
+ fs: { isFile: async (path: string) => [node, piCmd, entry].includes(path), isDirectory: async () => false, exists: async () => false, writable: async () => false,
593
+ realpath: async (path: string) => share(path),
594
+ readText: async (path: string) => (path === piCmd ? cmdShim("node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js") : Promise.reject(new Error("ENOENT"))) } });
595
+ assert.deepEqual(await instance.folders(), { node: { check: "unchecked", at: share(node) }, pi: { check: "parent-acl-mask" } },
596
+ "the share is unchecked for Node only; Pi's weak folder is still reported");
597
+ assert.equal(launches.length, 1, "one launch");
598
+ assert.deepEqual(launches[0], [piCmd, entry]);
599
+ assert.equal(launches[0].some((path) => !/^[A-Za-z]:\\/.test(path)), false, "no network path reaches PowerShell");
600
+ });
601
+
602
+ // A1: a path the walk could not check (an owner denying READ_CONTROL, for example)
603
+ // is a notice of its own; a concrete finding for the same tool comes first.
604
+ test("Windows: a tool path the walk could not check is reported as such, after any concrete finding", async () => {
605
+ const exe = "C:\\Go\\bin\\go.exe";
606
+ const cmd = `${W_APPDATA_NPM}\\pi.cmd`;
607
+ const layout = (results: Record<string, Finding>) => createProbes({ platform: "win32", env: { Path: `C:\\Go\\bin;${W_APPDATA_NPM};${W_NODE_DIR}`, PATHEXT: ".EXE;.CMD" },
608
+ run: async () => ({ code: 1 }), storage: () => {}, storageMany: (list: string[]) => list.map((path) => results[path] ?? null),
609
+ fs: { isFile: async (path: string) => [exe, cmd, W_NODE, W_PI_ENTRY].includes(path), isDirectory: async () => false, exists: async () => false,
610
+ realpath: async (path: string) => path, writable: async () => false,
611
+ readText: async (path: string) => (path === cmd ? cmdShim("node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js") : Promise.reject(new Error("ENOENT"))) } });
612
+ const weak = { check: "parent-acl-mask", at: W_NPM_ROOT };
613
+ assert.deepEqual(await layout({ [exe]: { check: "unchecked", at: exe }, [cmd]: { check: "unchecked", at: cmd }, [W_PI_ENTRY]: weak }).folders(),
614
+ { go: { check: "unchecked", at: exe }, pi: weak });
615
+ });
616
+
617
+ test("Windows npm counts by behavior whatever installed it: Node.js, nvm-windows, fnm or a Volta npm.exe", async () => {
618
+ const layouts = {
619
+ node: { dir: W_NODE_DIR, npm: "npm.cmd" },
620
+ nvm: { dir: "C:\\nvm4w\\nodejs", npm: "npm.cmd" },
621
+ fnm: { dir: `${W_LOCAL}\\fnm_multishells\\1234_1700000000000`, npm: "npm.cmd" },
622
+ volta: { dir: "C:\\Program Files\\Volta", npm: "npm.exe" },
623
+ };
624
+ for (const [name, { dir, npm }] of Object.entries(layouts)) {
625
+ const node = `${dir}\\node.exe`; const cli = `${dir}\\node_modules\\npm\\bin\\npm-cli.js`;
626
+ const command = npm === "npm.exe" ? `${dir}\\npm.exe` : node;
627
+ const prefix = npm === "npm.exe" ? [] : [cli];
628
+ const h = probes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD",
629
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
630
+ files: [node, `${dir}\\${npm}`, cli], texts: { [`${dir}\\npm.cmd`]: nodeNpmCmd },
631
+ results: { [`${node} --version`]: { code: 0, stdout: "v24.18.0\r\n" }, [[command, ...prefix, "--version"].join(" ")]: { code: 0, stdout: "11.6.2\r\n" },
632
+ [[command, ...prefix, "config", "get", "prefix"].join(" ")]: { code: 0, stdout: `${W_APPDATA_NPM}\r\n` } } });
633
+ assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, name);
634
+ }
635
+ // mise's file shim cannot be run without cmd.exe: no usable npm, so the plan persists one.
636
+ const mise = `${W_LOCAL}\\mise\\shims`;
637
+ const h = probes({ platform: "win32", env: { LOCALAPPDATA: W_LOCAL, Path: `${mise};${W_NODE_DIR}`, PATHEXT: ".COM;.EXE;.BAT;.CMD",
638
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
639
+ files: [`${mise}\\npm.cmd`, W_NODE], texts: { [`${mise}\\npm.cmd`]: "@echo off\r\nsetlocal\r\nmise x -- %*\r\n" },
640
+ results: { [`${W_NODE} --version`]: { code: 0, stdout: "v24.18.0\r\n" } } });
641
+ assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: true, npm: false });
642
+ });
643
+
644
+ function listing(dependencies: object) {
645
+ return JSON.stringify([{ path: `${PNPM_HOME}/global/v11/def`, dependencies }]);
646
+ }
647
+ const pnpmVersion = { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: "[]" } };
648
+
649
+ test("clean global list: Pi, Shell and Gentle AI absent and setup needed, from one list call", async () => {
650
+ const h = probes({ files: [TOOLS_PNPM], results: pnpmVersion });
651
+ assert.deepEqual(await h.probes.pi(), { available: false });
652
+ assert.deepEqual(await h.probes.shell(), { available: false });
653
+ assert.deepEqual(await h.probes.gentleAi(), { available: false });
654
+ assert.equal(await h.probes.setup(), false);
655
+ assert.equal(h.calls.length, 1);
656
+ assert.equal(h.calls[0].env.PATH.split(":")[0], BIN);
657
+ });
658
+
659
+ test("pnpm-global Pi and Shell report versions; verified package-native Gentle AI is compatible", async () => {
660
+ const stdout = listing({ "@earendil-works/pi-coding-agent": { version: PI_INSTALL_VERSION, path: `${PNPM_HOME}/global/v11/def/node_modules/pi` },
661
+ "gentle-pi": { version: requirements.shell, path: SHELL_ROOT } });
662
+ const files = [TOOLS_PNPM, `${BIN}/gentle-shell`, gentleAiBinaryPath(SHELL_ROOT, "linux")];
663
+ const h = probes({ files, dirs: [HOME, PNPM_HOME, SHELL_ROOT], texts: besidePi(PI_INSTALL_VERSION), results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } });
664
+ assert.deepEqual(await h.probes.pi(), { available: true, version: PI_INSTALL_VERSION, usable: true });
665
+ assert.deepEqual(await h.probes.shell(), { available: true, version: requirements.shell, usable: true, global: true, owner: "pnpm" });
666
+ assert.deepEqual(await h.probes.gentleAi(), { available: true, version: requirements.gentleAi, usable: true, compatible: true });
667
+ assert.deepEqual(h.integrityCalls, [{ packageRoot: SHELL_ROOT, platform: "linux", env: bootstrapEnv, home: HOME }]);
668
+ // The pinned stack in one pnpm project under PNPM_HOME: only its setup may be rerun.
669
+ assert.deepEqual(await h.probes.setup(), { available: true, recoverable: true });
670
+ const missingBin = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } });
671
+ assert.equal((await missingBin.probes.shell()).usable, false);
672
+ });
673
+
674
+ test("setup is recoverable only for the pinned Pi and Shell owned by one pnpm project under PNPM_HOME", async () => {
675
+ const PI = "@earendil-works/pi-coding-agent";
676
+ const pi = { version: PI_INSTALL_VERSION, path: `${PNPM_HOME}/global/v11/def/node_modules/pi` };
677
+ const shell = { version: requirements.shell, path: SHELL_ROOT };
678
+ const setup = (stdout: string, extra: { realpaths?: Record<string, string>; texts?: Record<string, string> } = {}) => probes({ files: [TOOLS_PNPM],
679
+ dirs: [HOME, PNPM_HOME, SHELL_ROOT, "/elsewhere/gentle-pi"], texts: besidePi(PI_INSTALL_VERSION),
680
+ results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } }, ...extra }).probes.setup();
681
+ const recoverable = { available: true, recoverable: true };
682
+ assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell })), recoverable);
683
+ // The npm and pnpm persisted by an earlier run live in their own project and do not matter.
684
+ const persisted = { dependencies: { npm: { version: "11.19.0" }, pnpm: { version: "11.1.1" } } };
685
+ assert.deepEqual(await setup(JSON.stringify([persisted, { dependencies: { [PI]: pi, "gentle-pi": shell } }])), recoverable);
686
+ const foreign = [
687
+ listing({ [PI]: { ...pi, version: "1.2.1" }, "gentle-pi": shell }),
688
+ listing({ [PI]: pi, "gentle-pi": { ...shell, version: "3.0.0" } }),
689
+ listing({ "gentle-pi": shell }),
690
+ JSON.stringify([{ dependencies: { [PI]: pi, "gentle-pi": shell } }, { dependencies: { [PI]: pi } }]),
691
+ JSON.stringify([{ dependencies: { "gentle-pi": shell } }, { dependencies: { [PI]: pi } }]),
692
+ JSON.stringify([{ dependencies: { [PI]: pi, "gentle-pi": shell } }, { devDependencies: { "gentle-pi": shell } }]),
693
+ listing({ [PI]: pi, "gentle-pi": { ...shell, path: "/elsewhere/gentle-pi" } }),
694
+ listing({ [PI]: pi, "gentle-pi": { version: requirements.shell } }),
695
+ ];
696
+ for (const stdout of foreign) assert.deepEqual(await setup(stdout), { available: null }, stdout);
697
+ // A root that resolves outside PNPM_HOME through a symlink is not this pnpm's install.
698
+ assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { realpaths: { [SHELL_ROOT]: "/elsewhere/gentle-pi" } }),
699
+ { available: null });
700
+ // The Pi beside gentle-pi, which the launcher runs, may be pnpm's newer peer, but never below the minimum, unreadable or missing.
701
+ assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { texts: besidePi("1.2.1") }), recoverable);
702
+ for (const texts of [besidePi("0.99.0"), { [BESIDE_PI]: "not json" }, { [`${PNPM_HOME}/elsewhere`]: "" }]) {
703
+ assert.deepEqual(await setup(listing({ [PI]: pi, "gentle-pi": shell }), { texts }), { available: null }, JSON.stringify(texts));
704
+ }
705
+ });
706
+
707
+ test("Gentle AI is absent without its binary, unknown when unverified, unconfined or another Shell version", async () => {
708
+ const shell = (version: string, path = SHELL_ROOT) => ({ [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: listing({ "gentle-pi": { version, path } }) } });
709
+ const binary = gentleAiBinaryPath(SHELL_ROOT, "linux");
710
+ const dirs = [HOME, PNPM_HOME, SHELL_ROOT];
711
+ assert.deepEqual(await probes({ files: [TOOLS_PNPM], dirs, results: shell(requirements.shell) }).probes.gentleAi(), { available: false });
712
+ const unverified = probes({ files: [TOOLS_PNPM, binary], dirs, results: shell(requirements.shell), integrity: { ok: false } });
713
+ assert.deepEqual(await unverified.probes.gentleAi(), { available: null });
714
+ assert.deepEqual(await probes({ files: [TOOLS_PNPM, binary], dirs, results: shell("3.0.0") }).probes.gentleAi(), { available: null });
715
+ const outside = probes({ files: [TOOLS_PNPM, "/elsewhere/gentle-pi"], dirs: [...dirs, "/elsewhere/gentle-pi"],
716
+ results: shell(requirements.shell, "/elsewhere/gentle-pi") });
717
+ assert.deepEqual(await outside.probes.gentleAi(), { available: null });
718
+ });
719
+
720
+ test("a failed, unparseable or ambiguous global list makes Pi, Shell, Gentle AI and setup unknown", async () => {
721
+ const duplicated = JSON.stringify([{ dependencies: { "gentle-pi": { version: "4.0.0" } } }, { dependencies: { "gentle-pi": { version: "4.0.0" } } }]);
722
+ for (const result of [{ code: 1, stdout: "[]" }, { code: 0, stdout: "" }, { code: 0, stdout: "{}" }, { code: 0, stdout: "not json" },
723
+ { code: 0, timedOut: true, stdout: "[]" }, { code: 0, truncated: true, stdout: "[]" }]) {
724
+ const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} ${LIST}`]: result } });
725
+ for (const name of ["pi", "shell", "gentleAi", "setup"] as const) assert.deepEqual(await h.probes[name](), { available: null }, name);
726
+ }
727
+ const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: duplicated } } });
728
+ assert.deepEqual(await h.probes.shell(), { available: null });
729
+ });
730
+
731
+ test("a pnpm-global Shell from the main channel is present with its main version; other prereleases stay unknown", async () => {
732
+ const main = `${requirements.shell}-main.6e7e3a18f794`;
733
+ const cases: Array<[string, object]> = [[main, { available: true, version: main, usable: true, global: true, owner: "pnpm" }],
734
+ [`${requirements.shell}-rc.1`, { available: null }], [`${requirements.shell}-main.6E7E3A18F794`, { available: null }]];
735
+ for (const [version, expected] of cases) {
736
+ const stdout = listing({ "gentle-pi": { version, path: SHELL_ROOT } });
737
+ const h = probes({ files: [TOOLS_PNPM, `${BIN}/gentle-shell`], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } });
738
+ assert.deepEqual(await h.probes.shell(), expected, version);
739
+ }
740
+ });
741
+
742
+ const NPM_ROOT = "/usr/local/lib/node_modules";
743
+ const NPM_SHELL = `${NPM_ROOT}/gentle-pi`;
744
+ const outside = (shellVersion: string, { linked = false, piOutput = "1.2.4\n" } = {}) => probes({
745
+ env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/local/bin:/usr/bin` },
746
+ files: [TOOLS_PNPM, "/usr/local/bin/pi", "/usr/local/bin/gentle-shell", "/usr/local/bin/npm"],
747
+ dirs: [HOME, NPM_ROOT, "/home/u/work/gentle-pi"],
748
+ realpaths: { "/usr/local/bin/gentle-shell": `${linked ? "/home/u/work/gentle-pi" : NPM_SHELL}/bin/gentle-shell.mjs` },
749
+ texts: { [`${linked ? "/home/u/work/gentle-pi" : NPM_SHELL}/package.json`]: JSON.stringify({ name: "gentle-pi", version: shellVersion }) },
750
+ results: { ...pnpmVersion, "/usr/local/bin/npm root -g": { code: 0, stdout: `${NPM_ROOT}\n` },
751
+ "/usr/local/bin/pi --version": { code: 0, stdout: piOutput } },
752
+ });
753
+
754
+ test("a Pi on PATH that pnpm does not manage is reused with the version it reports", async () => {
755
+ assert.deepEqual(await outside("3.9.0").probes.pi(), { available: true, version: "1.2.4", usable: true, external: true });
756
+ for (const piOutput of ["", "pi dev build\n"]) {
757
+ assert.deepEqual(await outside("3.9.0", { piOutput }).probes.pi(), { available: null, outsidePnpm: true }, piOutput);
758
+ }
759
+ });
760
+
761
+ const PI_PACKAGE = "@earendil-works/pi-coding-agent";
762
+ const PI_ROOT = `${PNPM_HOME}/global/v11/abc/node_modules/${PI_PACKAGE}`;
763
+ const NPM_PI = `${NPM_ROOT}/${PI_PACKAGE}`;
764
+ // A Pi on PATH outside pnpm: npm's own global root, or another installation such as mise.
765
+ const outsidePi = (version: string, root = NPM_PI) => probes({
766
+ env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/local/bin:/usr/bin` },
767
+ files: [TOOLS_PNPM, "/usr/local/bin/pi", "/usr/local/bin/npm"],
768
+ dirs: [HOME, NPM_ROOT],
769
+ realpaths: { "/usr/local/bin/pi": `${root}/dist/cli.js` },
770
+ texts: { [`${root}/package.json`]: JSON.stringify({ name: PI_PACKAGE, version }) },
771
+ results: { ...pnpmVersion, "/usr/local/bin/npm root -g": { code: 0, stdout: `${NPM_ROOT}\n` },
772
+ "/usr/local/bin/pi --version": { code: 0, stdout: `${version}\n` } },
773
+ });
774
+
775
+ test("an older pnpm-global Pi reports pnpm as its owner, and locatePi finds its real root", async () => {
776
+ const older = probes({ files: [TOOLS_PNPM], dirs: [HOME, PI_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0,
777
+ stdout: listing({ [PI_PACKAGE]: { version: "0.87.1", path: PI_ROOT } }) } } });
778
+ assert.deepEqual(await older.probes.pi(), { available: true, version: "0.87.1", usable: true, owner: "pnpm" });
779
+ assert.deepEqual(await older.probes.locatePi(), { root: PI_ROOT, version: "0.87.1", owner: "pnpm" });
780
+ // A current Pi keeps its shape: nothing about it is updated.
781
+ const current = probes({ files: [TOOLS_PNPM], dirs: [HOME, PI_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0,
782
+ stdout: listing({ [PI_PACKAGE]: { version: PI_INSTALL_VERSION, path: PI_ROOT } }) } } });
783
+ assert.deepEqual(await current.probes.pi(), { available: true, version: PI_INSTALL_VERSION, usable: true });
784
+ assert.deepEqual(await current.probes.locatePi(), { root: PI_ROOT, version: PI_INSTALL_VERSION, owner: "pnpm" });
785
+ // Listed twice (two global projects): ambiguous, so nothing is located.
786
+ const twice = probes({ files: [TOOLS_PNPM], dirs: [HOME, PI_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0,
787
+ stdout: JSON.stringify([{ dependencies: { [PI_PACKAGE]: { version: "0.87.1", path: PI_ROOT } } },
788
+ { dependencies: { [PI_PACKAGE]: { version: PI_INSTALL_VERSION, path: PI_ROOT } } }]) } } });
789
+ assert.deepEqual(await twice.probes.pi(), { available: null });
790
+ assert.equal(await twice.probes.locatePi(), null);
791
+ });
792
+
793
+ test("a pnpm-global Pi newer than the installer's pin reports no owner: it is reused as it is", async () => {
794
+ for (const version of ["1.2.1", "1.3.0", "2.0.0"]) {
795
+ const newer = probes({ files: [TOOLS_PNPM], dirs: [HOME, PI_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0,
796
+ stdout: listing({ [PI_PACKAGE]: { version, path: PI_ROOT } }) } } });
797
+ assert.deepEqual(await newer.probes.pi(), { available: true, version, usable: true }, version);
798
+ }
799
+ // A newer Pi from elsewhere is reused as it is: pnpm does not own it.
800
+ assert.deepEqual(await outsidePi("1.2.1").probes.pi(), { available: true, version: "1.2.1", usable: true, external: true });
801
+ });
802
+
803
+ test("an older Pi in npm's global root reports npm as its owner; one elsewhere has no owner", async () => {
804
+ const npm = outsidePi("0.87.1");
805
+ assert.deepEqual(await npm.probes.pi(), { available: true, version: "0.87.1", usable: true, external: true, owner: "npm" });
806
+ assert.deepEqual(await npm.probes.locatePi(), { root: NPM_PI, version: "0.87.1", owner: "npm" });
807
+ const mise = outsidePi("0.87.1", "/home/u/.local/share/mise/installs/pi/lib/node_modules/@earendil-works/pi-coding-agent");
808
+ assert.deepEqual(await mise.probes.pi(), { available: true, version: "0.87.1", usable: true, external: true });
809
+ assert.equal((await mise.probes.locatePi())?.owner, null);
810
+ // A current npm Pi is reused as before, without asking npm where its global root is.
811
+ const current = outsidePi(PI_INSTALL_VERSION);
812
+ assert.deepEqual(await current.probes.pi(), { available: true, version: PI_INSTALL_VERSION, usable: true, external: true });
813
+ assert.equal(current.calls.some((call) => call.args.join(" ") === "root -g"), false);
814
+ // No Pi at all: nothing is located.
815
+ assert.equal(await probes({ files: [TOOLS_PNPM], results: pnpmVersion }).probes.locatePi(), null);
816
+ });
817
+
818
+ test("after the installer's Pi is added next to another one, the next probe reuses the pnpm-global Pi and never runs the other", async () => {
819
+ const h = probes({ env: { HOME, PATH: `${TOOLS}/pnpm/bin:/home/u/.local/share/mise/shims:/usr/bin` },
820
+ files: [TOOLS_PNPM, "/home/u/.local/share/mise/shims/pi"], dirs: [HOME, PI_ROOT],
821
+ results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: listing({ [PI_PACKAGE]: { version: PI_INSTALL_VERSION, path: PI_ROOT } }) } } });
822
+ assert.deepEqual(await h.probes.pi(), { available: true, version: PI_INSTALL_VERSION, usable: true });
823
+ assert.equal(h.calls.some((call) => call.command.includes("mise")), false);
824
+ assert.equal(planPreflight({ platform: "linux", arch: "x64", pi: await h.probes.pi() }).tools.pi.status, "reusable");
825
+ });
826
+
827
+ test("a Gentle Shell installed by npm reports its version and npm as its owner", async () => {
828
+ for (const version of ["3.9.0", `${requirements.shell}-main.6e7e3a18f794`]) {
829
+ assert.deepEqual(await outside(version).probes.shell(), { available: true, version, usable: true, global: true, owner: "npm" }, version);
830
+ }
831
+ });
832
+
833
+ test("a Gentle Shell that npm links from a source checkout stays unknown and is never claimed by npm", async () => {
834
+ assert.deepEqual(await outside("3.9.0", { linked: true }).probes.shell(), { available: null, outsidePnpm: true });
835
+ });
836
+
837
+ test("locateShell finds the installed Gentle Shell with its real root, version and owner", async () => {
838
+ const stdout = listing({ "gentle-pi": { version: "3.9.0", path: SHELL_ROOT } });
839
+ const pnpm = probes({ files: [TOOLS_PNPM], dirs: [HOME, SHELL_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } });
840
+ assert.deepEqual(await pnpm.probes.locateShell(), { root: SHELL_ROOT, version: "3.9.0", owner: "pnpm" });
841
+ assert.deepEqual(await outside("3.9.0").probes.locateShell(), { root: NPM_SHELL, version: "3.9.0", owner: "npm" });
842
+ assert.deepEqual(await outside("3.9.0", { linked: true }).probes.locateShell(), { root: "/home/u/work/gentle-pi", version: "3.9.0", owner: null });
843
+ assert.equal(await probes({ files: [TOOLS_PNPM], results: pnpmVersion }).probes.locateShell(), null);
844
+ });
845
+
846
+ test("Pi or Shell on the user's PATH but not pnpm-global is unknown, never absent, and says so", async () => {
847
+ const h = probes({ files: [TOOLS_PNPM, "/usr/local/bin/pi", "/usr/local/bin/gentle-shell"],
848
+ env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/local/bin` }, results: pnpmVersion });
849
+ assert.deepEqual(await h.probes.pi(), { available: null, outsidePnpm: true });
850
+ assert.deepEqual(await h.probes.shell(), { available: null, outsidePnpm: true });
851
+ assert.deepEqual(await h.probes.setup(), { available: null });
852
+ });
853
+
854
+ test("go version parsing, absence, devel builds and timeouts", async () => {
855
+ const env = { HOME, PATH: "/usr/local/go/bin:/usr/bin" };
856
+ for (const [stdout, version] of [["go version go1.26.0 linux/amd64\n", "1.26.0"], ["go version go1.22 linux/arm64", "1.22.0"]]) {
857
+ const h = probes({ env, files: ["/usr/local/go/bin/go"], results: { "/usr/local/go/bin/go version": { code: 0, stdout } } });
858
+ assert.deepEqual(await h.probes.go(), { available: true, version, usable: true });
859
+ }
860
+ for (const result of [{ code: 0, stdout: "go version devel go1.27-abc linux/amd64" }, { code: 0, stdout: "go version go1.26rc1 linux/amd64" },
861
+ { code: 0, timedOut: true, stdout: "go version go1.26.0 linux/amd64" }]) {
862
+ const h = probes({ env, files: ["/usr/local/go/bin/go"], results: { "/usr/local/go/bin/go version": result } });
863
+ assert.deepEqual(await h.probes.go(), { available: null });
864
+ }
865
+ assert.deepEqual(await probes({ env }).probes.go(), { available: false });
866
+ });
867
+
868
+ test("Windows PowerShell's PATHEXT with .CPL keeps resolving, and a .cpl found first is never run", async () => {
869
+ // The wizard inherits the helper's environment, which Windows PowerShell 5.1
870
+ // extends with .CPL. lookPath keeps PATH-then-PATHEXT order; spawnable refuses it.
871
+ const env = { USERPROFILE: "C:\\Users\\u", LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", Path: "C:\\Early;C:\\Go\\bin",
872
+ PATHEXT: ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL" };
873
+ const go = "C:\\Go\\bin\\go.exe";
874
+ const found = probes({ platform: "win32", env, files: [go], results: { [`${go} version`]: { code: 0, stdout: "go version go1.26.0 windows/amd64\r\n" } } });
875
+ assert.deepEqual(await found.probes.go(), { available: true, version: "1.26.0", usable: true });
876
+ for (const name of ["go", "node"] as const) {
877
+ const shadowed = probes({ platform: "win32", env, files: [go, `C:\\Early\\${name}.cpl`, "C:\\Go\\bin\\node.exe"] });
878
+ assert.deepEqual(await shadowed.probes[name](), { available: null }, name);
879
+ assert.deepEqual(shadowed.calls, [], `${name}.cpl is never executed`);
880
+ }
881
+ });
882
+
883
+ test("globalBin writability comes from the nearest existing ancestor of $PNPM_HOME/bin, without writing", async () => {
884
+ const share = "/home/u/.local/share";
885
+ assert.deepEqual(await probes({ dirs: [HOME, share], writable: [share] }).probes.globalBin(),
886
+ { available: true, path: BIN, writable: true, onPath: false });
887
+ assert.equal((await probes({ dirs: [HOME, share], writable: [HOME] }).probes.globalBin()).writable, false);
888
+ assert.equal((await probes({ dirs: [HOME, PNPM_HOME, BIN], writable: [BIN] }).probes.globalBin()).writable, true);
889
+ assert.equal((await probes({ dirs: [HOME], others: [`${HOME}/.local`], writable: [HOME, `${HOME}/.local`] }).probes.globalBin()).writable, false);
890
+ const onPath = await probes({ env: { HOME, PATH: `${BIN}:/usr/bin` }, dirs: [HOME, PNPM_HOME, BIN], writable: [BIN] }).probes.globalBin();
891
+ assert.deepEqual(onPath, { available: true, path: BIN, writable: true, onPath: true });
892
+ assert.deepEqual(await probes({ env: { PATH: "/usr/bin" } }).probes.globalBin(), { available: null });
893
+ assert.deepEqual(await probes({ dirs: [], writable: [] }).probes.globalBin(), { available: null });
894
+ });
895
+
896
+ test("real probes feed collectInventory; a bootstrap-only clean Linux host plans runtime persistence", async () => {
897
+ const h = probes({ files: [...bootstrapNode.files], realpaths: bootstrapNode.realpaths, texts: bootstrapNode.texts,
898
+ dirs: [HOME, "/home/u/.local/share"], writable: ["/home/u/.local/share"],
899
+ results: { ...bootstrapNode.results, [`${TOOLS_PNPM} --version`]: { code: 0, stdout: "11.1.1\n" }, ...pnpmVersion } });
900
+ const inventory = await collectInventory({ platform: "linux", arch: "x64", probes: h.probes });
901
+ assert.deepEqual(h.unexpected, []);
902
+ const plan = planPreflight(inventory);
903
+ assert.deepEqual(plan.blockers, []);
904
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id), ["setup-global-bin", "persist-node", "persist-package-managers",
905
+ "configure-npm-prefix", "install-pi", "install-shell", "setup-shell", "verify-readiness"]);
906
+ // Only fixed read-only argv ever runs: versions and the global listing.
907
+ for (const call of h.calls) assert.match(call.args.join(" "), /^(--version|list -g --depth 0 --json)$/);
908
+ });
909
+
910
+ test("host adapters: argv without a shell, exit codes, deadlines and bounded output", async () => {
911
+ const { run } = hostAdapters({ maxOutputBytes: 64 });
912
+ const env = { PATH: process.env.PATH ?? "" };
913
+ const ok = await run(process.execPath, ["-e", "process.stdout.write('ok $HOME')"], { env, deadlineMs: 10_000 });
914
+ assert.deepEqual(ok, { code: 0, signal: null, timedOut: false, truncated: false, stdout: "ok $HOME" });
915
+ assert.equal((await run(process.execPath, ["-e", "process.exit(3)"], { env, deadlineMs: 10_000 })).code, 3);
916
+ const started = Date.now();
917
+ const slow = await run(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { env, deadlineMs: 200 });
918
+ assert.equal(slow.timedOut, true);
919
+ assert.equal(slow.signal, "SIGKILL");
920
+ assert.ok(Date.now() - started < 10_000);
921
+ const big = await run(process.execPath, ["-e", "process.stdout.write('x'.repeat(5000))"], { env, deadlineMs: 10_000 });
922
+ assert.equal(big.truncated, true);
923
+ assert.equal(big.stdout.length, 64);
924
+ const missing = await run(join(tmpdir(), "gentle-probe-missing-command"), [], { env, deadlineMs: 10_000 });
925
+ assert.equal(missing.code, null);
926
+ assert.equal(missing.timedOut, false);
927
+ });
928
+
929
+ test("host run runs in the requested working directory, and in the current one without it", async () => {
930
+ const { run } = hostAdapters();
931
+ const env = { PATH: process.env.PATH ?? "" };
932
+ const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-probe-cwd-")));
933
+ try {
934
+ const inside = await run(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { env, cwd: directory, deadlineMs: 10_000 });
935
+ assert.equal(inside.stdout, directory);
936
+ const ambient = await run(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { env, deadlineMs: 10_000 });
937
+ assert.equal(ambient.stdout, process.cwd());
938
+ } finally {
939
+ rmSync(directory, { recursive: true, force: true });
940
+ }
941
+ });
942
+
943
+ test("host run discards stderr unless a bounded stderr tail is requested", async () => {
944
+ const { run } = hostAdapters();
945
+ const env = { PATH: process.env.PATH ?? "" };
946
+ const noisy = "process.stderr.write('head-'.repeat(2000) + 'Error: tail line'); process.stdout.write('out'); process.exit(1)";
947
+ const discarded = await run(process.execPath, ["-e", noisy], { env, deadlineMs: 10_000 });
948
+ assert.deepEqual(discarded, { code: 1, signal: null, timedOut: false, truncated: false, stdout: "out" });
949
+ const tailed = await run(process.execPath, ["-e", noisy], { env, deadlineMs: 10_000, stderrTail: 32 });
950
+ assert.equal(tailed.code, 1);
951
+ assert.equal(tailed.stdout, "out");
952
+ assert.equal(tailed.stderrTail, `${"head-".repeat(2000)}Error: tail line`.slice(-32));
953
+ const quiet = await run(process.execPath, ["-e", "process.exit(0)"], { env, deadlineMs: 10_000, stderrTail: 32 });
954
+ assert.equal(quiet.stderrTail, "");
955
+ // Larger requests are clamped to the 4-KiB ceiling; invalid ones keep stderr discarded.
956
+ const clamped = await run(process.execPath, ["-e", noisy], { env, deadlineMs: 10_000, stderrTail: 1_000_000 });
957
+ assert.equal(Buffer.byteLength(clamped.stderrTail), 4096);
958
+ for (const stderrTail of [0, -1, 1.5, "32", null]) {
959
+ assert.equal("stderrTail" in await run(process.execPath, ["-e", noisy], { env, deadlineMs: 10_000, stderrTail }), false, String(stderrTail));
960
+ }
961
+ const slow = await run(process.execPath, ["-e", "process.stderr.write('before kill'); setTimeout(() => {}, 30000)"], { env, deadlineMs: 500, stderrTail: 64 });
962
+ assert.equal(slow.timedOut, true);
963
+ assert.equal(slow.signal, "SIGKILL");
964
+ assert.equal(slow.stderrTail, "before kill");
965
+ });
966
+
967
+ test("host fs adapter is read-only and bounded", async () => {
968
+ const dir = mkdtempSync(join(tmpdir(), "gentle-probes-fs-"));
969
+ try {
970
+ const { fs } = hostAdapters({ maxTextBytes: 16 });
971
+ const file = join(dir, "small.txt");
972
+ writeFileSync(file, "hello");
973
+ writeFileSync(join(dir, "large.txt"), "x".repeat(64));
974
+ mkdirSync(join(dir, "locked"));
975
+ chmodSync(join(dir, "locked"), 0o500);
976
+ symlinkSync(join(dir, "missing-target"), join(dir, "dangling"));
977
+ assert.equal(await fs.isFile(file), true);
978
+ assert.equal(await fs.isDirectory(dir), true);
979
+ assert.equal(await fs.isFile(join(dir, "nope")), false);
980
+ assert.equal(await fs.exists(join(dir, "dangling")), true);
981
+ assert.equal(await fs.isDirectory(join(dir, "dangling")), false);
982
+ assert.equal(await fs.exists(join(dir, "nope")), false);
983
+ assert.deepEqual([await fs.isLink(join(dir, "dangling")), await fs.isLink(file), await fs.isLink(dir), await fs.isLink(join(dir, "nope"))], [true, false, false, false]);
984
+ assert.equal(await fs.readText(file), "hello");
985
+ await assert.rejects(fs.readText(join(dir, "large.txt")));
986
+ assert.equal(await fs.writable(dir), true);
987
+ // chmod 0o500 does not make a Windows directory read-only; root bypasses mode bits.
988
+ if (process.platform !== "win32" && process.getuid?.() !== 0) assert.equal(await fs.writable(join(dir, "locked")), false);
989
+ // The adapter uses the native realpath: Windows runners may expose %TEMP% as an
990
+ // 8.3 short path (RUNNER~1) that only the native call expands to its long form.
991
+ assert.equal(await fs.realpath(join(dir, ".", "small.txt")), realpathSync.native(file));
992
+ } finally {
993
+ chmodSync(join(dir, "locked"), 0o700);
994
+ rmSync(dir, { recursive: true, force: true });
995
+ }
996
+ });
997
+
998
+ test("host run settles at the deadline even when the child's stdout never closes", async () => {
999
+ const { EventEmitter } = await import("node:events");
1000
+ const killed: string[] = [];
1001
+ let destroyed = false;
1002
+ const stdout = Object.assign(new EventEmitter(), { destroy: () => { destroyed = true; } });
1003
+ // A child whose direct process ignores the kill and whose stdout stays open (as with a grandchild holding it).
1004
+ const child = Object.assign(new EventEmitter(), { stdout, stdin: null, stderr: null, kill: (signal: string) => { killed.push(signal); return true; } });
1005
+ const spawned: unknown[][] = [];
1006
+ const { run } = hostAdapters({ spawn: (...args: unknown[]) => { spawned.push(args); setTimeout(() => stdout.emit("data", Buffer.from("partial")), 5); return child; } });
1007
+ const started = Date.now();
1008
+ const result = await Promise.race([
1009
+ run("/fake/tool", ["--version"], { env: {}, deadlineMs: 50 }),
1010
+ new Promise((resolve) => setTimeout(() => resolve("still pending"), 2000)),
1011
+ ]);
1012
+ assert.deepEqual(result, { code: null, signal: "SIGKILL", timedOut: true, truncated: false, stdout: "partial" });
1013
+ assert.ok(Date.now() - started < 1500);
1014
+ assert.deepEqual(killed, ["SIGKILL"]);
1015
+ assert.equal(destroyed, true);
1016
+ assert.deepEqual(spawned[0].slice(0, 2), ["/fake/tool", ["--version"]]);
1017
+ assert.equal((spawned[0][2] as { shell: boolean }).shell, false);
1018
+ // A late close after the deadline does not change the settled result.
1019
+ child.emit("close", 0, null);
1020
+ });
1021
+
1022
+ // S6: a pnpm whose storage fails the walk is never run, not even with --version.
1023
+ const W_PRIVATE = "C:\\Users\\u\\.pnpm";
1024
+ test("Windows: the user's pnpm in a folder another principal may write is never run; the bootstrap's pnpm is used", async () => {
1025
+ const bin = `${W_LOCAL}\\pnpm\\bin`;
1026
+ const exe = `${W_LOCAL}\\pnpm\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe`;
1027
+ const walked: string[] = [];
1028
+ const layout = (storage: (file: string) => void) => probes({ platform: "win32", storage,
1029
+ env: { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", PNPM_HOME: W_PRIVATE, Path: bin, GENTLE_BOOTSTRAP_TOOLS: W_TOOLS,
1030
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY },
1031
+ files: [`${bin}\\pnpm.cmd`, exe],
1032
+ texts: { [`${bin}\\pnpm.cmd`]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\5f1a\\node_modules\\@pnpm\\exe\\pnpm.exe\" %*\r\n" },
1033
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} --version`]: { code: 0, stdout: "11.1.1\r\n" }, [`${exe} --version`]: { code: 0, stdout: "10.0.0\r\n" } } });
1034
+ const weak = layout((file) => {
1035
+ walked.push(file);
1036
+ if (file.startsWith(`${W_LOCAL}\\pnpm`)) throw Object.assign(new Error("Windows ACL evidence rejected"), { check: "parent-acl-mask" });
1037
+ });
1038
+ assert.deepEqual(await weak.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false });
1039
+ assert.equal(weak.calls.some((call) => call.command === exe), false, "the untrusted pnpm.exe never runs");
1040
+ assert.ok(walked.includes(`${bin}\\pnpm.cmd`), "its shim is walked before anything runs");
1041
+ // Any failed walk, not only a rejection code, means it is not usable.
1042
+ const unknown = layout(() => { throw new Error("Windows prerequisite process failed"); });
1043
+ await unknown.probes.pnpm();
1044
+ assert.equal(unknown.calls.some((call) => call.command === exe), false);
1045
+ // A trusted one is still run for the version it reports, as before.
1046
+ const trusted = layout(() => {});
1047
+ assert.deepEqual(await trusted.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false, found: "10.0.0" });
1048
+ });
1049
+
1050
+ test("Windows: a private PNPM_HOME not created yet holds no global packages, so pnpm list -g never runs before consent", async () => {
1051
+ const env = { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", PNPM_HOME: W_PRIVATE, Path: "C:\\Windows",
1052
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY };
1053
+ const pnpmHome = { available: true, path: W_PRIVATE, source: "private", rejected: { path: `${W_LOCAL}\\pnpm`, check: "target-acl-mask" } };
1054
+ const absentHome = probes({ platform: "win32", env, pnpmHome, dirs: ["C:\\Users\\u"] });
1055
+ assert.deepEqual([await absentHome.probes.shell(), await absentHome.probes.pi(), await absentHome.probes.gentleAi(), await absentHome.probes.setup()],
1056
+ [{ available: false }, { available: false }, { available: false }, false]);
1057
+ assert.deepEqual(absentHome.calls, []);
1058
+ // Once it exists (created by an earlier run), its packages are listed as usual.
1059
+ const existing = probes({ platform: "win32", env, pnpmHome, dirs: ["C:\\Users\\u", W_PRIVATE],
1060
+ results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} ${LIST}`]: { code: 0, stdout: "[]" } } });
1061
+ assert.deepEqual(await existing.probes.shell(), { available: false });
1062
+ assert.equal(existing.calls.length, 1);
1063
+ assert.equal(existing.calls[0].env.PNPM_HOME, W_PRIVATE);
1064
+ });
1065
+
1066
+ test("Windows: with a blocked PNPM_HOME decision the real probes run no command and the plan blocks", async () => {
1067
+ const pnpmHome = { available: true, path: `${W_LOCAL}\\pnpm`, source: "default", installed: true,
1068
+ untrusted: { check: "target-acl-mask", at: `${W_LOCAL}\\pnpm`, sid: "S-1-5-21-1", rights: "0x001301BF" } };
1069
+ const env = { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", Path: `${W_LOCAL}\\pnpm\\bin;${W_NODE_DIR}`, PATHEXT: ".COM;.EXE;.BAT;.CMD",
1070
+ GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY };
1071
+ const walked: string[] = [];
1072
+ const h = probes({ platform: "win32", env, pnpmHome, storage: (file) => { walked.push(file); },
1073
+ files: [W_NODE, `${W_NODE_DIR}\\npm.cmd`, W_NPM_CLI, `${W_LOCAL}\\pnpm\\bin\\npm.cmd`], texts: { [`${W_NODE_DIR}\\npm.cmd`]: nodeNpmCmd },
1074
+ results: { [`${W_NODE} --version`]: { code: 0, stdout: "v24.18.0\r\n" }, [`${W_NODE} ${W_NPM_CLI} --version`]: { code: 0, stdout: "11.6.2\r\n" } } });
1075
+ const inventory = await collectInventory({ platform: "win32", arch: "x64", probes: h.probes, pnpmHome });
1076
+ assert.deepEqual([h.calls, walked], [[], []], "no command runs and nothing is walked");
1077
+ assert.deepEqual(planPreflight(inventory).blockers, [{ code: "untrusted-pnpm-home", tool: "pnpmHome" }]);
1078
+ });
1079
+
1080
+ // Git is not needed to install, but review, worktrees and repository features use it:
1081
+ // the plan only notes when it is missing. Git itself is never run.
1082
+ test("the git probe reports whether Git is on the user's PATH without running it", async () => {
1083
+ assert.deepEqual(await probes({ files: ["/usr/bin/git"] }).probes.git(), { available: true });
1084
+ assert.deepEqual(await probes().probes.git(), { available: false });
1085
+ // macOS: /usr/bin/git is a shim that opens an install dialog without the Command Line
1086
+ // Tools, so only `xcode-select -p` is asked, which never opens one.
1087
+ const env = { HOME, PATH: "/usr/bin:/bin" };
1088
+ for (const [code, available] of [[0, true], [2, false]] as const) {
1089
+ const mac = probes({ platform: "darwin", env, files: ["/usr/bin/git"], results: { "/usr/bin/xcode-select -p": { code, stdout: "/Library/Developer/CommandLineTools\n" } } });
1090
+ assert.deepEqual(await mac.probes.git(), { available }, String(code));
1091
+ assert.deepEqual(mac.calls.map((call) => call.command), ["/usr/bin/xcode-select"]);
1092
+ }
1093
+ // Another Git first on PATH (Homebrew, for example) is real Git.
1094
+ const brew = probes({ platform: "darwin", env: { HOME, PATH: "/opt/homebrew/bin:/usr/bin:/bin" }, files: ["/opt/homebrew/bin/git", "/usr/bin/git"] });
1095
+ assert.deepEqual(await brew.probes.git(), { available: true });
1096
+ assert.deepEqual(brew.calls, []);
1097
+ });
1098
+
1099
+ test("collectInventory keeps the git probe's record only when the probe exists", async () => {
1100
+ const withGit = await collectInventory({ platform: "linux", arch: "x64", probes: { git: async () => ({ available: false }) } });
1101
+ assert.deepEqual(withGit.git, { available: false });
1102
+ const without = await collectInventory({ platform: "linux", arch: "x64", probes: {} });
1103
+ assert.equal("git" in without, false);
1104
+ const failing = await collectInventory({ platform: "linux", arch: "x64", probes: { git: async () => { throw new Error("/private/path"); } } });
1105
+ assert.equal("git" in failing, false);
1106
+ });