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,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { createAssistantMessageEventStream, getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai";
7
+ import { createAgentSession, DefaultResourceLoader, ModelRuntime, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent";
8
+ import gentleAgents, { type SessionTransportFactory } from "../extensions/gentle-agents.ts";
9
+ import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
10
+
11
+ const sdk = import.meta.resolve("@earendil-works/pi-coding-agent");
12
+ const { UserMessageComponent } = await import(new URL("./modes/interactive/components/user-message.js", sdk).href);
13
+ const { initTheme } = await import(new URL("./modes/interactive/theme/theme.js", sdk).href);
14
+ initTheme("dark");
15
+ const transport: SessionTransportFactory = {
16
+ createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
17
+ createListener: registry => ({ registry, start: async () => {}, close: async () => {} }),
18
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("Offline fixture forbids transport"); } }),
19
+ };
20
+ const tick = () => new Promise<void>(resolve => setImmediate(resolve));
21
+ async function until(predicate: () => boolean) {
22
+ const deadline = Date.now() + 2000;
23
+ while (!predicate() && Date.now() < deadline) await tick();
24
+ assert.ok(predicate(), "offline lifecycle did not settle");
25
+ }
26
+
27
+ test("actual SDK Bridge wake retains preparation, history, reload and resumed rendering offline", async () => {
28
+ const root = mkdtempSync(join(tmpdir(), "bridge-lifecycle-"));
29
+ const home = join(root, "home"), cwd = join(root, "project"), agentDir = join(root, "agent");
30
+ mkdirSync(home, { recursive: true });
31
+ mkdirSync(cwd); mkdirSync(join(agentDir, "agents"), { recursive: true });
32
+ writeFileSync(join(agentDir, "agents", "explore.md"), "---\ndescription: Offline fixture\n---\nReturn a fixture answer.");
33
+ const children: FakeChild[] = [], requests: TranscriptContext[] = [];
34
+ let preparations = 0;
35
+ const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "offline-auth.json"), modelsPath: null, refreshOnCreate: false });
36
+ // The provider name tests routing, but this model has no live implementation.
37
+ modelRuntime.registerProvider("claude-bridge", {
38
+ api: "bridge-offline-test", apiKey: "explicit-offline-dummy", baseUrl: "http://offline.invalid",
39
+ models: [{ id: "offline-recording", name: "Offline recording", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1000 }],
40
+ streamSimple: (model, context) => {
41
+ assert.equal(model.id, "offline-recording");
42
+ requests.push(structuredClone(context));
43
+ const stream = createAssistantMessageEventStream();
44
+ const message: AssistantMessage = { role: "assistant", api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), content: [{ type: "text", text: "Offline continued" }], stopReason: "stop", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
45
+ queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "done", reason: "stop", message }); stream.end(); });
46
+ return stream;
47
+ },
48
+ });
49
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
50
+ const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
51
+ extensionFactories: [pi => {
52
+ gentleAgents(pi, {}, { home, agentHome: agentDir, env: { PATH: "/usr/bin:/bin" }, pi: { command: "offline-child", args: [] }, spawn: () => { const child = fakeChild(); children.push(child); return child.child; }, schedule: () => () => {}, sessionTransport: transport, resolveWorktree: () => undefined });
53
+ pi.registerTool({ name: "sentinel", label: "Sentinel", description: "Offline sentinel tool", parameters: { type: "object", properties: {} } as never, execute: async () => ({ content: [], details: undefined }) });
54
+ pi.on("before_agent_start", event => {
55
+ preparations++;
56
+ pi.setActiveTools(["sentinel"]);
57
+ event.systemPromptOptions.appendSystemPrompt += `\nBRIDGE_PREPARATION_SENTINEL_${preparations}`;
58
+ event.systemPromptOptions.selectedTools = ["sentinel"];
59
+ });
60
+ }],
61
+ });
62
+ await loader.reload();
63
+ const manager = SessionManager.create(cwd, join(root, "sessions"));
64
+ const model = modelRuntime.getModel("claude-bridge", "offline-recording")!;
65
+ const errors: string[] = [];
66
+ const create = async (sessionManager: SessionManager) => {
67
+ const result = await createAgentSession({ cwd, agentDir, modelRuntime, model, tools: ["sentinel"], settingsManager, resourceLoader: loader, sessionManager });
68
+ await result.session.bindExtensions({ mode: "rpc", onError: error => errors.push(error.error) });
69
+ return result.session;
70
+ };
71
+ let session = await create(manager);
72
+ try {
73
+ await session.prompt("Ordinary human turn");
74
+ const launch = async (id: string) => {
75
+ const tool = session.extensionRunner.getAllRegisteredTools().find(tool => tool.definition.name === "subagent_run")!.definition;
76
+ const count = children.length;
77
+ const result = await tool.execute(id, { agent: "explore", task: "Offline work", mode: "background" }, undefined, undefined, session.extensionRunner.createToolContext(id, undefined));
78
+ assert.ok(JSON.stringify(result).includes("taskId"), JSON.stringify(result));
79
+ await until(() => children.length > count);
80
+ return children.at(-1)!;
81
+ };
82
+ const child = await launch("completion");
83
+ child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "OFFLINE_CHILD_RESULT" }] }] });
84
+ child.emit({ type: "agent_settled" });
85
+ await until(() => requests.length === 2 && session.isIdle);
86
+ assert.equal(preparations, 2, "wake traverses normal prompt/before_agent_start");
87
+ assert.match(getCurrentSystemPrompt(requests[1].messages), /BRIDGE_PREPARATION_SENTINEL_2/);
88
+ assert.deepEqual(getCurrentTools(requests[1].messages).map(tool => tool.name), ["sentinel"]);
89
+ const wakeEntries = manager.getEntries().filter(entry => entry.type === "custom" && entry.customType === "gentle-agents.wake-identity");
90
+ assert.equal(wakeEntries.length, 1);
91
+ const identity = (wakeEntries[0] as { data: { text: string } }).data;
92
+ assert.ok(session.messages.some(message => message.role === "user" && JSON.stringify(message.content).includes(identity.text)), "wake is still user-role history");
93
+ assert.ok(JSON.stringify(requests[1].messages).includes(identity.text), "wake remains model-visible");
94
+ assert.equal(manager.getEntries().filter(entry => entry.type === "custom_message" && entry.customType === "gentle-agents.result").length, 1);
95
+ const render = (text: string) => new UserMessageComponent(text, undefined, 1, session.extensionRunner.getMarkdownTransformers()).render(80);
96
+ assert.deepEqual(render(identity.text), []);
97
+ assert.ok(render("Ordinary human turn").length > 0);
98
+ await session.reload();
99
+ assert.equal(session.extensionRunner.getMarkdownTransformers().length, 1);
100
+ assert.deepEqual(render(identity.text), [], "reload reconstructs the durable identity");
101
+ const file = manager.getSessionFile()!;
102
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
103
+ session.dispose();
104
+ await loader.reload();
105
+ session = await create(SessionManager.open(file));
106
+ assert.equal(session.extensionRunner.getMarkdownTransformers().length, 1);
107
+ assert.deepEqual(render(identity.text), [], "opening saved history restores suppression");
108
+ const queryChild = await launch("query");
109
+ queryChild.message({ id: "q1", kind: "query", message: "OFFLINE_CHILD_QUERY" });
110
+ await until(() => requests.length === 3 && session.isIdle);
111
+ assert.equal(preparations, 3);
112
+ assert.match(getCurrentSystemPrompt(requests[2].messages), /BRIDGE_PREPARATION_SENTINEL_3/);
113
+ assert.deepEqual(getCurrentTools(requests[2].messages).map(tool => tool.name), ["sentinel"]);
114
+ assert.equal(session.sessionManager.getEntries().filter(entry => entry.type === "custom" && entry.customType === "gentle-agents.wake-identity").length, 1, "resume reuses one identity");
115
+ assert.equal(session.sessionManager.getEntries().filter(entry => entry.type === "custom_message" && entry.customType === "gentle-agents.message").length, 1, "query stored once");
116
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
117
+ session.dispose();
118
+ await loader.reload();
119
+ session = await create(SessionManager.inMemory(cwd));
120
+ assert.equal(session.extensionRunner.getMarkdownTransformers().length, 1);
121
+ assert.ok(render(identity.text).length > 0, "switching to another session releases the old identity");
122
+ assert.ok(render("Ordinary human turn").length > 0);
123
+ assert.deepEqual(errors, []);
124
+ } finally {
125
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
126
+ session.dispose();
127
+ rmSync(root, { recursive: true, force: true });
128
+ }
129
+ });
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { createAgentSession, ModelRuntime, DefaultResourceLoader, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent";
7
+ import gentleAgents from "../extensions/gentle-agents.ts";
8
+
9
+ // Resolve from the installed SDK entry, never from a vendored runtime copy.
10
+ const sdk = import.meta.resolve("@earendil-works/pi-coding-agent");
11
+ const { UserMessageComponent } = await import(new URL("./modes/interactive/components/user-message.js", sdk).href);
12
+ const { initTheme } = await import(new URL("./modes/interactive/theme/theme.js", sdk).href);
13
+ initTheme("dark");
14
+ const oldWake = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue.";
15
+
16
+ test("real user renderer preserves ordinary content and removes only the owned reserved wake", async () => {
17
+ // Every path is owned by this test so CI never depends on ambient TMPDIR, HOME, or agent directories.
18
+ const root = mkdtempSync(join(tmpdir(), "bridge-rendering-"));
19
+ try {
20
+ const home = join(root, "home"), cwd = join(root, "project"), agentDir = join(root, "agent");
21
+ mkdirSync(home); mkdirSync(cwd); mkdirSync(agentDir);
22
+ const manager = SessionManager.inMemory(cwd);
23
+ const wake = `${oldWake} [gentle-agents wake: 67da57d9-7fbe-47b6-9187-c31109fe4eba]`;
24
+ manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "invalid", text: "Human continuation" });
25
+ manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: "other-session", nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: "Human continuation" });
26
+ manager.appendCustomEntry("gentle-agents.wake-identity", { sessionId: manager.getSessionId(), nonce: "67da57d9-7fbe-47b6-9187-c31109fe4eba", text: wake });
27
+ const settingsManager = SettingsManager.inMemory();
28
+ const loader = new DefaultResourceLoader({
29
+ cwd, agentDir, settingsManager,
30
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
31
+ extensionFactories: [pi => gentleAgents(pi, {}, { home, agentHome: agentDir, schedule: () => () => {} })],
32
+ });
33
+ await loader.reload();
34
+ const loaded = loader.getExtensions();
35
+ assert.deepEqual(loaded.errors, []);
36
+ const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "offline-auth.json"), modelsPath: null, refreshOnCreate: false });
37
+ const { session } = await createAgentSession({ cwd, agentDir, resourceLoader: loader, sessionManager: manager, settingsManager, modelRuntime, tools: [] });
38
+ await session.bindExtensions({});
39
+ try {
40
+ const transformers = session.extensionRunner.getMarkdownTransformers();
41
+ assert.equal(transformers.length, 1);
42
+ for (const width of [12, 80, 140]) {
43
+ assert.deepEqual(new UserMessageComponent(wake, undefined, 1, transformers).render(width), [], "no lines, padding, bubble, or OSC markers");
44
+ for (const ordinary of [oldWake, `Quoted: ${wake}`, ` ${wake}`, `${wake}\n`, "Human continuation"]) {
45
+ assert.ok(new UserMessageComponent(ordinary, undefined, 1, transformers).render(width).length > 0);
46
+ }
47
+ }
48
+ assert.equal(transformers[0](wake, { messageType: "assistant", isStreaming: false, availableWidth: 80 }), wake);
49
+ } finally {
50
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
51
+ session.dispose();
52
+ }
53
+ } finally {
54
+ rmSync(root, { recursive: true, force: true });
55
+ }
56
+ });
@@ -0,0 +1,269 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import test, { type TestContext } from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { DISTRIBUTION_ASSETS, claimPrefix, distributionAsset, distributionFiles, installVersion, pnpmEnvironment, readDistribution } from "../scripts/bundled-install.mjs";
9
+ import { buildDistribution, main, verifyDistribution } from "../scripts/build-distribution.mjs";
10
+ import { artifactFor } from "../scripts/installer-downloads.mjs";
11
+ import { PI_INSTALL_VERSION } from "../scripts/installer-preflight.mjs";
12
+
13
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
14
+ // Real directories, modes and uids need a POSIX host.
15
+ const posixHost = process.platform === "win32" ? "POSIX filesystem fixtures need a POSIX host" : false;
16
+ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) => test(name, { skip: posixHost }, fn);
17
+ const manifest = { shell: "4.1.0", pi: "1.0.2" };
18
+ const LOCKFILE = "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n";
19
+ const sha256 = (text: string) => createHash("sha256").update(text, "utf8").digest("hex");
20
+
21
+ /** A new folder in the system tmpdir, removed when the test ends. */
22
+ function temporary(t: TestContext) {
23
+ const root = mkdtempSync(join(realpathSync(tmpdir()), "build-distribution-"));
24
+ t.after(() => rmSync(root, { recursive: true, force: true }));
25
+ return root;
26
+ }
27
+ function claimed(t: TestContext) {
28
+ const home = join(temporary(t), "home");
29
+ mkdirSync(home, { mode: 0o700 });
30
+ return claimPrefix({ platform: "darwin", env: { HOME: home }, home });
31
+ }
32
+ type Run = { command: string; args: string[]; cwd: string; env: Record<string, string>; files: Record<string, string> };
33
+ /** Our pnpm resolving the lockfile: it records what it saw, writes the lockfile,
34
+ * and, like pnpm 11 after skipped builds, may rewrite pnpm-workspace.yaml. */
35
+ function fakeResolver({ lockfile = LOCKFILE, rewrite = false, status = 0, missing = 0 } = {}) {
36
+ const runs: Run[] = [];
37
+ const run = (command: string, args: string[], { cwd, env }: { cwd: string; env: Record<string, string> }) => {
38
+ runs.push({ command, args, cwd, env, files: Object.fromEntries(readdirSync(cwd).map((name) => [name, readFileSync(join(cwd, name), "utf8")])) });
39
+ // As pnpm 11.1.1 prints it, on stdout, before the registry lists a just-published version.
40
+ const name = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")).version;
41
+ if (runs.length <= missing) return { status: 1, stdout: `[ERR_PNPM_NO_MATCHING_VERSION] No matching version found for gentle-pi@${name} while fetching it from https://registry.npmjs.org/\n`, stderr: "" };
42
+ if (status !== 0) return { status, stdout: "", stderr: "ERR_PNPM_FETCH_404" };
43
+ writeFileSync(join(cwd, "pnpm-lock.yaml"), lockfile);
44
+ if (rewrite) writeFileSync(join(cwd, "pnpm-workspace.yaml"), "allowBuilds:\n gentle-pi: true\n koffi: set this to true or false\nstrictDepBuilds: false\n");
45
+ return { status: 0, stdout: "", stderr: "" };
46
+ };
47
+ return { runs, run };
48
+ }
49
+
50
+ test("the distribution asset records the exact versions, the pins, the workspace files installVersion writes and the lockfile sha256", () => {
51
+ const text = distributionAsset(manifest, LOCKFILE);
52
+ assert.deepEqual(DISTRIBUTION_ASSETS, { distribution: "gentle-shell-distribution.json", lockfile: "gentle-shell-distribution-lock.yaml" });
53
+ assert.deepEqual(JSON.parse(text), {
54
+ schema: 1, shell: "4.1.0", pi: "1.0.2", id: "4.1.0-1.0.2",
55
+ generatedWith: { node: artifactFor("node", "linux", "x64").version, pnpm: artifactFor("pnpm").version },
56
+ files: distributionFiles(manifest),
57
+ lockfile: { name: "gentle-shell-distribution-lock.yaml", sha256: sha256(LOCKFILE) },
58
+ });
59
+ assert.equal(text.endsWith("}\n"), true);
60
+ assert.equal(distributionAsset({ ...manifest }, `${LOCKFILE}`), text, "the same inputs give the same bytes");
61
+ assert.notEqual(distributionAsset(manifest, `${LOCKFILE}\n`), text);
62
+ for (const bad of [{ shell: "^4.1.0", pi: "1.0.2" }, { shell: "4.1", pi: "1.0.2" }, { shell: "4.1.0", pi: "latest" }, { shell: "4.1.0", pi: "v1.0.2" }, { shell: "4.1.0", pi: "1.x" }]) {
63
+ assert.throws(() => distributionAsset(bad, LOCKFILE), /version rejected/, JSON.stringify(bad));
64
+ }
65
+ assert.throws(() => distributionAsset(manifest, ""), /lockfile/i);
66
+ });
67
+
68
+ test("readDistribution accepts only an unchanged pair whose workspace files are the ones installVersion writes", () => {
69
+ const text = distributionAsset(manifest, LOCKFILE);
70
+ assert.deepEqual(readDistribution(text, LOCKFILE), { manifest, lockfile: LOCKFILE });
71
+ assert.throws(() => readDistribution(text, `${LOCKFILE}# changed\n`), /lockfile sha256/);
72
+ const asset = JSON.parse(text);
73
+ const tampered = (change: (value: typeof asset) => void) => { const copy = structuredClone(asset); change(copy); return `${JSON.stringify(copy, null, "\t")}\n`; };
74
+ assert.throws(() => readDistribution(tampered((value) => { value.files["pnpm-workspace.yaml"] = "allowBuilds:\n koffi: true\n"; }), LOCKFILE), /workspace files/);
75
+ assert.throws(() => readDistribution(tampered((value) => { value.schema = 2; }), LOCKFILE), /schema/);
76
+ assert.throws(() => readDistribution(tampered((value) => { value.pi = "^1.0.2"; }), LOCKFILE), /version rejected/);
77
+ assert.throws(() => readDistribution("not json", LOCKFILE), /Distribution asset rejected/);
78
+ // A release built with other pinned runtimes still installs with ours: only recorded, not compared.
79
+ assert.deepEqual(readDistribution(tampered((value) => { value.generatedWith.pnpm = "11.2.0"; }), LOCKFILE).manifest, manifest);
80
+ });
81
+
82
+ posixTest("buildDistribution resolves the lockfile with our pnpm from the unmodified distribution files and returns both assets", async (t) => {
83
+ const layout = claimed(t);
84
+ const resolver = fakeResolver({ rewrite: true });
85
+ const env = { PATH: "/usr/bin", npm_config_registry: "http://user.example" };
86
+ const result = await buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", env, adapters: { run: resolver.run } });
87
+ assert.equal(resolver.runs.length, 1);
88
+ const [run] = resolver.runs;
89
+ assert.equal(run.command, layout.node);
90
+ assert.deepEqual(run.args, [layout.pnpm, "install", "--lockfile-only"]);
91
+ assert.deepEqual(run.files, distributionFiles(manifest), "pnpm resolves exactly the files installVersion writes, and no lockfile");
92
+ assert.deepEqual(run.env, pnpmEnvironment(layout, { env }));
93
+ assert.equal(dirname(run.cwd), layout.tmp);
94
+ assert.equal(existsSync(run.cwd), false, "the resolution folder is removed");
95
+ assert.deepEqual(result.manifest, manifest);
96
+ assert.deepEqual(Object.keys(result.assets).sort(), [DISTRIBUTION_ASSETS.distribution, DISTRIBUTION_ASSETS.lockfile].sort());
97
+ assert.equal(result.assets[DISTRIBUTION_ASSETS.lockfile], LOCKFILE);
98
+ // pnpm's rewritten workspace file (placeholders for skipped builds) never reaches the asset.
99
+ assert.equal(result.assets[DISTRIBUTION_ASSETS.distribution], distributionAsset(manifest, LOCKFILE));
100
+ assert.equal(JSON.parse(result.assets[DISTRIBUTION_ASSETS.distribution]).lockfile.sha256, sha256(LOCKFILE));
101
+ const again = await buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", env, adapters: { run: fakeResolver({ rewrite: true }).run } });
102
+ assert.deepEqual(again.assets, result.assets, "the same lockfile gives byte-identical assets");
103
+ });
104
+
105
+ posixTest("buildDistribution pins Pi to PI_INSTALL_VERSION and refuses a non-exact version or a failed resolution before publishing anything", async (t) => {
106
+ const layout = claimed(t);
107
+ const pinned = await buildDistribution({ layout, shell: "4.1.0", adapters: { run: fakeResolver().run } });
108
+ assert.deepEqual(pinned.manifest, { shell: "4.1.0", pi: PI_INSTALL_VERSION });
109
+ assert.equal(JSON.parse(pinned.assets[DISTRIBUTION_ASSETS.distribution]).pi, PI_INSTALL_VERSION);
110
+ for (const [shell, pi] of [["^4.1.0", "1.0.2"], ["4.1.0", "~1.0.2"], ["latest", "1.0.2"], ["4.1.0", "1.0.2 || 1.0.3"], ["4.1.0+build", "1.0.2"]]) {
111
+ const resolver = fakeResolver();
112
+ await assert.rejects(buildDistribution({ layout, shell, pi, adapters: { run: resolver.run } }), /version rejected/, `${shell} ${pi}`);
113
+ assert.equal(resolver.runs.length, 0, "pnpm never runs for a non-exact version");
114
+ }
115
+ const failed = fakeResolver({ status: 1 });
116
+ await assert.rejects(buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", adapters: { run: failed.run, sleep: async () => {} } }),
117
+ (error: Error & { output?: string }) => /lockfile resolution failed/.test(error.message) && error.output === "ERR_PNPM_FETCH_404");
118
+ assert.equal(failed.runs.length, 1, "only a gentle-pi missing from the registry is retried");
119
+ await assert.rejects(buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", adapters: { run: fakeResolver({ lockfile: "" }).run } }), /lockfile/i);
120
+ assert.deepEqual(readdirSync(layout.tmp), [], "no resolution folder is left behind");
121
+ });
122
+
123
+ posixTest("the assets install with installVersion: it writes the recorded workspace files and the lockfile byte for byte", async (t) => {
124
+ const layout = claimed(t);
125
+ const { assets } = await buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", adapters: { run: fakeResolver().run } });
126
+ const { manifest: read, lockfile } = readDistribution(assets[DISTRIBUTION_ASSETS.distribution], assets[DISTRIBUTION_ASSETS.lockfile]);
127
+ let written: Record<string, string> = {};
128
+ const run = (_command: string, args: string[], { cwd }: { cwd: string }) => {
129
+ assert.deepEqual(args.slice(1), ["install", "--frozen-lockfile"]);
130
+ written = Object.fromEntries(["package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml"].map((name) => [name, readFileSync(join(cwd, name), "utf8")]));
131
+ const shell = join(cwd, "node_modules", "gentle-pi"); const pi = join(cwd, "node_modules", "@earendil-works", "pi-coding-agent");
132
+ mkdirSync(join(shell, "bin"), { recursive: true }); mkdirSync(pi, { recursive: true });
133
+ writeFileSync(join(shell, "package.json"), JSON.stringify({ name: "gentle-pi", version: read.shell }));
134
+ writeFileSync(join(shell, "bin", "gentle-shell.mjs"), "");
135
+ writeFileSync(join(pi, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: read.pi }));
136
+ return { status: 0, stdout: "", stderr: "" };
137
+ };
138
+ await installVersion({ layout, manifest: read, lockfile, platform: "darwin", env: {}, adapters: { run } });
139
+ const recorded = JSON.parse(assets[DISTRIBUTION_ASSETS.distribution]).files;
140
+ assert.deepEqual(written, { ...recorded, "pnpm-lock.yaml": assets[DISTRIBUTION_ASSETS.lockfile] });
141
+ });
142
+
143
+ posixTest("verifyDistribution installs the pair with our runtimes, activates it and requires the launcher to report both exact versions", async (t) => {
144
+ const layout = claimed(t);
145
+ // Runtimes already published under their pinned URLs are reused, so nothing is downloaded.
146
+ for (const [name, directory, entry] of [["node", layout.nodeDir, layout.node], ["pnpm", layout.pnpmDir, layout.pnpm]]) {
147
+ mkdirSync(dirname(entry), { recursive: true });
148
+ writeFileSync(entry, "");
149
+ writeFileSync(join(directory, ".gentle-shell-runtime"), `${artifactFor(name, "darwin", "arm64").url}\n`);
150
+ }
151
+ const distribution = distributionAsset(manifest, LOCKFILE);
152
+ const installs: string[][] = [];
153
+ const run = (_command: string, args: string[], { cwd }: { cwd: string }) => {
154
+ installs.push(args.slice(1));
155
+ const shell = join(cwd, "node_modules", "gentle-pi"); const pi = join(cwd, "node_modules", "@earendil-works", "pi-coding-agent");
156
+ mkdirSync(join(shell, "bin"), { recursive: true }); mkdirSync(pi, { recursive: true });
157
+ writeFileSync(join(shell, "package.json"), JSON.stringify({ name: "gentle-pi", version: manifest.shell }));
158
+ writeFileSync(join(shell, "bin", "gentle-shell.mjs"), "");
159
+ writeFileSync(join(pi, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: manifest.pi }));
160
+ return { status: 0, stdout: "", stderr: "" };
161
+ };
162
+ const launches: string[] = [];
163
+ const launch = (stdout: string) => (launcher: string) => { launches.push(launcher); return { status: 0, stdout, stderr: "" }; };
164
+ const options = { layout, distribution, lockfile: LOCKFILE, platform: "darwin", arch: "arm64", env: {} };
165
+ const result = await verifyDistribution({ ...options, adapters: { run, launch: launch("gentle-shell 4.1.0\npi 1.0.2\nhome isolated /h\n") } });
166
+ assert.deepEqual(installs, [["install", "--frozen-lockfile"]]);
167
+ assert.deepEqual(launches, [layout.launcher]);
168
+ assert.deepEqual(result, { id: "4.1.0-1.0.2", version: "gentle-shell 4.1.0\npi 1.0.2\nhome isolated /h" });
169
+ assert.equal(readFileSync(layout.launcher, "utf8").includes("gentle-shell bundled launcher"), true);
170
+ // Another Pi (a fallback) or another gentle-shell fails the verification.
171
+ await assert.rejects(verifyDistribution({ ...options, adapters: { run, launch: launch("gentle-shell 4.1.0\npi 1.1.0\n") } }), /launcher reported/);
172
+ await assert.rejects(verifyDistribution({ ...options, adapters: { run, launch: () => ({ status: 1, stdout: "", stderr: "boom" }) } }), /launcher reported/);
173
+ await assert.rejects(verifyDistribution({ ...options, lockfile: `${LOCKFILE}x`, adapters: { run, launch: launch("") } }), /lockfile sha256/);
174
+ // The launcher runs in the same filtered environment as our pnpm, without Pi or Gentle overrides.
175
+ const host = { HOME: "/h", PATH: "/usr/bin", NODE_OPTIONS: "--require /x.js", NODE_PATH: "/x", npm_config_prefix: "/x", PNPM_HOME: "/x", PI_CODING_AGENT_DIR: "/x", GENTLE_SHELL_PI: "/x" };
176
+ let seen: Record<string, string> = {};
177
+ await verifyDistribution({ ...options, env: host, adapters: { run, launch: (_launcher: string, { env }: { env: Record<string, string> }) => {
178
+ seen = env; return { status: 0, stdout: "gentle-shell 4.1.0\npi 1.0.2\n", stderr: "" };
179
+ } } });
180
+ const { PI_CODING_AGENT_DIR: _pi, GENTLE_SHELL_PI: _shell, ...kept } = host;
181
+ assert.deepEqual(seen, pnpmEnvironment(layout, { platform: "darwin", env: kept }));
182
+ for (const key of ["NODE_OPTIONS", "NODE_PATH", "npm_config_prefix", "PI_CODING_AGENT_DIR", "GENTLE_SHELL_PI"]) assert.equal(key in seen, false, key);
183
+ assert.equal(seen.PATH.split(":")[0], dirname(layout.node), "our Node first on PATH");
184
+ });
185
+
186
+ posixTest("buildDistribution retries while the just-published gentle-pi is not on the registry, then fails clearly", async (t) => {
187
+ const layout = claimed(t);
188
+ const waits: number[] = [];
189
+ const sleep = async (ms: number) => { waits.push(ms); };
190
+ const late = fakeResolver({ missing: 2 });
191
+ const result = await buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", adapters: { run: late.run, sleep } });
192
+ assert.equal(result.assets[DISTRIBUTION_ASSETS.lockfile], LOCKFILE);
193
+ assert.equal(late.runs.length, 3);
194
+ assert.deepEqual(waits, [30000, 30000]);
195
+ assert.equal(new Set(late.runs.map((run) => run.cwd)).size, 3, "each attempt resolves in a fresh folder");
196
+ for (const run of late.runs) assert.deepEqual(run.files, distributionFiles(manifest));
197
+ waits.length = 0;
198
+ const never = fakeResolver({ missing: 99 });
199
+ await assert.rejects(buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", adapters: { run: never.run, sleep } }),
200
+ (error: Error & { output?: string }) => /gentle-pi@4\.1\.0 is not on the registry after 10 attempts/.test(error.message) && /ERR_PNPM_NO_MATCHING_VERSION/.test(error.output ?? ""));
201
+ assert.equal(never.runs.length, 10);
202
+ assert.equal(waits.reduce((sum, ms) => sum + ms, 0), 270000, "about five minutes in all");
203
+ // A missing Pi, or another gentle-pi version, is a real failure: never retried.
204
+ for (const missing of ["@earendil-works/pi-coding-agent@1.0.2", "gentle-pi@4.1.0-beta.1", "gentle-pi@4.1.01"]) {
205
+ let runs = 0;
206
+ const run = () => { runs += 1; return { status: 1, stdout: `[ERR_PNPM_NO_MATCHING_VERSION] No matching version found for ${missing} while fetching it\n`, stderr: "" }; };
207
+ await assert.rejects(buildDistribution({ layout, shell: "4.1.0", pi: "1.0.2", adapters: { run, sleep } }), /lockfile resolution failed/, missing);
208
+ assert.equal(runs, 1, missing);
209
+ }
210
+ assert.deepEqual(readdirSync(layout.tmp), [], "no resolution folder is left behind");
211
+ });
212
+
213
+ posixTest("the CLI removes the temporary prefix it created when build or verify fails, and refuses bad input before creating one", async (t) => {
214
+ const root = temporary(t);
215
+ const options = { temporaryRoot: root, env: { PATH: "/usr/bin" }, adapters: { download: async () => Buffer.from("not the pinned archive") } };
216
+ await assert.rejects(main(["verify", "--assets", join(root, "missing")], options), /ENOENT/);
217
+ assert.deepEqual(readdirSync(root), [], "verify with unreadable assets");
218
+ const assets = join(root, "assets"); mkdirSync(assets);
219
+ writeFileSync(join(assets, DISTRIBUTION_ASSETS.distribution), distributionAsset(manifest, LOCKFILE));
220
+ writeFileSync(join(assets, DISTRIBUTION_ASSETS.lockfile), LOCKFILE);
221
+ await assert.rejects(main(["verify", "--assets", assets], options), /Download integrity mismatch/);
222
+ assert.deepEqual(readdirSync(root), ["assets"], "verify whose runtime download fails");
223
+ await assert.rejects(main(["build", "--shell", "4.1.0", "--out", join(root, "out")], options), /Download integrity mismatch/);
224
+ assert.deepEqual(readdirSync(root), ["assets"], "build whose runtime download fails");
225
+ await assert.rejects(main(["build", "--shell", "^4.1.0", "--out", join(root, "out")], options), /version rejected/);
226
+ await assert.rejects(main(["publish"], options), /Usage/);
227
+ assert.deepEqual(readdirSync(root), ["assets"]);
228
+ });
229
+
230
+ // publish.yml: the distribution assets reach the release only after the frozen
231
+ // install passed on all three systems, and the existing jobs keep their shape.
232
+ function jobs(workflow: string) {
233
+ const body = workflow.slice(workflow.indexOf("\njobs:\n") + "\njobs:\n".length);
234
+ return new Map([...body.matchAll(/^ {2}([A-Za-z0-9_-]+):\n([\s\S]*?)(?=^ {2}[A-Za-z0-9_-]+:\n|(?![\s\S]))/gm)].map((match) => [match[1], match[2]]));
235
+ }
236
+ test("publish.yml builds the distribution after npm publication, verifies it on three systems and only then uploads it", () => {
237
+ const workflow = readFileSync(join(ROOT, ".github", "workflows", "publish.yml"), "utf8");
238
+ const all = jobs(workflow);
239
+ assert.deepEqual([...all.keys()], ["publish", "installers", "distribution", "distribution-verify", "distribution-assets"]);
240
+ for (const name of ["publish", "installers"]) assert.doesNotMatch(all.get(name)!, /distribution/, `${name} is unchanged`);
241
+ assert.match(workflow, /^permissions:\n {2}contents: read\n/m, "the workflow default stays read-only");
242
+ for (const use of workflow.matchAll(/uses: (\S+)/g)) assert.match(use[1], /^[\w-]+\/[\w-]+@[0-9a-f]{40}$/, `${use[1]} is pinned by commit SHA`);
243
+
244
+ const build = all.get("distribution")!;
245
+ assert.match(build, /^ {4}needs: publish$/m, "gentle-pi@<tag> must be on npm before it is resolved");
246
+ assert.match(build, /^ {4}permissions:\n {6}contents: read\n(?! {6})/m);
247
+ assert.match(build, /ref: \$\{\{ github\.sha \}\}/, "built on the verified release commit");
248
+ assert.match(build, /RELEASE_TAG: \$\{\{ needs\.publish\.outputs\.tag \}\}/);
249
+ assert.match(build, /node scripts\/build-distribution\.mjs build --shell "\$\{RELEASE_TAG#v\}" --out "\$\{RUNNER_TEMP\}\/distribution"/);
250
+ assert.match(build, /uses: actions\/upload-artifact@[0-9a-f]{40} # v[\d.]+\n {8}with:\n {10}name: gentle-shell-distribution\n/);
251
+
252
+ const verify = all.get("distribution-verify")!;
253
+ assert.match(verify, /^ {4}needs: distribution$/m);
254
+ assert.match(verify, /^ {4}permissions:\n {6}contents: read\n(?! {6})/m);
255
+ assert.match(verify, /matrix:\n {8}os: \[ubuntu-latest, macos-latest, windows-latest\]\n/);
256
+ assert.match(verify, /fail-fast: false/);
257
+ assert.match(verify, /runs-on: \$\{\{ matrix\.os \}\}/);
258
+ assert.match(verify, /ref: \$\{\{ github\.sha \}\}/);
259
+ assert.match(verify, /uses: actions\/download-artifact@[0-9a-f]{40} # v[\d.]+\n {8}with:\n {10}name: gentle-shell-distribution\n/);
260
+ assert.match(verify, /node scripts\/build-distribution\.mjs verify --assets "\$\{RUNNER_TEMP\}\/distribution"/);
261
+ assert.doesNotMatch(verify, /gh release|contents: write/);
262
+
263
+ const upload = all.get("distribution-assets")!;
264
+ assert.match(upload, /^ {4}needs: \[publish, distribution-verify\]$/m, "uploaded only after every verify lane passed");
265
+ assert.match(upload, /^ {4}permissions:\n {6}contents: write\n(?! {6})/m);
266
+ assert.match(upload, /RELEASE_TAG: \$\{\{ needs\.publish\.outputs\.tag \}\}/);
267
+ assert.match(upload, /gh release upload "\$\{RELEASE_TAG\}" "\$\{RUNNER_TEMP\}\/distribution\/gentle-shell-distribution\.json" "\$\{RUNNER_TEMP\}\/distribution\/gentle-shell-distribution-lock\.yaml" --repo "\$\{GITHUB_REPOSITORY\}" --clobber/);
268
+ for (const [name, job] of all) if (name !== "distribution-assets") assert.doesNotMatch(job, /gentle-shell-distribution\.json/, `${name} never uploads the assets`);
269
+ });