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.
- package/README.md +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, chmod, mkdtemp, open, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { isNotificationSound, type NotificationSound } from "./notification-policy.ts";
|
|
9
|
+
import type { PlaybackPermit } from "./notification-scheduler.ts";
|
|
10
|
+
import { NativePulseNotPermittedError, NativePulsePlayer } from "./notification-audio-native.ts";
|
|
11
|
+
import { NativeWindowsPlayer } from "./notification-audio-windows.ts";
|
|
12
|
+
|
|
13
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
/** User-facing duration ceiling for every accepted container; the play deadlines below add a 1 s margin. */
|
|
15
|
+
const MAX_DURATION_MS = 10000;
|
|
16
|
+
const PLAYBACK_TIMEOUT_MS = 11000;
|
|
17
|
+
const STDERR_LIMIT = 4096;
|
|
18
|
+
/** Strict RIFF PCM: unknown chunks allowed, with word padding; one fmt and one nonempty data. */
|
|
19
|
+
export function validateNotificationWav(bytes: Buffer): number {
|
|
20
|
+
const invalid = () => { throw new TypeError("Invalid notification WAV"); };
|
|
21
|
+
if (bytes.length < 44 || bytes.length > MAX_BYTES || bytes.toString("ascii", 0, 4) !== "RIFF"
|
|
22
|
+
|| bytes.toString("ascii", 8, 12) !== "WAVE" || bytes.readUInt32LE(4) + 8 !== bytes.length) return invalid();
|
|
23
|
+
let offset = 12; let format: { rate: number; align: number } | undefined; let dataSize: number | undefined;
|
|
24
|
+
while (offset < bytes.length) {
|
|
25
|
+
if (offset + 8 > bytes.length) return invalid();
|
|
26
|
+
const id = bytes.toString("ascii", offset, offset + 4); const size = bytes.readUInt32LE(offset + 4);
|
|
27
|
+
const start = offset + 8; const end = start + size;
|
|
28
|
+
if (end + (size % 2) > bytes.length) return invalid();
|
|
29
|
+
if (id === "fmt ") {
|
|
30
|
+
if (format || (size !== 16 && size !== 18) || bytes.readUInt16LE(start) !== 1
|
|
31
|
+
|| (size === 18 && bytes.readUInt16LE(start + 16) !== 0)) return invalid();
|
|
32
|
+
const channels = bytes.readUInt16LE(start + 2); const rate = bytes.readUInt32LE(start + 4);
|
|
33
|
+
const bits = bytes.readUInt16LE(start + 14); const align = channels * bits / 8;
|
|
34
|
+
if (channels < 1 || channels > 2 || rate < 8000 || rate > 192000 || ![8, 16, 24, 32].includes(bits)
|
|
35
|
+
|| bytes.readUInt16LE(start + 12) !== align || bytes.readUInt32LE(start + 8) !== rate * align) return invalid();
|
|
36
|
+
format = { rate, align };
|
|
37
|
+
} else if (id === "data") {
|
|
38
|
+
if (!format || dataSize !== undefined || size === 0) return invalid();
|
|
39
|
+
dataSize = size;
|
|
40
|
+
}
|
|
41
|
+
offset = end + (size % 2);
|
|
42
|
+
}
|
|
43
|
+
if (!format || dataSize === undefined || dataSize % format.align !== 0) return invalid();
|
|
44
|
+
const duration = dataSize / format.align / format.rate * 1000;
|
|
45
|
+
if (duration > MAX_DURATION_MS) return invalid();
|
|
46
|
+
return duration;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type NotificationAudioFormat = "wav" | "ogg" | "flac";
|
|
50
|
+
/** Content detection only (magic bytes, never the extension). Unknown containers are refused
|
|
51
|
+
* so an unrecognized file can never reach the player as white noise. */
|
|
52
|
+
export function validateNotificationAudio(bytes: Buffer): { format: NotificationAudioFormat; durationMs: number } {
|
|
53
|
+
let format: NotificationAudioFormat;
|
|
54
|
+
let durationMs: number;
|
|
55
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE") {
|
|
56
|
+
format = "wav"; durationMs = validateNotificationWav(bytes);
|
|
57
|
+
} else if (bytes.toString("ascii", 0, 4) === "fLaC") {
|
|
58
|
+
format = "flac"; durationMs = validateNotificationFlac(bytes);
|
|
59
|
+
} else if (bytes.toString("ascii", 0, 4) === "OggS") {
|
|
60
|
+
format = "ogg"; durationMs = validateNotificationOgg(bytes);
|
|
61
|
+
} else throw new TypeError("Unsupported notification audio format");
|
|
62
|
+
// WAV already enforces this internally; the shared guard also bounds the decoded OGG/FLAC durations.
|
|
63
|
+
if (durationMs > MAX_DURATION_MS) throw new TypeError("Notification audio exceeds 10 seconds");
|
|
64
|
+
return { format, durationMs };
|
|
65
|
+
}
|
|
66
|
+
/** FLAC duration from STREAMINFO: the first metadata block must be STREAMINFO of exactly 34 bytes
|
|
67
|
+
* and total samples must be known, otherwise a duration limit cannot be verified. */
|
|
68
|
+
export function validateNotificationFlac(bytes: Buffer): number {
|
|
69
|
+
const invalid = () => { throw new TypeError("Invalid notification FLAC"); };
|
|
70
|
+
if (bytes.length < 42 || bytes.length > MAX_BYTES || bytes.toString("ascii", 0, 4) !== "fLaC") return invalid();
|
|
71
|
+
const lastFlagAndType = bytes[4];
|
|
72
|
+
const length = (bytes[5] << 16) | (bytes[6] << 8) | bytes[7];
|
|
73
|
+
if ((lastFlagAndType & 0x7f) !== 0 || length !== 34) return invalid();
|
|
74
|
+
const s = 8;
|
|
75
|
+
const sampleRate = (bytes[s + 10] << 12) | (bytes[s + 11] << 4) | (bytes[s + 12] >> 4);
|
|
76
|
+
const channels = ((bytes[s + 12] >> 1) & 0x07) + 1;
|
|
77
|
+
const bitsPerSample = (((bytes[s + 12] & 0x01) << 4) | (bytes[s + 13] >> 4)) + 1;
|
|
78
|
+
const totalSamples = (bytes[s + 13] & 0x0f) * 2 ** 32 + bytes.readUInt32BE(s + 14);
|
|
79
|
+
if (sampleRate < 8000 || sampleRate > 192000 || channels < 1 || channels > 2
|
|
80
|
+
|| bitsPerSample < 4 || bitsPerSample > 32 || totalSamples <= 0) return invalid();
|
|
81
|
+
return totalSamples / sampleRate * 1000;
|
|
82
|
+
}
|
|
83
|
+
/** OGG duration from the last page granule, without decoding: Vorbis uses the header sample rate,
|
|
84
|
+
* Opus is always 48 kHz and subtracts the header pre-skip. Only Vorbis/Opus are accepted. */
|
|
85
|
+
export function validateNotificationOgg(bytes: Buffer): number {
|
|
86
|
+
const invalid = () => { throw new TypeError("Invalid notification OGG"); };
|
|
87
|
+
if (bytes.length < 27 || bytes.length > MAX_BYTES) return invalid();
|
|
88
|
+
let offset = 0; let first = true; let opus = false;
|
|
89
|
+
let channels: number | undefined; let sampleRate: number | undefined; let preSkip = 0;
|
|
90
|
+
let endGranule = 0; let foundGranule = false;
|
|
91
|
+
while (offset < bytes.length) {
|
|
92
|
+
if (offset + 27 > bytes.length) return invalid();
|
|
93
|
+
if (bytes.toString("ascii", offset, offset + 4) !== "OggS" || bytes[offset + 4] !== 0) return invalid();
|
|
94
|
+
const headerType = bytes[offset + 5];
|
|
95
|
+
const low = bytes.readUInt32LE(offset + 6); const high = bytes.readInt32LE(offset + 10);
|
|
96
|
+
const noGranule = high === -1 && low === 0xffffffff;
|
|
97
|
+
const pageSegments = bytes[offset + 26];
|
|
98
|
+
if (offset + 27 + pageSegments > bytes.length) return invalid();
|
|
99
|
+
let bodySize = 0;
|
|
100
|
+
for (let i = 0; i < pageSegments; i++) bodySize += bytes[offset + 27 + i];
|
|
101
|
+
const bodyStart = offset + 27 + pageSegments; const next = bodyStart + bodySize;
|
|
102
|
+
if (next > bytes.length) return invalid();
|
|
103
|
+
if (first) {
|
|
104
|
+
// The BOS page must carry the identification header for exactly one supported codec.
|
|
105
|
+
if ((headerType & 0x02) === 0) return invalid();
|
|
106
|
+
if (bodySize >= 7 && bytes[bodyStart] === 0x01 && bytes.toString("ascii", bodyStart + 1, bodyStart + 7) === "vorbis") {
|
|
107
|
+
if (bodySize < 16) return invalid();
|
|
108
|
+
channels = bytes[bodyStart + 11]; sampleRate = bytes.readUInt32LE(bodyStart + 12);
|
|
109
|
+
} else if (bodySize >= 8 && bytes.toString("ascii", bodyStart, bodyStart + 8) === "OpusHead") {
|
|
110
|
+
if (bodySize < 12) return invalid();
|
|
111
|
+
channels = bytes[bodyStart + 9]; preSkip = bytes.readUInt16LE(bodyStart + 10);
|
|
112
|
+
sampleRate = 48000; opus = true;
|
|
113
|
+
} else return invalid();
|
|
114
|
+
first = false;
|
|
115
|
+
}
|
|
116
|
+
if (!noGranule) { const value = high * 2 ** 32 + low; if (!foundGranule || value > endGranule) endGranule = value; foundGranule = true; }
|
|
117
|
+
offset = next;
|
|
118
|
+
}
|
|
119
|
+
if (first || !foundGranule || channels === undefined || sampleRate === undefined || channels < 1 || channels > 2) return invalid();
|
|
120
|
+
if (!opus && (sampleRate < 8000 || sampleRate > 192000)) return invalid();
|
|
121
|
+
const durationMs = opus ? Math.max(0, endGranule - preSkip) / 48000 * 1000 : endGranule / sampleRate * 1000;
|
|
122
|
+
if (!(durationMs > 0)) return invalid();
|
|
123
|
+
return durationMs;
|
|
124
|
+
}
|
|
125
|
+
/** Canonical capability order; native WAV never removes the CLI OGG/FLAC capabilities. */
|
|
126
|
+
const CANONICAL_FORMATS: readonly NotificationAudioFormat[] = ["wav", "ogg", "flac"];
|
|
127
|
+
function formatsForExecutable(executable: string): readonly NotificationAudioFormat[] {
|
|
128
|
+
const name = basename(executable);
|
|
129
|
+
if (name === "paplay" || name === "pw-play") return ["wav", "ogg", "flac"];
|
|
130
|
+
if (name === "aplay") return ["wav"];
|
|
131
|
+
if (name === "afplay") return ["wav", "flac"];
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
function canonicalFormats(formats: readonly NotificationAudioFormat[]): readonly NotificationAudioFormat[] {
|
|
135
|
+
return CANONICAL_FORMATS.filter(format => formats.includes(format));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Pure, IO-free default backend selection. Native Windows (`win32`) and the
|
|
140
|
+
* derived WSL Windows-interop target (`linux` plus a valid interop env) take the
|
|
141
|
+
* bounded Windows adapter; every other platform stays on the local Pulse bridge,
|
|
142
|
+
* whose `supportsTarget()` is false on macOS so the legacy `/usr/bin/afplay` CLI
|
|
143
|
+
* still owns playback. Construction performs no probe, access, spawn or read; a
|
|
144
|
+
* caller may still inject `options.native` to override this choice entirely.
|
|
145
|
+
*/
|
|
146
|
+
export function createDefaultNotificationBackend(
|
|
147
|
+
platform: string = process.platform,
|
|
148
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
149
|
+
): NativeAudioBackend {
|
|
150
|
+
const windows = new NativeWindowsPlayer({ platform, env });
|
|
151
|
+
return windows.supportsTarget() ? windows : new NativePulsePlayer({ platform, env });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Typed seam over the owned native bridge (lazy: no IO until an explicit probe/play). */
|
|
155
|
+
export interface NativeAudioBackend {
|
|
156
|
+
supportsTarget(): boolean;
|
|
157
|
+
probe(signal?: AbortSignal): Promise<{ available: boolean; formats: readonly NotificationAudioFormat[] }>;
|
|
158
|
+
getNativeFormats(): readonly NotificationAudioFormat[];
|
|
159
|
+
play(snapshot: string, signal?: AbortSignal, gate?: (() => boolean) | { permit(): boolean }): Promise<void>;
|
|
160
|
+
}
|
|
161
|
+
interface DiscoveredSystem { executable: string; formats: readonly NotificationAudioFormat[]; }
|
|
162
|
+
interface Discovery { native: readonly NotificationAudioFormat[]; system?: DiscoveredSystem; }
|
|
163
|
+
|
|
164
|
+
export interface AudioIO {
|
|
165
|
+
open(path: string, flags: number): Promise<{
|
|
166
|
+
stat(): Promise<{ isFile(): boolean; size: number }>;
|
|
167
|
+
read(buffer: Buffer): Promise<{ bytesRead: number }>;
|
|
168
|
+
close(): Promise<void>;
|
|
169
|
+
}>;
|
|
170
|
+
mkdtemp(prefix: string): Promise<string>;
|
|
171
|
+
chmod(path: string, mode: number): Promise<void>;
|
|
172
|
+
writeFile(path: string, bytes: Buffer, options: { mode: number; flag: "wx" }): Promise<void>;
|
|
173
|
+
unlink(path: string): Promise<void>;
|
|
174
|
+
rmdir(path: string): Promise<void>;
|
|
175
|
+
}
|
|
176
|
+
export interface AudioChild extends EventEmitter {
|
|
177
|
+
stderr: EventEmitter | null;
|
|
178
|
+
kill(signal: "SIGKILL"): boolean;
|
|
179
|
+
}
|
|
180
|
+
export interface AudioOptions {
|
|
181
|
+
platform?: string;
|
|
182
|
+
io?: AudioIO;
|
|
183
|
+
tempRoot?: string;
|
|
184
|
+
builtinRoot?: string;
|
|
185
|
+
native?: NativeAudioBackend;
|
|
186
|
+
executableAvailable?(path: string): Promise<boolean>;
|
|
187
|
+
spawn?(executable: string, args: string[], options: {
|
|
188
|
+
shell: false; windowsHide: true; detached: false; stdio: ["ignore", "ignore", "pipe"];
|
|
189
|
+
}): AudioChild;
|
|
190
|
+
setTimeout?(fn: () => void, ms: number): ReturnType<typeof setTimeout> | number;
|
|
191
|
+
clearTimeout?(timer: ReturnType<typeof setTimeout> | number): void;
|
|
192
|
+
}
|
|
193
|
+
const defaultIO: AudioIO = { open, mkdtemp, chmod, writeFile, unlink, rmdir };
|
|
194
|
+
/** Owner invokes only for enable/preview (or explicit availability UI). No import-time detection/IO.
|
|
195
|
+
* Windows deliberately unavailable until a safe native adapter and manual evidence exist.
|
|
196
|
+
* Source symlinks are rejected (O_NOFOLLOW); descriptor read is bounded, then validated bytes
|
|
197
|
+
* are snapshotted into a private directory so subsequent source mutation cannot affect playback. */
|
|
198
|
+
export class NotificationPlayer {
|
|
199
|
+
private readonly options: AudioOptions;
|
|
200
|
+
private readonly io: AudioIO;
|
|
201
|
+
private readonly native: NativeAudioBackend;
|
|
202
|
+
private discovery?: Promise<Discovery>;
|
|
203
|
+
constructor(options: AudioOptions = {}) {
|
|
204
|
+
this.options = options;
|
|
205
|
+
this.io = options.io ?? defaultIO;
|
|
206
|
+
this.native = options.native ?? createDefaultNotificationBackend(options.platform);
|
|
207
|
+
}
|
|
208
|
+
async availability(): Promise<"available" | "unavailable"> {
|
|
209
|
+
const discovered = await this.discover();
|
|
210
|
+
return discovered.native.length > 0 || discovered.system ? "available" : "unavailable";
|
|
211
|
+
}
|
|
212
|
+
/** Aggregate, canonical capabilities: native WAV cannot shadow the CLI OGG/FLAC support. */
|
|
213
|
+
async capabilities(): Promise<Set<NotificationAudioFormat>> {
|
|
214
|
+
const discovered = await this.discover();
|
|
215
|
+
const present = new Set<NotificationAudioFormat>([...discovered.native, ...(discovered.system?.formats ?? [])]);
|
|
216
|
+
return new Set(CANONICAL_FORMATS.filter(format => present.has(format)));
|
|
217
|
+
}
|
|
218
|
+
/** Explicit discovery only: native probe once on Linux, then trusted absolute CLI candidates. */
|
|
219
|
+
private discover(): Promise<Discovery> {
|
|
220
|
+
return this.discovery ??= (async () => {
|
|
221
|
+
const platform = this.options.platform ?? process.platform;
|
|
222
|
+
let native: readonly NotificationAudioFormat[] = [];
|
|
223
|
+
if (this.native.supportsTarget()) {
|
|
224
|
+
try { const probed = await this.native.probe(); if (probed.available) native = canonicalFormats(probed.formats); }
|
|
225
|
+
catch { native = []; }
|
|
226
|
+
}
|
|
227
|
+
const executable = await this.detectExecutable(platform);
|
|
228
|
+
return { native, system: executable ? { executable, formats: formatsForExecutable(executable) } : undefined };
|
|
229
|
+
})();
|
|
230
|
+
}
|
|
231
|
+
private async detectExecutable(platform: string): Promise<string | undefined> {
|
|
232
|
+
const candidates = platform === "linux" ? ["/usr/bin/paplay", "/usr/bin/pw-play", "/usr/bin/aplay"]
|
|
233
|
+
: platform === "darwin" ? ["/usr/bin/afplay"] : [];
|
|
234
|
+
for (const path of candidates) {
|
|
235
|
+
try {
|
|
236
|
+
const available = this.options.executableAvailable ? await this.options.executableAvailable(path)
|
|
237
|
+
: await access(path, constants.X_OK).then(() => true);
|
|
238
|
+
if (available) return path;
|
|
239
|
+
} catch { /* Missing/denied executable is a local availability result, not an agent failure. */ }
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
async play(sound: Exclude<NotificationSound, null>, signal: AbortSignal, permit: PlaybackPermit): Promise<void> {
|
|
244
|
+
if (signal.aborted) return;
|
|
245
|
+
const discovered = await this.discover();
|
|
246
|
+
if (signal.aborted) return;
|
|
247
|
+
// No usable backend: preserve the old behavior of no read, snapshot or spawn.
|
|
248
|
+
if (discovered.native.length === 0 && !discovered.system) return;
|
|
249
|
+
const flavor = (this.options.platform ?? process.platform) === "win32" ? "win32" : "posix";
|
|
250
|
+
if (!isNotificationSound(sound, flavor)) throw new TypeError("Invalid notification sound");
|
|
251
|
+
const source = sound.startsWith("file:") ? sound.slice(5)
|
|
252
|
+
: join(this.options.builtinRoot ?? fileURLToPath(new URL("../assets/sounds/", import.meta.url)), `${sound.slice(8)}.wav`);
|
|
253
|
+
const { bytes, format } = await this.readSource(source);
|
|
254
|
+
if (signal.aborted) return;
|
|
255
|
+
const useNative = format === "wav" && discovered.native.length > 0;
|
|
256
|
+
const executable = !useNative && discovered.system?.formats.includes(format) ? discovered.system.executable : undefined;
|
|
257
|
+
if (!useNative && !executable) throw new Error("Notification audio: no backend supports the format");
|
|
258
|
+
const directory = await this.io.mkdtemp(join(this.options.tempRoot ?? tmpdir(), "gentle-notification-"));
|
|
259
|
+
const snapshot = join(directory, `sound.${format}`);
|
|
260
|
+
// Early returns below leave this inner function only; the cleanup verdict is decided after it.
|
|
261
|
+
const playSnapshot = async (): Promise<void> => {
|
|
262
|
+
await this.io.chmod(directory, 0o700);
|
|
263
|
+
await this.io.writeFile(snapshot, bytes, { mode: 0o600, flag: "wx" });
|
|
264
|
+
if (signal.aborted) return;
|
|
265
|
+
if (useNative) {
|
|
266
|
+
// The owned bridge calls the scheduler gate once synchronously before spawning; a false
|
|
267
|
+
// permit resolves silently and any real native failure is never retried through the CLI.
|
|
268
|
+
try { await this.native.play(snapshot, signal, () => permit.start()); }
|
|
269
|
+
catch (error) { if (error instanceof NativePulseNotPermittedError) return; throw error; }
|
|
270
|
+
} else {
|
|
271
|
+
// No await between permit and spawn: scheduler TTL/generation owns the final start gate.
|
|
272
|
+
if (!permit.start()) return;
|
|
273
|
+
await this.run(executable!, snapshot, signal);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
let cleanupFailed = false;
|
|
277
|
+
try { await playSnapshot(); } finally {
|
|
278
|
+
// Cleanup failures never replace a playback error; they surface only when playback itself succeeded.
|
|
279
|
+
try { await this.io.unlink(snapshot); } catch (error) {
|
|
280
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") cleanupFailed = true;
|
|
281
|
+
}
|
|
282
|
+
try { await this.io.rmdir(directory); } catch { cleanupFailed = true; }
|
|
283
|
+
}
|
|
284
|
+
if (cleanupFailed) throw new Error("Audio snapshot cleanup failed");
|
|
285
|
+
}
|
|
286
|
+
private async readSource(source: string): Promise<{ bytes: Buffer; format: NotificationAudioFormat }> {
|
|
287
|
+
const handle = await this.io.open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
288
|
+
try {
|
|
289
|
+
const stat = await handle.stat();
|
|
290
|
+
if (!stat.isFile() || stat.size > MAX_BYTES || stat.size < 44) throw new TypeError("Invalid notification file");
|
|
291
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
292
|
+
// One bounded descriptor read: short reads fail closed rather than accepting truncation.
|
|
293
|
+
const { bytesRead } = await handle.read(buffer);
|
|
294
|
+
if (bytesRead !== stat.size) throw new TypeError("Notification file changed or truncated");
|
|
295
|
+
const bytes = Buffer.from(buffer.subarray(0, bytesRead));
|
|
296
|
+
return { bytes, format: validateNotificationAudio(bytes).format };
|
|
297
|
+
} finally { await handle.close(); }
|
|
298
|
+
}
|
|
299
|
+
private run(executable: string, snapshot: string, signal: AbortSignal): Promise<void> {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
const child = (this.options.spawn ?? spawn)(executable, [snapshot], {
|
|
302
|
+
shell: false, windowsHide: true, detached: false, stdio: ["ignore", "ignore", "pipe"],
|
|
303
|
+
});
|
|
304
|
+
let failed = false; let timedOut = false; let killed = false; let stderr = Buffer.alloc(0);
|
|
305
|
+
const capture = (chunk: Buffer | string) => {
|
|
306
|
+
const remaining = STDERR_LIMIT - stderr.length;
|
|
307
|
+
if (remaining > 0) stderr = Buffer.concat([stderr, Buffer.from(typeof chunk === "string" ? chunk.slice(0, remaining) : chunk.subarray(0, remaining)).subarray(0, remaining)]);
|
|
308
|
+
};
|
|
309
|
+
const kill = () => {
|
|
310
|
+
if (!killed) {
|
|
311
|
+
killed = true;
|
|
312
|
+
try { child.kill("SIGKILL"); } catch { failed = true; } // Keep reservation until close even if OS denies kill.
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const error = () => { failed = true; }; // Node emits close after error even for failed spawn.
|
|
316
|
+
child.stderr?.on("data", capture); child.on("error", error);
|
|
317
|
+
const timer = (this.options.setTimeout ?? setTimeout)(() => { timedOut = true; kill(); }, PLAYBACK_TIMEOUT_MS);
|
|
318
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
319
|
+
child.once("close", (code: number | null) => {
|
|
320
|
+
(this.options.clearTimeout ?? clearTimeout)(timer);
|
|
321
|
+
signal.removeEventListener("abort", kill); child.removeListener("error", error);
|
|
322
|
+
child.stderr?.removeListener("data", capture);
|
|
323
|
+
const detail = stderr.toString("utf8").trim(); stderr = Buffer.alloc(0);
|
|
324
|
+
if (signal.aborted) resolve();
|
|
325
|
+
else if (timedOut) reject(new Error("Audio playback timed out"));
|
|
326
|
+
else if (failed || code !== 0) reject(new Error(detail ? `Audio process failed: ${detail}` : "Audio process failed"));
|
|
327
|
+
else resolve();
|
|
328
|
+
});
|
|
329
|
+
if (signal.aborted) kill();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
BUILTIN_NOTIFICATION_IDS, NOTIFICATION_EVENTS,
|
|
5
|
+
type NotificationEvent, type NotificationSettings, type NotificationSound,
|
|
6
|
+
} from "./notification-policy.ts";
|
|
7
|
+
import { getNotificationService, type NotificationService } from "./notification-service.ts";
|
|
8
|
+
import type { CustomizeInline, CustomizeRow } from "./visual-customize-view.ts";
|
|
9
|
+
|
|
10
|
+
/** Silence first, then the builtins; Enter wraps back to silence. Priority is the event's, not the sound's. */
|
|
11
|
+
const SOUND_CYCLE: readonly NotificationSound[] = [null, ...BUILTIN_NOTIFICATION_IDS.map(id => `builtin:${id}` as const)];
|
|
12
|
+
/** Human labels for the builtin tones; the basic card never prints a `builtin:` id. */
|
|
13
|
+
const HUMAN_TONES: Readonly<Record<string, string>> = Object.freeze({
|
|
14
|
+
"builtin:success": "success tone",
|
|
15
|
+
"builtin:error": "error tone",
|
|
16
|
+
"builtin:attention": "attention tone",
|
|
17
|
+
});
|
|
18
|
+
/** Footer hint shared by every row that can assign its own sound (basic groups and per-event exceptions). */
|
|
19
|
+
const SOUND_ROW_HINT = "Tab panes · Enter change · f sound · p test · Esc close";
|
|
20
|
+
|
|
21
|
+
interface GroupPlan {
|
|
22
|
+
name: string;
|
|
23
|
+
recommended: `builtin:${typeof BUILTIN_NOTIFICATION_IDS[number]}`;
|
|
24
|
+
targets: readonly NotificationEvent[];
|
|
25
|
+
}
|
|
26
|
+
/** Fixed, verified targets per basic type. Attention never intersects `subagent.waiting`. */
|
|
27
|
+
const GROUP_PLANS: readonly GroupPlan[] = [
|
|
28
|
+
{ name: "Success", recommended: "builtin:success", targets: ["agent.completed", "subagent.completed"] },
|
|
29
|
+
{ name: "Error", recommended: "builtin:error", targets: ["agent.failed", "subagent.failed", "subagent.timed_out"] },
|
|
30
|
+
{ name: "Attention", recommended: "builtin:attention", targets: ["agent.attention"] },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Human basename for a local audio reference; never leaks the absolute path or the `file:` prefix. */
|
|
34
|
+
function fileBasename(ref: NotificationSound): string {
|
|
35
|
+
const path = ref !== null && ref.startsWith("file:") ? ref.slice(5) : "";
|
|
36
|
+
return path.split(/[\\/]/).pop() || path;
|
|
37
|
+
}
|
|
38
|
+
/** Basic label for a uniform selection: human tone, silence or file basename. */
|
|
39
|
+
function selectionLabel(sound: NotificationSound): string {
|
|
40
|
+
if (sound === null) return "silence";
|
|
41
|
+
if (sound.startsWith("builtin:")) return HUMAN_TONES[sound] ?? "tone";
|
|
42
|
+
return fileBasename(sound);
|
|
43
|
+
}
|
|
44
|
+
type GroupSelection = { kind: "uniform"; sound: NotificationSound } | { kind: "mixed" };
|
|
45
|
+
/** A group is uniform only when every target resolves to the identical reference; missing keys count as silence. */
|
|
46
|
+
function groupSelection(settings: NotificationSettings, group: GroupPlan): GroupSelection {
|
|
47
|
+
const values = group.targets.map(target => settings.audio.events[target] ?? null);
|
|
48
|
+
const first = values[0] ?? null;
|
|
49
|
+
return values.every(value => value === first) ? { kind: "uniform", sound: first } : { kind: "mixed" };
|
|
50
|
+
}
|
|
51
|
+
function groupSelectionLabel(settings: NotificationSettings, group: GroupPlan): string {
|
|
52
|
+
const selection = groupSelection(settings, group);
|
|
53
|
+
return selection.kind === "mixed" ? "Custom (varies)" : selectionLabel(selection.sound);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Preview description for the Advanced per-event rows. */
|
|
57
|
+
function soundDescription(sound: NotificationSound): string {
|
|
58
|
+
if (sound === null) return "silence";
|
|
59
|
+
return sound.startsWith("builtin:") ? `${sound} · builtin tone` : `local sound ${fileBasename(sound)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds the Notifications category for the shared customize card. The basic
|
|
64
|
+
* card exposes the global switch, process mute and three independent type
|
|
65
|
+
* groups (Success/Error/Attention) that each own their included tone or a
|
|
66
|
+
* local sound (WAV/OGG/FLAC), plus the `Advanced` toggle. Per-event exceptions stay folded
|
|
67
|
+
* behind `Advanced`. Every label/preview is read-only, and every action
|
|
68
|
+
* resolves the owner facade at action time: importing or rendering this module
|
|
69
|
+
* never discovers a player, reads a file or writes settings. Rows use the
|
|
70
|
+
* inline bridge instead of nested `ctx.ui.select/input/confirm` dialogs. The
|
|
71
|
+
* service keeps `restorePreset`/`availability` as an internal contract, but the
|
|
72
|
+
* card no longer surfaces them.
|
|
73
|
+
*/
|
|
74
|
+
export function buildNotificationRows(ctx: ExtensionContext): CustomizeRow[] {
|
|
75
|
+
const category = "Notifications" as const;
|
|
76
|
+
let advanced = false;
|
|
77
|
+
const notify = (message: string, error = false): void => { ctx.ui.notify(message, error ? "warning" : "info"); };
|
|
78
|
+
const serviceOrNotify = (): NotificationService | undefined => {
|
|
79
|
+
const service = getNotificationService();
|
|
80
|
+
if (!service) notify("Audio notifications are unavailable in this session.", true);
|
|
81
|
+
return service;
|
|
82
|
+
};
|
|
83
|
+
const state = () => getNotificationService()?.getState();
|
|
84
|
+
/** Recovery consent is per attempt, strictly boolean, and re-resolved after every prompt. */
|
|
85
|
+
const withRecovery = async (inline: CustomizeInline, run: (service: NotificationService, confirmRecovery: boolean) => boolean): Promise<void> => {
|
|
86
|
+
const initial = serviceOrNotify();
|
|
87
|
+
if (!initial) return;
|
|
88
|
+
let confirmRecovery = false;
|
|
89
|
+
const resolution = initial.getState();
|
|
90
|
+
if (resolution.malformed || resolution.readError) {
|
|
91
|
+
confirmRecovery = await inline.confirm("Replace invalid audio configuration? The existing file is malformed or unreadable.");
|
|
92
|
+
if (!confirmRecovery || inline.disposed) return;
|
|
93
|
+
}
|
|
94
|
+
const service = getNotificationService();
|
|
95
|
+
if (!service || inline.disposed) return;
|
|
96
|
+
const ok = run(service, confirmRecovery);
|
|
97
|
+
notify(ok ? "Audio notification preferences saved." : "Audio notification preferences not saved; choose again to retry.", !ok);
|
|
98
|
+
};
|
|
99
|
+
const mutate = (inline: CustomizeInline, change: (settings: NotificationSettings) => void): Promise<void> =>
|
|
100
|
+
withRecovery(inline, (service, confirmRecovery) => {
|
|
101
|
+
const settings = structuredClone(service.getState().settings);
|
|
102
|
+
change(settings);
|
|
103
|
+
return service.setConfig(ctx, settings, { confirmRecovery });
|
|
104
|
+
});
|
|
105
|
+
/** One atomic write touching exactly `targets`; every other key (and `enabled`) is preserved. */
|
|
106
|
+
const setTargets = (inline: CustomizeInline, targets: readonly NotificationEvent[], sound: NotificationSound): Promise<void> =>
|
|
107
|
+
mutate(inline, settings => { for (const target of targets) settings.audio.events[target] = sound; });
|
|
108
|
+
const previewSound = (sound: NotificationSound): void => {
|
|
109
|
+
const service = serviceOrNotify();
|
|
110
|
+
if (!service) return;
|
|
111
|
+
if (sound === null) { notify("Silence has no sound to preview; assign one first."); return; }
|
|
112
|
+
if (!service.preview(ctx, sound)) notify("Preview unavailable or audio busy; try again.", true);
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* The only place a local sound is read: shared by the three basic groups and the
|
|
116
|
+
* Advanced per-event rows. Reuses the service's validated `validateFile` (WAV/OGG/FLAC,
|
|
117
|
+
* ≤2 MiB, ≤10 s, no shell/URLs) and re-checks owner/disposal before the
|
|
118
|
+
* single atomic write. Returns the field task so the view keeps the card busy.
|
|
119
|
+
*/
|
|
120
|
+
const chooseFileFor = async (inline: CustomizeInline, targets: readonly NotificationEvent[], prefill: string): Promise<boolean> => {
|
|
121
|
+
const expected = serviceOrNotify();
|
|
122
|
+
if (!expected) return true;
|
|
123
|
+
const path = await inline.input({ prompt: "Local audio path (WAV/OGG/FLAC, absolute, no URLs)", value: prefill });
|
|
124
|
+
if (path === undefined) return true;
|
|
125
|
+
const sound = `file:${path}` as const;
|
|
126
|
+
if (getNotificationService() !== expected) return true;
|
|
127
|
+
const valid = await expected.validateFile(ctx, sound);
|
|
128
|
+
// A replaced owner, closed card or changed session invalidates the validated choice before any write.
|
|
129
|
+
if (!valid || inline.disposed || getNotificationService() !== expected) {
|
|
130
|
+
if (!inline.disposed && getNotificationService() === expected) notify("Select a readable local sound (WAV/OGG/FLAC, ≤2 MiB, ≤10 seconds); absolute local path only.", true);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
await setTargets(inline, targets, sound);
|
|
134
|
+
return true;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const rows: CustomizeRow[] = [];
|
|
138
|
+
rows.push({
|
|
139
|
+
category,
|
|
140
|
+
label: () => { const current = state(); return `Audio notifications: ${current ? (current.settings.enabled ? "on" : "off") : "unavailable"}`; },
|
|
141
|
+
preview: () => {
|
|
142
|
+
const current = state();
|
|
143
|
+
return { title: "Audio notifications · global switch",
|
|
144
|
+
sample: current ? `opt-in audio · ${current.settings.enabled ? "enabled" : "disabled"} · mute: ${current.muted ? "on" : "off"} · separate from visual settings` : "Unavailable in this session" };
|
|
145
|
+
},
|
|
146
|
+
action: async inline => {
|
|
147
|
+
const service = serviceOrNotify();
|
|
148
|
+
if (!service) return;
|
|
149
|
+
const enabled = !service.getState().settings.enabled;
|
|
150
|
+
await mutate(inline, settings => { settings.enabled = enabled; });
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
rows.push({
|
|
154
|
+
category,
|
|
155
|
+
label: () => { const current = state(); return `Audio: ${current ? (current.muted ? "muted" : "unmuted") : "unavailable"}`; },
|
|
156
|
+
preview: () => ({ title: "Audio · process mute", sample: "mute survives reload and session replacement; restarting Pi clears it; muted or disabled events are never replayed" }),
|
|
157
|
+
action: () => {
|
|
158
|
+
const service = serviceOrNotify();
|
|
159
|
+
if (!service) return;
|
|
160
|
+
if (!service.setMuted(ctx, !service.getState().muted)) notify("Audio mute could not be changed.", true);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
for (const group of GROUP_PLANS) {
|
|
164
|
+
rows.push({
|
|
165
|
+
category,
|
|
166
|
+
keyhint: SOUND_ROW_HINT,
|
|
167
|
+
label: () => { const current = state(); return current ? `${group.name}: ${groupSelectionLabel(current.settings, group)}` : `${group.name}: unavailable`; },
|
|
168
|
+
preview: () => {
|
|
169
|
+
const current = state();
|
|
170
|
+
if (!current) return { title: `${group.name} sounds`, sample: "Unavailable in this session" };
|
|
171
|
+
const selection = groupSelection(current.settings, group);
|
|
172
|
+
const detail = selection.kind === "mixed" ? `mixes several sounds across ${group.targets.length} events · expand Advanced for per-event exceptions`
|
|
173
|
+
: selection.sound === null ? "silence · Enter cycles tone or assign your own sound with f"
|
|
174
|
+
: selection.sound.startsWith("builtin:") ? `${selectionLabel(selection.sound)} · builtin tone · f assigns your own sound`
|
|
175
|
+
: `${selectionLabel(selection.sound)} (own group file) · f replaces it`;
|
|
176
|
+
return { title: `${group.name} sounds · applies to ${group.targets.length} event${group.targets.length === 1 ? "" : "s"}`, sample: `${detail} · p tests the selected sound` };
|
|
177
|
+
},
|
|
178
|
+
action: async inline => {
|
|
179
|
+
const service = serviceOrNotify();
|
|
180
|
+
if (!service) return;
|
|
181
|
+
const selection = groupSelection(service.getState().settings, group);
|
|
182
|
+
const next = selection.kind === "mixed" ? group.recommended
|
|
183
|
+
: SOUND_CYCLE[(SOUND_CYCLE.indexOf(selection.sound) + 1) % SOUND_CYCLE.length]!;
|
|
184
|
+
await setTargets(inline, group.targets, next);
|
|
185
|
+
},
|
|
186
|
+
key: (data, inline) => {
|
|
187
|
+
if (matchesKey(data, "p")) {
|
|
188
|
+
const service = getNotificationService();
|
|
189
|
+
if (!service) return true;
|
|
190
|
+
const selection = groupSelection(service.getState().settings, group);
|
|
191
|
+
if (selection.kind === "mixed") notify("This group mixes sounds; expand Advanced to review per-event exceptions.");
|
|
192
|
+
else previewSound(selection.sound);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (matchesKey(data, "f")) {
|
|
196
|
+
const service = getNotificationService();
|
|
197
|
+
const selection = service ? groupSelection(service.getState().settings, group) : undefined;
|
|
198
|
+
const prefill = selection?.kind === "uniform" && selection.sound !== null && selection.sound.startsWith("file:")
|
|
199
|
+
? selection.sound.slice(5) : "";
|
|
200
|
+
return chooseFileFor(inline, group.targets, prefill);
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
rows.push({
|
|
207
|
+
category,
|
|
208
|
+
label: () => `Advanced: ${advanced ? "hide" : "show"} per-event exceptions`,
|
|
209
|
+
preview: () => ({ title: "Advanced · per-event exceptions", sample: advanced ? "hiding folds the per-event rows again; the basic type choices are unaffected" : "reveals one-off per-event overrides; the basic type choices stay independent" }),
|
|
210
|
+
action: () => { advanced = !advanced; },
|
|
211
|
+
});
|
|
212
|
+
const events = getNotificationService()?.getState().supportedEvents
|
|
213
|
+
?? NOTIFICATION_EVENTS.filter(event => event !== "session.shutdown");
|
|
214
|
+
for (const event of events) {
|
|
215
|
+
rows.push({
|
|
216
|
+
category,
|
|
217
|
+
visible: () => advanced,
|
|
218
|
+
keyhint: SOUND_ROW_HINT,
|
|
219
|
+
label: () => { const current = state(); const sound = current?.settings.audio.events[event] ?? null; return `${event}: ${sound === null ? "silence" : sound}`; },
|
|
220
|
+
preview: () => {
|
|
221
|
+
const sound = state()?.settings.audio.events[event] ?? null;
|
|
222
|
+
return { title: `${event} · audio event`, sample: `selected: ${soundDescription(sound)} · Enter cycles silence/success/error/attention · f local sound · p test sound` };
|
|
223
|
+
},
|
|
224
|
+
action: async inline => {
|
|
225
|
+
const service = serviceOrNotify();
|
|
226
|
+
if (!service) return;
|
|
227
|
+
const current = service.getState().settings.audio.events[event] ?? null;
|
|
228
|
+
const index = SOUND_CYCLE.findIndex(candidate => candidate === current);
|
|
229
|
+
const next = SOUND_CYCLE[(index + 1) % SOUND_CYCLE.length]!;
|
|
230
|
+
await mutate(inline, settings => { settings.audio.events[event] = next; });
|
|
231
|
+
},
|
|
232
|
+
key: (data, inline) => {
|
|
233
|
+
if (matchesKey(data, "p")) { previewSound(getNotificationService()?.getState().settings.audio.events[event] ?? null); return true; }
|
|
234
|
+
// Return the field/validation promise so the view keeps the card busy until it settles.
|
|
235
|
+
if (matchesKey(data, "f")) return chooseFileFor(inline, [event], "");
|
|
236
|
+
return false;
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return rows;
|
|
241
|
+
}
|