gentle-pi 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (398) hide show
  1. package/README.md +122 -8
  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 +703 -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/bundled-install.md +397 -0
  28. package/docs/delegated-verification.md +3 -5
  29. package/docs/gentle-agents-activity.md +513 -0
  30. package/docs/gentle-shell-uninstall.md +136 -0
  31. package/docs/gentle-shell.md +81 -31
  32. package/docs/install-wizard.md +2040 -0
  33. package/docs/native-authority-architecture.md +1 -1
  34. package/docs/permission-modes.md +82 -0
  35. package/docs/prompt-history.md +1 -1
  36. package/docs/readme-reference.md +267 -40
  37. package/docs/render-cache-performance.md +28 -0
  38. package/docs/sound-notifications-proposal.md +191 -0
  39. package/docs/sound-notifications.md +114 -0
  40. package/docs/yolo-mode.md +9 -9
  41. package/extensions/child-context.ts +11 -2
  42. package/extensions/child-safety.ts +4 -2
  43. package/extensions/codegraph-tools.ts +18 -5
  44. package/extensions/gentle-agents.ts +688 -88
  45. package/extensions/gentle-ai.ts +886 -238
  46. package/extensions/gentle-herdr-activity.ts +81 -0
  47. package/extensions/gentle-notifications.ts +104 -0
  48. package/extensions/gentle-shell.ts +265 -69
  49. package/extensions/gentle-todo.ts +108 -13
  50. package/extensions/history/index.ts +11 -6
  51. package/extensions/pi-pretty.ts +39 -3
  52. package/extensions/quiet-tools.ts +91 -21
  53. package/extensions/skill-registry.ts +175 -46
  54. package/extensions/startup-banner.ts +11 -2
  55. package/lib/agent-home.ts +10 -1
  56. package/lib/agent-model-resolution.ts +80 -0
  57. package/lib/agent-profiles.ts +13 -3
  58. package/lib/agents-completion-delivery.ts +11 -3
  59. package/lib/agents-messaging.ts +1 -1
  60. package/lib/agents-protocol.ts +40 -1
  61. package/lib/agents-runner.ts +90 -15
  62. package/lib/auto-permission-classifier.ts +280 -0
  63. package/lib/auto-permission-runtime.ts +268 -0
  64. package/lib/background-jobs-tools.ts +202 -0
  65. package/lib/background-jobs.ts +253 -0
  66. package/lib/background-monitor.ts +154 -0
  67. package/lib/bounded-writer-admission.ts +83 -10
  68. package/lib/builtin-codemode-optout.ts +166 -0
  69. package/lib/card-content-policy.ts +85 -0
  70. package/lib/child-package-injection.ts +62 -0
  71. package/lib/codemode-renderer.ts +14 -4
  72. package/lib/command-palette-catalog.ts +3 -2
  73. package/lib/context-threshold.ts +26 -0
  74. package/lib/gentle-ai-binary.ts +109 -4
  75. package/lib/gentle-shell-bundled.ts +517 -0
  76. package/lib/gentle-shell-launcher.ts +74 -23
  77. package/lib/gentle-shell-startup-flow.ts +54 -0
  78. package/lib/gentle-shell-startup-update.ts +92 -0
  79. package/lib/gentle-shell-uninstall.ts +840 -0
  80. package/lib/gentle-shell-update-panel.ts +231 -0
  81. package/lib/herdr-activity.ts +183 -0
  82. package/lib/inprocess-reviewer.ts +3 -0
  83. package/lib/jobs-sidebar-state.ts +20 -0
  84. package/lib/jobs-view.ts +161 -0
  85. package/lib/model-price-ratio.ts +83 -0
  86. package/lib/nan-provider.ts +33 -11
  87. package/lib/native-review-cli.ts +194 -73
  88. package/lib/notification-audio-native.ts +162 -0
  89. package/lib/notification-audio-windows.ts +399 -0
  90. package/lib/notification-audio.ts +332 -0
  91. package/lib/notification-customize.ts +241 -0
  92. package/lib/notification-events.ts +113 -0
  93. package/lib/notification-policy.ts +148 -0
  94. package/lib/notification-pulse-client.ts +367 -0
  95. package/lib/notification-pulse-protocol.ts +305 -0
  96. package/lib/notification-pulse-stream.ts +213 -0
  97. package/lib/notification-pulse-worker.ts +66 -0
  98. package/lib/notification-scheduler.ts +173 -0
  99. package/lib/notification-service.ts +202 -0
  100. package/lib/notification-ui.ts +87 -0
  101. package/lib/odd-phase.ts +1 -1
  102. package/lib/orchestrator-alias.ts +20 -0
  103. package/lib/orchestrator-catalog.ts +79 -0
  104. package/lib/orchestrator-consultation.ts +61 -0
  105. package/lib/orchestrator-discovery.ts +49 -0
  106. package/lib/orchestrator-helper-consent.ts +122 -0
  107. package/lib/orchestrator-helper.ts +167 -0
  108. package/lib/orchestrator-message-card.ts +105 -0
  109. package/lib/orchestrator-presence.ts +115 -14
  110. package/lib/orchestrator-scope.ts +80 -0
  111. package/lib/orchestrator-state.ts +107 -0
  112. package/lib/orchestrator-tool-card.ts +280 -0
  113. package/lib/orchestrator-work-search.ts +218 -0
  114. package/lib/orchestrator-work.ts +79 -0
  115. package/lib/permission-mode-policy.ts +59 -0
  116. package/lib/review-candidate-view-owner.ts +8 -6
  117. package/lib/review-candidate-view.ts +3 -2
  118. package/lib/review-compact-contract.ts +2 -2
  119. package/lib/review-host-relay.ts +7 -2
  120. package/lib/review-integration-v2.ts +100 -22
  121. package/lib/review-publication-gate.ts +1 -1
  122. package/lib/review-risk-assessment.ts +77 -51
  123. package/lib/review-sidebar-state.ts +2 -1
  124. package/lib/review-transaction.ts +72 -3
  125. package/lib/runtime-metrics-children.ts +1 -1
  126. package/lib/runtime-metrics-native.ts +3 -2
  127. package/lib/selection-engine.ts +18 -6
  128. package/lib/session-change-capture.ts +5 -4
  129. package/lib/session-changes.ts +27 -22
  130. package/lib/session-profile-binding.ts +83 -0
  131. package/lib/session-profile-freeze.ts +266 -0
  132. package/lib/session-worktree-registry.ts +74 -8
  133. package/lib/shell-bar.ts +41 -11
  134. package/lib/shell-changes-view.ts +40 -13
  135. package/lib/shell-changes.ts +86 -71
  136. package/lib/shell-sidebar-layout.ts +5 -1
  137. package/lib/shell-todo.ts +62 -19
  138. package/lib/shell-usage.ts +4 -3
  139. package/lib/status-timing-diagnostics.ts +201 -0
  140. package/lib/telemetry-trigger.ts +3 -1
  141. package/lib/vim-editor-adapter.ts +7 -3
  142. package/lib/visual-customization-policy.ts +20 -9
  143. package/lib/visual-customize-view.ts +171 -20
  144. package/lib/writer-surfaces.ts +162 -0
  145. package/lib/yolo-session-policy.ts +198 -121
  146. package/package.json +1 -1
  147. package/runtime/child-package-injection.mjs +63 -0
  148. package/runtime/gentle-ai-binary.mjs +109 -4
  149. package/runtime/gentle-shell-bundled.mjs +518 -0
  150. package/runtime/gentle-shell-launcher.mjs +73 -22
  151. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  152. package/runtime/gentle-shell-startup-update.mjs +93 -0
  153. package/runtime/gentle-shell-uninstall.mjs +841 -0
  154. package/runtime/gentle-shell-update-panel.mjs +232 -0
  155. package/runtime/native-review-cli.mjs +191 -70
  156. package/runtime/review-integration-v2.mjs +100 -22
  157. package/runtime/review-risk-assessment.mjs +77 -51
  158. package/runtime/status-timing-diagnostics.mjs +202 -0
  159. package/runtime/telemetry-trigger.mjs +3 -1
  160. package/scripts/bootstrap.cmd +255 -0
  161. package/scripts/bootstrap.sh +168 -0
  162. package/scripts/build-distribution.mjs +157 -0
  163. package/scripts/build-installer-bundles.mjs +136 -0
  164. package/scripts/build-runtime-modules.mjs +7 -0
  165. package/scripts/bundled-install.mjs +1308 -0
  166. package/scripts/bundled-wizard.mjs +399 -0
  167. package/scripts/check-types.mjs +4 -6
  168. package/scripts/gentle-ai-installer.mjs +79 -32
  169. package/scripts/install-gentle-ai.mjs +48 -1
  170. package/scripts/install-tui-mode-setting.mjs +10 -1
  171. package/scripts/install-wizard-preview.mjs +154 -0
  172. package/scripts/installer-downloads.mjs +648 -0
  173. package/scripts/installer-preflight.mjs +387 -0
  174. package/scripts/installer-probes.mjs +564 -0
  175. package/scripts/installer-runner.mjs +1102 -0
  176. package/scripts/installer-server.mjs +881 -0
  177. package/scripts/installer-windows-artifacts.json +15 -0
  178. package/scripts/installer-windows.mjs +752 -0
  179. package/scripts/main-channel.mjs +452 -0
  180. package/scripts/npm-pack-result.mjs +31 -0
  181. package/scripts/render-cache-performance-worker.mjs +205 -0
  182. package/scripts/render-cache-performance.mjs +115 -0
  183. package/scripts/run-test-suite.mjs +22 -8
  184. package/scripts/test-installer-acceptance.sh +161 -0
  185. package/scripts/test-packed-runner.mjs +23 -5
  186. package/scripts/types-baseline.json +2 -92
  187. package/scripts/verify-package-files.mjs +82 -18
  188. package/skills/gentle-ai/SKILL.md +12 -11
  189. package/tests/agent-home.test.ts +56 -3
  190. package/tests/agent-model-resolution.test.ts +113 -0
  191. package/tests/agent-profiles.test.ts +12 -0
  192. package/tests/agent-risk-escalation.test.ts +123 -0
  193. package/tests/agents-completion-delivery.test.ts +17 -0
  194. package/tests/agents-protocol.test.ts +57 -0
  195. package/tests/agents-queries.test.ts +9 -9
  196. package/tests/agents-runner-process.test.ts +31 -0
  197. package/tests/agents-runner.test.ts +89 -4
  198. package/tests/auto-mode-invariants.test.ts +258 -0
  199. package/tests/auto-mode-sdk.test.ts +57 -0
  200. package/tests/auto-mode.test.ts +49 -0
  201. package/tests/auto-permission-classifier.test.ts +283 -0
  202. package/tests/auto-permission-runtime.test.ts +196 -0
  203. package/tests/background-cache-warming.test.ts +2 -1
  204. package/tests/background-jobs.test.ts +227 -0
  205. package/tests/background-monitor.test.ts +195 -0
  206. package/tests/background-subagents-default-mode.test.ts +28 -5
  207. package/tests/bounded-stop-contract.test.ts +85 -0
  208. package/tests/bounded-writer-admission.test.ts +52 -1
  209. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  210. package/tests/bridge-wake-rendering.test.ts +56 -0
  211. package/tests/build-distribution.test.ts +269 -0
  212. package/tests/builtin-codemode-optout.test.ts +242 -0
  213. package/tests/bundled-install.test.ts +1319 -0
  214. package/tests/bundled-wizard.test.ts +505 -0
  215. package/tests/card-content-policy.test.ts +68 -0
  216. package/tests/card-style-policy.test.ts +34 -2
  217. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  218. package/tests/child-package-entrypoints.test.ts +65 -0
  219. package/tests/child-package-injection.test.ts +74 -0
  220. package/tests/child-safety.test.ts +12 -0
  221. package/tests/child-web-package.test.ts +224 -0
  222. package/tests/codegraph-tools.test.ts +35 -0
  223. package/tests/codemode-rendering.test.ts +107 -2
  224. package/tests/command-palette.test.ts +5 -5
  225. package/tests/context-threshold.test.ts +31 -0
  226. package/tests/delegate-for-reason-contract.test.ts +327 -0
  227. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  228. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  229. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  230. package/tests/engram-safety.test.ts +288 -0
  231. package/tests/generic-agent-tools.test.ts +16 -1
  232. package/tests/gentle-agents.test.ts +1722 -59
  233. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  234. package/tests/gentle-ai-binary.test.ts +19 -2
  235. package/tests/gentle-ai-child-guards.test.ts +202 -0
  236. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  237. package/tests/gentle-ai-installer.test.ts +548 -58
  238. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  239. package/tests/gentle-ai-renderer.test.ts +6 -6
  240. package/tests/gentle-ai.test.ts +999 -31
  241. package/tests/gentle-herdr-activity.test.ts +100 -0
  242. package/tests/gentle-notifications.test.ts +427 -0
  243. package/tests/gentle-shell-bin.test.ts +249 -5
  244. package/tests/gentle-shell-bundled.test.ts +1002 -0
  245. package/tests/gentle-shell-launcher.test.ts +234 -13
  246. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  247. package/tests/gentle-shell-startup-update.test.ts +85 -0
  248. package/tests/gentle-shell-uninstall.test.ts +978 -0
  249. package/tests/gentle-shell-update-panel.test.ts +158 -0
  250. package/tests/gentle-shell.test.ts +541 -96
  251. package/tests/gentle-todo.test.ts +253 -2
  252. package/tests/herdr-activity.test.ts +243 -0
  253. package/tests/history-agent-home.test.ts +81 -0
  254. package/tests/history-child-guard.test.ts +70 -0
  255. package/tests/inprocess-reviewer.test.ts +20 -0
  256. package/tests/install-gentle-ai-failure.test.ts +69 -0
  257. package/tests/install-tui-mode-setting.test.ts +15 -0
  258. package/tests/install-wizard.test.ts +1223 -0
  259. package/tests/installer-bundles.test.ts +122 -0
  260. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  261. package/tests/installer-preflight.test.ts +750 -0
  262. package/tests/installer-probes.test.ts +1106 -0
  263. package/tests/installer-runner.test.ts +2158 -0
  264. package/tests/installer-server.test.ts +1567 -0
  265. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  266. package/tests/jobs-view.test.ts +160 -0
  267. package/tests/main-channel-upgrade.test.ts +389 -0
  268. package/tests/main-channel.test.ts +344 -0
  269. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  270. package/tests/model-price-ratio.test.ts +116 -0
  271. package/tests/model-profiles-preservation.test.ts +153 -0
  272. package/tests/model-routing-package.test.ts +10 -0
  273. package/tests/nan-provider-runtime.test.ts +156 -0
  274. package/tests/nan-provider.test.ts +28 -18
  275. package/tests/native-binary-gate.test.ts +2 -0
  276. package/tests/native-review-capability-contract.test.ts +7 -1
  277. package/tests/native-review-cli.test.ts +236 -1
  278. package/tests/native-review-consent.test.ts +2 -2
  279. package/tests/native-review-parity-runtime.test.ts +1 -1
  280. package/tests/native-review-parity.test.ts +12 -48
  281. package/tests/native-risk-evidence-parity.test.ts +21 -0
  282. package/tests/no-self-pause-contract.test.ts +87 -0
  283. package/tests/notification-audio-native.test.ts +281 -0
  284. package/tests/notification-audio-windows.test.ts +581 -0
  285. package/tests/notification-audio.test.ts +548 -0
  286. package/tests/notification-customize.test.ts +655 -0
  287. package/tests/notification-events.test.ts +115 -0
  288. package/tests/notification-policy.test.ts +201 -0
  289. package/tests/notification-pulse-client.test.ts +356 -0
  290. package/tests/notification-pulse-protocol.test.ts +213 -0
  291. package/tests/notification-pulse-stream.test.ts +320 -0
  292. package/tests/notification-scheduler.test.ts +258 -0
  293. package/tests/notification-ui.test.ts +157 -0
  294. package/tests/npm-pack-result.test.ts +41 -0
  295. package/tests/odd-integration.test.ts +14 -5
  296. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  297. package/tests/odd-routing-contract.test.ts +80 -23
  298. package/tests/orchestrator-budget.test.ts +29 -6
  299. package/tests/orchestrator-catalog.test.ts +146 -0
  300. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  301. package/tests/orchestrator-consultation.test.ts +111 -0
  302. package/tests/orchestrator-discovery.test.ts +241 -0
  303. package/tests/orchestrator-helper-consent.test.ts +171 -0
  304. package/tests/orchestrator-helper.test.ts +297 -0
  305. package/tests/orchestrator-message-card.test.ts +190 -0
  306. package/tests/orchestrator-presence.test.ts +23 -0
  307. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  308. package/tests/orchestrator-scope.test.ts +107 -0
  309. package/tests/orchestrator-state.test.ts +114 -0
  310. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  311. package/tests/orchestrator-tool-card.test.ts +398 -0
  312. package/tests/orchestrator-work-search.test.ts +318 -0
  313. package/tests/orchestrator-work.test.ts +147 -0
  314. package/tests/package-manifest.test.ts +101 -17
  315. package/tests/packed-runner-owned-path.test.ts +35 -1
  316. package/tests/parallel-reason-contract.test.ts +21 -0
  317. package/tests/parallel-writer-admission.test.ts +210 -0
  318. package/tests/permission-mode-policy.test.ts +194 -0
  319. package/tests/persona-single-channel.test.ts +4 -12
  320. package/tests/pi-pretty.test.ts +56 -0
  321. package/tests/provider-defect-handoff.test.ts +4 -3
  322. package/tests/quiet-tool-rendering.test.ts +160 -3
  323. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  324. package/tests/render-cache-performance.test.ts +48 -0
  325. package/tests/review-agent-end-preflight.test.ts +62 -10
  326. package/tests/review-candidate-view.test.ts +101 -12
  327. package/tests/review-contract-prompt.test.ts +33 -4
  328. package/tests/review-controller-native-recovery.test.ts +327 -17
  329. package/tests/review-controller-native-routing.test.ts +255 -1
  330. package/tests/review-controller-workspace-root.test.ts +43 -2
  331. package/tests/review-controller.test.ts +2 -2
  332. package/tests/review-correction-lifecycle.test.ts +2 -1
  333. package/tests/review-gate.test.ts +7 -4
  334. package/tests/review-graph-schema.test.ts +2 -2
  335. package/tests/review-host-relay-routing.test.ts +5 -3
  336. package/tests/review-host-relay.test.ts +49 -2
  337. package/tests/review-integration-v2-forward.test.ts +21 -1
  338. package/tests/review-json-arguments.test.ts +167 -0
  339. package/tests/review-last-event-closure.test.ts +157 -8
  340. package/tests/review-ledger-contract.test.ts +5 -4
  341. package/tests/review-policy-judgment-day.test.ts +1 -1
  342. package/tests/review-policy-ordinary.test.ts +2 -2
  343. package/tests/review-relay-transport-agent.test.ts +66 -1
  344. package/tests/review-risk-assessment.test.ts +109 -33
  345. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  346. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  347. package/tests/review-test-fixtures.ts +7 -1
  348. package/tests/review-transaction.test.ts +18 -1
  349. package/tests/run-test-suite.test.ts +83 -5
  350. package/tests/runtime-harness.mjs +42 -15
  351. package/tests/runtime-metrics-extension.test.ts +1 -1
  352. package/tests/runtime-metrics-native.test.ts +31 -1
  353. package/tests/selection-engine.test.ts +30 -0
  354. package/tests/session-change-capture.test.ts +10 -10
  355. package/tests/session-changes-shell.test.ts +242 -48
  356. package/tests/session-changes.test.ts +17 -24
  357. package/tests/session-profile-binding.test.ts +163 -0
  358. package/tests/session-profile-freeze.test.ts +381 -0
  359. package/tests/session-subject-routing-contract.test.ts +34 -0
  360. package/tests/session-worktree-registry.test.ts +57 -2
  361. package/tests/shell-bar.test.ts +40 -0
  362. package/tests/shell-changes-view.test.ts +85 -34
  363. package/tests/shell-changes.test.ts +61 -62
  364. package/tests/shell-render-cache-runtime.test.ts +69 -0
  365. package/tests/shell-render-cache.test.ts +129 -0
  366. package/tests/shell-sidebar-layout.test.ts +27 -2
  367. package/tests/shell-todo.test.ts +120 -2
  368. package/tests/shell-usage-view.test.ts +1 -1
  369. package/tests/shell-usage.test.ts +35 -0
  370. package/tests/short-check-routing-contract.test.ts +49 -0
  371. package/tests/skill-registry-watcher.test.ts +119 -0
  372. package/tests/skill-registry.test.ts +311 -5
  373. package/tests/startup-banner.test.ts +117 -3
  374. package/tests/startup-home-repo.test.ts +170 -0
  375. package/tests/status-timing-diagnostics.test.ts +283 -0
  376. package/tests/support/orchestrator-modules.ts +19 -0
  377. package/tests/task-size-routing-contract.test.ts +241 -0
  378. package/tests/telemetry-trigger.test.ts +27 -1
  379. package/tests/test-strength-contract.test.ts +124 -0
  380. package/tests/verify-agent-contract.test.ts +90 -0
  381. package/tests/verify-first-launch-contract.test.ts +139 -0
  382. package/tests/verify-package-files.test.ts +56 -0
  383. package/tests/vim-editor-adapter.test.ts +7 -7
  384. package/tests/visual-customization-policy.test.ts +32 -3
  385. package/tests/visual-customize-view.test.ts +288 -10
  386. package/tests/writer-edit-surface-scope.test.ts +59 -2
  387. package/tests/writer-surfaces.test.ts +199 -0
  388. package/tests/yolo-customize.test.ts +18 -3
  389. package/tests/yolo-mode-runtime.test.ts +37 -10
  390. package/tests/yolo-mode.test.ts +32 -4
  391. package/tests/yolo-session-policy.test.ts +1 -1
  392. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  393. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  394. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  395. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  396. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  397. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  398. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,951 @@
