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,100 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { createHerdrActivityExtension } from "../extensions/gentle-herdr-activity.ts";
5
+ import { oddPhaseRegistry } from "../lib/odd-phase.ts";
6
+ import { metadataArgs } from "../lib/herdr-activity.ts";
7
+
8
+ type Handler = (event: any, ctx: ExtensionContext) => void;
9
+ function harness(overrides: NodeJS.ProcessEnv = {}, socket = true) {
10
+ const handlers = new Map<string, Handler>();
11
+ const values: Array<string | null> = [];
12
+ const reports: string[][] = [];
13
+ let tick: (() => void) | undefined;
14
+ let stopped = false;
15
+ let now = 1000;
16
+ let columns = 30;
17
+ const options = {
18
+ env: { HERDR_ENV: "1", HERDR_PANE_ID: "pane", HERDR_SOCKET_PATH: "/socket", ...overrides },
19
+ isSocket: () => socket,
20
+ now: () => now,
21
+ columns: () => columns,
22
+ send: async (summary: string | null, seq: number) => {
23
+ values.push(summary); reports.push(metadataArgs("pane", summary, seq));
24
+ },
25
+ watch: (fn: () => void) => { tick = fn; return () => { stopped = true; }; },
26
+ };
27
+ createHerdrActivityExtension(options)({ on: (name: string, fn: Handler) => handlers.set(name, fn) } as unknown as ExtensionAPI);
28
+ const ctx = { mode: "tui", hasUI: true, sessionManager: { getSessionId: () => "root", getBranch: () => [] } } as unknown as ExtensionContext;
29
+ return { handlers, values, reports, ctx, tick: () => tick?.(), stopped: () => stopped,
30
+ advance: (ms: number) => { now += ms; }, columns: (value: number) => { columns = value; },
31
+ fire: (name: string, event = {}, context = ctx) => handlers.get(name)?.(event, context) };
32
+ }
33
+ const wait = () => new Promise((resolve) => setTimeout(resolve, 180));
34
+ const todo = (title?: string) => ({ toolName: "todo", result: { details: { gentleTodo: {
35
+ tasks: title === undefined ? [] : [{ title, status: "in_progress" }],
36
+ } } } });
37
+ test("tools and failed Todo snapshots never supply fallback summaries", async () => {
38
+ const h = harness(); h.fire("session_start"); h.fire("agent_start");
39
+ h.fire("tool_execution_start", { toolName: "read", args: { path: "secret" } });
40
+ h.tick(); await wait(); assert.equal(h.values.at(-1), null);
41
+ h.fire("tool_execution_end", { toolName: "read" });
42
+ h.tick(); await wait(); assert.equal(h.values.at(-1), null);
43
+ h.fire("tool_execution_end", { toolName: "todo", isError: true,
44
+ result: { details: { gentleTodo: { tasks: [{ title: "failed", status: "in_progress" }] } } } });
45
+ h.tick(); await wait(); assert.equal(h.values.at(-1), null);
46
+ h.fire("session_shutdown");
47
+ });
48
+ test("children, missing Herdr and socket, and non-TUI never publish", async () => {
49
+ for (const h of [harness({ GENTLE_PI_AGENTS_CHILD: "1" }), harness({ HERDR_ENV: "0" }), harness({ HERDR_PANE_ID: "" }), harness({}, false)]) {
50
+ h.fire("session_start"); h.fire("agent_start"); await wait(); assert.deepEqual(h.values, []);
51
+ }
52
+ const h = harness();
53
+ h.fire("session_start", {}, { ...h.ctx, mode: "rpc" } as ExtensionContext);
54
+ h.fire("agent_start"); await wait(); assert.deepEqual(h.values, []);
55
+ });
56
+
57
+ test("root uses only Todo despite ODD; TTL, idle, ownership and cleanup remain guarded", async () => {
58
+ const h = harness(); h.fire("session_start"); h.fire("agent_start");
59
+ oddPhaseRegistry.report("root", "exploring");
60
+ h.fire("tool_execution_end", { toolName: "todo", result: { details: { gentleTodo: { tasks: [{ title: "Revisar integración Herdr", status: "in_progress" }] } } } });
61
+ h.tick(); await wait(); assert.equal(h.values.at(-1), "◐ Revisar integración Herdr");
62
+ const count = h.values.length;
63
+ h.tick(); await wait(); assert.equal(h.values.length, count);
64
+ h.advance(10000); h.tick(); await wait(); assert.equal(h.values.length, count + 1);
65
+ h.fire("agent_settled", {}, { ...h.ctx, sessionManager: { getSessionId: () => "child" } } as ExtensionContext);
66
+ assert.notEqual(h.values.at(-1), null);
67
+ h.fire("agent_settled"); await wait(); assert.equal(h.values.at(-1), null);
68
+ h.fire("agent_start"); h.tick(); await wait();
69
+ h.fire("session_start"); await wait(); assert.equal(h.values.at(-1), null);
70
+ h.fire("session_shutdown"); await wait(); assert.ok(h.stopped());
71
+ oddPhaseRegistry.clear("root");
72
+ });
73
+
74
+ test("width changes reproject and long-to-short/no-task/idle clear both rows together", async () => {
75
+ const h = harness(); h.fire("session_start"); h.fire("agent_start"); h.columns(12);
76
+ h.fire("tool_execution_end", todo("one two three four")); h.tick(); await wait();
77
+ assert.equal(h.values.at(-1), "◐ one two\n three four");
78
+ h.columns(30); h.tick(); await wait();
79
+ assert.equal(h.values.at(-1), "◐ one two three four");
80
+ h.columns(12); h.tick(); await wait();
81
+ h.fire("tool_execution_end", todo("short")); h.tick(); await wait();
82
+ assert.equal(h.values.at(-1), "◐ short");
83
+ assert.deepEqual(h.reports.at(-1)?.slice(-4), ["--token", "summary=◐ short", "--clear-token", "summary2"]);
84
+ h.fire("tool_execution_end", todo()); h.tick(); await wait();
85
+ assert.equal(h.values.at(-1), null);
86
+ h.fire("tool_execution_end", todo("active")); h.tick(); await wait();
87
+ h.fire("session_tree"); await wait(); assert.equal(h.values.at(-1), null);
88
+ assert.deepEqual(h.reports.at(-1)?.slice(-4), ["--clear-token", "summary", "--clear-token", "summary2"]);
89
+ h.fire("session_shutdown");
90
+ });
91
+
92
+ test("malformed successful Todo snapshots clear previous activity, unlike failed snapshots", async () => {
93
+ const h = harness(); h.fire("session_start"); h.fire("agent_start");
94
+ for (const tasks of [null, {}, [{ title: 42, status: "in_progress" }]]) {
95
+ h.fire("tool_execution_end", todo("valid")); h.tick(); await wait();
96
+ h.fire("tool_execution_end", { toolName: "todo", result: { details: { gentleTodo: { tasks } } } });
97
+ h.tick(); await wait(); assert.equal(h.values.at(-1), null);
98
+ }
99
+ h.fire("session_shutdown");
100
+ });
@@ -0,0 +1,427 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { createNotificationExtension } from "../extensions/gentle-notifications.ts";
8
+ import { getNotificationService } from "../lib/notification-service.ts";
9
+ import { DEFAULT_NOTIFICATION_SETTINGS, NOTIFICATION_EVENTS, type NotificationSettings } from "../lib/notification-policy.ts";
10
+ import { NOTIFICATION_SOURCE_EVENT, NOTIFICATION_ATTENTION_EVENT } from "../lib/notification-events.ts";
11
+ import type { SchedulerOptions } from "../lib/notification-scheduler.ts";
12
+
13
+ const drain = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
14
+ let nextSession = 0;
15
+ interface Options {
16
+ sessionId?: string;
17
+ enabled?: boolean; mode?: string; child?: boolean; malformed?: boolean; readError?: boolean; absent?: boolean;
18
+ deferred?: boolean; slowProbe?: boolean; holdPlayback?: boolean; noUnsubscribe?: boolean; unavailable?: boolean; fail?: boolean;
19
+ capabilities?: Set<"wav" | "ogg" | "flac">;
20
+ percent?: number | null; visualNotice?: boolean;
21
+ }
22
+ function harness(options: Options = {}) {
23
+ const id = options.sessionId ?? `parent-${++nextSession}`;
24
+ const native = new Map<string, Set<(event: any, ctx: ExtensionContext) => unknown>>();
25
+ const bus = new Map<string, Set<(data: unknown) => void>>();
26
+ const listen = (map: Map<string, Set<any>>, key: string, fn: any) => {
27
+ if (!map.has(key)) map.set(key, new Set()); map.get(key)!.add(fn);
28
+ return () => { map.get(key)!.delete(fn); };
29
+ };
30
+ // SAFETY: only lifecycle hooks and the bus are used by this factory; a minimal fake traps that contract.
31
+ const pi = { on: (key: string, fn: any) => {
32
+ const off = listen(native, key, fn); return options.noUnsubscribe ? undefined : off;
33
+ }, events: { on: (key: string, fn: any) => listen(bus, key, fn), emit: (key: string, data: unknown) => {
34
+ for (const fn of [...bus.get(key) ?? []]) fn(data);
35
+ } } } as unknown as ExtensionAPI;
36
+ let time = 100; let nextTimer = 0; let probes = 0; let writes = 0; let attempts = 0;
37
+ const timers = new Map<number, { fn: () => void; at: number }>();
38
+ const played: string[] = []; const signals: AbortSignal[] = []; const permits: Parameters<SchedulerOptions["player"]>[2][] = [];
39
+ const releases: (() => void)[] = []; const probeReleases: (() => void)[] = []; const notes: string[] = [];
40
+ let settings = structuredClone(DEFAULT_NOTIFICATION_SETTINGS);
41
+ settings.enabled = options.enabled ?? !options.absent; settings.audio.minimumIntervalMs = 0; settings.audio.coalesceWindowMs = 0;
42
+ settings.audio.events = Object.fromEntries(NOTIFICATION_EVENTS.map(e => [e,
43
+ e === "context.high" ? "builtin:attention" : e.endsWith("failed") ? "builtin:error" : e === "agent.cancelled" ? "builtin:attention" : "builtin:success"]));
44
+ // SAFETY: this owner reads only mode, session identity, context usage and local notify, never session entries/model APIs.
45
+ let percent: number | null = options.percent ?? null;
46
+ const ctx = { mode: options.mode ?? "tui", hasUI: true, sessionManager: { getSessionId: () => id },
47
+ getContextUsage: () => ({ tokens: null, contextWindow: 100, percent }),
48
+ ui: { notify: (s: string) => notes.push(s) } } as unknown as ExtensionContext;
49
+ const deps = { now: () => time, env: { GENTLE_PI_AGENTS_CHILD: options.child ? "1" : "0", GENTLE_SHELL_INTERACTIVE_HOST: "1" },
50
+ visualNotice: () => options.visualNotice ?? true,
51
+ setTimeout: (fn: () => void, delay: number) => { const id = ++nextTimer; timers.set(id, { fn, at: time + delay }); return id; },
52
+ clearTimeout: (id: number) => { timers.delete(id); },
53
+ read: () => ({ settings: structuredClone(settings), source: options.absent ? "default" as const : "global_file" as const,
54
+ malformed: options.malformed ?? false, readError: options.readError ?? false, globalFile: "/config/notifications.json" }),
55
+ write: (s: NotificationSettings) => { writes++; settings = structuredClone(s); return "/config/notifications.json"; },
56
+ backend: { availability: async () => {
57
+ probes++; if (options.slowProbe) await new Promise<void>(resolve => probeReleases.push(resolve));
58
+ return options.unavailable ? "unavailable" as const : "available" as const;
59
+ }, capabilities: async () => options.capabilities ?? new Set(["wav", "ogg", "flac"] as const), play: async (sound: string, signal: AbortSignal, permit: Parameters<SchedulerOptions["player"]>[2]) => {
60
+ attempts++; signals.push(signal); permits.push(permit);
61
+ if (options.deferred) await new Promise<void>(resolve => releases.push(resolve));
62
+ if (permit.start()) played.push(sound);
63
+ if (options.holdPlayback) await new Promise<void>(resolve => releases.push(resolve));
64
+ if (options.fail) throw new Error("private backend detail");
65
+ } },
66
+ };
67
+ const retire = createNotificationExtension(pi, deps);
68
+ const emit = async (type: string, data: object = {}, context = ctx) => {
69
+ for (const fn of [...native.get(type) ?? []]) await fn({ type, ...data }, context);
70
+ await drain();
71
+ };
72
+ const start = async (reason = "reload") => { await emit("session_start", { reason }); getNotificationService()?.setMuted(ctx, false); await drain(); };
73
+ const source = (sequence: number, extra: object = {}) => pi.events.emit(NOTIFICATION_SOURCE_EVENT, {
74
+ source: "subagent", producerId: "producer", sessionId: id, parentSessionId: id, taskId: "task",
75
+ runId: "run", sequence, occurredAt: time, event: "subagent.completed", status: "completed", ...extra,
76
+ });
77
+ const advance = (ms: number) => { time += ms;
78
+ for (const [id, timer] of [...timers]) if (timer.at <= time) { timers.delete(id); timer.fn(); }
79
+ };
80
+ return { id, pi, ctx, deps, retire, emit, start, source, played, signals, permits, releases, probeReleases, notes, native, bus, timers, advance,
81
+ setPercent: (value: number | null) => { percent = value; },
82
+ get probes() { return probes; }, get writes() { return writes; }, get attempts() { return attempts; } };
83
+ }
84
+ const listeners = (map: Map<string, Set<any>>) => [...map.values()].reduce((n, set) => n + set.size, 0);
85
+
86
+ for (const absent of [false, true]) test(`off/absent=${absent}: lazy, preview bypasses mute/off without writing`, async () => {
87
+ const h = harness({ enabled: false, absent }); try {
88
+ await h.start(); await h.emit("agent_start"); await h.emit("agent_before_settle", { outcome: "completed" }); await h.emit("agent_settled");
89
+ assert.equal(h.probes, 0); assert.equal(h.attempts, 0); assert.equal(h.writes, 0);
90
+ const service = getNotificationService()!; service.setMuted(h.ctx, true);
91
+ assert.equal(service.preview(h.ctx, "builtin:success"), true); await drain();
92
+ assert.deepEqual(h.played, ["builtin:success"]); assert.equal(h.writes, 0);
93
+ assert.equal(await service.availability(h.ctx), "available"); assert.equal(h.probes, 2);
94
+ } finally { h.retire(); }
95
+ });
96
+
97
+ test("settled uses latest boundary, never agent_end; fallback only corresponding run", async () => {
98
+ const h = harness(); try {
99
+ await h.start();
100
+ for (const [outcome, sound] of [["completed", "builtin:success"], ["error", "builtin:error"], ["aborted", "builtin:attention"]]) {
101
+ await h.emit("agent_start"); h.played.length = 0;
102
+ await h.emit("agent_before_settle", { outcome: "error" }); await h.emit("agent_before_settle", { outcome });
103
+ await h.emit("turn_end", { outcome: "error" }); await h.emit("agent_end"); assert.deepEqual(h.played, []);
104
+ await h.emit("agent_settled"); await h.emit("agent_settled"); assert.deepEqual(h.played, [sound]);
105
+ }
106
+ await h.emit("agent_start"); h.played.length = 0; await h.emit("turn_end", { outcome: "completed" }); await h.emit("agent_settled");
107
+ assert.deepEqual(h.played, ["builtin:success"]);
108
+ await h.emit("agent_start"); h.played.length = 0; await h.emit("agent_settled"); assert.deepEqual(h.played, []);
109
+ await h.emit("agent_start"); h.played.length = 0; await h.emit("agent_before_settle", { outcome: "unknown" }); await h.emit("agent_settled");
110
+ assert.deepEqual(h.played, []); assert.equal(h.native.has("agent_end"), false);
111
+ } finally { h.retire(); }
112
+ });
113
+
114
+ test("context.high warns once per high episode and rearms only below the threshold", async () => {
115
+ const h = harness({ visualNotice: false }); try {
116
+ await h.start();
117
+ const warnings = () => h.played.filter(sound => sound === "builtin:attention").length;
118
+ h.setPercent(90); await h.emit("turn_end", { outcome: "completed" });
119
+ assert.equal(warnings(), 1, "reaching the threshold plays once");
120
+ h.setPercent(92); await h.emit("turn_end", { outcome: "completed" });
121
+ assert.equal(warnings(), 1, "repeated readings above the threshold stay silent");
122
+ h.played.length = 0;
123
+ await h.emit("agent_start", {}); await h.emit("model_select", {});
124
+ assert.equal(warnings(), 0, "agent_start and model_change re-evaluate without warning again");
125
+ h.setPercent(40); await h.emit("turn_end", { outcome: "completed" });
126
+ h.setPercent(95); await h.emit("turn_end", { outcome: "completed" });
127
+ assert.equal(warnings(), 1, "dropping below the threshold rearms a new episode");
128
+ } finally { h.retire(); }
129
+ });
130
+
131
+ test("same-session reload preserves the context episode until usage drops below 90%", async () => {
132
+ const h = harness({ percent: 93 }); try {
133
+ await h.start(); await h.emit("turn_end");
134
+ assert.equal(h.played.length, 1); assert.equal(h.notes.length, 1);
135
+ await h.start("reload"); await h.emit("turn_end");
136
+ assert.equal(h.played.length, 1, "same-session reload must not repeat audio");
137
+ assert.equal(h.notes.length, 1, "same-session reload must not repeat the visual notice");
138
+ h.setPercent(40); await h.emit("turn_end");
139
+ h.setPercent(93); await h.emit("turn_end");
140
+ assert.equal(h.played.length, 2); assert.equal(h.notes.length, 2);
141
+ } finally { h.retire(); }
142
+ });
143
+
144
+ test("shutdown followed by the same session ID rearms the context episode", async () => {
145
+ const h = harness({ percent: 93 }); try {
146
+ await h.start(); await h.emit("turn_end");
147
+ assert.equal(h.played.length, 1); assert.equal(h.notes.length, 1);
148
+ await h.emit("session_shutdown", { reason: "reload" });
149
+ await h.start("reload"); await h.emit("turn_end");
150
+ assert.equal(h.played.length, 2, "a restarted owner warns even with the same ID");
151
+ assert.equal(h.notes.length, 2);
152
+ } finally { h.retire(); }
153
+ });
154
+
155
+ test("changed session ID without shutdown rearms the context episode", async () => {
156
+ const h = harness({ percent: 93 }); try {
157
+ await h.start(); await h.emit("turn_end");
158
+ const fresh = { ...h.ctx, sessionManager: { ...h.ctx.sessionManager, getSessionId: () => "fresh-without-shutdown" } };
159
+ await h.emit("session_start", { reason: "new" }, fresh);
160
+ await h.emit("turn_end", {}, fresh);
161
+ assert.equal(h.played.length, 2); assert.equal(h.notes.length, 2);
162
+ } finally { h.retire(); }
163
+ });
164
+
165
+ test("successful compaction rearms while a canceled compaction preserves the latch", async () => {
166
+ const h = harness(); try {
167
+ await h.start();
168
+ h.setPercent(91); await h.emit("turn_end", { outcome: "completed" }); assert.equal(h.played.length, 1);
169
+ h.setPercent(null); await h.emit("session_compact_failed", { aborted: true }); await h.emit("turn_end", { outcome: "completed" });
170
+ assert.equal(h.played.length, 1, "a canceled or failed compaction must not rearm");
171
+ h.setPercent(93); await h.emit("turn_end", { outcome: "completed" });
172
+ assert.equal(h.played.length, 1, "an unknown reading preserves the latch so the episode cannot warn twice");
173
+ await h.emit("session_compact", { reason: "threshold" });
174
+ h.setPercent(93); await h.emit("turn_end", { outcome: "completed" });
175
+ assert.equal(h.played.length, 2, "a successful compaction rearms");
176
+ } finally { h.retire(); }
177
+ });
178
+
179
+ test("a new session rearms the context warning for the fresh session", async () => {
180
+ const h = harness(); try {
181
+ await h.start();
182
+ h.setPercent(91); await h.emit("turn_end", { outcome: "completed" }); assert.equal(h.played.length, 1);
183
+ await h.emit("session_shutdown", { reason: "new" });
184
+ const fresh = { ...h.ctx, sessionManager: { ...h.ctx.sessionManager, getSessionId: () => "fresh-context" } };
185
+ await h.emit("session_start", { reason: "new" }, fresh);
186
+ h.setPercent(91); await h.emit("turn_end", { outcome: "completed" }, fresh);
187
+ assert.equal(h.played.length, 2, "the new session warns again");
188
+ } finally { h.retire(); }
189
+ });
190
+
191
+ test("the visual context warning is independent from the audio switch", async () => {
192
+ const silent = harness({ enabled: false }); try {
193
+ await silent.start(); silent.setPercent(93); await silent.emit("turn_end", { outcome: "completed" });
194
+ assert.equal(silent.played.length, 0, "disabled audio stays silent");
195
+ assert.equal(silent.notes.length, 1, "the visual warning still fires with audio off");
196
+ assert.match(silent.notes[0]!, /90/);
197
+ } finally { silent.retire(); }
198
+ const visualOff = harness({ visualNotice: false }); try {
199
+ await visualOff.start(); visualOff.setPercent(93); await visualOff.emit("turn_end", { outcome: "completed" });
200
+ assert.equal(visualOff.played.length, 1, "audio still fires when the visual warning is off");
201
+ assert.deepEqual(visualOff.notes, [], "the visual warning stays off");
202
+ } finally { visualOff.retire(); }
203
+ });
204
+
205
+ test("subagent validation, highwater beyond cap, foreign/future/history sources silent", async () => {
206
+ const h = harness(); try {
207
+ await h.start();
208
+ for (const extra of [{ parentSessionId: "foreign" }, { sessionId: "foreign", parentSessionId: "foreign" },
209
+ { occurredAt: 101 }, { occurredAt: 99 }, { source: "main" }, { sequence: 0 }, { status: "failed" }]) { h.source(1, extra); await drain(); }
210
+ assert.deepEqual(h.played, []);
211
+ for (let i = 1; i <= 260; i++) { h.source(i); await drain(); }
212
+ assert.equal(h.played.length, 260); h.source(1); h.source(259); h.source(260); await drain(); assert.equal(h.played.length, 260);
213
+ h.source(1, { producerId: "new", runId: "new" }); await drain(); assert.equal(h.played.length, 261);
214
+ } finally { h.retire(); }
215
+ });
216
+
217
+ test("attention is active main edge only, generic/own UI prompts and idle ignored", async () => {
218
+ const h = harness(); try {
219
+ await h.start(); h.pi.events.emit(NOTIFICATION_ATTENTION_EVENT, { active: true }); await drain(); assert.equal(h.played.length, 0);
220
+ await h.emit("agent_start"); h.played.length = 0; await h.emit("ui_prompt_start", { kind: "confirm" }); assert.equal(h.played.length, 0);
221
+ h.pi.events.emit(NOTIFICATION_ATTENTION_EVENT, { active: true }); await drain();
222
+ h.pi.events.emit(NOTIFICATION_ATTENTION_EVENT, { active: true }); await drain(); assert.equal(h.played.length, 1);
223
+ h.pi.events.emit(NOTIFICATION_ATTENTION_EVENT, { active: false }); h.pi.events.emit(NOTIFICATION_ATTENTION_EVENT, { active: true }); await drain();
224
+ assert.equal(h.played.length, 2); await h.emit("agent_settled"); h.pi.events.emit(NOTIFICATION_ATTENTION_EVENT, { active: true }); await drain(); assert.equal(h.played.length, 2);
225
+ } finally { h.retire(); }
226
+ });
227
+
228
+ for (const options of [{ child: true }, { mode: "rpc" }, { mode: "print" }, { mode: "json" }]) test(`silent guard ${JSON.stringify(options)}`, async () => {
229
+ const h = harness(options); try {
230
+ await h.start(); await h.emit("agent_start"); h.source(1); const service = getNotificationService();
231
+ assert.equal(service?.preview(h.ctx, "builtin:success") ?? false, false);
232
+ assert.equal(await service?.availability(h.ctx) ?? "unavailable", "unavailable");
233
+ assert.equal(h.probes, 0); assert.equal(h.attempts, 0); assert.deepEqual(h.notes, []);
234
+ } finally { h.retire(); }
235
+ });
236
+
237
+ test("duplicate factory retires listeners; mute survives same-id shutdown/runtime rebuild", async () => {
238
+ const a = harness(); await a.start(); const old = getNotificationService()!; old.setMuted(a.ctx, true);
239
+ const b = harness({ sessionId: a.id }); try {
240
+ await b.emit("session_start", { reason: "reload" }); assert.equal(getNotificationService()!.getState().muted, true);
241
+ assert.equal(listeners(a.native), 0); assert.equal(listeners(a.bus), 0); assert.equal(old.preview(a.ctx, "builtin:success"), false);
242
+ await b.emit("session_shutdown", { reason: "reload" }); await b.emit("session_shutdown", { reason: "quit" });
243
+ assert.equal(b.timers.size, 0); assert.equal(listeners(b.bus), 0);
244
+ await b.emit("session_start", { reason: "reload" }); assert.equal(getNotificationService()!.getState().muted, true);
245
+ getNotificationService()!.setMuted(b.ctx, false); b.source(1); await drain(); assert.equal(b.played.length, 1);
246
+ } finally { a.retire(); b.retire(); }
247
+ });
248
+
249
+ test("late validation permit cannot spawn after shutdown or overlap replacement owner", async () => {
250
+ const a = harness({ deferred: true }); await a.start(); a.source(1); await drain(); assert.equal(a.attempts, 1);
251
+ await a.emit("session_shutdown", { reason: "reload" }); assert.equal(a.signals[0].aborted, true);
252
+ const b = harness(); try {
253
+ await b.start(); assert.equal(getNotificationService()!.preview(b.ctx, "builtin:success"), false);
254
+ a.releases[0](); await drain(); assert.equal(a.permits[0].start(), false); assert.equal(a.played.length, 0);
255
+ assert.equal(getNotificationService()!.preview(b.ctx, "builtin:success"), true); await drain(); assert.equal(b.played.length, 1);
256
+ } finally { a.retire(); b.retire(); }
257
+ });
258
+
259
+ for (const invalid of [{ malformed: true }, { readError: true }]) test(`invalid config ${JSON.stringify(invalid)}: no implicit write, recovery consent, restore`, async () => {
260
+ const h = harness(invalid); try {
261
+ await h.start(); h.source(1); await drain(); assert.equal(h.attempts, 0); assert.equal(h.notes.length, 1);
262
+ const service = getNotificationService()!; const state = service.getState(); assert.equal(state.settings.enabled, false);
263
+ state.settings.enabled = true; assert.equal(service.getState().settings.enabled, false);
264
+ assert.equal(service.setConfig(h.ctx, { ...state.settings, enabled: true }), false); assert.equal(h.writes, 0);
265
+ assert.equal(service.setConfig(h.ctx, { ...state.settings, enabled: true },
266
+ { confirmRecovery: "yes" as unknown as boolean }), false); assert.equal(h.writes, 0);
267
+ assert.equal(service.setConfig(h.ctx, { ...state.settings, enabled: true }, { confirmRecovery: true }), true);
268
+ assert.equal(service.restorePreset(h.ctx), true); assert.equal(service.getState().settings.enabled, true);
269
+ assert.equal(service.getState().supportedEvents.includes("session.shutdown"), false);
270
+ } finally { h.retire(); }
271
+ });
272
+
273
+ test("facade is disposable even for same-id restart", async () => {
274
+ const h = harness(); try {
275
+ await h.start(); const old = getNotificationService()!;
276
+ await h.emit("session_shutdown", { reason: "reload" }); await h.start();
277
+ assert.equal(old.preview(h.ctx, "builtin:success"), false);
278
+ assert.equal(old.setConfig(h.ctx, old.getState().settings), false); assert.equal(h.writes, 0);
279
+ } finally { h.retire(); }
280
+ });
281
+
282
+ test("same process factory rebuild preserves producer highwater with no duplicate replay", async () => {
283
+ const a = harness(); await a.start(); a.source(290); await drain(); assert.equal(a.played.length, 1);
284
+ const b = harness({ sessionId: a.id }); try {
285
+ await b.start(); b.source(290); b.source(1); await drain(); assert.equal(b.played.length, 0);
286
+ b.source(291); await drain(); assert.equal(b.played.length, 1);
287
+ } finally { a.retire(); b.retire(); }
288
+ });
289
+
290
+ test("startup occurs once per process, never resume/fork history or quit", async () => {
291
+ const h = harness(); try {
292
+ await h.start("startup"); assert.equal(h.played.length, 1);
293
+ for (const reason of ["reload", "resume", "fork", "new", "startup"]) {
294
+ await h.emit("session_shutdown", { reason: "quit" }); await h.start(reason);
295
+ }
296
+ assert.equal(h.played.length, 1);
297
+ const duplicateModule = await import(new URL("../lib/notification-service.ts?runtime-copy", import.meta.url).href);
298
+ assert.equal(duplicateModule.getNotificationService(), getNotificationService());
299
+ } finally { h.retire(); }
300
+ });
301
+
302
+ test("slow availability abort plus late rejection never spawns or notifies after shutdown", async () => {
303
+ const h = harness({ slowProbe: true }); try {
304
+ await h.start(); h.source(1); await drain(); assert.equal(h.probes, 1);
305
+ await h.emit("session_shutdown", { reason: "quit" }); h.probeReleases[0](); await drain();
306
+ assert.equal(h.attempts, 0); assert.equal(h.timers.size, 0); assert.deepEqual(h.notes, []);
307
+ } finally { h.retire(); }
308
+ const late = harness({ deferred: true, fail: true }); try {
309
+ await late.start(); late.source(1); await drain(); await late.emit("session_shutdown", { reason: "quit" });
310
+ late.releases[0](); await drain(); assert.equal(late.played.length, 0); assert.deepEqual(late.notes, []);
311
+ } finally { late.retire(); }
312
+ });
313
+
314
+ test("active playback abort retains serial reservation through close even across factory collision", async () => {
315
+ const a = harness({ holdPlayback: true }); await a.start(); a.source(1); await drain(); assert.equal(a.played.length, 1);
316
+ const b = harness(); try {
317
+ await b.start(); assert.equal(a.signals[0].aborted, true);
318
+ assert.equal(getNotificationService()!.preview(b.ctx, "builtin:success"), false);
319
+ b.source(1); await drain(); assert.equal(b.attempts, 0);
320
+ a.releases[0](); await drain(); b.source(2); await drain(); assert.equal(b.played.length, 1);
321
+ } finally { a.retire(); b.retire(); }
322
+ });
323
+
324
+ test("settings change cancels pending/stale mappings; mute consumes events with no replay", async () => {
325
+ const h = harness(); try {
326
+ await h.start(); const service = getNotificationService()!; const settings = service.getState().settings;
327
+ settings.audio.coalesceWindowMs = 300; service.setConfig(h.ctx, settings);
328
+ h.source(1); assert.equal(h.timers.size, 1); assert.equal(service.preview(h.ctx, "builtin:success"), false);
329
+ settings.audio.events["subagent.completed"] = "builtin:error"; service.setConfig(h.ctx, settings);
330
+ assert.equal(h.timers.size, 0); h.advance(300); await drain(); assert.equal(h.played.length, 0);
331
+ h.source(2); service.setMuted(h.ctx, true); h.advance(300); service.setMuted(h.ctx, false); h.source(2); await drain(); assert.equal(h.played.length, 0);
332
+ h.source(3); h.advance(300); await drain(); assert.deepEqual(h.played, ["builtin:error"]);
333
+ settings.enabled = false; service.setConfig(h.ctx, settings); service.restorePreset(h.ctx); assert.equal(service.getState().settings.enabled, false);
334
+ } finally { h.retire(); }
335
+ });
336
+
337
+ test("local diagnostics are once/category, generic only, outside-context facade silent", async () => {
338
+ for (const unavailable of [true, false]) {
339
+ const h = harness({ unavailable, fail: !unavailable }); try {
340
+ await h.start(); h.source(1); await drain(); h.advance(6000); h.source(2); await drain();
341
+ assert.equal(h.notes.length, 1); assert.equal(h.notes[0].includes("private backend detail"), false);
342
+ const service = getNotificationService()!;
343
+ const foreign = { ...h.ctx, mode: "rpc" as const };
344
+ assert.equal(service.preview(foreign, "builtin:success"), false);
345
+ assert.equal(service.setConfig(foreign, service.getState().settings), false);
346
+ const probes = h.probes; assert.equal(await service.availability(foreign), "unavailable"); assert.equal(h.probes, probes);
347
+ const wrongSession = { ...h.ctx, sessionManager: { ...h.ctx.sessionManager, getSessionId: () => "foreign" } };
348
+ assert.equal(service.preview(wrongSession, "builtin:success"), false);
349
+ } finally { h.retire(); }
350
+ }
351
+ });
352
+
353
+ test("supported subagent states are all live-wired, waiting is not main attention", async () => {
354
+ const h = harness(); try {
355
+ await h.start(); let sequence = 0;
356
+ for (const status of ["queued", "running", "waiting", "completed", "failed", "cancelled", "timed_out"]) {
357
+ h.source(++sequence, { status, event: `subagent.${status}` }); await drain();
358
+ }
359
+ assert.equal(h.played.length, 7); assert.equal(h.played[4], "builtin:error");
360
+ } finally { h.retire(); }
361
+ });
362
+
363
+ test("new session invalidates old context, permits and pending without replay", async () => {
364
+ const h = harness(); try {
365
+ await h.start(); const old = getNotificationService()!;
366
+ await h.emit("session_shutdown", { reason: "new" });
367
+ const fresh = { ...h.ctx, sessionManager: { ...h.ctx.sessionManager, getSessionId: () => "fresh-parent" } };
368
+ await h.emit("session_start", { reason: "new" }, fresh);
369
+ const service = getNotificationService()!; service.setMuted(fresh, false);
370
+ assert.equal(old.preview(h.ctx, "builtin:success"), false); h.source(100); await drain(); assert.equal(h.played.length, 0);
371
+ assert.equal(service.preview(h.ctx, "builtin:success"), false);
372
+ assert.equal(service.preview(fresh, "builtin:success"), true); await drain(); assert.equal(h.played.length, 1);
373
+ } finally { h.retire(); }
374
+ });
375
+
376
+ test("legacy native handlers without unsubscribe become inert when their lease is retired", async () => {
377
+ const a = harness({ noUnsubscribe: true }); await a.start(); const b = harness(); try {
378
+ await b.start(); await a.emit("session_start", { reason: "startup" }); await a.emit("agent_start");
379
+ await a.emit("agent_before_settle", { outcome: "completed" }); await a.emit("agent_settled");
380
+ await a.emit("session_shutdown", { reason: "quit" }); assert.equal(a.attempts, 0);
381
+ b.source(1); await drain(); assert.equal(b.played.length, 1); assert.equal(listeners(a.bus), 0);
382
+ } finally { a.retire(); b.retire(); }
383
+ });
384
+
385
+ // Byte-exact fixtures so assignment-capability rejection is verified without external encoders.
386
+ function oggPage(headerType: number, granule: number, body: Buffer, seqno = 0): Buffer {
387
+ const segments = body.length === 0 ? 0 : Math.ceil(body.length / 255);
388
+ const table = Buffer.alloc(segments); let remaining = body.length;
389
+ for (let i = 0; i < segments; i++) { table[i] = Math.min(255, remaining); remaining -= table[i]!; }
390
+ const head = Buffer.alloc(27); head.write("OggS", 0); head[4] = 0; head[5] = headerType;
391
+ head.writeUInt32LE(granule >>> 0, 6); head.writeUInt32LE(0, 10); head.writeUInt32LE(0, 14);
392
+ head.writeUInt32LE(seqno, 18); head.writeUInt32LE(0, 22); head[26] = segments;
393
+ return Buffer.concat([head, table, body]);
394
+ }
395
+ function vorbisId(): Buffer {
396
+ const b = Buffer.alloc(30); b[0] = 0x01; b.write("vorbis", 1); b[11] = 1; b.writeUInt32LE(8000, 12); b[29] = 0x01; return b;
397
+ }
398
+ function oggVorbis(): Buffer {
399
+ return Buffer.concat([oggPage(0x02, 0, vorbisId(), 0), oggPage(0x00, 8000, Buffer.alloc(0), 1)]);
400
+ }
401
+ function flac(): Buffer {
402
+ const sampleRate = 8000; const totalSamples = 8000; const b = Buffer.alloc(46);
403
+ b.write("fLaC", 0); b[4] = 0x80; b[7] = 34; const s = 8;
404
+ b[s + 10] = (sampleRate >> 12) & 0xff; b[s + 11] = (sampleRate >> 4) & 0xff; b[s + 12] = (sampleRate & 0x0f) << 4;
405
+ b[s + 13] = 0xf0; b.writeUInt32BE(totalSamples, s + 14); return b;
406
+ }
407
+
408
+ for (const capabilities of [["wav"], ["wav", "ogg", "flac"]] as const) {
409
+ test(`validateFile gates OGG/FLAC on the detected player capabilities ${JSON.stringify(capabilities)}`, async () => {
410
+ const dir = await mkdtemp(join(tmpdir(), "gentle-notification-validate-"));
411
+ const wavPath = join(dir, "sound.wav"); const oggPath = join(dir, "sound.ogg"); const flacPath = join(dir, "sound.flac");
412
+ await writeFile(wavPath, Buffer.alloc(0)); await writeFile(oggPath, oggVorbis()); await writeFile(flacPath, flac());
413
+ const h = harness({ capabilities: new Set(capabilities) }); try {
414
+ await h.start(); const service = getNotificationService()!;
415
+ assert.equal(await service.validateFile(h.ctx, `file:${wavPath}`), false, "an empty file is never a valid sound");
416
+ const wavBytes = Buffer.alloc(44 + 4); wavBytes.write("RIFF", 0); wavBytes.writeUInt32LE(wavBytes.length - 8, 4);
417
+ wavBytes.write("WAVEfmt ", 8); wavBytes.writeUInt32LE(16, 16); wavBytes.writeUInt16LE(1, 20); wavBytes.writeUInt16LE(1, 22);
418
+ wavBytes.writeUInt32LE(8000, 24); wavBytes.writeUInt32LE(16000, 28); wavBytes.writeUInt16LE(2, 32);
419
+ wavBytes.writeUInt16LE(16, 34); wavBytes.write("data", 36); wavBytes.writeUInt32LE(4, 40);
420
+ await writeFile(wavPath, wavBytes);
421
+ assert.equal(await service.validateFile(h.ctx, `file:${wavPath}`), true, "WAV stays accepted for every player");
422
+ const supports = new Set(capabilities);
423
+ assert.equal(await service.validateFile(h.ctx, `file:${oggPath}`), supports.has("ogg"), "OGG requires the player capability");
424
+ assert.equal(await service.validateFile(h.ctx, `file:${flacPath}`), supports.has("flac"), "FLAC requires the player capability");
425
+ } finally { h.retire(); await rm(dir, { recursive: true, force: true }); }
426
+ });
427
+ }