gentle-pi 3.7.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  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 +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AgentBeforeSettleEvent, ExtensionAPI, TurnEndEvent } from "@earendil-works/pi-coding-agent";
3
+ import { TASK_STATUS, type TaskStore, type TaskStatusChange, type TaskStatus } from "./agents-protocol.ts";
4
+ import { NOTIFICATION_EVENTS, type NotificationEvent } from "./notification-policy.ts";
5
+ import type { NotificationOccurrence } from "./notification-scheduler.ts";
6
+
7
+ /** Content-free bus; producers never depend on an audio player or UI. */
8
+ export const NOTIFICATION_SOURCE_EVENT = "gentle-pi:notification:source";
9
+ export const NOTIFICATION_ATTENTION_EVENT = "herdr:blocked";
10
+ interface SourceOccurrence extends NotificationOccurrence { producerId: string }
11
+ export type NotificationSourceEvent =
12
+ | (SourceOccurrence & { source: "main"; event: Extract<NotificationEvent, `agent.${string}`> })
13
+ | (SourceOccurrence & { source: "session"; event: Extract<NotificationEvent, `session.${string}`> })
14
+ | (SourceOccurrence & { source: "subagent"; event: `subagent.${TaskStatus}`; parentSessionId: string; taskId: string; status: TaskStatus });
15
+
16
+ function record(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function identity(value: unknown): value is string {
20
+ return typeof value === "string" && value.length > 0 && value.length <= 512;
21
+ }
22
+ /** Validate untrusted bus payloads before forwarding to scheduler; never inspect transcript content. */
23
+ export function isNotificationSourceEvent(value: unknown): value is NotificationSourceEvent {
24
+ if (!record(value) || !identity(value.producerId) || !identity(value.sessionId) || !identity(value.runId)
25
+ || !Number.isSafeInteger(value.sequence) || (value.sequence as number) < 1
26
+ || typeof value.occurredAt !== "number" || !Number.isFinite(value.occurredAt) || value.occurredAt < 0
27
+ || !NOTIFICATION_EVENTS.includes(value.event as NotificationEvent)) return false;
28
+ if (value.source === "main") return (value.event as string).startsWith("agent.");
29
+ if (value.source === "session") return (value.event as string).startsWith("session.");
30
+ return value.source === "subagent" && value.parentSessionId === value.sessionId && identity(value.taskId)
31
+ && Object.values(TASK_STATUS).includes(value.status as TaskStatus) && value.event === `subagent.${value.status}`;
32
+ }
33
+
34
+ export function subagentNotificationEvent(change: TaskStatusChange, occurredAt: number): NotificationSourceEvent {
35
+ return { source: "subagent", producerId: change.producerId, sessionId: change.sessionId,
36
+ parentSessionId: change.parentSessionId, taskId: change.taskId, status: change.status,
37
+ // Scheduler dedupes session/run/sequence. Include instance identity across reloads.
38
+ runId: `${change.producerId}:${change.runId}`, sequence: change.sequence, event: `subagent.${change.status}`, occurredAt };
39
+ }
40
+
41
+ /** Sole runtime caller is gentle-agents. Subscribe before launches, stop before cancellation. */
42
+ export function publishTaskStatusChanges(store: TaskStore, events: Pick<ExtensionAPI["events"], "emit">, now: () => number): () => void {
43
+ return store.subscribeStatusChanges(change => {
44
+ events.emit(NOTIFICATION_SOURCE_EVENT, subagentNotificationEvent(change, now()));
45
+ });
46
+ }
47
+
48
+ export function sessionNotificationEvent(type: "session_start" | "session_shutdown", reason: string): NotificationEvent | undefined {
49
+ return type === "session_start" && reason === "startup" ? "session.started"
50
+ : type === "session_shutdown" && reason === "quit" ? "session.shutdown" : undefined;
51
+ }
52
+
53
+ type Outcome = AgentBeforeSettleEvent["outcome"] | TurnEndEvent["outcome"];
54
+ function outcomeEvent(outcome: unknown): Extract<NotificationEvent, `agent.${string}`> | undefined {
55
+ return outcome === "completed" ? "agent.completed" : outcome === "error" ? "agent.failed"
56
+ : outcome === "aborted" ? "agent.cancelled" : undefined;
57
+ }
58
+ interface MainRun {
59
+ sessionId: string;
60
+ runId: string;
61
+ outcome?: Extract<NotificationEvent, `agent.${string}`>;
62
+ boundary: boolean;
63
+ blocked: boolean;
64
+ }
65
+ /** Pure adapter, NOT a Pi hook registrar. Native events have no runId: owner captures
66
+ * the begin token synchronously and uses it for every boundary; stale callbacks must
67
+ * retain their old token, never look up a replacement run after an await.
68
+ * agent_end is deliberately not an input. reset on EVERY shutdown, including reload.
69
+ */
70
+ export class MainNotificationRun {
71
+ private readonly producerId = randomUUID();
72
+ private sequence = 0;
73
+ private run?: MainRun;
74
+ private readonly now: () => number;
75
+ constructor(now: () => number) { this.now = now; }
76
+
77
+ begin(sessionId: string): NotificationSourceEvent {
78
+ this.run = { sessionId, runId: `${this.producerId}:${randomUUID()}`, boundary: false, blocked: false };
79
+ return this.emit(this.run, "agent.started");
80
+ }
81
+ reset(): void { this.run = undefined; }
82
+
83
+ beforeSettle(sessionId: string, runId: string, outcome: Outcome | unknown): void {
84
+ const run = this.current(sessionId, runId);
85
+ if (!run) return;
86
+ run.boundary = true;
87
+ run.outcome = outcomeEvent(outcome);
88
+ }
89
+ turnEnd(sessionId: string, runId: string, outcome: Outcome | unknown): void {
90
+ const run = this.current(sessionId, runId);
91
+ if (run && !run.boundary) run.outcome = outcomeEvent(outcome);
92
+ }
93
+ settled(sessionId: string, runId: string): NotificationSourceEvent | undefined {
94
+ const run = this.current(sessionId, runId);
95
+ if (!run) return undefined;
96
+ this.run = undefined;
97
+ return run.outcome ? this.emit(run, run.outcome) : undefined;
98
+ }
99
+ /** Only feed the verified herdr aggregate channel, never ui_prompt_start. */
100
+ blocked(sessionId: string, runId: string, payload: unknown): NotificationSourceEvent | undefined {
101
+ const run = this.current(sessionId, runId);
102
+ if (!run || !record(payload) || typeof payload.active !== "boolean" || payload.active === run.blocked) return undefined;
103
+ run.blocked = payload.active;
104
+ return payload.active ? this.emit(run, "agent.attention") : undefined;
105
+ }
106
+ private current(sessionId: string, runId: string): MainRun | undefined {
107
+ return this.run?.sessionId === sessionId && this.run.runId === runId ? this.run : undefined;
108
+ }
109
+ private emit(run: MainRun, event: Extract<NotificationEvent, `agent.${string}`>): NotificationSourceEvent {
110
+ return { source: "main", producerId: this.producerId, sessionId: run.sessionId, runId: run.runId,
111
+ sequence: ++this.sequence, event, occurredAt: this.now() };
112
+ }
113
+ }
@@ -0,0 +1,148 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join, posix, win32 } from "node:path";
4
+ import { gentlePiConfigHome } from "./agent-home.ts";
5
+ import type { TaskStatus } from "./agents-protocol.ts";
6
+
7
+ export const NOTIFICATION_SCHEMA = "gentle-shell.notifications/v1";
8
+ /** Contract vocabulary, NOT proof of runtime support. Adapters must verify producers before UI exposure. */
9
+ const SUBAGENT_STATUSES = ["queued", "running", "waiting", "completed", "failed", "cancelled", "timed_out"] as const satisfies readonly TaskStatus[];
10
+ export type NotificationEvent = "session.started" | "session.shutdown" | "context.high"
11
+ | `agent.${"started" | "completed" | "failed" | "cancelled" | "attention"}` | `subagent.${TaskStatus}`;
12
+ export const NOTIFICATION_EVENTS: readonly NotificationEvent[] = [
13
+ "session.started", "session.shutdown", "agent.started", "agent.completed", "agent.failed", "agent.cancelled", "agent.attention",
14
+ ...SUBAGENT_STATUSES.map(status => `subagent.${status}` as const),
15
+ // Context is a main-process runtime signal, not a producer bus origin.
16
+ "context.high",
17
+ ];
18
+ export const BUILTIN_NOTIFICATION_IDS = ["success", "error", "attention"] as const;
19
+ export type NotificationSound = `builtin:${typeof BUILTIN_NOTIFICATION_IDS[number]}` | `file:${string}` | null;
20
+ /** Priority belongs to the event, never the selected sound. waiting does not imply attention. */
21
+ export const NOTIFICATION_PRIORITY: Readonly<Record<NotificationEvent, number>> = Object.fromEntries(
22
+ NOTIFICATION_EVENTS.map(event => [event, event.endsWith(".failed") || event.endsWith(".timed_out") ? 3
23
+ : event === "agent.attention" || event === "context.high" ? 2 : event.endsWith(".completed") ? 1 : 0]),
24
+ ) as Record<NotificationEvent, number>;
25
+ /** Integer milliseconds, inclusive bounds. Zero permits disabling a timing window. TTL belongs to scheduler. */
26
+ export const NOTIFICATION_TIMING_LIMITS = {
27
+ minimumIntervalMs: { min: 0, max: 60000 },
28
+ coalesceWindowMs: { min: 0, max: 2000 },
29
+ } as const;
30
+ export interface NotificationSettings {
31
+ enabled: boolean;
32
+ audio: {
33
+ backend: "auto";
34
+ minimumIntervalMs: number;
35
+ coalesceWindowMs: number;
36
+ events: Partial<Record<NotificationEvent, NotificationSound>>;
37
+ };
38
+ }
39
+ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = Object.freeze({
40
+ enabled: false,
41
+ audio: Object.freeze({
42
+ backend: "auto" as const, minimumIntervalMs: 1000, coalesceWindowMs: 300,
43
+ events: Object.freeze(Object.fromEntries(NOTIFICATION_EVENTS.map(event => [event,
44
+ NOTIFICATION_PRIORITY[event] === 3 ? "builtin:error" : NOTIFICATION_PRIORITY[event] === 2 ? "builtin:attention"
45
+ : NOTIFICATION_PRIORITY[event] === 1 ? "builtin:success" : null]))),
46
+ }),
47
+ });
48
+ export type NotificationPathFlavor = "posix" | "win32";
49
+ export interface NotificationIO {
50
+ readFile(path: string): string;
51
+ mkdir(path: string): void;
52
+ writeFile(path: string, content: string, options: { flag: "wx"; mode: number }): void;
53
+ rename(from: string, to: string): void;
54
+ unlink(path: string): void;
55
+ }
56
+ export interface NotificationOptions {
57
+ gentlePiConfigHome?: string;
58
+ pathFlavor?: NotificationPathFlavor;
59
+ io?: NotificationIO;
60
+ }
61
+ export interface NotificationResolution {
62
+ settings: NotificationSettings;
63
+ source: "global_file" | "default";
64
+ malformed: boolean;
65
+ readError: boolean;
66
+ globalFile: string;
67
+ }
68
+ const DEFAULT_IO: NotificationIO = {
69
+ readFile: path => readFileSync(path, "utf8"),
70
+ mkdir: path => { mkdirSync(path, { recursive: true }); },
71
+ writeFile: (path, content, options) => writeFileSync(path, content, options),
72
+ rename: renameSync, unlink: unlinkSync,
73
+ };
74
+ const nativeFlavor: NotificationPathFlavor = process.platform === "win32" ? "win32" : "posix";
75
+ function record(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+ function keysMatch(value: Record<string, unknown>, keys: readonly string[]): boolean {
79
+ return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
80
+ }
81
+ /** Syntax validation only: no stat, WAV parsing or backend detection. No expansion is ever performed. */
82
+ export function isNotificationSound(value: unknown, flavor: NotificationPathFlavor = nativeFlavor): value is NotificationSound {
83
+ if (value === null) return true;
84
+ if (typeof value !== "string") return false;
85
+ if (BUILTIN_NOTIFICATION_IDS.some(id => value === `builtin:${id}`)) return true;
86
+ if (!value.startsWith("file:")) return false;
87
+ const path = value.slice(5);
88
+ if (!path || /[\x00-\x1f\x7f$`]|%[^%]+%/.test(path) || path.includes("://")) return false;
89
+ // No network URLs/UNC/device paths, drive-relative paths or shell-home aliases.
90
+ return flavor === "win32"
91
+ ? /^[A-Za-z]:[\\/]/.test(path) && win32.isAbsolute(path) && !path.slice(2).includes(":")
92
+ : posix.isAbsolute(path) && !path.startsWith("//");
93
+ }
94
+ export function isNotificationSettings(value: unknown, flavor: NotificationPathFlavor = nativeFlavor): value is NotificationSettings {
95
+ if (!record(value) || !keysMatch(value, ["enabled", "audio"]) || typeof value.enabled !== "boolean") return false;
96
+ const audio = value.audio;
97
+ if (!record(audio) || !keysMatch(audio, ["backend", "minimumIntervalMs", "coalesceWindowMs", "events"]) || audio.backend !== "auto") return false;
98
+ for (const key of ["minimumIntervalMs", "coalesceWindowMs"] as const) {
99
+ const timing = audio[key]; const { min, max } = NOTIFICATION_TIMING_LIMITS[key];
100
+ if (typeof timing !== "number" || !Number.isInteger(timing) || timing < min || timing > max) return false;
101
+ }
102
+ return record(audio.events) && Object.entries(audio.events).every(([event, sound]) =>
103
+ NOTIFICATION_EVENTS.includes(event as NotificationEvent) && isNotificationSound(sound, flavor));
104
+ }
105
+ /** Never merge the preset on read: missing event keys remain silent, including future additions. */
106
+ export function parseNotificationSettingsFile(raw: string, flavor: NotificationPathFlavor = nativeFlavor): NotificationSettings | undefined {
107
+ try {
108
+ const value: unknown = JSON.parse(raw);
109
+ if (!record(value) || value.schema !== NOTIFICATION_SCHEMA) return undefined;
110
+ const { schema: _schema, ...settings } = value;
111
+ return isNotificationSettings(settings, flavor) ? settings : undefined;
112
+ } catch { return undefined; }
113
+ }
114
+ export function notificationSoundFor(settings: NotificationSettings, event: NotificationEvent): NotificationSound {
115
+ return settings.audio.events[event] ?? null;
116
+ }
117
+ /** Explicit user action only; enabled is preserved, even when currently disabled. */
118
+ export function restoreNotificationPreset(settings: NotificationSettings): NotificationSettings {
119
+ return { ...structuredClone(DEFAULT_NOTIFICATION_SETTINGS), enabled: settings.enabled };
120
+ }
121
+ export function resolveNotificationSettings(options: NotificationOptions = {}): NotificationResolution {
122
+ const globalFile = join(options.gentlePiConfigHome || gentlePiConfigHome(), "notifications.json");
123
+ try {
124
+ const settings = parseNotificationSettingsFile((options.io ?? DEFAULT_IO).readFile(globalFile), options.pathFlavor);
125
+ return { settings: settings ?? structuredClone(DEFAULT_NOTIFICATION_SETTINGS), source: "global_file", malformed: settings === undefined, readError: false, globalFile };
126
+ } catch (error) {
127
+ const missing = record(error) && error.code === "ENOENT";
128
+ return { settings: structuredClone(DEFAULT_NOTIFICATION_SETTINGS), source: missing ? "default" : "global_file", malformed: false, readError: !missing, globalFile };
129
+ }
130
+ }
131
+ /** Explicit persistence operation; caller owns confirmation before replacing malformed/unreadable config. */
132
+ export function writeNotificationSettings(settings: NotificationSettings, options: NotificationOptions = {}): string {
133
+ if (!isNotificationSettings(settings, options.pathFlavor)) throw new TypeError("Invalid notification settings");
134
+ const io = options.io ?? DEFAULT_IO;
135
+ const home = options.gentlePiConfigHome || gentlePiConfigHome();
136
+ const path = join(home, "notifications.json");
137
+ const temporary = `${path}.${randomUUID()}.tmp`;
138
+ io.mkdir(home);
139
+ let created = false;
140
+ try {
141
+ io.writeFile(temporary, `${JSON.stringify({ schema: NOTIFICATION_SCHEMA, ...settings })}\n`, { flag: "wx", mode: 0o600 });
142
+ created = true;
143
+ io.rename(temporary, path);
144
+ } finally {
145
+ if (created) { try { io.unlink(temporary); } catch { /* Rename consumed it; preserve original IO error. */ } }
146
+ }
147
+ return path;
148
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * PulseAudio native-protocol v13 local client: Unix transport, AUTH (zero
3
+ * cookie fallback for trusted local peer credentials), SET_CLIENT_NAME and a
4
+ * read-only GET_SERVER_INFO capability probe. Original MIT (repo-licensed)
5
+ * implementation derived from the official protocol declaration only; no
6
+ * PulseAudio/LGPL implementation code is copied.
7
+ *
8
+ * Reference (read-only, PulseAudio v17.0): PROTOCOL, src/pulsecore/native-common.h,
9
+ * src/pulsecore/protocol-native.c, src/pulse/context.c. See
10
+ * odd/tasks/native-audio-backend.md for the verified wire tables.
11
+ *
12
+ * Lazy by construction: import and construction perform no IO. Only an explicit
13
+ * connect()/probe() opens the Unix socket. No stream creation, no samples, no
14
+ * ancillary FDs/SHM, no TCP, no cookies logged or written.
15
+ */
16
+ import { constants } from "node:fs";
17
+ import { open, type FileHandle } from "node:fs/promises";
18
+ import { connect as netConnect, type Socket } from "node:net";
19
+ import { userInfo } from "node:os";
20
+ import {
21
+ CONTROL_CHANNEL, MAX_CONTROL_PAYLOAD, NATIVE_PROTOCOL_VERSION, PulseCommand, PulseTagWriter,
22
+ boundedPulseFrameDecoder, boundedPulseTagReader, boundedPulseTagWriter, encodePulseFrame,
23
+ type PulseFrame, type PulseTagReader,
24
+ } from "./notification-pulse-protocol.ts";
25
+
26
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 900;
27
+ /** Cap for one control request/DRAIN: 10 s maximum validated duration plus a 1 s termination margin. */
28
+ export const MAX_REQUEST_TIMEOUT_MS = 11000;
29
+ export const MAX_PENDING_REQUESTS = 32;
30
+ export const PULSE_COOKIE_BYTES = 256;
31
+
32
+ /** Server-to-client notifications that are safe to ignore silently. */
33
+ const BENIGN_EVENTS = new Set<number>([62, 63, 64, 65, 76, 77, 78, 79, 86, 94, 95]);
34
+
35
+ export class PulseCookieError extends Error {
36
+ constructor() { super("Pulse client: invalid cookie"); this.name = "PulseCookieError"; }
37
+ }
38
+
39
+ function abortError(): Error { const error = new Error("Pulse client: aborted"); error.name = "AbortError"; return error; }
40
+ function unrefTimer(handle: ReturnType<typeof setTimeout> | number): void { (handle as { unref?: () => void }).unref?.(); }
41
+
42
+ /** Absolute literal only: no URLs, TCP, lists, CWD/`~`/env expansion, spaces, commas, NUL or control chars. */
43
+ function hasControlOrSeparator(value: string): boolean {
44
+ for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index); if (code <= 0x1f || code === 0x7f) return true; }
45
+ return /[\s,~$%]/.test(value);
46
+ }
47
+ function literalAbsolutePath(value: unknown): string {
48
+ if (typeof value !== "string" || !value.startsWith("/") || hasControlOrSeparator(value)
49
+ || value.split("/").some(segment => segment === "." || segment === ".."))
50
+ throw new Error("Pulse client: invalid path");
51
+ return value;
52
+ }
53
+
54
+ function currentUid(): number {
55
+ try { return userInfo().uid; } catch { throw new Error("Pulse client: no user id"); }
56
+ }
57
+
58
+ export function resolvePulseSocketPath(env: NodeJS.ProcessEnv = {}, uid?: number): string {
59
+ const server = env.PULSE_SERVER;
60
+ if (server !== undefined && server !== "") {
61
+ if (!server.startsWith("unix:") && !server.startsWith("/")) throw new Error("Pulse client: unsupported server address");
62
+ return literalAbsolutePath(server.startsWith("unix:") ? server.slice(5) : server);
63
+ }
64
+ const runtime = env.XDG_RUNTIME_DIR;
65
+ if (runtime) return literalAbsolutePath(`${runtime}/pulse/native`);
66
+ return `/run/user/${uid ?? currentUid()}/pulse/native`;
67
+ }
68
+
69
+ export function resolvePulseCookiePath(env: NodeJS.ProcessEnv = {}): string | undefined {
70
+ const cookie = env.PULSE_COOKIE;
71
+ if (cookie) return literalAbsolutePath(cookie);
72
+ const configHome = env.XDG_CONFIG_HOME;
73
+ if (configHome) return `${literalAbsolutePath(configHome)}/pulse/cookie`;
74
+ const home = env.HOME;
75
+ if (home) return `${home}/.config/pulse/cookie`;
76
+ return undefined;
77
+ }
78
+
79
+ /** Missing file (ENOENT only) yields a zero cookie; every other failure is a cookie error. */
80
+ export async function loadPulseCookie(path: string | undefined, signal?: AbortSignal): Promise<Buffer> {
81
+ if (signal?.aborted) throw abortError();
82
+ if (!path) return Buffer.alloc(PULSE_COOKIE_BYTES);
83
+ let handle: FileHandle;
84
+ try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
85
+ catch (error) {
86
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return Buffer.alloc(PULSE_COOKIE_BYTES);
87
+ throw new PulseCookieError();
88
+ }
89
+ try {
90
+ if (signal?.aborted) throw abortError();
91
+ const stat = await handle.stat();
92
+ if (!stat.isFile() || stat.size !== PULSE_COOKIE_BYTES) throw new PulseCookieError();
93
+ const buffer = Buffer.alloc(PULSE_COOKIE_BYTES + 1);
94
+ const { bytesRead } = await handle.read(buffer, 0, PULSE_COOKIE_BYTES + 1, 0);
95
+ if (signal?.aborted) throw abortError();
96
+ if (bytesRead !== PULSE_COOKIE_BYTES) throw new PulseCookieError();
97
+ return Buffer.from(buffer.subarray(0, PULSE_COOKIE_BYTES));
98
+ } finally {
99
+ await handle.close().catch(() => { /* close failure must not mask the outcome */ });
100
+ }
101
+ }
102
+
103
+ export interface PulseClientOptions {
104
+ env?: NodeJS.ProcessEnv;
105
+ uid?: number;
106
+ socketPath?: string;
107
+ cookiePath?: string | null;
108
+ cookie?: Buffer;
109
+ connectTimeoutMs?: number;
110
+ requestTimeoutMs?: number;
111
+ setTimeout?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout> | number;
112
+ clearTimeout?: (handle: ReturnType<typeof setTimeout> | number) => void;
113
+ }
114
+ export interface PulseProbeResult { readonly available: boolean; readonly formats: readonly ("wav")[]; }
115
+ export interface PulseServerInfo {
116
+ readonly name: string;
117
+ readonly version: string;
118
+ readonly defaultSink: string | null;
119
+ readonly defaultSource: string | null;
120
+ readonly cookie: number;
121
+ }
122
+ interface PendingRequest { resolve(reader: PulseTagReader): void; reject(error: Error): void; cleanup(): void; }
123
+
124
+ export class PulseClient {
125
+ private readonly options: PulseClientOptions;
126
+ private readonly decoder = boundedPulseFrameDecoder();
127
+ private readonly pending = new Map<number, PendingRequest>();
128
+ private readonly listeners = new Set<(command: number, reader: PulseTagReader) => void>();
129
+ private socket?: Socket;
130
+ private tagCounter = 0;
131
+ private failed?: Error;
132
+ private closed = false;
133
+ private closing = false;
134
+ private negotiatedVersion?: number;
135
+ private info?: PulseServerInfo;
136
+ private readonly setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout> | number;
137
+ private readonly clearTimer: (handle: ReturnType<typeof setTimeout> | number) => void;
138
+ private readonly sendWaiters = new Set<(error: Error) => void>();
139
+
140
+ constructor(options: PulseClientOptions = {}) {
141
+ this.options = { ...options };
142
+ this.setTimer = options.setTimeout ?? ((callback, ms) => setTimeout(callback, ms));
143
+ this.clearTimer = options.clearTimeout ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>));
144
+ }
145
+
146
+ get protocolVersion(): number | undefined { return this.negotiatedVersion; }
147
+ get serverDetails(): PulseServerInfo | undefined { return this.info; }
148
+
149
+ /** Private callback registry; no EventEmitter, no global listeners. */
150
+ onEvent(listener: (command: number, reader: PulseTagReader) => void): () => void {
151
+ this.listeners.add(listener);
152
+ return () => { this.listeners.delete(listener); };
153
+ }
154
+
155
+ async connect(signal?: AbortSignal): Promise<void> {
156
+ if (this.socket) throw new Error("Pulse client: already connected");
157
+ if (signal?.aborted) throw abortError();
158
+ const path = this.options.socketPath ? literalAbsolutePath(this.options.socketPath)
159
+ : resolvePulseSocketPath(this.options.env ?? {}, this.options.uid);
160
+ const socket = netConnect({ path });
161
+ this.socket = socket;
162
+ try {
163
+ await new Promise<void>((resolve, reject) => {
164
+ let settled = false;
165
+ const cleanup = () => { this.clearTimer(timer); socket.off("connect", onConnect); socket.off("error", onError); signal?.removeEventListener("abort", onAbort); };
166
+ const onConnect = () => { if (!settled) { settled = true; cleanup(); resolve(); } };
167
+ const onError = () => { if (!settled) { settled = true; cleanup(); reject(new Error("Pulse client: connection failed")); } };
168
+ const onAbort = () => { if (!settled) { settled = true; cleanup(); socket.destroy(); reject(abortError()); } };
169
+ const timer = this.setTimer(() => { if (!settled) { settled = true; cleanup(); socket.destroy(); reject(new Error("Pulse client: connection timed out")); } }, this.options.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
170
+ unrefTimer(timer);
171
+ socket.once("connect", onConnect); socket.once("error", onError);
172
+ signal?.addEventListener("abort", onAbort, { once: true });
173
+ if (signal?.aborted) onAbort();
174
+ });
175
+ } catch (error) {
176
+ this.socket = undefined; this.failed = error as Error; socket.destroy(); throw error;
177
+ }
178
+ socket.setNoDelay(true);
179
+ socket.on("data", chunk => this.handleData(chunk));
180
+ socket.on("error", () => this.fail(new Error("Pulse client: connection error")));
181
+ socket.on("close", () => this.fail(new Error("Pulse client: connection closed")));
182
+ }
183
+
184
+ private handleData(chunk: Buffer): void {
185
+ try { for (const frame of this.decoder.push(chunk)) this.dispatch(frame); }
186
+ catch { this.fail(new Error("Pulse client: protocol error")); }
187
+ }
188
+
189
+ private take(tag: number): PendingRequest | undefined {
190
+ const pending = this.pending.get(tag);
191
+ if (!pending) return undefined;
192
+ this.pending.delete(tag);
193
+ pending.cleanup();
194
+ return pending;
195
+ }
196
+
197
+ private dispatch(frame: PulseFrame): void {
198
+ if (frame.channel !== CONTROL_CHANNEL) { this.fail(new Error("Pulse client: unexpected frame channel")); return; }
199
+ let command: number; let tag: number; let reader: PulseTagReader;
200
+ try { const parsed = boundedPulseTagReader(frame.payload); command = parsed.u32(); tag = parsed.u32(); reader = parsed; }
201
+ catch { this.fail(new Error("Pulse client: malformed reply")); return; }
202
+ if (command === PulseCommand.REPLY) {
203
+ const pending = this.take(tag);
204
+ if (!pending) { this.fail(new Error("Pulse client: unmatched reply")); return; }
205
+ pending.resolve(reader); return;
206
+ }
207
+ if (command === PulseCommand.ERROR || command === PulseCommand.TIMEOUT) {
208
+ const pending = this.take(tag);
209
+ this.fail(new Error("Pulse client: server error"));
210
+ pending?.reject(new Error("Pulse client: server error")); return;
211
+ }
212
+ if (command === PulseCommand.REQUEST) {
213
+ for (const listener of this.listeners) { try { listener(command, reader); } catch { /* listener failures never break the transport */ } }
214
+ return;
215
+ }
216
+ if (BENIGN_EVENTS.has(command)) return;
217
+ this.fail(new Error("Pulse client: unexpected command"));
218
+ }
219
+
220
+ private fail(error: Error): void {
221
+ if (this.failed) return;
222
+ this.failed = error;
223
+ const pendings = [...this.pending.values()];
224
+ this.pending.clear();
225
+ for (const pending of pendings) { pending.cleanup(); pending.reject(error); }
226
+ this.listeners.clear();
227
+ const waiters = [...this.sendWaiters];
228
+ this.sendWaiters.clear();
229
+ for (const rejectWaiter of waiters) rejectWaiter(error);
230
+ if (this.socket && !this.closing && !this.socket.destroyed) this.socket.destroy();
231
+ }
232
+
233
+ request(command: number, args: Buffer | PulseTagWriter, signal?: AbortSignal, timeoutMs?: number): Promise<PulseTagReader> {
234
+ if (this.failed) return Promise.reject(this.failed);
235
+ if (!this.socket || this.closing || this.closed) return Promise.reject(new Error("Pulse client: not connected"));
236
+ if (this.pending.size >= MAX_PENDING_REQUESTS) return Promise.reject(new Error("Pulse client: too many pending requests"));
237
+ if (!Number.isInteger(command) || command < 0 || command > 0xffffffff) return Promise.reject(new Error("Pulse client: invalid command"));
238
+ const tag = (this.tagCounter = (this.tagCounter + 1) >>> 0);
239
+ const argsBuffer = args instanceof PulseTagWriter ? args.finish() : args;
240
+ const prefix = boundedPulseTagWriter().u32(command).u32(tag).finish();
241
+ if (prefix.length + argsBuffer.length > MAX_CONTROL_PAYLOAD) return Promise.reject(new Error("Pulse client: request too large"));
242
+ const payload = Buffer.concat([prefix, argsBuffer]);
243
+ const socket = this.socket;
244
+ return new Promise<PulseTagReader>((resolve, reject) => {
245
+ if (signal?.aborted) { reject(abortError()); return; }
246
+ let timer: ReturnType<typeof setTimeout> | number;
247
+ const onAbort = () => { const pending = this.take(tag); pending?.reject(abortError()); };
248
+ const pending: PendingRequest = {
249
+ resolve, reject,
250
+ cleanup: () => { this.clearTimer(timer); signal?.removeEventListener("abort", onAbort); },
251
+ };
252
+ const bounded = Math.min(timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS);
253
+ timer = this.setTimer(() => {
254
+ const active = this.take(tag);
255
+ if (active) { active.reject(new Error("Pulse client: request timed out")); this.fail(new Error("Pulse client: request timed out")); }
256
+ }, bounded);
257
+ unrefTimer(timer);
258
+ this.pending.set(tag, pending);
259
+ signal?.addEventListener("abort", onAbort, { once: true });
260
+ try { socket.write(encodePulseFrame({ channel: CONTROL_CHANNEL, offset: 0n, flags: 0 }, payload)); }
261
+ catch { this.take(tag)?.reject(new Error("Pulse client: write failed")); }
262
+ });
263
+ }
264
+
265
+ /** Reserved for N2 PCM writes; resolves once queued/drained or rejects on timeout/abort/close. */
266
+ sendDataFrame(channel: number, payload: Uint8Array, signal?: AbortSignal, timeoutMs?: number): Promise<void> {
267
+ if (this.failed) return Promise.reject(this.failed);
268
+ if (!this.socket || this.closing || this.closed) return Promise.reject(new Error("Pulse client: not connected"));
269
+ let encoded: Buffer;
270
+ try { encoded = encodePulseFrame({ channel, offset: 0n, flags: 0 }, payload); }
271
+ catch (error) { return Promise.reject(error as Error); }
272
+ const socket = this.socket;
273
+ if (socket.write(encoded)) return Promise.resolve();
274
+ return new Promise<void>((resolve, reject) => {
275
+ let settled = false;
276
+ const bounded = Math.min(timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS);
277
+ const finish = (error?: Error) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(); };
278
+ const onDrain = () => finish();
279
+ const onAbort = () => finish(abortError());
280
+ const waiter = (error: Error) => finish(error);
281
+ const timer = this.setTimer(() => finish(new Error("Pulse client: send timed out")), bounded);
282
+ unrefTimer(timer);
283
+ const cleanup = () => { this.clearTimer(timer); socket.off("drain", onDrain); this.sendWaiters.delete(waiter); signal?.removeEventListener("abort", onAbort); };
284
+ this.sendWaiters.add(waiter);
285
+ socket.once("drain", onDrain);
286
+ signal?.addEventListener("abort", onAbort, { once: true });
287
+ if (signal?.aborted) onAbort();
288
+ });
289
+ }
290
+
291
+ /** AUTH v13 with the given 256-byte cookie (zero bytes when no cookie exists), then SET_CLIENT_NAME. */
292
+ async authenticate(cookie: Buffer, options: { name?: string; type?: string } = {}, signal?: AbortSignal): Promise<number> {
293
+ if (!Buffer.isBuffer(cookie) || cookie.length !== PULSE_COOKIE_BYTES) throw new Error("Pulse client: invalid cookie length");
294
+ const reply = await this.request(PulseCommand.AUTH, boundedPulseTagWriter().u32(NATIVE_PROTOCOL_VERSION).arbitrary(cookie), signal);
295
+ const version = reply.u32() & 0xffff;
296
+ if (version < NATIVE_PROTOCOL_VERSION) throw new Error("Pulse client: unsupported server version");
297
+ reply.assertEOF();
298
+ this.negotiatedVersion = version;
299
+ const properties = boundedPulseTagWriter().proplist([
300
+ ["application.name", options.name ?? "gentle-shell"],
301
+ ["application.type", options.type ?? "notification"],
302
+ ]);
303
+ const named = await this.request(PulseCommand.SET_CLIENT_NAME, properties, signal);
304
+ named.u32(); // client_index
305
+ named.assertEOF();
306
+ return version;
307
+ }
308
+
309
+ /** v13 GET_SERVER_INFO: no channel_map at the negotiated version. `user`/`host` are discarded, never logged. */
310
+ async serverInfo(signal?: AbortSignal): Promise<PulseServerInfo> {
311
+ const reply = await this.request(PulseCommand.GET_SERVER_INFO, Buffer.alloc(0), signal);
312
+ const name = this.requiredString(reply);
313
+ const version = this.requiredString(reply);
314
+ reply.string();
315
+ reply.string();
316
+ reply.sampleSpec();
317
+ const defaultSink = reply.string();
318
+ const defaultSource = reply.string();
319
+ const cookie = reply.u32();
320
+ reply.assertEOF();
321
+ this.info = { name, version, defaultSink, defaultSource, cookie };
322
+ return this.info;
323
+ }
324
+
325
+ private requiredString(reader: PulseTagReader): string {
326
+ const value = reader.string();
327
+ if (value === null) throw new Error("Pulse client: missing server field");
328
+ return value;
329
+ }
330
+
331
+ private async loadCookie(signal?: AbortSignal): Promise<Buffer> {
332
+ if (this.options.cookie) return this.options.cookie;
333
+ const path = this.options.cookiePath === null ? undefined : (this.options.cookiePath ?? resolvePulseCookiePath(this.options.env ?? {}));
334
+ return loadPulseCookie(path, signal);
335
+ }
336
+
337
+ /** Read-only capability probe: no stream, no samples, no activation, no UI. */
338
+ async probe(signal?: AbortSignal): Promise<PulseProbeResult> {
339
+ try {
340
+ await this.connect(signal);
341
+ const cookie = this.options.cookie ?? await this.loadCookie(signal);
342
+ await this.authenticate(cookie, {}, signal);
343
+ const info = await this.serverInfo(signal);
344
+ const available = typeof info.defaultSink === "string" && info.defaultSink.length > 0;
345
+ return { available, formats: available ? (["wav"] as const) : ([] as const) };
346
+ } finally {
347
+ await this.close();
348
+ }
349
+ }
350
+
351
+ async close(): Promise<void> {
352
+ if (this.closed) return;
353
+ this.closed = true; this.closing = true;
354
+ const pendings = [...this.pending.values()];
355
+ this.pending.clear();
356
+ for (const pending of pendings) { pending.cleanup(); pending.reject(new Error("Pulse client: closed")); }
357
+ this.listeners.clear();
358
+ const waiters = [...this.sendWaiters];
359
+ this.sendWaiters.clear();
360
+ for (const rejectWaiter of waiters) rejectWaiter(new Error("Pulse client: closed"));
361
+ this.failed ??= new Error("Pulse client: closed");
362
+ const socket = this.socket;
363
+ this.socket = undefined;
364
+ if (!socket || socket.destroyed) return;
365
+ await new Promise<void>(resolve => { socket.once("close", () => resolve()); socket.destroy(); });
366
+ }
367
+ }