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,173 @@
1
+ import {
2
+ NOTIFICATION_PRIORITY, notificationSoundFor,
3
+ type NotificationEvent, type NotificationSettings, type NotificationSound,
4
+ } from "./notification-policy.ts";
5
+
6
+ export const NOTIFICATION_TTL_MS = 2000;
7
+ export const NOTIFICATION_DEDUPE_CAPACITY = 256;
8
+ export interface NotificationOccurrence {
9
+ sessionId: string;
10
+ /** Run or task identity; sequence identifies the occurrence, not the state name. */
11
+ runId: string;
12
+ sequence: number;
13
+ event: NotificationEvent;
14
+ /** Same monotonic clock domain as SchedulerOptions.now. */
15
+ occurredAt: number;
16
+ }
17
+ export interface PlaybackPermit {
18
+ /** Backend MUST call once, synchronously immediately before spawn, after async detection/validation.
19
+ * false means do not spawn. Backend owns bounded playback timeout and must honor abort,
20
+ * settling only after its process stops. Resolving early violates serial playback. */
21
+ start(): boolean;
22
+ }
23
+ export interface SchedulerOptions {
24
+ now(): number;
25
+ setTimeout(callback: () => void, delayMs: number): number;
26
+ clearTimeout(handle: number): void;
27
+ player(sound: Exclude<NotificationSound, null>, signal: AbortSignal, permit: PlaybackPermit): Promise<void>;
28
+ /** Local diagnostics only. Exceptions here are contained; at most one report per 5 seconds. */
29
+ onError?(error: unknown): void;
30
+ dedupeCapacity?: number;
31
+ }
32
+ interface Pending {
33
+ sound: Exclude<NotificationSound, null>;
34
+ priority: number;
35
+ expiresAt: number;
36
+ readyAt: number;
37
+ /** Time spent waiting for started playback does not consume the remaining TTL. */
38
+ pausedAt?: number;
39
+ }
40
+ interface Active {
41
+ controller: AbortController;
42
+ generation: number;
43
+ started: boolean;
44
+ expiryTimer?: number;
45
+ }
46
+ /** Owner supplies validated settings and TUI/parent guards (including preview). No IO or backend detection here. */
47
+ export class NotificationScheduler {
48
+ private settings?: NotificationSettings;
49
+ private muted = false;
50
+ private sessionId?: string;
51
+ private generation = 0;
52
+ private disposed = false;
53
+ private pending?: Pending;
54
+ private active?: Active;
55
+ private timer?: number;
56
+ private lastStart = -Infinity;
57
+ private lastError = -Infinity;
58
+ private readonly seen = new Set<string>();
59
+ private readonly capacity: number;
60
+ private readonly options: SchedulerOptions;
61
+
62
+ constructor(options: SchedulerOptions) {
63
+ this.options = options;
64
+ this.capacity = options.dedupeCapacity ?? NOTIFICATION_DEDUPE_CAPACITY;
65
+ if (!Number.isInteger(this.capacity) || this.capacity < 1) throw new RangeError("Invalid dedupe capacity");
66
+ }
67
+ configure(settings: NotificationSettings, muted: boolean): void {
68
+ if (this.disposed) return;
69
+ this.settings = structuredClone(settings); this.muted = muted;
70
+ if (!settings.enabled || muted) this.invalidate();
71
+ else this.schedule();
72
+ }
73
+ resetSession(sessionId: string): void {
74
+ if (this.disposed || this.sessionId === sessionId) return;
75
+ this.invalidate(); this.sessionId = sessionId; this.seen.clear();
76
+ }
77
+ /** Returns accepted-to-coalesce, NOT a promise of playback. Off/muted occurrences are consumed too.
78
+ * Selected sound is snapshotted here; priority always comes from the event, not the sound. */
79
+ enqueue(occurrence: NotificationOccurrence): boolean {
80
+ if (this.disposed || occurrence.sessionId !== this.sessionId) return false;
81
+ const key = JSON.stringify([occurrence.sessionId, occurrence.runId, occurrence.sequence]);
82
+ if (this.seen.has(key)) return false;
83
+ this.seen.add(key);
84
+ if (this.seen.size > this.capacity) this.seen.delete(this.seen.values().next().value!);
85
+ const now = this.options.now();
86
+ const expiresAt = occurrence.occurredAt + NOTIFICATION_TTL_MS;
87
+ if (!this.automaticEnabled() || !Number.isFinite(occurrence.occurredAt) || occurrence.occurredAt > now || now >= expiresAt) return false;
88
+ const sound = notificationSoundFor(this.settings!, occurrence.event);
89
+ if (sound === null) return false;
90
+ if (this.pending && this.pending.pausedAt === undefined && now >= this.pending.expiresAt) this.pending = undefined;
91
+ const priority = NOTIFICATION_PRIORITY[occurrence.event];
92
+ if (this.pending && priority < this.pending.priority) return false;
93
+ this.pending = { sound, priority, expiresAt,
94
+ readyAt: this.pending?.readyAt ?? now + this.settings!.audio.coalesceWindowMs,
95
+ pausedAt: this.active?.started ? now : undefined };
96
+ this.schedule(); return true;
97
+ }
98
+ /** Explicit, bypasses enabled/mute only. Busy when active OR pending; never flushes autos.
99
+ * Preview starts count toward automatic minimum interval, but previews themselves are not throttled. */
100
+ preview(sound: Exclude<NotificationSound, null>): boolean {
101
+ if (this.disposed || this.active || this.pending) return false;
102
+ this.play(sound); return true;
103
+ }
104
+ dispose(): void {
105
+ if (this.disposed) return;
106
+ this.disposed = true; this.invalidate(); this.seen.clear();
107
+ }
108
+ private automaticEnabled(): boolean { return !!this.settings?.enabled && !this.muted && !this.disposed; }
109
+ private clearTimer(): void {
110
+ if (this.timer !== undefined) this.options.clearTimeout(this.timer);
111
+ this.timer = undefined;
112
+ }
113
+ private invalidate(): void {
114
+ this.generation++; this.pending = undefined; this.clearTimer();
115
+ if (this.active) {
116
+ this.active.controller.abort();
117
+ if (this.active.expiryTimer !== undefined) this.options.clearTimeout(this.active.expiryTimer);
118
+ }
119
+ // Keep active reservation until backend settles, even after abort. Never overlap an uncooperative backend.
120
+ }
121
+ private schedule(): void {
122
+ this.clearTimer();
123
+ if (!this.pending || !this.automaticEnabled()) return;
124
+ const now = this.options.now();
125
+ if (this.pending.pausedAt !== undefined && !this.active?.started) {
126
+ this.pending.expiresAt += now - this.pending.pausedAt;
127
+ this.pending.pausedAt = undefined;
128
+ }
129
+ if (this.pending.pausedAt === undefined && now >= this.pending.expiresAt) { this.pending = undefined; return; }
130
+ if (this.active?.started) {
131
+ // Backend timeout bounds playback. Keep just one candidate, not an accumulating queue.
132
+ this.pending.pausedAt ??= now;
133
+ return;
134
+ }
135
+ const due = Math.max(this.pending.readyAt, this.lastStart + this.settings!.audio.minimumIntervalMs);
136
+ if (!this.active && now >= due) {
137
+ const pending = this.pending; this.pending = undefined;
138
+ this.play(pending.sound, pending.expiresAt); return;
139
+ }
140
+ const wake = this.active ? this.pending.expiresAt : Math.min(due, this.pending.expiresAt);
141
+ this.timer = this.options.setTimeout(() => { this.timer = undefined; this.schedule(); }, wake - now);
142
+ }
143
+ private play(sound: Exclude<NotificationSound, null>, expiresAt?: number): void {
144
+ const active: Active = { controller: new AbortController(), generation: this.generation, started: false };
145
+ this.active = active;
146
+ const permit: PlaybackPermit = { start: () => {
147
+ if (this.active !== active || active.started || active.controller.signal.aborted || this.disposed || active.generation !== this.generation
148
+ || (expiresAt !== undefined && (!this.automaticEnabled() || this.options.now() >= expiresAt))) return false;
149
+ active.started = true; this.lastStart = this.options.now();
150
+ if (active.expiryTimer !== undefined) this.options.clearTimeout(active.expiryTimer);
151
+ this.schedule(); return true;
152
+ } };
153
+ if (expiresAt !== undefined) active.expiryTimer = this.options.setTimeout(() => {
154
+ if (!active.started) active.controller.abort();
155
+ }, expiresAt - this.options.now());
156
+ // Calling synchronously also supports a player that throws before returning its promise.
157
+ try { Promise.resolve(this.options.player(sound, active.controller.signal, permit))
158
+ .then(() => this.finish(active), error => { this.report(error, active); this.finish(active); }); }
159
+ catch (error) { this.report(error, active); this.finish(active); }
160
+ }
161
+ private report(error: unknown, active: Active): void {
162
+ if (active.controller.signal.aborted || active.generation !== this.generation || this.disposed) return;
163
+ const now = this.options.now();
164
+ if (now - this.lastError < 5000) return;
165
+ this.lastError = now;
166
+ try { this.options.onError?.(error); } catch { /* Diagnostics must not produce unhandled rejections. */ }
167
+ }
168
+ private finish(active: Active): void {
169
+ if (active.expiryTimer !== undefined) this.options.clearTimeout(active.expiryTimer);
170
+ if (this.active === active) this.active = undefined;
171
+ this.schedule();
172
+ }
173
+ }
@@ -0,0 +1,202 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { NotificationPlayer, validateNotificationAudio } from "./notification-audio.ts";
3
+ import { constants } from "node:fs";
4
+ import { open } from "node:fs/promises";
5
+ import {
6
+ DEFAULT_NOTIFICATION_SETTINGS, NOTIFICATION_EVENTS, isNotificationSettings, isNotificationSound,
7
+ resolveNotificationSettings, restoreNotificationPreset, writeNotificationSettings,
8
+ type NotificationOptions, type NotificationResolution, type NotificationSettings, type NotificationSound,
9
+ } from "./notification-policy.ts";
10
+ import { NotificationScheduler, type NotificationOccurrence, type SchedulerOptions } from "./notification-scheduler.ts";
11
+
12
+ export interface NotificationServiceDependencies {
13
+ now?: () => number;
14
+ env?: NodeJS.ProcessEnv;
15
+ policy?: NotificationOptions;
16
+ read?: () => NotificationResolution;
17
+ write?: (settings: NotificationSettings) => string;
18
+ backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities">;
19
+ setTimeout?: SchedulerOptions["setTimeout"];
20
+ clearTimeout?: SchedulerOptions["clearTimeout"];
21
+ }
22
+ export interface NotificationState extends NotificationResolution {
23
+ muted: boolean;
24
+ supportedEvents: readonly NotificationOccurrence["event"][];
25
+ }
26
+ /** UI consumers only retrieve this facade. They never claim a lease or subscribe to events. */
27
+ export interface NotificationService {
28
+ getState(): NotificationState;
29
+ setConfig(ctx: ExtensionContext, settings: NotificationSettings, options?: { confirmRecovery?: boolean }): boolean;
30
+ restorePreset(ctx: ExtensionContext, options?: { confirmRecovery?: boolean }): boolean;
31
+ setMuted(ctx: ExtensionContext, muted: boolean): boolean;
32
+ preview(ctx: ExtensionContext, sound: Exclude<NotificationSound, null>): boolean;
33
+ availability(ctx: ExtensionContext): Promise<"available" | "unavailable">;
34
+ /** Selection-time validation only; never discovers or starts a player. */
35
+ validateFile(ctx: ExtensionContext, sound: `file:${string}`): Promise<boolean>;
36
+ }
37
+ interface Holder {
38
+ muted: boolean;
39
+ epoch: number;
40
+ ownerLease?: symbol;
41
+ retire?: () => void;
42
+ service?: NotificationService;
43
+ startupNotified: boolean;
44
+ sourceSession?: string;
45
+ highwater: Map<string, number>;
46
+ /** A retired scheduler's backend must settle before any replacement can play. */
47
+ playback?: object;
48
+ }
49
+ const HOLDER = Symbol.for("gentle-pi.notification-service.v1");
50
+ function holder(): Holder {
51
+ const root = globalThis as typeof globalThis & { [HOLDER]?: Holder };
52
+ return root[HOLDER] ??= { muted: false, epoch: 0, startupNotified: false, highwater: new Map() };
53
+ }
54
+ export function getNotificationService(): NotificationService | undefined { return holder().service; }
55
+ export function notificationContextAllowed(ctx: ExtensionContext, env: NodeJS.ProcessEnv = process.env): boolean {
56
+ // Dialog-capable interactive RPC is intentionally NOT an audio host.
57
+ return ctx.mode === "tui" && env.GENTLE_PI_AGENTS_CHILD !== "1";
58
+ }
59
+
60
+ /** Owner-only claim; factory collision invalidates old hooks before the new owner attaches. */
61
+ export function claimNotificationOwner(retireRuntime: () => void, deps: NotificationServiceDependencies = {}) {
62
+ const shared = holder(); shared.retire?.();
63
+ const lease = Symbol("notification-owner"); const epoch = ++shared.epoch;
64
+ shared.ownerLease = lease;
65
+ const now = deps.now ?? (() => performance.now());
66
+ const env = deps.env ?? process.env;
67
+ const backend = deps.backend ?? new NotificationPlayer();
68
+ let ctx: ExtensionContext | undefined; let sessionId: string | undefined;
69
+ let scheduler: NotificationScheduler | undefined; let disposed = false; let attachment = 0;
70
+ let resolution: NotificationResolution | undefined;
71
+ const diagnostics = new Set<string>();
72
+ const current = () => !disposed && shared.ownerLease === lease && shared.epoch === epoch;
73
+ const allowed = (candidate?: ExtensionContext) => current() && !!ctx && !!candidate
74
+ && notificationContextAllowed(candidate, env) && notificationContextAllowed(ctx, env)
75
+ && candidate.sessionManager.getSessionId() === sessionId && ctx.sessionManager.getSessionId() === sessionId;
76
+ const report = (category: string, message: string) => {
77
+ if (!allowed(ctx) || diagnostics.has(category)) return;
78
+ diagnostics.add(category);
79
+ try { ctx!.ui.notify(message, "warning"); } catch { /* Local diagnostics cannot fail a run. */ }
80
+ };
81
+ const read = () => {
82
+ resolution = structuredClone((deps.read ?? (() => resolveNotificationSettings(deps.policy)))());
83
+ if (resolution.malformed || resolution.readError) {
84
+ resolution.settings = structuredClone(DEFAULT_NOTIFICATION_SETTINGS);
85
+ report("config", "Notification configuration unavailable or invalid; audio disabled.");
86
+ }
87
+ };
88
+ const makeScheduler = () => {
89
+ scheduler = new NotificationScheduler({ now,
90
+ // SAFETY: scheduler treats this handle as opaque; Node's Timeout is returned unchanged to clearTimeout.
91
+ setTimeout: deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms) as unknown as number),
92
+ clearTimeout: deps.clearTimeout ?? (id => clearTimeout(id)),
93
+ player: async (sound, signal, permit) => {
94
+ if (!allowed(ctx) || shared.playback || signal.aborted) return;
95
+ const reservation = {}; shared.playback = reservation;
96
+ try {
97
+ const available = await backend.availability();
98
+ if (signal.aborted || !allowed(ctx)) return;
99
+ if (available === "unavailable") { report("backend", "Notification audio backend unavailable."); return; }
100
+ await backend.play(sound, signal, { start: () => allowed(ctx) && permit.start() });
101
+ } finally { if (shared.playback === reservation) shared.playback = undefined; }
102
+ },
103
+ onError: () => report("playback", "Notification audio could not be played."),
104
+ });
105
+ scheduler.resetSession(sessionId!); scheduler.configure(resolution!.settings, shared.muted);
106
+ };
107
+ const shutdown = () => {
108
+ scheduler?.dispose(); scheduler = undefined; ctx = undefined; sessionId = undefined;
109
+ attachment++;
110
+ if (shared.ownerLease === lease) shared.service = undefined;
111
+ };
112
+ const service: NotificationService = {
113
+ getState: () => ({ ...structuredClone(resolution!), muted: shared.muted,
114
+ // Quit cannot await lazy detection/playback without delaying cleanup/exit.
115
+ supportedEvents: NOTIFICATION_EVENTS.filter(event => event !== "session.shutdown") }),
116
+ setConfig: (candidate, settings, options = {}) => {
117
+ if (!allowed(candidate) || !resolution || !isNotificationSettings(settings, deps.policy?.pathFlavor)
118
+ || ((resolution.malformed || resolution.readError) && options.confirmRecovery !== true)) return false;
119
+ try {
120
+ const snapshot = structuredClone(settings);
121
+ const globalFile = (deps.write ?? (value => writeNotificationSettings(value, deps.policy)))(snapshot);
122
+ // Invalidate even enabled->enabled changes: pending mappings must not become stale.
123
+ scheduler?.dispose();
124
+ resolution = { settings: snapshot, globalFile, source: "global_file", malformed: false, readError: false };
125
+ makeScheduler(); return true;
126
+ } catch { report("write", "Notification configuration could not be saved."); return false; }
127
+ },
128
+ restorePreset: (candidate, options) => service.setConfig(candidate, restoreNotificationPreset(resolution!.settings), options),
129
+ setMuted: (candidate, muted) => {
130
+ if (!allowed(candidate) || typeof muted !== "boolean") return false;
131
+ shared.muted = muted; scheduler?.configure(resolution!.settings, muted); return true;
132
+ },
133
+ preview: (candidate, sound) => allowed(candidate) && !shared.playback && isNotificationSound(sound, deps.policy?.pathFlavor)
134
+ && sound !== null && !!scheduler?.preview(sound),
135
+ validateFile: async (candidate, sound) => {
136
+ if (!allowed(candidate) || !isNotificationSound(sound) || !sound.startsWith("file:")) return false;
137
+ const activeScheduler = scheduler;
138
+ try {
139
+ const handle = await open(sound.slice(5), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
140
+ try {
141
+ const stat = await handle.stat();
142
+ if (!stat.isFile() || stat.size < 44 || stat.size > 2 * 1024 * 1024) return false;
143
+ const bytes = Buffer.alloc(stat.size + 1);
144
+ const { bytesRead } = await handle.read(bytes);
145
+ if (bytesRead !== stat.size) return false;
146
+ const { format } = validateNotificationAudio(bytes.subarray(0, bytesRead));
147
+ // Assignment-time rejection: a player that cannot play this container must never receive it.
148
+ if (format !== "wav" && !(await backend.capabilities()).has(format)) return false;
149
+ return allowed(candidate) && scheduler === activeScheduler;
150
+ } finally { await handle.close(); }
151
+ } catch { return false; }
152
+ },
153
+ availability: async candidate => {
154
+ if (!allowed(candidate)) return "unavailable";
155
+ const activeScheduler = scheduler;
156
+ try {
157
+ const result = await backend.availability();
158
+ return allowed(candidate) && scheduler === activeScheduler ? result : "unavailable";
159
+ } catch { return "unavailable"; }
160
+ },
161
+ };
162
+ const bindService = (): NotificationService => {
163
+ const generation = attachment;
164
+ const live = () => generation === attachment && current();
165
+ return {
166
+ getState: service.getState,
167
+ setConfig: (candidate, settings, options) => live() && service.setConfig(candidate, settings, options),
168
+ restorePreset: (candidate, options) => live() && service.restorePreset(candidate, options),
169
+ setMuted: (candidate, muted) => live() && service.setMuted(candidate, muted),
170
+ preview: (candidate, sound) => live() && service.preview(candidate, sound),
171
+ availability: candidate => live() ? service.availability(candidate) : Promise.resolve("unavailable"),
172
+ validateFile: (candidate, sound) => live() ? service.validateFile(candidate, sound) : Promise.resolve(false),
173
+ };
174
+ };
175
+ const retire = () => {
176
+ if (disposed) return;
177
+ shutdown(); disposed = true; retireRuntime();
178
+ if (shared.ownerLease === lease) { shared.ownerLease = undefined; shared.retire = undefined; }
179
+ };
180
+ shared.retire = retire;
181
+ return { now, current, allowed, shutdown, retire,
182
+ acceptSource: (producerId: string, runId: string, sequence: number) => {
183
+ if (!allowed(ctx)) return false;
184
+ const key = JSON.stringify([producerId, runId]);
185
+ if (sequence <= (shared.highwater.get(key) ?? 0)) return false;
186
+ shared.highwater.set(key, sequence); return true;
187
+ },
188
+ attach: (candidate: ExtensionContext) => {
189
+ if (!current()) return false;
190
+ shutdown();
191
+ if (!notificationContextAllowed(candidate, env)) return false;
192
+ ctx = candidate; sessionId = candidate.sessionManager.getSessionId();
193
+ if (shared.sourceSession !== sessionId) { shared.highwater.clear(); shared.sourceSession = sessionId; }
194
+ read(); makeScheduler(); shared.service = bindService(); return true;
195
+ },
196
+ enqueue: (event: NotificationOccurrence) => allowed(ctx) && scheduler?.enqueue(event),
197
+ startup: () => {
198
+ if (!allowed(ctx) || shared.startupNotified) return false;
199
+ shared.startupNotified = true; return true;
200
+ },
201
+ };
202
+ }
@@ -0,0 +1,87 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { BUILTIN_NOTIFICATION_IDS, isNotificationSound, type NotificationEvent, type NotificationSettings, type NotificationSound } from "./notification-policy.ts";
3
+ import { getNotificationService, notificationContextAllowed, type NotificationService } from "./notification-service.ts";
4
+
5
+ /**
6
+ * Native dialogs, outside the transcript. No owner creation, commands or visual-store writes.
7
+ * @deprecated Superseded by the direct rows in `lib/notification-customize.ts`; kept as an
8
+ * unused test-covered fallback, not wired into `/gentle:customize` anymore.
9
+ */
10
+ export async function openNotificationPanel(ctx: ExtensionContext): Promise<void> {
11
+ if (!ctx.hasUI || !notificationContextAllowed(ctx)) return;
12
+ const fresh = (expected: NotificationService) => {
13
+ const service = getNotificationService();
14
+ return service === expected ? service : undefined;
15
+ };
16
+ const notify = (message: string, error = false) => ctx.ui.notify(message, error ? "warning" : "info");
17
+ const chooseSound = async (expected: NotificationService): Promise<NotificationSound | undefined> => {
18
+ const choice = await ctx.ui.select("Notification sound", ["Silence", ...BUILTIN_NOTIFICATION_IDS.map(id => `builtin:${id}`), "Local WAV file"]);
19
+ if (!fresh(expected) || choice === undefined) return undefined;
20
+ if (choice === "Silence") return null;
21
+ if (choice !== "Local WAV file") return isNotificationSound(choice) ? choice : undefined;
22
+ const path = await ctx.ui.input("Local WAV file", "Absolute path (no URLs or shell expansion)");
23
+ const service = fresh(expected);
24
+ if (!service || path === undefined) return undefined;
25
+ const sound = `file:${path}` as const;
26
+ if (!isNotificationSound(sound) || !await service.validateFile(ctx, sound)) {
27
+ if (fresh(expected)) notify("Select a readable regular PCM WAV (≤2 MiB, ≤10 seconds); absolute local path only.", true);
28
+ return undefined;
29
+ }
30
+ return fresh(expected) ? sound : undefined;
31
+ };
32
+ const save = async (expected: NotificationService, change?: (settings: NotificationSettings) => void) => {
33
+ let service = fresh(expected);
34
+ if (!service) return;
35
+ const state = service.getState();
36
+ let confirmRecovery = false;
37
+ if (state.malformed || state.readError) {
38
+ confirmRecovery = await ctx.ui.confirm("Replace invalid notification configuration?",
39
+ "The existing configuration is invalid or unreadable. Replace it with these settings? Cancellation leaves it untouched.") === true;
40
+ if (!confirmRecovery) return;
41
+ }
42
+ service = fresh(expected);
43
+ if (!service) return;
44
+ // Read again after all prompts: never overwrite another action with an old snapshot.
45
+ const settings = service.getState().settings;
46
+ change?.(settings);
47
+ const saved = change ? service.setConfig(ctx, settings, { confirmRecovery }) : service.restorePreset(ctx, { confirmRecovery });
48
+ notify(saved ? "Notification preferences saved." : "Notification preferences not saved; choose again to retry.", !saved);
49
+ };
50
+ while (true) {
51
+ const service = getNotificationService();
52
+ if (!service) return;
53
+ const state = service.getState();
54
+ const enable = state.settings.enabled ? "Disable audio" : "Enable audio";
55
+ const mute = state.muted ? "Resume" : "Mute";
56
+ const action = await ctx.ui.select(`Notifications · audio ${state.settings.enabled ? "on" : "off"} · ${state.muted ? "muted" : "unmuted"}`,
57
+ [enable, "Assign sound", "Preview sound", mute, "Check availability", "Restore preset"]);
58
+ if (!fresh(service) || action === undefined) return;
59
+ try {
60
+ if (action === enable) await save(service, settings => { settings.enabled = action === "Enable audio"; });
61
+ else if (action === mute) fresh(service)?.setMuted(ctx, action === "Mute");
62
+ else if (action === "Restore preset") await save(service);
63
+ else if (action === "Check availability") {
64
+ const result = await fresh(service)!.availability(ctx);
65
+ if (fresh(service)) notify(`Notification audio backend: ${result}.`);
66
+ } else if (action === "Preview sound") {
67
+ const sound = await chooseSound(service);
68
+ const current = fresh(service);
69
+ if (current && sound !== undefined && sound !== null && !current.preview(ctx, sound)) notify("Preview unavailable or audio busy; try again.", true);
70
+ } else if (action === "Assign sound") {
71
+ const origins = [...new Set(service.getState().supportedEvents.map(event => event.split(".")[0]!))];
72
+ const origin = await ctx.ui.select("Notification origin", origins);
73
+ let current = fresh(service);
74
+ if (!current || origin === undefined || !origins.includes(origin)) continue;
75
+ const events = current.getState().supportedEvents.filter(event => event.startsWith(`${origin}.`));
76
+ const event = await ctx.ui.select("Notification event", [...events]);
77
+ current = fresh(service);
78
+ if (!current || !events.includes(event as NotificationEvent)) continue;
79
+ const sound = await chooseSound(service);
80
+ if (sound !== undefined) await save(service, settings => { settings.audio.events[event as NotificationEvent] = sound; });
81
+ }
82
+ } catch {
83
+ if (fresh(service)) notify("Notification action failed; no automatic retry.", true);
84
+ }
85
+ if (!fresh(service)) return;
86
+ }
87
+ }
package/lib/odd-phase.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // (lib/odd-phase-inference.ts, wired in extensions/gentle-shell.ts) and
5
5
  // explicit reports by the orchestrator through gentle_odd_phase, which