1
+ // Gentle Shell installation wizard (browser side).
2
+ //
3
+ // Loaded as an ES module under the host's strict CSP (`default-src 'self'`):
4
+ // no inline code or styles, no HTML parsing of server data (DOM APIs and text
5
+ // nodes only) and no requests except same-origin `/api/*` calls. The pure
6
+ // models, renderers and the controller factory are exported so Node tests can
7
+ // load this file without a browser; it starts itself only inside a page.
8
+
9
+ const API_HEADERS = Object.freeze({ "X-Gentle-Install": "1" });
10
+ const LOG_LIMIT = 200;
11
+
12
+ /** Human labels for every step the runner logs. Unknown ids are shown as-is. */
13
+ export const stepLabels = Object.freeze({
14
+ gate: "Safety checks",
15
+ "prepare-pnpm-home": "Create the private pnpm folder",
16
+ "check-npm": "Check npm",
17
+ "check-global-bin": "Check the pnpm global bin directory",
18
+ "check-existing-stack": "Check for an existing installation",
19
+ "check-recoverable-stack": "Check the existing installation",
20
+ "persist-node": "Install Node.js under PNPM_HOME",
21
+ "persist-package-managers": "Install npm and pnpm under PNPM_HOME",
22
+ "persist-npm": "Install npm under PNPM_HOME",
23
+ "persist-pnpm": "Install pnpm under PNPM_HOME",
24
+ "verify-persistent-runtime": "Verify the persistent Node.js and npm",
25
+ "verify-persistent-pnpm": "Verify the persistent pnpm",
26
+ "configure-npm-prefix": "Configure npm's global prefix",
27
+ "install-global": "Install Pi and Gentle Shell",
28
+ "verify-global-list": "Verify the installed packages",
29
+ "verify-shell-bin": "Verify the gentle-shell command",
30
+ "verify-gentle-ai": "Verify Gentle AI",
31
+ "shell-setup": "Run gentle-shell setup",
32
+ "persist-path": "Add the global bin directory to PATH",
33
+ "build-gentle-ai-main": "Build Gentle AI from main",
34
+ "install-shell-main": "Install Gentle Shell from main",
35
+ "record-channel": "Save the main channel",
36
+ "acquire-go": "Download and verify Go",
37
+ "verify-go": "Verify the downloaded Go",
38
+ "check-existing-shell": "Check for an existing Gentle Shell",
39
+ "check-installed-shell": "Find the installed Gentle Shell",
40
+ "install-pi": "Install Pi",
41
+ "update-shell": "Update Gentle Shell",
42
+ "verify-updated-shell": "Verify the updated Gentle Shell",
43
+ "check-installed-pi": "Find the installed Pi",
44
+ "update-pi": "Update Pi",
45
+ "verify-updated-pi": "Verify the updated Pi",
46
+ "check-existing-pi": "Check for an existing pnpm Pi",
47
+ "verify-installed-pi": "Verify the installed Pi",
48
+ "claim-prefix": "Create Gentle Shell's private folder",
49
+ "copy-npm-settings": "Copy your npm registry and proxy settings",
50
+ "install-runtime": "Install Gentle Shell's Node.js, npm and pnpm",
51
+ "install-version": "Install Gentle Shell from the release lockfile",
52
+ "activate-version": "Activate the installed version",
53
+ "write-launcher": "Write the gentle-shell launcher",
54
+ "bundled-path": "Add gentle-shell to PATH",
55
+ "verify-launcher": "Check the new gentle-shell launcher",
56
+ "remove-global-shell": "Remove the old global gentle-pi",
57
+ });
58
+ export function stepLabel(id) {
59
+ return typeof id === "string" && Object.hasOwn(stepLabels, id) ? stepLabels[id] : String(id);
60
+ }
61
+
62
+ const toolLabels = Object.freeze({
63
+ node: "Node.js",
64
+ pnpm: "pnpm",
65
+ pi: "Pi",
66
+ shell: "Gentle Shell",
67
+ gentleAi: "Gentle AI",
68
+ go: "Go",
69
+ globalBin: "pnpm global bin directory",
70
+ pnpmHome: "pnpm home folder",
71
+ setup: "Gentle Shell setup",
72
+ target: "This computer",
73
+ });
74
+ const statusText = Object.freeze({
75
+ done: "Done",
76
+ failed: "Failed",
77
+ blocked: "Blocked",
78
+ running: "In progress",
79
+ pending: "Pending",
80
+ skipped: "Not run",
81
+ unknown: "Unknown",
82
+ });
83
+ const stages = Object.freeze([
84
+ ["check", "Check"],
85
+ ["review", "Review"],
86
+ ["install", "Install"],
87
+ ["done", "Done"],
88
+ ]);
89
+
90
+ function text(value) {
91
+ return typeof value === "string" ? value : "";
92
+ }
93
+ function list(value) {
94
+ return Array.isArray(value) ? value : [];
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Pure models
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * The runner's fixed step sequence for a plan, used to show upcoming steps.
103
+ * It mirrors runStandardInstall; progressModel tolerates any divergence.
104
+ */
105
+ // The main channel overlay the runner adds after verifying the release install.
106
+ const mainSteps = ["build-gentle-ai-main", "install-shell-main", "record-channel"];
107
+ function withMain(ids, steps) {
108
+ if (!ids.has("install-shell-main")) return steps;
109
+ const at = steps.indexOf("shell-setup");
110
+ return [...steps.slice(0, at), ...mainSteps, ...steps.slice(at)];
111
+ }
112
+
113
+ export function expectedSteps(actionIds) {
114
+ const ids = new Set(list(actionIds));
115
+ // The bundled install (runBundledInstall): the PATH step only when the installer applies it; a
116
+ // migration checks the new launcher before the PATH step and removes the old global gentle-pi last.
117
+ if (ids.has("bundled-version")) {
118
+ const migration = ids.has("remove-global-shell");
119
+ return ["claim-prefix", "copy-npm-settings", "install-runtime", "install-version", "activate-version", "write-launcher",
120
+ ...(migration ? ["verify-launcher"] : []), ...(ids.has("bundled-path") ? ["bundled-path"] : []), "shell-setup", ...(migration ? ["remove-global-shell"] : [])];
121
+ }
122
+ // An older Pi is found, updated and verified before any Gentle Shell step.
123
+ const piCheck = ids.has("update-pi") ? ["check-installed-pi"] : [];
124
+ const piSteps = ids.has("update-pi") ? ["update-pi", "verify-updated-pi"] : [];
125
+ // An existing Gentle Shell is updated in place, then set up again.
126
+ // The installer's pinned Go is acquired after the checks, before anything is installed.
127
+ const go = ids.has("acquire-go") ? ["acquire-go", "verify-go"] : [];
128
+ const update = ["update-shell-release", "update-shell-main"].find((id) => ids.has(id));
129
+ if (update) {
130
+ return ["check-npm", "check-global-bin", "check-installed-shell", ...piCheck, ...go, ...(ids.has("install-pi") ? ["install-pi"] : []),
131
+ ...piSteps, "update-shell", "verify-updated-shell", ...(update === "update-shell-release" ? ["verify-gentle-ai"] : []), "shell-setup"];
132
+ }
133
+ if (piOnlyPlan(actionIds)) {
134
+ // The installer's Pi alone, next to an older one neither pnpm nor npm owns.
135
+ return ids.has("update-pi") ? ["check-npm", "check-global-bin", ...piCheck, ...piSteps]
136
+ : ["check-npm", "check-global-bin", "check-existing-pi", "install-pi", "verify-installed-pi"];
137
+ }
138
+ const addNpm = ids.has("persist-package-managers") || ids.has("persist-npm");
139
+ const addPnpm = ids.has("persist-package-managers") || ids.has("persist-pnpm");
140
+ if (recoveryPlan(actionIds)) {
141
+ // Setup recovery re-verifies the installed stack and never reinstalls it.
142
+ const steps = ["check-npm", "check-global-bin", "check-recoverable-stack", "verify-global-list", "verify-shell-bin",
143
+ "verify-gentle-ai", "shell-setup"];
144
+ return withMain(ids, ids.has("setup-global-bin") ? [...steps, "persist-path"] : steps);
145
+ }
146
+ // An npm that is about to be installed is checked after it is added.
147
+ // With Pi already installed, only Gentle Shell is added, so only it must be absent.
148
+ const shellOnly = ids.has("install-shell") && !ids.has("install-pi");
149
+ // The pinned Go comes right after the checks, before anything is persisted.
150
+ const steps = [...(addNpm ? [] : ["check-npm"]), "check-global-bin", shellOnly ? "check-existing-shell" : "check-existing-stack", ...piCheck, ...go];
151
+ if (ids.has("persist-node")) {
152
+ // Only npm is added next to a persistent pnpm, which is never downgraded.
153
+ steps.push("persist-node", ids.has("persist-npm") ? "persist-npm" : "persist-package-managers", "verify-persistent-runtime", "check-npm", "configure-npm-prefix");
154
+ } else if (addNpm || addPnpm) {
155
+ const add = ["persist-package-managers", "persist-npm", "persist-pnpm"].find((id) => ids.has(id));
156
+ steps.push(add);
157
+ if (addNpm) steps.push("check-npm");
158
+ if (addPnpm) steps.push("verify-persistent-pnpm");
159
+ }
160
+ steps.push(...piSteps, "install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup");
161
+ if (ids.has("setup-global-bin")) steps.push("persist-path");
162
+ return withMain(ids, steps);
163
+ }
164
+
165
+ /** Pi alone (a current Gentle Shell): only its update, or the installer's Pi, and readiness. */
166
+ function piOnlyPlan(actionIds) {
167
+ const ids = list(actionIds);
168
+ return ["update-pi", "install-pi"].some((pi) => ids.includes(pi) && ids.every((id) => [pi, "verify-readiness"].includes(id)));
169
+ }
170
+
171
+ /** The runner's fixed setup recovery: only setup, readiness and optionally PATH. */
172
+ function recoveryPlan(actionIds) {
173
+ const ids = list(actionIds).filter((id) => !mainSteps.includes(id));
174
+ return ["setup-shell", "verify-readiness"].every((id) => ids.includes(id)) &&
175
+ ids.every((id) => ["setup-global-bin", "setup-shell", "verify-readiness"].includes(id));
176
+ }
177
+
178
+ /** Review screen model for a GET /api/plan view. */
179
+ export function planModel(view) {
180
+ const actions = list(view?.actions).map((action) => ({ id: text(action?.id), description: text(action?.description) }));
181
+ const blockers = list(view?.blockers).map((blocker) => ({
182
+ code: text(blocker?.code),
183
+ tool: text(blocker?.tool),
184
+ toolLabel: Object.hasOwn(toolLabels, blocker?.tool) ? toolLabels[blocker.tool] : "A required tool",
185
+ guidance: text(blocker?.guidance),
186
+ }));
187
+ let kind = "install";
188
+ if (view?.bundled === true && blockers.length === 0) kind = "bundled";
189
+ else if (blockers.length > 0) kind = "blocked";
190
+ else if (actions.every((action) => action.id === "verify-readiness")) kind = "nothing";
191
+ else if (actions.some((action) => action.id.startsWith("update-shell-"))) kind = "update";
192
+ else if (piOnlyPlan(actions.map((action) => action.id))) kind = actions.some((action) => action.id === "update-pi") ? "update-pi" : "install-pi";
193
+ else if (recoveryPlan(actions.map((action) => action.id))) kind = "recovery";
194
+ const profile = view?.profileChange ?? {};
195
+ const persistence = view?.persistence ?? {};
196
+ const tools = list(persistence.tools).filter((tool) => typeof tool === "string");
197
+ return {
198
+ planId: text(view?.planId),
199
+ channel: view?.channel === "main" ? "main" : "release",
200
+ kind,
201
+ actions,
202
+ blockers,
203
+ // S6: reused tools in folders another account can change; a notice, never a blocker.
204
+ notice: typeof view?.sharedFolders?.description === "string" ? text(view.sharedFolders.description) : null,
205
+ // A missing Git: noted before consent, never a blocker.
206
+ gitNotice: typeof view?.gitNotice?.description === "string" ? text(view.gitNotice.description) : null,
207
+ disclosures: [
208
+ {
209
+ id: "profile",
210
+ title: "Your PATH",
211
+ changes: profile.changesProfile === true,
212
+ state: profile.changesProfile === true ? "Will change" : "No change",
213
+ text: text(profile.description),
214
+ detail: typeof profile.binDir === "string" ? profile.binDir : null,
215
+ },
216
+ {
217
+ id: "persistence",
218
+ title: kind === "bundled" ? "Gentle Shell's own runtimes" : "Runtimes under PNPM_HOME",
219
+ changes: tools.length > 0,
220
+ state: kind === "bundled" ? `Uses its own ${tools.join(", ")}` : tools.length > 0 ? `Will add ${tools.join(", ")}` : "No change",
221
+ text: text(persistence.description),
222
+ detail: typeof persistence.pnpmHome === "string" ? persistence.pnpmHome : null,
223
+ },
224
+ ],
225
+ steps: expectedSteps(actions.map((action) => action.id)),
226
+ };
227
+ }
228
+
229
+ function stepItem(id, status) {
230
+ return { id, label: stepLabel(id), status, statusText: statusText[status] };
231
+ }
232
+
233
+ /**
234
+ * Step list for the progress screen: logged entries in order, then the
235
+ * expected steps that have not run yet. The runner logs a step when it ends,
236
+ * so the first remaining step is the one in progress.
237
+ */
238
+ export function progressModel(steps, entries, { running = false, outcome = null } = {}) {
239
+ const expected = list(steps);
240
+ const items = [];
241
+ let cursor = 0;
242
+ for (const entry of list(entries)) {
243
+ const at = expected.indexOf(entry?.step, cursor);
244
+ if (at !== -1) cursor = at + 1;
245
+ const status = ["done", "failed", "blocked"].includes(entry?.status) ? entry.status : "unknown";
246
+ items.push(stepItem(text(entry?.step) || "unknown", status));
247
+ }
248
+ expected.slice(cursor).forEach((id, index) => {
249
+ let status = "pending";
250
+ if (outcome) status = "skipped";
251
+ else if (running && index === 0) status = "running";
252
+ items.push(stepItem(id, status));
253
+ });
254
+ return {
255
+ items,
256
+ done: items.filter((item) => item.status === "done").length,
257
+ total: items.length,
258
+ finished: Boolean(outcome),
259
+ };
260
+ }
261
+
262
+ const detailCommands = new Map([["shell-setup", "gentle-shell setup"], ["install-version", "pnpm install"], ["persist-path", "pnpm setup"], ["acquire-go", "the Go download"],
263
+ ["install-global", "pnpm add -g"], ["install-shell-main", "the Gentle Shell main install"], ["build-gentle-ai-main", "the Gentle AI main build"],
264
+ ["update-shell", "the Gentle Shell update"], ["claim-prefix", "the bundled install"], ["copy-npm-settings", "the bundled install"],
265
+ ["install-runtime", "the bundled install"], ["activate-version", "the bundled install"], ["write-launcher", "the bundled install"],
266
+ ["verify-launcher", "gentle-shell --version"], ["bundled-path", "the bundled install"]]);
267
+
268
+ /** Final screen model for every runner outcome. Guidance always comes from the host. */
269
+ export function outcomeModel(outcome) {
270
+ const completed = list(outcome?.completed).map((id) => ({ id: text(id), label: stepLabel(text(id)) }));
271
+ const guidance = text(outcome?.guidance);
272
+ const notes = [];
273
+ if (outcome?.npmPrefix === "configured") notes.push("npm's user-level global prefix now points at PNPM_HOME.");
274
+ if (outcome?.npmPrefix === "unchanged") notes.push("npm's global prefix was already correct and was left unchanged.");
275
+ // A migration whose old global gentle-pi could not be removed (the host sends only the fixed commands).
276
+ if (typeof outcome?.removeCommand === "string") notes.push(`The old global gentle-pi could not be removed. Remove it with \`${text(outcome.removeCommand)}\`.`);
277
+ const base = { outcome: outcome?.outcome, guidance, completed, notes, reason: null, failedStep: null, command: null, detail: null, detailCommand: null };
278
+ if (outcome?.outcome === "ready") {
279
+ return { ...base, tone: "success", badge: "Installed", title: "Gentle Shell is ready",
280
+ lead: "Everything is installed and verified on this computer.", command: "gentle-shell",
281
+ next: ["Open any terminal.", "Run `gentle-shell` to start."] };
282
+ }
283
+ // The bundled install could not edit the shell profile: the user adds the exact line.
284
+ const pathLine = outcome?.action === "add-path-line" ? outcome.pathLine : null;
285
+ if (outcome?.outcome === "terminal-action-required" && typeof pathLine?.file === "string" && typeof pathLine?.line === "string") {
286
+ return { ...base, tone: "success", badge: "One more step", title: "Add Gentle Shell to your PATH",
287
+ lead: "Gentle Shell is installed. The installer did not edit your shell profile, so new terminals need one line to find it.", command: pathLine.line,
288
+ next: [`Add the line above to ${pathLine.file}.`, "Open a new terminal window or tab.", "Run `gentle-shell` to start."] };
289
+ }
290
+ if (outcome?.outcome === "terminal-action-required") {
291
+ return { ...base, tone: "success", badge: "One more step", title: "Open a new terminal",
292
+ lead: "Your PATH was updated, and only terminals opened from now on can see it.", command: "gentle-shell",
293
+ next: ["Open a new terminal window or tab.", "Run `gentle-shell` to start."] };
294
+ }
295
+ if (outcome?.outcome === "blocked") {
296
+ return { ...base, tone: "warning", badge: "Blocked", title: "Installation blocked",
297
+ lead: "Nothing was installed. A safety check stopped the installation before any change.", reason: text(outcome.reason) || null,
298
+ next: ["Follow the guidance above.", "Run the installer again from your terminal."] };
299
+ }
300
+ const id = text(outcome?.failedStep);
301
+ // The host sends a detail only for these fixed steps; bound it again here.
302
+ const detailCommand = detailCommands.get(id) ?? null;
303
+ const detail = detailCommand ? Array.from(text(outcome?.detail)).slice(0, 300).join("") : "";
304
+ return { ...base, outcome: "failed", tone: "error", badge: "Failed", title: "Installation failed",
305
+ detail: detail || null, detailCommand: detail ? detailCommand : null,
306
+ lead: id && id !== "unknown"
307
+ ? `The step “${stepLabel(id)}” failed. Steps that finished before it were kept; nothing was rolled back.`
308
+ : "The installation stopped unexpectedly. Steps that finished were kept; nothing was rolled back.",
309
+ failedStep: id && id !== "unknown" ? { id, label: stepLabel(id) } : null,
310
+ next: ["Follow the guidance above.", "Run the installer again from your terminal."] };
311
+ }
312
+
313
+ /** The only install request body: null until consent is explicit. */
314
+ export function installBody(planId, consent) {
315
+ if (consent !== true || typeof planId !== "string" || planId.length === 0) return null;
316
+ return { planId, consent: true };
317
+ }
318
+
319
+ /** Maps a POST /api/install answer to the wizard's next step. */
320
+ export function interpretInstall(status, body) {
321
+ const error = body?.error;
322
+ if (status === 202) return { next: "progress", message: "" };
323
+ if (status === 409 && error === "plan-changed") {
324
+ return { next: "reload-plan", message: "This computer changed since you reviewed the plan. Review the updated plan and confirm again." };
325
+ }
326
+ if (status === 409 && error === "install-running") return { next: "progress", message: "" };
327
+ if (status === 409 && error === "already-completed") return { next: "outcome", message: "" };
328
+ if (status === 401 || status === 403) return { next: "session", message: "" };
329
+ if (status === 400) return { next: "error", message: "The installer rejected the request. Review the plan and try again." };
330
+ return { next: "error", message: "The installer could not start. Check the terminal for details, then try again." };
331
+ }
332
+
333
+ /** Progress polling: quick while steps finish, slower while quiet, bounded on errors. */
334
+ export const pollPolicy = Object.freeze({ initialMs: 500, maxMs: 4000, factor: 1.5, errorMaxMs: 8000, maxFailures: 5, maxIdle: 3 });
335
+ export function initialPoll() {
336
+ return { delay: pollPolicy.initialMs, failures: 0, idle: 0, stop: false, reason: null };
337
+ }
338
+ export function nextPoll(state, event) {
339
+ const policy = pollPolicy;
340
+ if (event.type === "error") {
341
+ const failures = state.failures + 1;
342
+ const stop = failures >= policy.maxFailures;
343
+ return { delay: Math.min(Math.max(state.delay, policy.initialMs) * 2, policy.errorMaxMs), failures, idle: state.idle, stop,
344
+ reason: stop ? "errors" : null };
345
+ }
346
+ if (event.outcome) return { delay: policy.initialMs, failures: 0, idle: 0, stop: true, reason: "outcome" };
347
+ // Neither running nor finished should not last: the host is in an unexpected state.
348
+ const idle = event.running ? 0 : state.idle + 1;
349
+ if (idle >= policy.maxIdle) return { delay: state.delay, failures: 0, idle, stop: true, reason: "stalled" };
350
+ const delay = event.count > 0 ? policy.initialMs : Math.min(Math.round(state.delay * policy.factor), policy.maxMs);
351
+ return { delay, failures: 0, idle, stop: false, reason: null };
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Rendering: DOM APIs and text nodes only
356
+ // ---------------------------------------------------------------------------
357
+
358
+ const headingOf = new WeakMap();
359
+
360
+ function el(doc, tag, attributes = {}, ...children) {
361
+ const node = doc.createElement(tag);
362
+ for (const [name, value] of Object.entries(attributes)) {
363
+ if (value === undefined || value === null || value === false) continue;
364
+ node.setAttribute(name, value === true ? "" : String(value));
365
+ }
366
+ for (const child of children.flat()) {
367
+ if (child === undefined || child === null || child === false) continue;
368
+ node.append(typeof child === "string" ? doc.createTextNode(child) : child);
369
+ }
370
+ return node;
371
+ }
372
+
373
+ /** Text with `code` spans: backtick segments become <code> elements, never markup. */
374
+ function rich(doc, value) {
375
+ return text(value).split("`").map((part, index) => (index % 2 === 1 ? el(doc, "code", {}, part) : doc.createTextNode(part)));
376
+ }
377
+
378
+ function panel(doc, { stage, eyebrow, title, lead, tone }, ...children) {
379
+ const heading = el(doc, "h1", { id: "view-title", tabindex: "-1" }, title);
380
+ const node = el(doc, "section", { class: `panel${tone ? ` panel-${tone}` : ""}`, "aria-labelledby": "view-title", "data-stage": stage,
381
+ "data-tone": tone },
382
+ el(doc, "p", { class: "eyebrow" }, eyebrow),
383
+ heading,
384
+ lead ? el(doc, "p", { class: "lead" }, rich(doc, lead)) : null,
385
+ ...children);
386
+ headingOf.set(node, heading);
387
+ return node;
388
+ }
389
+
390
+ function actionButton(doc, label, variant, onClick) {
391
+ const node = el(doc, "button", { type: "button", class: `button button-${variant}` }, label);
392
+ node.addEventListener("click", (event) => {
393
+ event?.preventDefault?.();
394
+ onClick(node);
395
+ });
396
+ return node;
397
+ }
398
+
399
+ /** Stage list; `stopped` marks the install stage when it ended blocked or failed. */
400
+ export function renderStages(doc, current, { stopped = false } = {}) {
401
+ const index = stages.findIndex(([id]) => id === current);
402
+ return stages.map(([id, label], position) => {
403
+ let state = position < index ? "done" : position === index ? "current" : "upcoming";
404
+ if (stopped && id === "install") state = "issue";
405
+ const glyph = { done: "✓", issue: "!" }[state] ?? String(position + 1);
406
+ const note = { done: " (completed)", issue: " (stopped)" }[state];
407
+ return el(doc, "li", { class: `stage stage-${state}`, "aria-current": state === "current" ? "step" : null },
408
+ el(doc, "span", { class: "stage-dot", "aria-hidden": "true" }, glyph),
409
+ el(doc, "span", { class: "stage-label" }, label),
410
+ note ? el(doc, "span", { class: "sr-only" }, note) : null);
411
+ });
412
+ }
413
+
414
+ export function renderLoading(doc) {
415
+ return panel(doc, { stage: "check", eyebrow: "Step 1 of 4 · Check", title: "Checking this computer",
416
+ lead: "Looking for Node.js, pnpm, Pi and Gentle Shell. Nothing changes until you confirm a plan." },
417
+ el(doc, "div", { class: "loader", "aria-hidden": "true" }, el(doc, "span"), el(doc, "span"), el(doc, "span")));
418
+ }
419
+
420
+ function disclosureCard(doc, item) {
421
+ return el(doc, "article", { class: `card disclosure${item.changes ? " disclosure-changes" : ""}` },
422
+ el(doc, "div", { class: "card-head" },
423
+ el(doc, "h2", { class: "card-title" }, item.title),
424
+ el(doc, "span", { class: `pill ${item.changes ? "pill-accent" : "pill-muted"}` },
425
+ el(doc, "span", { "aria-hidden": "true" }, item.changes ? "● " : "○ "), item.state)),
426
+ el(doc, "p", {}, rich(doc, item.text)),
427
+ item.detail ? el(doc, "p", { class: "detail" }, el(doc, "code", {}, item.detail)) : null);
428
+ }
429
+
430
+ const channels = Object.freeze([
431
+ { value: "release", label: "Latest release", hint: "Recommended. The published Gentle Shell and its pinned Gentle AI binary." },
432
+ { value: "main", label: "Latest main", hint: "Development builds: Gentle Shell and Gentle AI from the latest commit of `main`, built on this computer with Go. If yours is missing, older or cannot be checked, the installer downloads its own pinned Go only for the build." },
433
+ ]);
434
+
435
+ /** Channel choice shown on every review screen; a change reloads the plan. */
436
+ function channelChoice(doc, model, handlers) {
437
+ return el(doc, "fieldset", { class: "block channel" },
438
+ el(doc, "legend", { class: "section-title" }, "What to install"),
439
+ channels.map((channel) => {
440
+ const id = `channel-${channel.value}`;
441
+ const input = el(doc, "input", { type: "radio", name: "channel", id, value: channel.value, class: "channel-input" });
442
+ input.checked = model.channel === channel.value;
443
+ input.addEventListener("change", () => {
444
+ if (input.checked && model.channel !== channel.value) handlers.channelChanged?.(channel.value);
445
+ });
446
+ return el(doc, "div", { class: "channel-option" }, input,
447
+ el(doc, "label", { for: id, class: "channel-label" }, channel.label),
448
+ el(doc, "p", { class: "hint" }, rich(doc, channel.hint)));
449
+ }));
450
+ }
451
+
452
+ /** Review screen. handlers: install({ consent, checkbox, button }), reload(), close(), channelChanged(channel). */
453
+ export function renderPlan(doc, model, handlers) {
454
+ const close = actionButton(doc, "Close installer", "ghost", () => handlers.close());
455
+ const choice = channelChoice(doc, model, handlers);
456
+ if (model.kind === "blocked") {
457
+ return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Something needs attention first",
458
+ lead: "Nothing was changed. Resolve these items, then check again.", tone: "warning" },
459
+ choice,
460
+ el(doc, "ul", { class: "blockers", role: "list" }, model.blockers.map((blocker) => el(doc, "li", { class: "alert alert-warning" },
461
+ el(doc, "h2", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, "! "), blocker.toolLabel),
462
+ el(doc, "p", {}, rich(doc, blocker.guidance)),
463
+ el(doc, "p", { class: "meta" }, "Code: ", el(doc, "code", {}, blocker.code))))),
464
+ el(doc, "div", { class: "actions" }, actionButton(doc, "Check again", "primary", () => handlers.reload()), close));
465
+ }
466
+ if (model.kind === "nothing") {
467
+ return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Gentle Shell is already set up",
468
+ lead: "This computer already has everything the wizard installs." },
469
+ choice,
470
+ el(doc, "p", {}, rich(doc, model.channel === "main"
471
+ ? "Run `gentle-shell` in a terminal. To follow main, run `gentle-shell upgrade --channel main`."
472
+ : "Run `gentle-shell` in a terminal. To upgrade, run `gentle-shell upgrade`.")),
473
+ el(doc, "div", { class: "actions" }, close));
474
+ }
475
+ const recovery = model.kind === "recovery";
476
+ const updating = model.kind === "update";
477
+ const updatingPi = model.kind === "update-pi";
478
+ const installingPi = model.kind === "install-pi";
479
+ const piUpdate = model.actions.some((action) => action.id === "update-pi");
480
+ const checkbox = el(doc, "input", { type: "checkbox", id: "consent", class: "consent-input", "aria-describedby": "consent-hint" });
481
+ const label = recovery ? "Complete setup" : updating ? "Update Gentle Shell" : updatingPi ? "Update Pi" : installingPi ? "Install Pi"
482
+ : "Install Gentle Shell";
483
+ const install = actionButton(doc, label, "primary", (button) =>
484
+ handlers.install({ consent: checkbox.checked === true, checkbox, button }));
485
+ checkbox.addEventListener("change", () => {
486
+ if (checkbox.checked) checkbox.removeAttribute("aria-invalid");
487
+ handlers.consentChanged?.(checkbox.checked === true);
488
+ });
489
+ const heading = recovery
490
+ ? { title: "Finish setting up Gentle Shell",
491
+ lead: "Gentle Shell is already installed; this completes setup. Nothing is reinstalled, and nothing changes until you confirm." }
492
+ : updating
493
+ ? { title: "Update Gentle Shell",
494
+ lead: "Gentle Shell is already installed. This updates it with the package manager that installed it; your settings and sessions stay. Nothing changes until you confirm." }
495
+ : updatingPi
496
+ ? { title: "Update Pi",
497
+ lead: "Gentle Shell is already installed, but Pi is older than it needs. This updates Pi with the package manager that installed it; your settings and sessions stay. Nothing changes until you confirm." }
498
+ : model.kind === "bundled"
499
+ ? { title: "Install Gentle Shell as one self-contained product",
500
+ lead: "Nothing changes until you confirm. Gentle Shell brings its own pinned Node.js, npm, pnpm and Pi; your own Node.js, npm, pnpm, Go and Pi are not used or changed." }
501
+ : installingPi
502
+ ? { title: "Install Pi for Gentle Shell",
503
+ lead: "Gentle Shell is already installed, but Pi is missing or older than it needs. This installs Pi globally with pnpm; any other Pi on this computer is left unchanged. Nothing changes until you confirm." }
504
+ : { title: "Review the installation plan",
505
+ lead: "Nothing changes until you confirm. This is exactly what the installer will do on this computer." };
506
+ return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", ...heading },
507
+ choice,
508
+ el(doc, "section", { class: "block", "aria-labelledby": "changes-title" },
509
+ el(doc, "h2", { id: "changes-title", class: "section-title" }, "What changes on this computer"),
510
+ el(doc, "div", { class: "grid" }, model.disclosures.map((item) => disclosureCard(doc, item)))),
511
+ model.notice ? el(doc, "section", { class: "alert alert-warning", "aria-labelledby": "notice-title" },
512
+ el(doc, "h2", { id: "notice-title", class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, "! "), "Tools in folders other accounts can change"),
513
+ el(doc, "p", {}, rich(doc, model.notice))) : null,
514
+ model.gitNotice ? el(doc, "section", { class: "alert alert-warning", "aria-labelledby": "git-notice-title" },
515
+ el(doc, "h2", { id: "git-notice-title", class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, "! "), "Git not found"),
516
+ el(doc, "p", {}, rich(doc, model.gitNotice))) : null,
517
+ el(doc, "section", { class: "block", "aria-labelledby": "steps-title" },
518
+ el(doc, "h2", { id: "steps-title", class: "section-title" }, recovery ? "Setup steps" : updating || updatingPi ? "Update steps" : "Installation steps"),
519
+ el(doc, "ol", { class: "plan-steps" }, model.actions.map((action) => el(doc, "li", {},
520
+ el(doc, "span", { class: "plan-step-text" }, rich(doc, action.description)))))),
521
+ el(doc, "div", { class: "consent" },
522
+ checkbox,
523
+ el(doc, "div", {},
524
+ el(doc, "label", { for: "consent", class: "consent-label" }, "I reviewed this plan and agree to these changes on this computer."),
525
+ el(doc, "p", { id: "consent-hint", class: "hint" }, updating
526
+ ? "The installer runs only the steps listed above and updates only Gentle Shell (and Pi, if it is missing or older than required)."
527
+ : updatingPi
528
+ ? "The installer runs only the steps listed above and updates only Pi, never to an older version."
529
+ : installingPi
530
+ ? "The installer runs only the steps listed above and adds only Pi. Existing installations are not replaced."
531
+ : piUpdate
532
+ ? "The installer runs only the steps listed above. Existing installations are not replaced, except the older Pi it updates."
533
+ : "The installer runs only the steps listed above. Existing installations are not replaced."))),
534
+ el(doc, "div", { class: "actions" }, install, close));
535
+ }
536
+
537
+ function logLine(doc, entry) {
538
+ const status = text(entry.status) || "unknown";
539
+ return el(doc, "li", { class: `log-line log-${status}` },
540
+ el(doc, "span", { class: "log-seq" }, String(entry.seq).padStart(3, "0")),
541
+ el(doc, "span", { class: "log-status" }, status),
542
+ el(doc, "span", { class: "log-step" }, text(entry.step)),
543
+ entry.reason ? el(doc, "span", { class: "log-reason" }, text(entry.reason)) : null);
544
+ }
545
+
546
+ function logPanel(doc, entries) {
547
+ const lines = el(doc, "ol", { class: "log-lines" });
548
+ const empty = el(doc, "p", { class: "log-empty" }, "Waiting for the first step…");
549
+ const node = el(doc, "section", { class: "log", "aria-labelledby": "log-title" },
550
+ el(doc, "div", { class: "log-head" },
551
+ el(doc, "span", { class: "log-dots", "aria-hidden": "true" }, el(doc, "span"), el(doc, "span"), el(doc, "span")),
552
+ el(doc, "h2", { id: "log-title", class: "log-title" }, "Installer log")),
553
+ el(doc, "div", { class: "log-body", tabindex: "0", role: "region", "aria-label": "Installer log lines" }, empty, lines));
554
+ const update = (all) => {
555
+ lines.replaceChildren(...all.map((entry) => logLine(doc, entry)));
556
+ if (all.length > 0) empty.setAttribute("hidden", "");
557
+ else empty.removeAttribute("hidden");
558
+ };
559
+ update(entries);
560
+ return { node, update };
561
+ }
562
+
563
+ const stepIcons = Object.freeze({ done: "✓", failed: "✕", blocked: "!", running: "", pending: "", skipped: "–", unknown: "?" });
564
+ function stepRow(doc, item) {
565
+ return el(doc, "li", { class: `step step-${item.status}`, "aria-current": item.status === "running" ? "step" : null },
566
+ el(doc, "span", { class: "step-icon", "aria-hidden": "true" }, stepIcons[item.status]),
567
+ el(doc, "span", { class: "step-label" }, item.label),
568
+ el(doc, "span", { class: "step-status" }, item.statusText));
569
+ }
570
+
571
+ /** Progress screen with in-place updates, so focus stays on the heading. */
572
+ export function renderProgress(doc) {
573
+ const bar = el(doc, "progress", { id: "install-progress", max: "1", value: "0", "aria-describedby": "progress-count" });
574
+ const count = el(doc, "span", { id: "progress-count", class: "progress-count" }, "Starting…");
575
+ const steps = el(doc, "ol", { class: "steps" });
576
+ const log = logPanel(doc, []);
577
+ const node = panel(doc, { stage: "install", eyebrow: "Step 3 of 4 · Install", title: "Installing Gentle Shell",
578
+ lead: "This can take a few minutes. Keep this tab and the terminal open." },
579
+ el(doc, "div", { class: "progress" },
580
+ el(doc, "div", { class: "progress-head" }, el(doc, "label", { for: "install-progress", class: "progress-label" }, "Progress"), count),
581
+ bar),
582
+ el(doc, "div", { class: "progress-body" },
583
+ el(doc, "section", { class: "block", "aria-labelledby": "progress-steps-title" },
584
+ el(doc, "h2", { id: "progress-steps-title", class: "section-title" }, "Steps"), steps),
585
+ log.node));
586
+ const update = (model, entries) => {
587
+ bar.setAttribute("max", String(Math.max(model.total, 1)));
588
+ bar.setAttribute("value", String(model.done));
589
+ count.textContent = model.total > 0 ? `${model.done} of ${model.total} steps` : "Starting…";
590
+ steps.replaceChildren(...model.items.map((item) => stepRow(doc, item)));
591
+ log.update(entries);
592
+ };
593
+ return { node, update };
594
+ }
595
+
596
+ /** Final screen. handlers: close(), copy?(text) -> Promise<boolean>. */
597
+ export function renderOutcome(doc, model, handlers, entries = []) {
598
+ const children = [
599
+ el(doc, "div", { class: `alert alert-${model.tone}` },
600
+ el(doc, "p", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, model.tone === "success" ? "✓ " : model.tone === "warning" ? "! " : "✕ "),
601
+ model.badge),
602
+ el(doc, "p", {}, rich(doc, model.guidance))),
603
+ ];
604
+ if (model.command) {
605
+ const command = el(doc, "code", { class: "command-text" }, model.command);
606
+ const copy = handlers.copy
607
+ ? actionButton(doc, "Copy", "ghost", async (button) => {
608
+ const copied = await handlers.copy(model.command);
609
+ button.textContent = copied ? "Copied" : "Copy failed";
610
+ })
611
+ : null;
612
+ children.push(el(doc, "section", { class: "block", "aria-labelledby": "next-title" },
613
+ el(doc, "h2", { id: "next-title", class: "section-title" }, "Next"),
614
+ el(doc, "ol", { class: "next-steps" }, model.next.map((line) => el(doc, "li", {}, el(doc, "span", { class: "plan-step-text" }, rich(doc, line))))),
615
+ el(doc, "div", { class: "command" }, el(doc, "span", { class: "command-prompt", "aria-hidden": "true" }, "$"), command, copy)));
616
+ } else {
617
+ children.push(el(doc, "section", { class: "block", "aria-labelledby": "next-title" },
618
+ el(doc, "h2", { id: "next-title", class: "section-title" }, "What to do next"),
619
+ el(doc, "ol", { class: "next-steps" }, model.next.map((line) => el(doc, "li", {}, el(doc, "span", { class: "plan-step-text" }, rich(doc, line)))))));
620
+ }
621
+ if (model.notes.length > 0) children.push(el(doc, "ul", { class: "notes" }, model.notes.map((note) => el(doc, "li", {}, note))));
622
+ const facts = [];
623
+ if (model.failedStep) facts.push(el(doc, "li", {}, "Failed step: ", el(doc, "code", {}, model.failedStep.id)));
624
+ if (model.reason) facts.push(el(doc, "li", {}, "Reason: ", el(doc, "code", {}, model.reason)));
625
+ if (facts.length > 0) children.push(el(doc, "ul", { class: "facts" }, facts));
626
+ // Untrusted process output: one plain text node, never `rich` code spans.
627
+ if (model.detail) {
628
+ children.push(el(doc, "section", { class: "block", "aria-labelledby": "setup-detail-title" },
629
+ el(doc, "h2", { id: "setup-detail-title", class: "section-title" }, `Last error from ${model.detailCommand}`),
630
+ el(doc, "p", { class: "detail" }, el(doc, "code", {}, model.detail))));
631
+ }
632
+ if (model.completed.length > 0) {
633
+ children.push(el(doc, "details", { class: "completed" },
634
+ // A blocked run only passed read-only checks before it stopped.
635
+ el(doc, "summary", {}, `${model.outcome === "blocked" ? "Checks that passed" : "Completed steps"} (${model.completed.length})`),
636
+ el(doc, "ul", {}, model.completed.map((item) => el(doc, "li", {}, el(doc, "span", { "aria-hidden": "true" }, "✓ "), item.label)))));
637
+ }
638
+ if (entries.length > 0) {
639
+ const log = logPanel(doc, entries);
640
+ children.push(el(doc, "details", { class: "completed" }, el(doc, "summary", {}, `Installer log (${entries.length} entries)`), log.node));
641
+ }
642
+ children.push(el(doc, "div", { class: "actions" }, actionButton(doc, "Close installer", model.tone === "success" ? "primary" : "secondary",
643
+ () => handlers.close())));
644
+ return panel(doc, { stage: "done", eyebrow: "Step 4 of 4 · Done", title: model.title, lead: model.lead, tone: model.tone }, ...children);
645
+ }
646
+
647
+ export function renderClosed(doc, outcome) {
648
+ const installed = outcome?.outcome === "ready" || outcome?.outcome === "terminal-action-required";
649
+ return panel(doc, { stage: "done", eyebrow: "Finished", title: "Installer closed",
650
+ lead: installed
651
+ ? "You can close this tab. Run `gentle-shell` in a terminal to start."
652
+ : "You can close this tab. The terminal shows the same result, and nothing else will run." });
653
+ }
654
+
655
+ export function renderSession(doc) {
656
+ return panel(doc, { stage: "check", eyebrow: "Session ended", title: "This session has ended",
657
+ lead: "The installer link works once, in the browser that opened it, and stops when the installer closes. Run the installer again from your terminal to get a new link." });
658
+ }
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // Controller
662
+ // ---------------------------------------------------------------------------
663
+
664
+ function isProgress(body) {
665
+ return body !== null && typeof body === "object" && Array.isArray(body.entries);
666
+ }
667
+ const sessionEnded = (status) => status === 401 || status === 403;
668
+ const noAnswer = "The installer did not answer. Check that the terminal is still running it, then try again.";
669
+
670
+ /**
671
+ * createWizard({ document, fetch, setTimeout, clearTimeout, clipboard? })
672
+ * Expects #view, #stages, #alert (role=alert) and #status (role=status).
673
+ */
674
+ export function createWizard({ document: doc, fetch: request, setTimeout: later, clearTimeout: cancel, clipboard = null }) {
675
+ const roots = {
676
+ view: doc.getElementById("view"),
677
+ stages: doc.getElementById("stages"),
678
+ alert: doc.getElementById("alert"),
679
+ status: doc.getElementById("status"),
680
+ };
681
+ const state = { plan: null, steps: [], entries: [], lastSeq: 0, poll: initialPoll(), timer: null, outcome: null, busy: false,
682
+ progress: null, running: false, channel: "release" };
683
+
684
+ async function api(path, { method = "GET", body } = {}) {
685
+ const init = { method, headers: { ...API_HEADERS }, credentials: "same-origin", cache: "no-store" };
686
+ if (body !== undefined) {
687
+ init.headers["Content-Type"] = "application/json";
688
+ init.body = JSON.stringify(body);
689
+ }
690
+ const response = await request(path, init);
691
+ let data = null;
692
+ try {
693
+ data = await response.json();
694
+ } catch {
695
+ data = null;
696
+ }
697
+ return { status: response.status, body: data };
698
+ }
699
+
700
+ function announce(message) {
701
+ if (roots.status) roots.status.textContent = message;
702
+ }
703
+ function clearError() {
704
+ roots.alert?.replaceChildren();
705
+ }
706
+ function showError(message, retry = null, { tone = "error", title = "Problem" } = {}) {
707
+ const retryButton = retry ? actionButton(doc, "Try again", "secondary", () => {
708
+ clearError();
709
+ retry();
710
+ }) : null;
711
+ roots.alert?.replaceChildren(el(doc, "div", { class: `alert alert-${tone}` },
712
+ el(doc, "p", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, tone === "error" ? "✕ " : "! "), title),
713
+ el(doc, "p", {}, message),
714
+ retryButton ? el(doc, "div", { class: "actions" }, retryButton) : null));
715
+ }
716
+ function stopPolling() {
717
+ if (state.timer !== null) cancel(state.timer);
718
+ state.timer = null;
719
+ }
720
+ function show(node, { focus = true } = {}) {
721
+ const stage = node.getAttribute("data-stage");
722
+ const stopped = stage === "done" && ["warning", "error"].includes(node.getAttribute("data-tone"));
723
+ roots.stages?.replaceChildren(...renderStages(doc, stage, { stopped }));
724
+ roots.view.replaceChildren(node);
725
+ if (focus) headingOf.get(node)?.focus();
726
+ }
727
+ function ingest(entries) {
728
+ const fresh = list(entries).filter((entry) => Number.isInteger(entry?.seq) && entry.seq > state.lastSeq);
729
+ for (const entry of fresh) state.lastSeq = Math.max(state.lastSeq, entry.seq);
730
+ state.entries = [...state.entries, ...fresh].slice(-LOG_LIMIT);
731
+ return fresh;
732
+ }
733
+
734
+ async function resume() {
735
+ clearError();
736
+ let result;
737
+ try {
738
+ result = await api("/api/progress?after=0");
739
+ } catch {
740
+ showError(noAnswer, resume);
741
+ return;
742
+ }
743
+ if (sessionEnded(result.status)) return showSession();
744
+ if (result.status !== 200 || !isProgress(result.body)) {
745
+ showError(noAnswer, resume);
746
+ return;
747
+ }
748
+ ingest(result.body.entries);
749
+ if (result.body.outcome) return showOutcome(result.body.outcome);
750
+ if (result.body.running === true) return showProgress();
751
+ return loadPlan();
752
+ }
753
+
754
+ async function loadPlan(notice = "") {
755
+ clearError();
756
+ let result;
757
+ try {
758
+ result = await api(`/api/plan?channel=${state.channel === "main" ? "main" : "release"}`);
759
+ } catch {
760
+ showError(noAnswer, () => loadPlan(notice));
761
+ return;
762
+ }
763
+ if (sessionEnded(result.status)) return showSession();
764
+ if (result.status === 409 && result.body?.error === "install-running") return showProgress();
765
+ if (result.status !== 200 || result.body === null || typeof result.body !== "object") {
766
+ showError("The installer could not check this computer. Check the terminal for details, then try again.", () => loadPlan(notice));
767
+ return;
768
+ }
769
+ state.plan = planModel(result.body);
770
+ state.steps = state.plan.steps;
771
+ show(renderPlan(doc, state.plan, { install, reload: () => loadPlan(), close, consentChanged: (checked) => { if (checked) clearError(); },
772
+ channelChanged: (channel) => {
773
+ state.channel = channel;
774
+ return loadPlan();
775
+ } }));
776
+ if (notice) showError(notice, null, { tone: "warning", title: "Plan updated" });
777
+ }
778
+
779
+ async function install({ consent, checkbox, button }) {
780
+ if (state.busy) return;
781
+ const body = installBody(state.plan?.planId, consent);
782
+ if (body === null) {
783
+ showError("Check the box to confirm that you reviewed the plan. Nothing was installed.");
784
+ checkbox.setAttribute("aria-invalid", "true");
785
+ checkbox.focus();
786
+ return;
787
+ }
788
+ clearError();
789
+ state.busy = true;
790
+ button.disabled = true;
791
+ let result;
792
+ try {
793
+ result = await api("/api/install", { method: "POST", body });
794
+ } catch {
795
+ // The request may have reached the host before the connection broke: check
796
+ // once, and follow a running or finished installation instead of offering a retry.
797
+ const current = await currentProgress();
798
+ state.busy = false;
799
+ if (current?.outcome) return showOutcome(current.outcome);
800
+ if (current?.running) return showProgress();
801
+ button.disabled = false;
802
+ showError(noAnswer);
803
+ return;
804
+ }
805
+ state.busy = false;
806
+ const next = interpretInstall(result.status, result.body);
807
+ if (next.next === "progress") return showProgress();
808
+ if (next.next === "reload-plan") return loadPlan(next.message);
809
+ if (next.next === "outcome") return resume();
810
+ if (next.next === "session") return showSession();
811
+ button.disabled = false;
812
+ showError(next.message, () => loadPlan());
813
+ }
814
+
815
+ /** One GET /api/progress?after=0, or null when the host does not answer usefully. */
816
+ async function currentProgress() {
817
+ try {
818
+ const result = await api("/api/progress?after=0");
819
+ if (result.status !== 200 || !isProgress(result.body)) return null;
820
+ ingest(result.body.entries);
821
+ return { running: result.body.running === true, outcome: result.body.outcome ?? null };
822
+ } catch {
823
+ return null;
824
+ }
825
+ }
826
+
827
+ function progressSnapshot() {
828
+ return progressModel(state.steps, state.entries, { running: state.running, outcome: state.outcome });
829
+ }
830
+
831
+ function showProgress() {
832
+ stopPolling();
833
+ state.running = true;
834
+ state.poll = initialPoll();
835
+ state.progress = renderProgress(doc);
836
+ state.progress.update(progressSnapshot(), state.entries);
837
+ show(state.progress.node);
838
+ announce("Installation started.");
839
+ schedule(state.poll.delay);
840
+ }
841
+
842
+ function schedule(ms) {
843
+ stopPolling();
844
+ state.timer = later(() => {
845
+ state.timer = null;
846
+ void pollOnce();
847
+ }, ms);
848
+ }
849
+
850
+ function pollFailed(event) {
851
+ state.poll = nextPoll(state.poll, event);
852
+ if (state.poll.stop) return pollStopped();
853
+ schedule(state.poll.delay);
854
+ }
855
+
856
+ function pollStopped() {
857
+ const message = state.poll.reason === "stalled"
858
+ ? "The installer stopped reporting progress. Check the terminal, then try again."
859
+ : "Lost contact with the installer. Check that the terminal is still running it, then try again.";
860
+ showError(message, () => {
861
+ state.poll = initialPoll();
862
+ schedule(0);
863
+ });
864
+ }
865
+
866
+ async function pollOnce() {
867
+ let result;
868
+ try {
869
+ result = await api(`/api/progress?after=${state.lastSeq}`);
870
+ } catch {
871
+ return pollFailed({ type: "error" });
872
+ }
873
+ if (sessionEnded(result.status)) return showSession();
874
+ if (result.status !== 200 || !isProgress(result.body)) return pollFailed({ type: "error" });
875
+ const fresh = ingest(result.body.entries);
876
+ const outcome = result.body.outcome ?? null;
877
+ state.running = result.body.running === true;
878
+ state.poll = nextPoll(state.poll, { type: "entries", count: fresh.length, running: state.running, outcome });
879
+ if (outcome) return showOutcome(outcome);
880
+ clearError();
881
+ state.progress?.update(progressSnapshot(), state.entries);
882
+ const last = fresh.at(-1);
883
+ if (last) announce(`${stepLabel(last.step)}: ${statusText[last.status] ?? last.status}.`);
884
+ if (state.poll.stop) return pollStopped();
885
+ schedule(state.poll.delay);
886
+ }
887
+
888
+ function showOutcome(outcome) {
889
+ stopPolling();
890
+ state.outcome = outcome;
891
+ state.running = false;
892
+ const model = outcomeModel(outcome);
893
+ const copy = clipboard ? async (value) => {
894
+ try {
895
+ await clipboard.writeText(value);
896
+ announce("Command copied.");
897
+ return true;
898
+ } catch {
899
+ return false;
900
+ }
901
+ } : undefined;
902
+ show(renderOutcome(doc, model, { close, copy }, state.entries));
903
+ announce(`${model.title}.`);
904
+ }
905
+
906
+ function showSession() {
907
+ stopPolling();
908
+ clearError();
909
+ show(renderSession(doc));
910
+ }
911
+
912
+ async function close() {
913
+ if (state.busy) return;
914
+ state.busy = true;
915
+ let result;
916
+ try {
917
+ result = await api("/api/shutdown", { method: "POST", body: {} });
918
+ } catch {
919
+ state.busy = false;
920
+ showError("The installer did not answer. It may already be closed; check the terminal.");
921
+ return;
922
+ }
923
+ state.busy = false;
924
+ if (result.status === 200) {
925
+ stopPolling();
926
+ clearError();
927
+ show(renderClosed(doc, state.outcome));
928
+ announce("Installer closed.");
929
+ return;
930
+ }
931
+ if (sessionEnded(result.status)) return showSession();
932
+ if (result.status === 409) {
933
+ showError("An installation is still running. Wait for it to finish, then close the installer.");
934
+ return;
935
+ }
936
+ showError("The installer could not close. Press Ctrl+C in the terminal to stop it.");
937
+ }
938
+
939
+ return { start: resume, state };
940
+ }
941
+
942
+ if (typeof window !== "undefined" && typeof document !== "undefined" && typeof fetch === "function") {
943
+ const app = createWizard({
944
+ document,
945
+ fetch: (...args) => fetch(...args),
946
+ setTimeout: (fn, ms) => window.setTimeout(fn, ms),
947
+ clearTimeout: (id) => window.clearTimeout(id),
948
+ clipboard: window.navigator?.clipboard ?? null,
949
+ });
950
+ void app.start();
951
+ }