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,548 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import { constants } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import { test } from "node:test";
6
+ import { NotificationPlayer, createDefaultNotificationBackend, validateNotificationAudio, validateNotificationFlac, validateNotificationOgg, validateNotificationWav, type AudioIO } from "../lib/notification-audio.ts";
7
+ import type { NativeAudioBackend } from "../lib/notification-audio.ts";
8
+ import { NativePulsePlayer } from "../lib/notification-audio-native.ts";
9
+ import { FIXED_WINDOWS_POWERSHELL_EXE, NATIVE_WINDOWS_SCHEMA, NativeWindowsPlayer } from "../lib/notification-audio-windows.ts";
10
+ import { NotificationScheduler } from "../lib/notification-scheduler.ts";
11
+ import { DEFAULT_NOTIFICATION_SETTINGS } from "../lib/notification-policy.ts";
12
+
13
+ function wav(samples = 800) {
14
+ const b = Buffer.alloc(44 + samples * 2);
15
+ b.write("RIFF"); b.writeUInt32LE(b.length - 8, 4); b.write("WAVEfmt ", 8);
16
+ b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
17
+ b.writeUInt32LE(8000, 24); b.writeUInt32LE(16000, 28); b.writeUInt16LE(2, 32);
18
+ b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(samples * 2, 40); return b;
19
+ }
20
+ /** Minimal Ogg page (27-byte header + lacing table); bodies stay < 255 bytes so one lacing value suffices.
21
+ * CRC is deliberately written as zero: the validator never verifies it. */
22
+ function oggPage(headerType: number, granule: number | "none", body: Buffer, seqno = 0): Buffer {
23
+ const segments = body.length === 0 ? 0 : Math.ceil(body.length / 255);
24
+ const table = Buffer.alloc(segments);
25
+ let remaining = body.length;
26
+ for (let i = 0; i < segments; i++) { table[i] = Math.min(255, remaining); remaining -= table[i]!; }
27
+ const head = Buffer.alloc(27);
28
+ head.write("OggS", 0); head[4] = 0; head[5] = headerType;
29
+ if (granule === "none") { head.writeUInt32LE(0xffffffff, 6); head.writeUInt32LE(0xffffffff, 10); }
30
+ else { head.writeUInt32LE(granule >>> 0, 6); head.writeUInt32LE(Math.floor(granule / 2 ** 32), 10); }
31
+ head.writeUInt32LE(0, 14); head.writeUInt32LE(seqno, 18); head.writeUInt32LE(0, 22); head[26] = segments;
32
+ return Buffer.concat([head, table, body]);
33
+ }
34
+ function vorbisId(channels = 1, sampleRate = 8000): Buffer {
35
+ const b = Buffer.alloc(30); b[0] = 0x01; b.write("vorbis", 1); b[11] = channels; b.writeUInt32LE(sampleRate, 12); b[29] = 0x01; return b;
36
+ }
37
+ function opusHead(channels = 1, preSkip = 312): Buffer {
38
+ const b = Buffer.alloc(19); b.write("OpusHead", 0); b[8] = 1; b[9] = channels;
39
+ b.writeUInt16LE(preSkip, 10); b.writeUInt32LE(48000, 12); b[18] = 0; return b;
40
+ }
41
+ /** One identification page (BOS) plus a final granule page; default granule encodes exactly 1000 ms. */
42
+ function oggVorbis(sampleRate = 8000, granule = sampleRate): Buffer {
43
+ return Buffer.concat([oggPage(0x02, 0, vorbisId(1, sampleRate), 0), oggPage(0x00, granule, Buffer.alloc(0), 1)]);
44
+ }
45
+ function oggOpus(preSkip = 312, granule = preSkip + 48000): Buffer {
46
+ return Buffer.concat([oggPage(0x02, 0, opusHead(1, preSkip), 0), oggPage(0x00, granule, Buffer.alloc(0), 1)]);
47
+ }
48
+ /** Minimal FLAC: 4-byte magic + one last STREAMINFO block (34 bytes) + `extra` opaque trailing bytes.
49
+ * sampleRate is 20 bits, channels-1 3 bits, bitsPerSample-1 5 bits and totalSamples 36 bits. */
50
+ function flac(sampleRate = 8000, totalSamples = 8000, extra = 4): Buffer {
51
+ const b = Buffer.alloc(42 + extra);
52
+ b.write("fLaC", 0); b[4] = 0x80; b[7] = 34;
53
+ const s = 8;
54
+ b[s + 10] = (sampleRate >> 12) & 0xff; b[s + 11] = (sampleRate >> 4) & 0xff;
55
+ b[s + 12] = (sampleRate & 0x0f) << 4; // low rate nibble; channels-1 = 0; bps high bit = 0
56
+ b[s + 13] = 0xf0 | ((totalSamples / 2 ** 32) & 0x0f); // bps-1 low nibble (16 bps) + totalSamples bits 35..32
57
+ b.writeUInt32BE(totalSamples >>> 0, s + 14);
58
+ return b;
59
+ }
60
+ class Child extends EventEmitter {
61
+ stderr = new EventEmitter(); kills: string[] = [];
62
+ kill(signal: string) { this.kills.push(signal); return true; }
63
+ }
64
+ interface FakeNative {
65
+ readonly backend: NativeAudioBackend;
66
+ readonly snapshots: string[];
67
+ readonly gates: Array<(() => boolean) | { permit(): boolean } | undefined>;
68
+ probes(): number;
69
+ }
70
+ /** Injected native backend; records probes/plays and calls the gate exactly once like the real bridge. */
71
+ function nativeFake(options: { available?: boolean; formats?: readonly ("wav")[]; fail?: boolean; supported?: boolean } = {}): FakeNative {
72
+ const snapshots: string[] = [];
73
+ const gates: Array<(() => boolean) | { permit(): boolean } | undefined> = [];
74
+ let probes = 0;
75
+ const backend: NativeAudioBackend = {
76
+ supportsTarget: () => options.supported ?? true,
77
+ probe: async () => { probes++; return { available: options.available ?? false, formats: options.available ? (options.formats ?? (["wav"] as const)) : [] }; },
78
+ getNativeFormats: () => (options.available ? (options.formats ?? (["wav"] as const)) : []),
79
+ play: async (snapshot, _signal, gate) => {
80
+ gates.push(gate);
81
+ const permitted = typeof gate === "function" ? gate() : gate?.permit();
82
+ if (permitted === false) return;
83
+ snapshots.push(snapshot);
84
+ if (options.fail) throw new Error("native playback failed");
85
+ },
86
+ };
87
+ return { backend, snapshots, gates, probes: () => probes };
88
+ }
89
+ const unavailableNative = (): NativeAudioBackend => nativeFake({ available: false }).backend;
90
+ const tick = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
91
+ function fixture(platform = "linux", native: NativeAudioBackend = unavailableNative()) {
92
+ const child = new Child(); const calls: Array<{ executable: string; args: string[]; options: unknown }> = [];
93
+ const probes: string[] = []; const cleaned: string[] = []; const writes: Array<{ path: string; bytes: Buffer; mode: number }> = [];
94
+ let bytes: Buffer = wav(); let regular = true; let size = bytes.length; let available = true;
95
+ let detect: (() => Promise<boolean>) | undefined; let availablePaths: string[] | undefined;
96
+ let spawnError = false; let writeError = false; let cleanupError = false; let rmdirError = false;
97
+ let duringRead: (() => void) | undefined;
98
+ let timeout: (() => void) | undefined; let timeoutMs = 0; let cleared = 0;
99
+ const io: AudioIO = {
100
+ open: async (_path, flags) => {
101
+ assert.ok(flags & constants.O_NOFOLLOW); assert.ok(flags & constants.O_NONBLOCK);
102
+ return { stat: async () => ({ isFile: () => regular, size }),
103
+ read: async buffer => { duringRead?.(); bytes.copy(buffer); return { bytesRead: bytes.length }; }, close: async () => {} };
104
+ },
105
+ mkdtemp: async () => "/private/audio ; literal", chmod: async (_path, mode) => { assert.equal(mode, 0o700); },
106
+ writeFile: async (path, data, options) => { if (writeError) throw Error("write"); writes.push({ path, bytes: data, mode: options.mode }); },
107
+ unlink: async path => { cleaned.push(path); if (cleanupError) throw Error("cleanup"); },
108
+ rmdir: async path => { cleaned.push(path); if (rmdirError) throw Error("rmdir"); },
109
+ };
110
+ const player = new NotificationPlayer({ platform, io, tempRoot: "/private", builtinRoot: "/builtins", native,
111
+ executableAvailable: async path => { probes.push(path); return availablePaths ? availablePaths.includes(path) : (detect ? detect() : available); },
112
+ spawn: (executable, args, options) => { if (spawnError) throw Error("spawn"); calls.push({ executable, args, options }); return child; },
113
+ setTimeout: (fn, ms) => { timeout = fn; timeoutMs = ms; return 1; }, clearTimeout: () => { cleared++; },
114
+ });
115
+ const signal = new AbortController(); let permits = 0;
116
+ const play = (allow = true) => player.play("file:/input/a ; &(b).wav", signal.signal, { start: () => { permits++; return allow; } });
117
+ return { player, child, calls, probes, cleaned, writes, signal, play, timeout: () => timeout!(), timeoutMs: () => timeoutMs, cleared: () => cleared,
118
+ permits: () => permits, setAvailable: (v: boolean) => { available = v; }, setDetect: (v: () => Promise<boolean>) => { detect = v; },
119
+ setAvailablePaths: (paths: string[]) => { availablePaths = paths; },
120
+ setBytes: (v: Buffer) => { bytes = v; size = v.length; }, setRegular: () => { regular = false; },
121
+ setSize: (v: number) => { size = v; }, failSpawn: () => { spawnError = true; }, failWrite: () => { writeError = true; },
122
+ failCleanup: () => { cleanupError = true; }, failRmdir: () => { rmdirError = true; }, onRead: (fn: () => void) => { duringRead = fn; } };
123
+ }
124
+
125
+ test("WAV content and coherent PCM limits, not filename extension", () => {
126
+ assert.equal(validateNotificationWav(wav()), 100);
127
+ assert.equal(validateNotificationWav(wav(80000)), 10000);
128
+ const mutations = [
129
+ (b: Buffer) => b.write("NOPE"), (b: Buffer) => b.write("MP3!", 8),
130
+ (b: Buffer) => b.writeUInt32LE(1, 4), (b: Buffer) => b.writeUInt16LE(3, 20),
131
+ (b: Buffer) => b.writeUInt16LE(0, 22), (b: Buffer) => b.writeUInt32LE(0, 24),
132
+ (b: Buffer) => b.writeUInt32LE(1, 28), (b: Buffer) => b.writeUInt16LE(1, 32),
133
+ (b: Buffer) => b.writeUInt16LE(7, 34), (b: Buffer) => b.writeUInt32LE(0xffffffff, 40),
134
+ (b: Buffer) => b.writeUInt32LE(1, 40), (b: Buffer) => b.writeUInt32LE(0, 40),
135
+ ];
136
+ for (const mutate of mutations) { const b = wav(); mutate(b); assert.throws(() => validateNotificationWav(b)); }
137
+ for (const b of [wav().subarray(0, 43), wav(80001), Buffer.alloc(2 * 1024 * 1024 + 1)]) assert.throws(() => validateNotificationWav(b));
138
+ });
139
+
140
+ test("10 s is the exact validated boundary for WAV/OGG/FLAC and the old 5.58 s file is now accepted", () => {
141
+ // Exact 10 s is accepted for every current container (WAV PCM duration is also enforced internally).
142
+ assert.equal(validateNotificationWav(wav(80000)), 10000);
143
+ assert.deepEqual(validateNotificationAudio(oggVorbis(8000, 80000)), { format: "ogg", durationMs: 10000 });
144
+ assert.deepEqual(validateNotificationAudio(oggOpus(312, 312 + 48000 * 10)), { format: "ogg", durationMs: 10000 });
145
+ assert.deepEqual(validateNotificationAudio(flac(8000, 80000)), { format: "flac", durationMs: 10000 });
146
+ // The previously rejected 5.58 s equivalent now validates for each container, without cropping.
147
+ assert.equal(validateNotificationWav(wav(44640)), 5580);
148
+ assert.deepEqual(validateNotificationAudio(oggVorbis(8000, 44640)), { format: "ogg", durationMs: 5580 });
149
+ assert.deepEqual(validateNotificationAudio(flac(8000, 44640)), { format: "flac", durationMs: 5580 });
150
+ // One representable unit past 10 s is rejected; the 2 MiB ceiling stays independent.
151
+ assert.throws(() => validateNotificationWav(wav(80001)), /Invalid notification WAV/);
152
+ for (const long of [oggVorbis(8000, 80001), oggOpus(312, 312 + 48000 * 10 + 1), flac(8000, 80001)])
153
+ assert.throws(() => validateNotificationAudio(long), /exceeds 10 seconds|Invalid notification/);
154
+ const huge = Buffer.alloc(2 * 1024 * 1024 + 1); huge.write("RIFF"); huge.write("WAVE", 8);
155
+ assert.throws(() => validateNotificationWav(huge), /Invalid notification WAV/);
156
+ });
157
+
158
+ test("RIFF chunk padding, duplicates and bounds are validated", () => {
159
+ const original = wav(); const junk = Buffer.from([74, 85, 78, 75, 1, 0, 0, 0, 42, 0]);
160
+ const extended = Buffer.concat([original.subarray(0, 36), junk, original.subarray(36)]);
161
+ extended.writeUInt32LE(extended.length - 8, 4); assert.equal(validateNotificationWav(extended), 100);
162
+ for (const extra of [original.subarray(12, 36), original.subarray(36), Buffer.from([1, 2, 3])]) {
163
+ const b = Buffer.concat([original, extra]); b.writeUInt32LE(b.length - 8, 4);
164
+ assert.throws(() => validateNotificationWav(b));
165
+ }
166
+ });
167
+
168
+ test("content detection accepts WAV, OGG Vorbis/Opus and FLAC with decoded durations", () => {
169
+ assert.deepEqual(validateNotificationAudio(wav()), { format: "wav", durationMs: 100 });
170
+ assert.deepEqual(validateNotificationAudio(oggVorbis(8000, 4000)), { format: "ogg", durationMs: 500 });
171
+ assert.deepEqual(validateNotificationAudio(oggOpus(312, 312 + 24000)), { format: "ogg", durationMs: 500 });
172
+ assert.deepEqual(validateNotificationAudio(flac(8000, 4000)), { format: "flac", durationMs: 500 });
173
+ // Minimal FLAC without trailing bytes: 4 magic + 4 block header + 34 STREAMINFO is already sufficient.
174
+ assert.equal(validateNotificationFlac(flac().subarray(0, 42)), 1000);
175
+ assert.equal(validateNotificationOgg(oggVorbis()), 1000);
176
+ });
177
+
178
+ test("content detection rejects other containers, unknown Ogg codecs, malformed pages and over-limit audio", () => {
179
+ for (const other of [Buffer.from("ID3\x04\x00\x00\x00\x00\x00\x00"), Buffer.from("\x00\x00\x00\x18ftypmp42"), Buffer.alloc(0), Buffer.from("Ogg")])
180
+ assert.throws(() => validateNotificationAudio(other), /Unsupported notification audio format/);
181
+ const unsupported = Buffer.concat([oggPage(0x02, 0, Buffer.from("Xcodec!!", "ascii"), 0), oggPage(0x00, 1000, Buffer.alloc(0), 1)]);
182
+ assert.throws(() => validateNotificationAudio(unsupported), /Invalid notification OGG/);
183
+ // Duration is decoded per format and then rejected by the shared 10 s guard.
184
+ for (const long of [flac(8000, 8000 * 11), oggVorbis(8000, 8000 * 11), oggOpus(312, 312 + 48000 * 11)])
185
+ assert.throws(() => validateNotificationAudio(long), /exceeds 10 seconds|Invalid notification/);
186
+ // Size is bounded for every container, even before parsing.
187
+ const hugeOgg = Buffer.alloc(2 * 1024 * 1024 + 1); hugeOgg.write("OggS", 0);
188
+ const hugeFlac = Buffer.alloc(2 * 1024 * 1024 + 1); hugeFlac.write("fLaC", 0);
189
+ assert.throws(() => validateNotificationAudio(hugeOgg), /Invalid notification OGG/);
190
+ assert.throws(() => validateNotificationAudio(hugeFlac), /Invalid notification FLAC/);
191
+ // FLAC requires the first block to be STREAMINFO of exactly 34 bytes.
192
+ const notStreaminfo = flac(); notStreaminfo[4] = 0x80 | 0x04; assert.throws(() => validateNotificationFlac(notStreaminfo), /Invalid notification FLAC/);
193
+ const wrongLength = flac(); wrongLength[7] = 33; assert.throws(() => validateNotificationFlac(wrongLength), /Invalid notification FLAC/);
194
+ const unknownSamples = flac(8000, 0); assert.throws(() => validateNotificationFlac(unknownSamples), /Invalid notification FLAC/);
195
+ });
196
+
197
+ test("Ogg pages are walked with bounds: non-BOS first page, bad version, unsupported codec and truncation fail closed", () => {
198
+ const nonBos = Buffer.concat([oggPage(0x00, 0, vorbisId(), 0), oggPage(0x00, 1000, Buffer.alloc(0), 1)]);
199
+ assert.throws(() => validateNotificationOgg(nonBos), /Invalid notification OGG/);
200
+ const badVersion = oggVorbis(); badVersion[4] = 1; assert.throws(() => validateNotificationOgg(badVersion), /Invalid notification OGG/);
201
+ const badMagic = oggVorbis(); badMagic.write("XggS", 0); assert.throws(() => validateNotificationOgg(badMagic), /Invalid notification OGG/);
202
+ const badChannels = Buffer.concat([oggPage(0x02, 0, vorbisId(3), 0), oggPage(0x00, 1000, Buffer.alloc(0), 1)]);
203
+ assert.throws(() => validateNotificationOgg(badChannels), /Invalid notification OGG/);
204
+ const badRate = Buffer.concat([oggPage(0x02, 0, vorbisId(1, 4000), 0), oggPage(0x00, 1000, Buffer.alloc(0), 1)]);
205
+ assert.throws(() => validateNotificationOgg(badRate), /Invalid notification OGG/);
206
+ assert.throws(() => validateNotificationOgg(oggVorbis().subarray(0, 40)), /Invalid notification OGG/);
207
+ assert.throws(() => validateNotificationOgg(Buffer.concat([oggVorbis(), Buffer.from([1, 2, 3])])), /Invalid notification OGG/);
208
+ });
209
+
210
+ test("player capabilities map trusted executables to their real formats without starting playback", async () => {
211
+ const pick = (platform: string, match?: string) => new NotificationPlayer({ platform, native: unavailableNative(),
212
+ executableAvailable: async path => match === undefined ? true : path === match });
213
+ assert.deepEqual([...(await pick("linux", "/usr/bin/paplay").capabilities())], ["wav", "ogg", "flac"]);
214
+ assert.deepEqual([...(await pick("linux", "/usr/bin/pw-play").capabilities())], ["wav", "ogg", "flac"]);
215
+ assert.deepEqual([...(await pick("linux", "/usr/bin/aplay").capabilities())], ["wav"]);
216
+ assert.deepEqual([...(await pick("darwin", "/usr/bin/afplay").capabilities())], ["wav", "flac"]);
217
+ assert.deepEqual([...(await pick("win32", "/usr/bin/paplay").capabilities())], []);
218
+ assert.deepEqual([...(await pick("linux", "").capabilities())], []);
219
+ // Capabilities reuse the cached lazy detection: no second probe sequence.
220
+ const cached = pick("linux", "/usr/bin/aplay");
221
+ assert.equal(await cached.availability(), "available");
222
+ assert.deepEqual([...(await cached.capabilities())], ["wav"]);
223
+ });
224
+
225
+ test("detection lazy, cached, trusted absolute candidates only; absence and Windows silent", async () => {
226
+ const f = fixture(); assert.deepEqual(f.probes, []); f.setAvailable(false);
227
+ await f.play(); await f.play(); assert.equal(f.calls.length, 0); assert.equal(f.permits(), 0);
228
+ assert.deepEqual(f.probes, ["/usr/bin/paplay", "/usr/bin/pw-play", "/usr/bin/aplay"]);
229
+ assert.equal(await f.player.availability(), "unavailable");
230
+ const win = fixture("win32"); await win.play(); assert.deepEqual(win.probes, []); assert.equal(win.calls.length, 0);
231
+ const mac = fixture("darwin"); assert.equal(await mac.player.availability(), "available"); assert.deepEqual(mac.probes, ["/usr/bin/afplay"]);
232
+ const denied = fixture(); denied.setDetect(async () => { throw Error("denied"); }); await denied.play(); assert.equal(denied.calls.length, 0);
233
+ });
234
+
235
+ test("private snapshot, literal argv, no shell/detach; settle only after close and cleanup", async () => {
236
+ const f = fixture(); let done = false; const p = f.play().then(() => { done = true; }); await tick();
237
+ assert.equal(f.permits(), 1); assert.equal(done, false); assert.equal(f.calls.length, 1);
238
+ assert.deepEqual(f.calls[0], { executable: "/usr/bin/paplay", args: ["/private/audio ; literal/sound.wav"],
239
+ options: { shell: false, windowsHide: true, detached: false, stdio: ["ignore", "ignore", "pipe"] } });
240
+ assert.equal(f.writes[0].mode, 0o600); assert.deepEqual(f.writes[0].bytes, wav());
241
+ f.child.emit("exit", 0); await tick(); assert.equal(done, false);
242
+ f.child.emit("close", 0); await p; assert.equal(done, true); assert.equal(f.cleaned.length, 2); assert.equal(f.cleared(), 1);
243
+ });
244
+
245
+ test("snapshot extension follows the detected format and OGG/FLAC bytes reach the same secure flow", async () => {
246
+ for (const [bytes, extension] of [[oggVorbis(), "ogg"], [flac(), "flac"]] as const) {
247
+ const f = fixture(); f.setBytes(bytes); let done = false;
248
+ const p = f.play().then(() => { done = true; }); await tick();
249
+ assert.equal(f.permits(), 1); assert.equal(done, false);
250
+ assert.equal(f.calls[0]!.args[0], `/private/audio ; literal/sound.${extension}`);
251
+ assert.equal(f.writes[0]!.mode, 0o600); assert.deepEqual(f.writes[0]!.bytes, bytes);
252
+ f.child.emit("close", 0); await p; assert.equal(done, true); assert.equal(f.cleaned.length, 2);
253
+ }
254
+ // An unknown container never allocates a snapshot or spawns a player.
255
+ const unknown = fixture(); unknown.setBytes(Buffer.concat([Buffer.from("\x00\x00\x00\x18ftypmp42"), Buffer.alloc(40)]));
256
+ await assert.rejects(unknown.play(), /Unsupported notification audio format/);
257
+ assert.equal(unknown.calls.length, 0); assert.equal(unknown.writes.length, 0);
258
+ });
259
+
260
+ test("abort before/during detection, validation and late false permit never spawn", async () => {
261
+ const before = fixture(); before.signal.abort(); await before.play(); assert.deepEqual(before.probes, []);
262
+ const during = fixture(); let resolve!: (v: boolean) => void;
263
+ during.setDetect(() => new Promise(r => { resolve = r; })); const p = during.play(); await tick();
264
+ during.signal.abort(); resolve(true); await p; assert.equal(during.calls.length, 0); assert.equal(during.permits(), 0);
265
+ const validation = fixture(); validation.onRead(() => validation.signal.abort()); await validation.play(); assert.equal(validation.calls.length, 0);
266
+ const late = fixture(); await late.play(false); assert.equal(late.calls.length, 0); assert.equal(late.cleaned.length, 2);
267
+ });
268
+
269
+ test("abort and timeout kill but keep reservation until close; bounded stderr and failure cleanup", async () => {
270
+ for (const cancel of ["abort", "timeout"]) {
271
+ const f = fixture(); let done = false; const p = f.play().then(() => { done = true; }, () => { done = true; }); await tick();
272
+ f.child.stderr.emit("data", Buffer.alloc(100000, 65));
273
+ if (cancel === "abort") f.signal.abort(); else f.timeout();
274
+ assert.deepEqual(f.child.kills, ["SIGKILL"]); await tick(); assert.equal(done, false); assert.equal(f.cleaned.length, 0);
275
+ f.child.emit("close", null); await p; assert.equal(f.cleaned.length, 2); assert.equal(f.child.stderr.listenerCount("data"), 0);
276
+ }
277
+ const failed = fixture(); const p = failed.play(); await tick(); failed.child.emit("error", Error("secret stderr"));
278
+ await tick(); assert.equal(failed.cleaned.length, 0); failed.child.emit("close", -1);
279
+ await assert.rejects(p, /Audio process failed/); assert.equal(failed.cleaned.length, 2);
280
+ const nonzero = fixture(); const exit = nonzero.play(); await tick(); nonzero.child.emit("close", 1); await assert.rejects(exit, /Audio process failed/);
281
+ });
282
+
283
+ test("filesystem failures and synchronous spawn failure always clean snapshot", async () => {
284
+ for (const setup of [(f: ReturnType<typeof fixture>) => f.setRegular(),
285
+ (f: ReturnType<typeof fixture>) => f.setSize(2 * 1024 * 1024 + 1),
286
+ (f: ReturnType<typeof fixture>) => f.setBytes(wav().subarray(0, 20)),
287
+ (f: ReturnType<typeof fixture>) => f.setSize(wav().length + 2)]) {
288
+ const f = fixture(); setup(f); await assert.rejects(f.play()); assert.equal(f.calls.length, 0);
289
+ }
290
+ for (const fail of ["write", "spawn"]) {
291
+ const f = fixture(); if (fail === "write") f.failWrite(); else f.failSpawn();
292
+ await assert.rejects(f.play()); assert.equal(f.cleaned.length, 2);
293
+ }
294
+ const cleanup = fixture(); cleanup.failCleanup(); const p = cleanup.play(); await tick(); cleanup.child.emit("close", 0);
295
+ await assert.rejects(p, /cleanup/); assert.equal(cleanup.cleaned.length, 2);
296
+ for (const sound of ["file:https://example.org/a.wav", "file:relative.wav", "builtin:other"]) {
297
+ const f = fixture(); await assert.rejects(f.player.play(sound as "file:relative.wav", f.signal.signal, { start: () => true })); assert.equal(f.calls.length, 0);
298
+ }
299
+ });
300
+
301
+ test("cleanup failures never mask playback errors and surface on every exit path", async () => {
302
+ const breaks = [(f: ReturnType<typeof fixture>) => f.failCleanup(), (f: ReturnType<typeof fixture>) => f.failRmdir()];
303
+ for (const breakCleanup of breaks) {
304
+ // Successful playback: the cleanup failure is the only error.
305
+ const ok = fixture(); breakCleanup(ok); const p = ok.play(); await tick(); ok.child.emit("close", 0);
306
+ await assert.rejects(p, /Audio snapshot cleanup failed/); assert.equal(ok.cleaned.length, 2);
307
+ // Failed playback: the process error wins over the cleanup error.
308
+ const bad = fixture(); breakCleanup(bad); const q = bad.play(); await tick(); bad.child.emit("close", 3);
309
+ await assert.rejects(q, /Audio process failed/); assert.equal(bad.cleaned.length, 2);
310
+ // Denied permit returns early, yet the cleanup failure still surfaces.
311
+ const denied = fixture(); breakCleanup(denied);
312
+ await assert.rejects(denied.play(false), /Audio snapshot cleanup failed/); assert.equal(denied.calls.length, 0);
313
+ }
314
+ });
315
+
316
+ test("process failure message carries bounded stderr", async () => {
317
+ const f = fixture(); const p = f.play(); await tick();
318
+ f.child.stderr.emit("data", Buffer.from("ALSA lib: no such device\n")); f.child.emit("close", 1);
319
+ await assert.rejects(p, /Audio process failed: ALSA lib: no such device/);
320
+ });
321
+
322
+ test("scheduler reservation does not overlap aborted child still awaiting close", async () => {
323
+ const f = fixture(); const s = new NotificationScheduler({ now: () => 0, setTimeout: () => 1, clearTimeout: () => {},
324
+ player: (sound, signal, permit) => f.player.play(sound, signal, permit) });
325
+ assert.equal(s.preview("builtin:success"), true); await tick();
326
+ s.configure(structuredClone(DEFAULT_NOTIFICATION_SETTINGS), true);
327
+ assert.equal(s.preview("builtin:error"), false); assert.equal(f.calls.length, 1);
328
+ f.child.emit("close", null); await tick(); assert.equal(f.cleaned.length, 2);
329
+ assert.equal(s.preview("builtin:error"), true); await tick(); assert.equal(f.calls.length, 2);
330
+ f.child.emit("close", 0); await tick(); s.dispose();
331
+ });
332
+
333
+ test("all original builtin WAV assets pass the same validator", async () => {
334
+ for (const id of ["success", "error", "attention"]) {
335
+ const bytes = await readFile(new URL(`../assets/sounds/${id}.wav`, import.meta.url));
336
+ assert.ok(validateNotificationWav(bytes) > 0);
337
+ assert.equal(validateNotificationAudio(bytes).format, "wav");
338
+ }
339
+ });
340
+
341
+ test("native WAV backend is preferred and never spawns the CLI", async () => {
342
+ const native = nativeFake({ available: true, formats: ["wav"] });
343
+ const f = fixture("linux", native.backend);
344
+ await f.play();
345
+ assert.equal(native.snapshots.length, 1);
346
+ assert.match(native.snapshots[0]!, /sound\.wav$/);
347
+ assert.equal(f.calls.length, 0);
348
+ assert.equal(f.cleaned.length, 2);
349
+ assert.equal(native.probes(), 1);
350
+ });
351
+
352
+ test("native unavailable falls back to the trusted CLI for WAV", async () => {
353
+ const f = fixture("linux", unavailableNative());
354
+ let done = false; const p = f.play().then(() => { done = true; }); await tick();
355
+ assert.equal(f.calls.length, 1); assert.equal(done, false);
356
+ f.child.emit("close", 0); await p; assert.equal(done, true);
357
+ });
358
+
359
+ test("capabilities union native WAV with legacy CLI OGG/FLAC", async () => {
360
+ const native = nativeFake({ available: true, formats: ["wav"] });
361
+ const f = fixture("linux", native.backend);
362
+ assert.deepEqual([...(await f.player.capabilities())], ["wav", "ogg", "flac"]);
363
+ assert.equal(await f.player.availability(), "available");
364
+ assert.equal(native.probes(), 1);
365
+ });
366
+
367
+ test("OGG/FLAC route to the CLI by validated content, never the native WAV backend", async () => {
368
+ const native = nativeFake({ available: true, formats: ["wav"] });
369
+ const f = fixture("linux", native.backend);
370
+ f.setBytes(oggVorbis());
371
+ let done = false; const p = f.play().then(() => { done = true; }); await tick();
372
+ assert.equal(native.snapshots.length, 0);
373
+ assert.equal(f.calls.length, 1);
374
+ assert.equal(f.calls[0]!.args[0], "/private/audio ; literal/sound.ogg");
375
+ f.child.emit("close", 0); await p; assert.equal(done, true);
376
+ });
377
+
378
+ test("a format no backend supports is rejected before snapshot or spawn", async () => {
379
+ const f = fixture("linux", unavailableNative());
380
+ f.setAvailablePaths(["/usr/bin/aplay"]); // WAV only
381
+ f.setBytes(flac());
382
+ await assert.rejects(f.play(), /no backend|Unsupported/);
383
+ assert.equal(f.calls.length, 0); assert.equal(f.writes.length, 0); assert.equal(f.cleaned.length, 0);
384
+ });
385
+
386
+ test("a native play error never retries the CLI", async () => {
387
+ const native = nativeFake({ available: true, formats: ["wav"], fail: true });
388
+ const f = fixture("linux", native.backend);
389
+ await assert.rejects(f.play(), /native playback failed/);
390
+ assert.equal(native.snapshots.length, 1);
391
+ assert.equal(f.calls.length, 0);
392
+ assert.equal(f.cleaned.length, 2);
393
+ });
394
+
395
+ test("default CLI play deadline is 11 s, one second past the accepted 10 s maximum", async () => {
396
+ const f = fixture(); let done = false;
397
+ const p = f.play().then(() => { done = true; }); await tick();
398
+ assert.equal(f.calls.length, 1);
399
+ assert.equal(f.timeoutMs(), 11000);
400
+ assert.ok(f.timeoutMs() > 10000, "the CLI deadline must outlive the accepted 10 s file");
401
+ assert.equal(done, false);
402
+ f.child.emit("close", 0); await p; assert.equal(done, true);
403
+ });
404
+
405
+ test("native gate false starts once, spawns nothing and preserves cleanup", async () => {
406
+ const native = nativeFake({ available: true, formats: ["wav"] });
407
+ const f = fixture("linux", native.backend);
408
+ await f.play(false);
409
+ assert.equal(f.permits(), 1);
410
+ assert.equal(native.gates.length, 1);
411
+ assert.equal(native.snapshots.length, 0);
412
+ assert.equal(f.calls.length, 0);
413
+ assert.equal(f.cleaned.length, 2);
414
+ });
415
+
416
+ test("abort before discovery performs no probe, read or play", async () => {
417
+ const native = nativeFake({ available: true, formats: ["wav"] });
418
+ const f = fixture("linux", native.backend);
419
+ f.signal.abort();
420
+ await f.play();
421
+ assert.equal(native.probes(), 0);
422
+ assert.equal(native.snapshots.length, 0);
423
+ assert.equal(f.writes.length, 0);
424
+ assert.equal(f.calls.length, 0);
425
+ });
426
+
427
+ /** The default factory must select a prepared target from platform/env alone, with no probe or spawn. */
428
+ test("default factory selects the prepared target purely and lazily without probing", () => {
429
+ const wslEnv = { WSL_DISTRO_NAME: "Ubuntu", WSL_INTEROP: "/run/WSL/4242_interop" };
430
+ const win = createDefaultNotificationBackend("win32", {});
431
+ assert.ok(win instanceof NativeWindowsPlayer);
432
+ assert.ok(!(win instanceof NativePulsePlayer));
433
+ assert.equal(win.supportsTarget(), true);
434
+ assert.deepEqual(win.getNativeFormats(), []);
435
+ const wsl = createDefaultNotificationBackend("linux", wslEnv);
436
+ assert.ok(wsl instanceof NativeWindowsPlayer);
437
+ assert.ok(!(wsl instanceof NativePulsePlayer));
438
+ assert.equal(wsl.supportsTarget(), true);
439
+ assert.deepEqual(wsl.getNativeFormats(), []);
440
+ const linux = createDefaultNotificationBackend("linux", {});
441
+ assert.ok(linux instanceof NativePulsePlayer);
442
+ assert.ok(!(linux instanceof NativeWindowsPlayer));
443
+ assert.equal(linux.supportsTarget(), true);
444
+ assert.deepEqual(linux.getNativeFormats(), []);
445
+ // A WSL-looking env is not enough: a non-/mnt-c interop path, a bad distro name
446
+ // or a missing half must stay on the local Pulse class, never the Windows host.
447
+ const denied: Array<Record<string, string>> = [
448
+ { WSL_DISTRO_NAME: "Ubuntu", WSL_INTEROP: "/tmp/4242_interop" },
449
+ { WSL_DISTRO_NAME: "Ubuntu", WSL_INTEROP: "4242_interop" },
450
+ { WSL_DISTRO_NAME: "Ubuntu", WSL_INTEROP: "/run/WSL/../4242_interop" },
451
+ { WSL_DISTRO_NAME: "bad:name", WSL_INTEROP: "/run/WSL/4242_interop" },
452
+ { WSL_INTEROP: "/run/WSL/4242_interop" },
453
+ { WSL_DISTRO_NAME: "Ubuntu" },
454
+ ];
455
+ for (const env of denied) {
456
+ const backend = createDefaultNotificationBackend("linux", env);
457
+ assert.ok(backend instanceof NativePulsePlayer, JSON.stringify(env));
458
+ assert.ok(!(backend instanceof NativeWindowsPlayer), JSON.stringify(env));
459
+ assert.deepEqual(backend.getNativeFormats(), []);
460
+ }
461
+ // macOS has no owned native yet: even with WSL-like env it stays on the local class
462
+ // (supportsTarget false) so the legacy /usr/bin/afplay path owns playback.
463
+ const mac = createDefaultNotificationBackend("darwin", wslEnv);
464
+ assert.ok(mac instanceof NativePulsePlayer);
465
+ assert.equal(mac.supportsTarget(), false);
466
+ });
467
+
468
+ const WINDOWS_PROBE_ENVELOPE = JSON.stringify({ schema: NATIVE_WINDOWS_SCHEMA, ok: true, available: true, formats: ["wav"] });
469
+ const WINDOWS_PLAY_ENVELOPE = JSON.stringify({ schema: NATIVE_WINDOWS_SCHEMA, ok: true, played: true });
470
+ class WindowsChild extends EventEmitter {
471
+ readonly stdout = new EventEmitter();
472
+ readonly stderr = new EventEmitter();
473
+ readonly kills: string[] = [];
474
+ kill(signal: string): boolean { this.kills.push(signal); return true; }
475
+ }
476
+ /** Real Windows adapter with fully injected IO: no child process, no filesystem, no PowerShell. */
477
+ function windowsNativePlayer() {
478
+ const children: WindowsChild[] = [];
479
+ const spawned: Array<{ executable: string; args: string[]; options: unknown }> = [];
480
+ const native = new NativeWindowsPlayer({
481
+ platform: "win32",
482
+ env: { TEMP: "C:\\Temp" },
483
+ executableAvailable: async () => true,
484
+ spawn: (executable, args, options) => { spawned.push({ executable, args, options }); const child = new WindowsChild(); children.push(child); return child; },
485
+ setTimeout: () => 1, clearTimeout: () => {},
486
+ });
487
+ return { native, children, spawned };
488
+ }
489
+
490
+ test("win32 native route plays a custom Windows WAV through the fake PowerShell host", async () => {
491
+ const { native, children, spawned } = windowsNativePlayer();
492
+ const cleaned: string[] = []; const writes: Array<{ path: string; bytes: Buffer; mode: number }> = [];
493
+ const bytes = wav();
494
+ const io: AudioIO = {
495
+ open: async (_path, flags) => {
496
+ assert.ok(flags & constants.O_NOFOLLOW); assert.ok(flags & constants.O_NONBLOCK);
497
+ return { stat: async () => ({ isFile: () => true, size: bytes.length }),
498
+ read: async buffer => { bytes.copy(buffer); return { bytesRead: bytes.length }; }, close: async () => {} };
499
+ },
500
+ mkdtemp: async () => "C:\\Temp\\gentle-notification-1", chmod: async () => {},
501
+ writeFile: async (path, data, options) => { writes.push({ path, bytes: data, mode: options.mode }); },
502
+ unlink: async path => { cleaned.push(path); }, rmdir: async path => { cleaned.push(path); },
503
+ };
504
+ let cliSpawns = 0;
505
+ const player = new NotificationPlayer({ platform: "win32", io, tempRoot: "C:\\Temp", builtinRoot: "C:\\builtins", native,
506
+ executableAvailable: async () => false, spawn: () => { cliSpawns++; throw new Error("no CLI on win32"); },
507
+ setTimeout: () => 1, clearTimeout: () => {} });
508
+ const availability = player.availability();
509
+ await tick();
510
+ assert.equal(children.length, 1);
511
+ children[0]!.stdout.emit("data", WINDOWS_PROBE_ENVELOPE);
512
+ children[0]!.emit("close", 0);
513
+ assert.equal(await availability, "available");
514
+ assert.deepEqual([...(await player.capabilities())], ["wav"]);
515
+ let permits = 0;
516
+ const playing = player.play("file:C:\\audio.wav", new AbortController().signal, { start: () => { permits++; return true; } });
517
+ await tick();
518
+ assert.equal(children.length, 2);
519
+ assert.equal(permits, 1);
520
+ const call = spawned[1]!;
521
+ assert.equal(call.executable, FIXED_WINDOWS_POWERSHELL_EXE);
522
+ assert.deepEqual(call.args.slice(0, 4), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand"]);
523
+ const script = Buffer.from(call.args[4]!, "base64").toString("utf16le");
524
+ const embedded = script.match(/FromBase64String\('([^']*)'\)/)?.[1];
525
+ assert.equal(Buffer.from(embedded!, "base64").toString("utf8"), "C:\\Temp\\gentle-notification-1/sound.wav");
526
+ assert.ok(!script.includes("C:\\audio.wav"));
527
+ children[1]!.stdout.emit("data", WINDOWS_PLAY_ENVELOPE);
528
+ children[1]!.emit("close", 0);
529
+ await playing;
530
+ // The snapshot carries the validated WAV bytes; no Unix I/O and no CLI fallback ran.
531
+ assert.deepEqual(writes[0]!.bytes, wav());
532
+ assert.equal(writes[0]!.mode, 0o600);
533
+ assert.equal(cleaned.length, 2);
534
+ assert.equal(cliSpawns, 0);
535
+ });
536
+
537
+ test("macOS keeps the trusted afplay CLI for WAV and FLAC, never the Windows host", async () => {
538
+ for (const [bytes, extension] of [[wav(), "wav"], [flac(), "flac"]] as const) {
539
+ const backend = createDefaultNotificationBackend("darwin", { WSL_DISTRO_NAME: "Ubuntu", WSL_INTEROP: "/run/WSL/1_interop" });
540
+ const f = fixture("darwin", backend);
541
+ f.setBytes(bytes);
542
+ let done = false; const p = f.play().then(() => { done = true; }); await tick();
543
+ assert.equal(f.calls.length, 1);
544
+ assert.equal(f.calls[0]!.executable, "/usr/bin/afplay");
545
+ assert.equal(f.calls[0]!.args[0], `/private/audio ; literal/sound.${extension}`);
546
+ f.child.emit("close", 0); await p; assert.equal(done, true);
547
+ }
548
+ });