6
6
  // refine it with phases tools cannot show (see
7
- // assets/orchestrator-delegation.md). Never inferred from assistant prose;
7
+ // assets/orchestrator-tracking.md). Never inferred from assistant prose;
8
8
  // the prompt falls back to the generic "working…" label when nothing applies.
9
9
 
10
10
  // Covers the ODD protocol steps in AGENTS.md (1 authorize .. 7 close); not a
@@ -0,0 +1,20 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+
3
+ export interface TaskAliases { initialAlias: string | null; currentAlias: string | null }
4
+ /** Explicit public topic only; never infer from names or private conversation. */
5
+ export function normalizeTaskSubject(value: unknown): string | undefined {
6
+ if (value === undefined) return undefined;
7
+ if (typeof value !== "string" || /\p{Cs}/u.test(value)) throw new Error("invalid-task-subject");
8
+ const clean = stripVTControlCharacters(value).replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim();
9
+ return Array.from(clean).slice(0, 120).join("").trimEnd() || undefined;
10
+ }
11
+ export function decodeTaskAliases(value: unknown): TaskAliases | undefined {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
13
+ const input = value as Record<string, unknown>;
14
+ const valid = (v: unknown): v is string => typeof v === "string" && !!v && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(v)
15
+ && Array.from(v).length <= 120 && normalizeTaskSubject(v) === v;
16
+ if (Object.keys(input).length !== 2 || !Object.hasOwn(input, "initialAlias") || !Object.hasOwn(input, "currentAlias")
17
+ || !(input.initialAlias === null || valid(input.initialAlias))
18
+ || !(valid(input.currentAlias) || (input.currentAlias === null && input.initialAlias === null))) return undefined;
19
+ return { initialAlias: input.initialAlias as string | null, currentAlias: input.currentAlias as string | null };
20
+ }
@@ -0,0 +1,79 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { boundedRead, rootFor, sanitizeDisplayLabel, type Header } from "./orchestrator-presence.ts";
4
+
5
+ // Private bounded derivative, never an identity/authority registry. Each request
6
+ // reads at most one 64 KiB snapshot and returns eight entries of each category.
7
+ export const CATALOG_BYTES = 64 * 1024;
8
+ export const CATALOG_ENTRIES = 64;
9
+ export const CATALOG_PAGES = 8;
10
+ type Task = { id: string; label: string; status: string; cwd: string | null };
11
+ type Catalog = { tasks: Task[]; registered: (string | null)[]; omittedTasks: number; omittedRegistered: number };
12
+ export type CatalogPage = Catalog & { cursor?: string };
13
+ const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
14
+ const safePath = (path: string) => isAbsolute(path) && Buffer.byteLength(path) <= 256
15
+ && !/[\p{Cc}\p{Cf}\u00a0\u2000-\u200a\u202f\u205f\u3000]/u.test(path);
16
+ const pathFact = (path: string) => safePath(path) ? path : null;
17
+ const exact = (value: any, keys: string[]) => value && typeof value === "object" && !Array.isArray(value)
18
+ && Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k));
19
+ const count = (n: unknown) => Number.isSafeInteger(n) && (n as number) >= 0;
20
+ const text = (s: unknown) => typeof s === "string" && s === sanitizeDisplayLabel(s);
21
+ const path = (s: unknown) => s === null || typeof s === "string" && safePath(s);
22
+ function valid(c: any): c is Catalog {
23
+ return exact(c, ["tasks", "registered", "omittedTasks", "omittedRegistered"])
24
+ && count(c.omittedTasks) && count(c.omittedRegistered)
25
+ && Array.isArray(c.tasks) && c.tasks.length <= CATALOG_ENTRIES
26
+ && c.tasks.every((t: any) => exact(t, ["id", "label", "status", "cwd"])
27
+ && text(t.id) && text(t.label) && ["running", "queued", "waiting"].includes(t.status) && path(t.cwd))
28
+ && Array.isArray(c.registered) && c.registered.length <= CATALOG_ENTRIES && c.registered.every(path);
29
+ }
30
+ /** Explicit whitelist; eagerly detached, no task spreads or thread access. */
31
+ export function projectCatalog(tasks: readonly { id: string; label: string; status: string; cwd: string }[], roots: readonly string[]): Catalog {
32
+ const c: Catalog = { tasks: [], registered: [], omittedTasks: tasks.length, omittedRegistered: roots.length };
33
+ // Reserve envelope space; byte overflow is an exact omission, not truncation.
34
+ for (let i = 0; i < Math.min(CATALOG_ENTRIES, Math.max(tasks.length, roots.length)); i++) {
35
+ if (i < tasks.length) {
36
+ const t = tasks[i];
37
+ c.tasks.push({ id: sanitizeDisplayLabel(t.id), label: sanitizeDisplayLabel(t.label), status: t.status, cwd: pathFact(t.cwd) });
38
+ if (Buffer.byteLength(JSON.stringify(c)) > CATALOG_BYTES - 1024) c.tasks.pop();
39
+ else c.omittedTasks--;
40
+ }
41
+ if (i < roots.length) {
42
+ c.registered.push(pathFact(roots[i]));
43
+ if (Buffer.byteLength(JSON.stringify(c)) > CATALOG_BYTES - 1024) c.registered.pop();
44
+ else c.omittedRegistered--;
45
+ }
46
+ }
47
+ if (!valid(c)) throw new Error("malformed-catalog");
48
+ return c;
49
+ }
50
+ /** Cursor binds only public catalog fields and producer activation, not private
51
+ * activity churn. Current envelope/header generation must still agree on every
52
+ * read; no cached pages, paths or authority are carried by the cursor. */
53
+ export function readCatalog(profile: string, h: Header, activation: string, cursor?: string): { page?: CatalogPage; unavailable?: string } {
54
+ try {
55
+ const bytes = boundedRead(join(rootFor(profile, false, true), `${h.sessionHash}.${h.incarnation}.json`), CATALOG_BYTES);
56
+ const value = JSON.parse(bytes.toString("utf8"));
57
+ if (!exact(value, ["schema", "sessionHash", "incarnation", "generation", "activation", "tokens", "catalog"])
58
+ || value.schema !== 1 || value.sessionHash !== h.sessionHash || value.incarnation !== h.incarnation
59
+ || value.generation !== h.generation || value.activation !== activation || !valid(value.catalog)
60
+ || !Array.isArray(value.tokens) || value.tokens.length !== 7 || !value.tokens.every((s: unknown) => typeof s === "string" && /^[a-f0-9-]{36}$/.test(s))) throw new Error("unavailable");
61
+ const c: Catalog = value.catalog;
62
+ const publicBytes = JSON.stringify({ tasks: c.tasks.map(t => ({ id: t.id, label: t.label, status: t.status, cwd: t.cwd })),
63
+ registered: c.registered, omittedTasks: c.omittedTasks, omittedRegistered: c.omittedRegistered });
64
+ const binding = [h.sessionHash, h.incarnation, activation, hash(publicBytes)];
65
+ let offset = 0;
66
+ const token = (n: number) => Buffer.from(JSON.stringify([...binding, n, value.tokens[n / 8 - 1]])).toString("base64url");
67
+ if (cursor !== undefined) {
68
+ if (typeof cursor !== "string" || cursor.length > 1024) throw new Error("invalid-cursor");
69
+ const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
70
+ offset = decoded?.[4];
71
+ if (!count(offset) || offset < 8 || offset >= CATALOG_ENTRIES || offset % 8 || token(offset) !== cursor) throw new Error("invalid-cursor");
72
+ }
73
+ if (cursor && offset >= Math.max(c.tasks.length, c.registered.length)) throw new Error("invalid-cursor");
74
+ const end = offset + 8;
75
+ return { page: { tasks: c.tasks.slice(offset, end), registered: c.registered.slice(offset, end),
76
+ omittedTasks: c.omittedTasks, omittedRegistered: c.omittedRegistered,
77
+ ...(end < Math.max(c.tasks.length, c.registered.length) ? { cursor: token(end) } : {}) } };
78
+ } catch { return { unavailable: "catalog-unknown-or-invalid-cursor" }; }
79
+ }
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { PresenceRecord } from "./agents-session-transport.ts";
3
+ import { discoverOrchestrators } from "./orchestrator-discovery.ts";
4
+
5
+ export const CONSULTATION_BYTES = 16 * 1024;
6
+ export interface MetadataSelection { recipientSessionId: string; cursor?: string }
7
+ type Candidate = ReturnType<typeof discoverOrchestrators>[number];
8
+ export interface MetadataReceipt {
9
+ schema: "gentle-agents.consultation/v1";
10
+ kind: "metadata"; status: "available" | "unavailable";
11
+ source: "published_snapshot"; ownerReply: false; authority: "none";
12
+ targetSessionId: string; observedAt: number;
13
+ freshness: "unknown" | "recent" | "stale";
14
+ presenceObservedAt?: number;
15
+ digest?: string;
16
+ snapshot?: { label: string | null; aliases?: Candidate["aliases"]; workspace: string | null; tasks: Candidate["tasks"];
17
+ omittedTasks: number; scope: Candidate["scope"] | null; catalog: Candidate["catalog"] | null;
18
+ state: Candidate["state"] | null };
19
+ unknowns: string[]; omissions: string[];
20
+ }
21
+ function freeze<T>(value: T): T {
22
+ if (value && typeof value === "object") {
23
+ for (const child of Object.values(value)) freeze(child);
24
+ Object.freeze(value);
25
+ }
26
+ return value;
27
+ }
28
+ export function unavailableMetadata(targetSessionId: string, reason: string, now = Date.now(), freshness: MetadataReceipt["freshness"] = "unknown"): MetadataReceipt {
29
+ return freeze({ schema: "gentle-agents.consultation/v1", kind: "metadata", status: "unavailable",
30
+ source: "published_snapshot", ownerReply: false, authority: "none", targetSessionId, observedAt: now,
31
+ freshness, unknowns: [reason], omissions: ["private-context", "owner-decision", "reachability", "exclusive-writer-ownership"] });
32
+ }
33
+ /** Reads the existing bounded, selected-activation projection, never activity.
34
+ * Identity excludes private activity generation/digests and observation clocks.
35
+ * Existing readers validate exact field whitelists before this detached capture. */
36
+ export function consultPublishedMetadata(profile: string, peers: readonly PresenceRecord[], selection: MetadataSelection, now = Date.now()): MetadataReceipt {
37
+ const peer = discoverOrchestrators(profile, peers, now, { recipientSessionId: selection.recipientSessionId,
38
+ cursor: selection.cursor, consultation: true })[0];
39
+ if (!peer?.publication || peer.freshness !== "recent") return unavailableMetadata(selection.recipientSessionId, "publication-unknown-or-stale", now, peer?.freshness);
40
+ if (selection.cursor !== undefined && !peer.catalog) return unavailableMetadata(selection.recipientSessionId, "catalog-unknown-or-invalid-cursor", now);
41
+ const snapshot = {
42
+ label: peer.label || null, ...(peer.aliases ? { aliases: peer.aliases } : {}), workspace: peer.workspace || null,
43
+ tasks: peer.tasks?.map(t => ({ id: t.id, label: t.label, status: t.status, workspace: t.workspace })) ?? [],
44
+ omittedTasks: peer.omitted ?? 0, scope: peer.scope ?? null, catalog: peer.catalog ?? null, state: peer.state ?? null,
45
+ };
46
+ const unknowns = ["reachability", "exclusive-writer-ownership", "owner-decision"];
47
+ if (!snapshot.workspace) unknowns.push("workspace");
48
+ if (!snapshot.scope) unknowns.push("repository-scope");
49
+ if (!snapshot.catalog) unknowns.push("catalog");
50
+ if (!snapshot.state) unknowns.push("curated-state");
51
+ const digest = createHash("sha256").update(JSON.stringify([selection.recipientSessionId,
52
+ peer.publication.incarnation, peer.publication.activation, snapshot])).digest("hex");
53
+ const receipt: MetadataReceipt = { schema: "gentle-agents.consultation/v1", kind: "metadata", status: "available",
54
+ source: "published_snapshot", ownerReply: false, authority: "none", targetSessionId: selection.recipientSessionId,
55
+ observedAt: now, freshness: peer.freshness, presenceObservedAt: peer.publication.heartbeat, digest, snapshot,
56
+ unknowns, omissions: ["private-context", "git-facts-beyond-published-prefix"] };
57
+ // JSON capture detaches every nested list/record and guarantees plain JSON.
58
+ const bytes = JSON.stringify(receipt);
59
+ if (Buffer.byteLength(bytes) > CONSULTATION_BYTES) return unavailableMetadata(selection.recipientSessionId, "snapshot-too-large", now);
60
+ return freeze(JSON.parse(bytes) as MetadataReceipt);
61
+ }