gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,150 @@
1
+ import type { ImageContent } from "@earendil-works/pi-ai";
2
+ import { getImageDimensions, type ImageDimensions } from "@earendil-works/pi-tui";
3
+ import type { ClipboardImage } from "./clipboard-image.ts";
4
+
5
+ // Pasted images live in the draft as atomic `[Image#N]` chips (gentle-shell#2464).
6
+ // The bytes stay in memory: a chip deleted from the draft is never sent, and
7
+ // the chips still in the text become real image attachments on submit.
8
+ //
9
+ // The token has no whitespace on purpose: Pi's word motions, word deletion and
10
+ // word wrap treat any segment containing a space as whitespace unless it is
11
+ // one of Pi's own paste markers, so `[Image #1]` would be skipped and eaten
12
+ // together with the word before it.
13
+
14
+ export const IMAGE_TOKEN_PATTERN = /\[Image#(\d+)\]/g;
15
+
16
+ export interface ImageAttachment {
17
+ id: number;
18
+ token: string;
19
+ mimeType: string;
20
+ byteLength: number;
21
+ base64: string;
22
+ dimensions?: ImageDimensions;
23
+ }
24
+
25
+ export interface SubmittedImages {
26
+ text: string;
27
+ images: ImageContent[];
28
+ /** Chips the active model cannot receive; their tokens become a plain note. */
29
+ omitted: number;
30
+ }
31
+
32
+ export interface TextSpan { start: number; end: number }
33
+
34
+ interface Segment { segment: string; index: number; input: string }
35
+
36
+ export function imageToken(id: number): string {
37
+ return `[Image#${id}]`;
38
+ }
39
+
40
+ export class ImageAttachments {
41
+ private readonly entries = new Map<number, ImageAttachment>();
42
+ // Never reset: Pi's prompt history keeps the raw submitted text, so a
43
+ // reused id would let a recalled `[Image#1]` bind to a different image.
44
+ private counter = 0;
45
+
46
+ get size(): number { return this.entries.size; }
47
+
48
+ has(id: number): boolean { return this.entries.has(id); }
49
+
50
+ add(image: ClipboardImage): ImageAttachment {
51
+ const id = ++this.counter;
52
+ const base64 = Buffer.from(image.bytes).toString("base64");
53
+ const attachment: ImageAttachment = {
54
+ id,
55
+ token: imageToken(id),
56
+ mimeType: image.mimeType,
57
+ byteLength: image.bytes.length,
58
+ base64,
59
+ dimensions: getImageDimensions(base64, image.mimeType) ?? undefined,
60
+ };
61
+ this.entries.set(id, attachment);
62
+ return attachment;
63
+ }
64
+
65
+ /** Registered chips in the order they appear in `text`, each once. */
66
+ referenced(text: string): ImageAttachment[] {
67
+ const seen = new Set<number>();
68
+ const result: ImageAttachment[] = [];
69
+ for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
70
+ const id = Number(match[1]);
71
+ const attachment = this.entries.get(id);
72
+ if (!attachment || seen.has(id)) continue;
73
+ seen.add(id);
74
+ result.push(attachment);
75
+ }
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Resolve a submitted draft. Returns undefined when it holds no registered
81
+ * chip, so ordinary prompts pass through untouched.
82
+ *
83
+ * Pi can run input handlers after the editor already holds a new draft
84
+ * (a prompt queued during compaction), so only chips the current draft
85
+ * still shows survive; everything else, sent or deleted, is released.
86
+ */
87
+ submit(text: string, modelAcceptsImages: boolean, currentDraft = ""): SubmittedImages | undefined {
88
+ const attachments = this.referenced(text);
89
+ const keep = new Set(this.referenced(currentDraft).map((attachment) => attachment.id));
90
+ for (const id of [...this.entries.keys()]) if (!keep.has(id)) this.entries.delete(id);
91
+ if (attachments.length === 0) return undefined;
92
+ if (modelAcceptsImages) {
93
+ return { text, images: attachments.map((attachment) => ({ type: "image", data: attachment.base64, mimeType: attachment.mimeType })), omitted: 0 };
94
+ }
95
+ const ids = new Set(attachments.map((attachment) => attachment.id));
96
+ const noted = text.replace(IMAGE_TOKEN_PATTERN, (token, id: string) => ids.has(Number(id)) ? `${token.slice(0, -1)} omitted: the current model does not accept images]` : token);
97
+ return { text: noted, images: [], omitted: attachments.length };
98
+ }
99
+
100
+ /** Drop every pending image; numbering keeps counting up. */
101
+ clear(): void {
102
+ this.entries.clear();
103
+ }
104
+
105
+ /** Character spans of the registered chips in `text`. */
106
+ spans(text: string): TextSpan[] {
107
+ if (this.entries.size === 0 || !text.includes("[Image#")) return [];
108
+ const spans: TextSpan[] = [];
109
+ for (const match of text.matchAll(IMAGE_TOKEN_PATTERN)) {
110
+ if (this.entries.has(Number(match[1]))) spans.push({ start: match.index, end: match.index + match[0].length });
111
+ }
112
+ return spans;
113
+ }
114
+
115
+ /**
116
+ * Merge every registered chip into one segment, so cursor movement, word
117
+ * wrap and deletion treat it as a single unit (one Backspace removes it).
118
+ * Segments that already span several graphemes, such as Pi's own paste
119
+ * markers, pass through unchanged.
120
+ */
121
+ segment(text: string, segments: Iterable<Segment>): Iterable<Segment> {
122
+ const spans = this.spans(text);
123
+ if (spans.length === 0) return segments;
124
+ const merged: Segment[] = [];
125
+ let span = 0;
126
+ for (const segment of segments) {
127
+ while (span < spans.length && spans[span]!.end <= segment.index) span++;
128
+ const current = spans[span];
129
+ if (current && segment.index >= current.start && segment.index < current.end) {
130
+ if (segment.index === current.start) merged.push({ segment: text.slice(current.start, current.end), index: current.start, input: text });
131
+ continue;
132
+ }
133
+ merged.push(segment);
134
+ }
135
+ return merged;
136
+ }
137
+ }
138
+
139
+ export function formatBytes(bytes: number): string {
140
+ if (bytes < 1024) return `${bytes} B`;
141
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
142
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
143
+ }
144
+
145
+ /** `png · 1280×720 · 84 KB`: the chip's description when no preview is drawn. */
146
+ export function describeAttachment(attachment: Pick<ImageAttachment, "mimeType" | "byteLength" | "dimensions">): string {
147
+ const format = attachment.mimeType.replace(/^image\//, "").replace("jpeg", "jpg");
148
+ const size = attachment.dimensions ? `${attachment.dimensions.widthPx}×${attachment.dimensions.heightPx}` : undefined;
149
+ return [format, size, formatBytes(attachment.byteLength)].filter(Boolean).join(" · ");
150
+ }
@@ -153,6 +153,36 @@ function isTextContent(part: { type?: unknown }): part is TextContent {
153
153
  return part.type === "text";
154
154
  }
155
155
 
156
+ /** Completion diagnostics are facts only; never copy provider text, errors, or payloads. */
157
+ function emptyOutputEvidence(model: Model<Api>, assistant: AssistantMessage): Record<string, unknown> {
158
+ const evidence: Record<string, unknown> = { reviewerModel: `${model.provider}/${model.id}`.slice(0, 512) };
159
+ if (["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"].includes(assistant.stopReason)) {
160
+ evidence.stopReason = assistant.stopReason;
161
+ }
162
+ const addCounter = (target: Record<string, number>, key: string, value: unknown) => {
163
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) target[key] = value;
164
+ };
165
+ const usage: Record<string, number> = {};
166
+ for (const key of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"] as const) {
167
+ addCounter(usage, key, assistant.usage?.[key]);
168
+ }
169
+ if (Object.keys(usage).length > 0) evidence.usage = usage;
170
+ const text = assistant.content.filter(isTextContent);
171
+ const thinking = assistant.content.filter((part) => part.type === "thinking");
172
+ const content: Record<string, number> = {};
173
+ addCounter(content, "blocks", assistant.content.length);
174
+ addCounter(content, "textBlocks", text.length);
175
+ addCounter(content, "thinkingBlocks", thinking.length);
176
+ if (text.every((part) => typeof part.text === "string")) {
177
+ addCounter(content, "textChars", text.reduce((sum, part) => sum + part.text.length, 0));
178
+ }
179
+ if (thinking.every((part) => typeof part.thinking === "string")) {
180
+ addCounter(content, "thinkingChars", thinking.reduce((sum, part) => sum + part.thinking.length, 0));
181
+ }
182
+ evidence.content = content;
183
+ return evidence;
184
+ }
185
+
156
186
  /**
157
187
  * Mirrors pi's main-loop OpenCode attribution condition exactly
158
188
  * (core/provider-attribution.js#getSessionHeaders): the model's provider is
@@ -329,10 +359,11 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
329
359
 
330
360
  const text = assistant.content.filter(isTextContent).map((part) => part.text).join("");
331
361
  if (text.length === 0) {
362
+ const evidence = emptyOutputEvidence(model, assistant);
332
363
  return refuse(
333
364
  INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT,
334
- `Reviewer produced no text for ${request.routingKey} (stopReason: ${assistant.stopReason}).`,
335
- { stopReason: assistant.stopReason, ...(assistant.errorMessage === undefined ? {} : { errorMessage: assistant.errorMessage }) },
365
+ `Reviewer produced no text for ${request.routingKey} (stopReason: ${evidence.stopReason ?? "unknown"}).`,
366
+ evidence,
336
367
  );
337
368
  }
338
369
 
@@ -57,6 +57,8 @@ const CHAT_MODELS: NanChatModelConfig[] = ([
57
57
  {
58
58
  id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", input: ["text", "image"], contextWindow: 1_000_000,
59
59
  thinkingLevelMap: EXTENDED_THINKING_LEVEL_MAP, maxTokens: 16_384,
60
+ // NaN's DeepSeek route enforces this field, not max_completion_tokens.
61
+ compat: { maxTokensField: "max_tokens" },
60
62
  },
61
63
  {
62
64
  id: "glm5.3-flash", name: "GLM 5.3 Flash", input: ["text", "image"], contextWindow: 1_000_000,
@@ -96,6 +98,7 @@ function cloneModel(model: NanChatModelConfig): NanChatModelConfig {
96
98
  input: [...model.input],
97
99
  cost: { ...model.cost },
98
100
  thinkingLevelMap: model.thinkingLevelMap ? { ...model.thinkingLevelMap } : undefined,
101
+ ...(model.compat ? { compat: { ...model.compat } } : {}),
99
102
  };
100
103
  }
101
104
 
@@ -119,7 +119,8 @@ export interface NativeReviewCli {
119
119
  // Read-only risk assessment (gentle-ai#4295, landing in parallel with
120
120
  // gentle-pi#662). Same plain-versioned shape as reviewMode/reviewStatus:
121
121
  // an older binary without the verb, or any other process/decode failure,
122
- // rejects the returned promise -- callers fail closed to `high` risk.
122
+ // rejects the returned promise -- callers report the tier as `unassessable`, which informs
123
+ // the verification plan but never adds an independent verifier by itself.
123
124
  assess?(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1>;
124
125
  }
125
126
 
@@ -201,10 +202,10 @@ export interface NativeReviewModeResult {
201
202
  // stream while still succeeding. That last qualifier is what keeps this list
202
203
  // honest: v2.4.0 deleted reviewConsentSkippedDefaultProvenance ("Reviews are
203
204
  // on by default; this was never explicitly chosen. ..."), which used to ride
204
- // with the skip notice whenever the resolved mode source was `default`. Under
205
- // opt-in receipt-driven development a default-source clone is refused long
206
- // before the consent ceremony runs, so the pinned binary can no longer emit
207
- // that line and it is removed here rather than left as dead tolerance. Multi-
205
+ // with the skip notice whenever the resolved mode source was `default`. Reviews
206
+ // are on by default again since gentle-ai v4.0.0, but the pinned binary no
207
+ // longer has that function and cannot emit the line, so it is removed here
208
+ // rather than left as dead tolerance. Multi-
208
209
  // line stderr is still expected in principle — these are separate Fprintln
209
210
  // calls, never one joined string — which is why membership is per line.
210
211
  export const REVIEW_CONSENT_NOTICES = Object.freeze([
@@ -1082,6 +1083,20 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1082
1083
  // b04b6b75d1a446c942a816fbf92932d16f8c3d3001f93159dee2a92bbf2ebe44.
1083
1084
  // No new negotiated capability identity is asserted.
1084
1085
  "4.1.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1086
+ // v4.2.0 repeats 4.1.1: the published binary still advertises
1087
+ // capabilities/v2.6 under review-integration/v2. The provider contract moved
1088
+ // from 1.3.0 to 1.4.0 (routing/pi.md, gentle-ai#5536), with published
1089
+ // tarball SHA-256
1090
+ // 9d2927a84ba9a596f69029e3f854b16f86ca4c8b32cc5d63683764ccf2fb394b.
1091
+ // No new negotiated capability identity is asserted.
1092
+ "4.2.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1093
+ // v4.3.1 repeats 4.2.0: the published binary still advertises
1094
+ // capabilities/v2.6 under review-integration/v2, and the provider contract
1095
+ // stays at 1.4.0, with published tarball SHA-256
1096
+ // 58450471280aeb357443d6a28c819132d8c8eb78dd25b12bac64296b2f8c27c2.
1097
+ // The v4.3.0 tag has no release and is never pinned.
1098
+ // No new negotiated capability identity is asserted.
1099
+ "4.3.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1085
1100
  });
1086
1101
 
1087
1102
  export interface NativeReviewProcessDiagnostics {
@@ -8,6 +8,14 @@
8
8
  * worker as a Node child with fixed literal flags and a scrubbed environment.
9
9
  * Linux only; macOS/Windows never spawn. No real audio, no real Pulse stream
10
10
  * beyond the worker's own fake-test sockets; there is no physical-playback claim.
11
+ *
12
+ * Playback persistence: plays share ONE long-lived `serve` worker per bridge
13
+ * instance (line-delimited JSON over stdin, one bounded envelope per play).
14
+ * A crashed worker is replaced by a fresh spawn on the next play; abort,
15
+ * timeout, a malformed serve line and `dispose()` all SIGKILL it once and
16
+ * settle only on `close`. Requests are serialized through the worker with a
17
+ * bounded pending queue. Teardown is the owner's `dispose()` (session shutdown),
18
+ * plus the worker's own stdin-EOF exit when the owner process dies.
11
19
  */
12
20
  import { spawn } from "node:child_process";
13
21
  import type { EventEmitter } from "node:events";
@@ -21,18 +29,21 @@ const MAX_STDERR = 4096;
21
29
  const DEFAULT_PROBE_TIMEOUT_MS = 1200;
22
30
  /** 10 s maximum validated duration plus a 1 s termination margin; must outlive every accepted sound. */
23
31
  const DEFAULT_PLAY_TIMEOUT_MS = 11000;
32
+ /** Bounded pending plays per serve worker (1 active + queue), mirroring the repo's small caps. */
33
+ const MAX_PENDING_PLAYS = 8;
24
34
 
25
35
  export interface NativePulseProbeResult { readonly available: boolean; readonly formats: readonly ("wav")[]; }
26
36
  export interface NativePulseSpawnOptions {
27
37
  readonly shell: false;
28
38
  readonly windowsHide: true;
29
39
  readonly detached: false;
30
- readonly stdio: readonly ["ignore", "pipe", "pipe"];
40
+ readonly stdio: readonly ("ignore" | "pipe")[];
31
41
  readonly env: NodeJS.ProcessEnv;
32
42
  }
33
43
  export interface NativePulseChild {
34
44
  stdout: EventEmitter | null;
35
45
  stderr: EventEmitter | null;
46
+ stdin: { write(chunk: string): boolean } | null;
36
47
  kill(signal: string): boolean;
37
48
  once(event: string, listener: (...args: unknown[]) => void): EventEmitter;
38
49
  }
@@ -49,6 +60,25 @@ export interface NativePulseOptions {
49
60
 
50
61
  interface Envelope { schema?: unknown; ok?: unknown; available?: unknown; formats?: unknown; played?: unknown; }
51
62
  interface RunResult { readonly stdout: string; readonly stderr: string; readonly aborted: boolean; readonly timedOut: boolean; }
63
+ /** One queued/active play through the persistent serve worker. */
64
+ interface ServeRequest {
65
+ readonly snapshot: string;
66
+ readonly signal: AbortSignal | undefined;
67
+ readonly resolve: () => void;
68
+ readonly reject: (error: Error) => void;
69
+ timer?: ReturnType<typeof setTimeout> | number;
70
+ onAbort?: () => void;
71
+ aborted: boolean;
72
+ timedOut: boolean;
73
+ settled: boolean;
74
+ }
75
+ interface ServeWorker {
76
+ readonly child: NativePulseChild;
77
+ line: string;
78
+ active?: ServeRequest;
79
+ readonly queued: ServeRequest[];
80
+ dead: boolean;
81
+ }
52
82
 
53
83
  /** Distinct from other native failures so the owner can treat a false permit as "not started". */
54
84
  export class NativePulseNotPermittedError extends Error {
@@ -58,9 +88,8 @@ export class NativePulseNotPermittedError extends Error {
58
88
  function unrefTimer(handle: ReturnType<typeof setTimeout> | number): void { (handle as { unref?: () => void }).unref?.(); }
59
89
  function abortError(): Error { const error = new Error("Native pulse: aborted"); error.name = "AbortError"; return error; }
60
90
  function defaultSpawn(executable: string, args: string[], options: NativePulseSpawnOptions): NativePulseChild {
61
- const stdio: ("ignore" | "pipe")[] = ["ignore", "pipe", "pipe"];
62
- // SAFETY: Node's ChildProcess exposes stdout/stderr/once/kill compatible with the narrower NativePulseChild interface; the cast only bridges that structural narrowing.
63
- return spawn(executable, args, { ...options, stdio }) as unknown as NativePulseChild;
91
+ // SAFETY: Node's ChildProcess exposes stdout/stderr/stdin/once/kill compatible with the narrower NativePulseChild interface; the cast only bridges that structural narrowing.
92
+ return spawn(executable, args, { ...options, stdio: [...options.stdio] }) as unknown as NativePulseChild;
64
93
  }
65
94
  function parseEnvelope(stdout: string): Envelope | undefined {
66
95
  const text = stdout.trim();
@@ -78,6 +107,7 @@ export class NativePulsePlayer {
78
107
  private readonly setTimer: NonNullable<NativePulseOptions["setTimeout"]>;
79
108
  private readonly clearTimer: NonNullable<NativePulseOptions["clearTimeout"]>;
80
109
  private formats: readonly ("wav")[] = [];
110
+ private worker?: ServeWorker;
81
111
 
82
112
  constructor(options: NativePulseOptions = {}) {
83
113
  this.options = { ...options };
@@ -107,10 +137,9 @@ export class NativePulsePlayer {
107
137
  }
108
138
 
109
139
  /** Spawns synchronously; resolves ONLY on the child `close` event (never exit/error/kill). */
110
- private run(mode: "probe" | "play", snapshot: string | undefined, signal: AbortSignal | undefined, timeoutMs: number): Promise<RunResult> {
140
+ private run(signal: AbortSignal | undefined, timeoutMs: number): Promise<RunResult> {
111
141
  return new Promise<RunResult>(resolve => {
112
- const args = [...FIXED_NODE_FLAGS, this.workerPath(), mode];
113
- if (snapshot !== undefined) args.push(snapshot);
142
+ const args = [...FIXED_NODE_FLAGS, this.workerPath(), "probe"];
114
143
  const child = (this.options.spawn ?? defaultSpawn)(process.execPath, args, {
115
144
  shell: false, windowsHide: true, detached: false, stdio: ["ignore", "pipe", "pipe"], env: this.childEnv(),
116
145
  });
@@ -142,21 +171,128 @@ export class NativePulsePlayer {
142
171
  /** Offline/malformed/unsupported probe resolves unavailable; it never throws private IPC. */
143
172
  async probe(signal?: AbortSignal): Promise<NativePulseProbeResult> {
144
173
  if (!this.supportsTarget() || signal?.aborted) { this.formats = []; return { available: false, formats: [] }; }
145
- const result = await this.run("probe", undefined, signal, this.options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
174
+ const result = await this.run(signal, this.options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
146
175
  const parsed = result.aborted || result.timedOut ? { available: false, formats: [] as const } : this.parseProbe(result.stdout);
147
176
  this.formats = parsed.available ? ["wav"] : [];
148
177
  return parsed;
149
178
  }
150
179
 
151
- /** `gate` (permit or predicate) runs synchronously before spawn; false means no child at all. */
180
+ /** Spawns or reuses the persistent `serve` worker. A dead worker is replaced by a fresh spawn. */
181
+ private ensureWorker(): ServeWorker {
182
+ if (this.worker && !this.worker.dead) return this.worker;
183
+ const child = (this.options.spawn ?? defaultSpawn)(process.execPath, [...FIXED_NODE_FLAGS, this.workerPath(), "serve"], {
184
+ shell: false, windowsHide: true, detached: false, stdio: ["pipe", "pipe", "pipe"], env: this.childEnv(),
185
+ });
186
+ const worker: ServeWorker = { child, line: "", queued: [], dead: false };
187
+ this.worker = worker;
188
+ child.stdout?.on("data", chunk => this.onServeStdout(worker, chunk));
189
+ child.stderr?.on("data", () => { /* drained: a full stderr pipe must never stall the worker */ });
190
+ child.once("close", () => this.closeWorker(worker));
191
+ child.once("error", () => { /* Node emits close after spawn errors; keep waiting */ });
192
+ return worker;
193
+ }
194
+
195
+ private armGuards(request: ServeRequest): void {
196
+ const timer = this.setTimer(() => { request.timedOut = true; this.killWorker(); },
197
+ this.options.playTimeoutMs ?? DEFAULT_PLAY_TIMEOUT_MS);
198
+ unrefTimer(timer);
199
+ request.timer = timer;
200
+ request.onAbort = () => { request.aborted = true; this.killWorker(); };
201
+ request.signal?.addEventListener("abort", request.onAbort, { once: true });
202
+ if (request.signal?.aborted) request.onAbort();
203
+ }
204
+
205
+ private settleSuccess(request: ServeRequest): void {
206
+ if (request.settled) return;
207
+ request.settled = true;
208
+ if (request.timer !== undefined) this.clearTimer(request.timer);
209
+ if (request.onAbort) request.signal?.removeEventListener("abort", request.onAbort);
210
+ request.resolve();
211
+ }
212
+
213
+ /** Private failure: aborted requests reject with the abort error, everything else with the generic one. */
214
+ private settleFailure(request: ServeRequest): void {
215
+ if (request.settled) return;
216
+ request.settled = true;
217
+ if (request.timer !== undefined) this.clearTimer(request.timer);
218
+ if (request.onAbort) request.signal?.removeEventListener("abort", request.onAbort);
219
+ request.reject(request.aborted ? abortError() : new Error("Native pulse playback failed"));
220
+ }
221
+
222
+ /** SIGKILL once; the pending plays settle on the child `close` event (never exit/error/kill). */
223
+ private killWorker(): void {
224
+ const worker = this.worker;
225
+ if (!worker || worker.dead) return;
226
+ worker.dead = true;
227
+ try { worker.child.kill("SIGKILL"); } catch { /* close still settles every pending play */ }
228
+ }
229
+
230
+ private closeWorker(worker: ServeWorker): void {
231
+ worker.dead = true;
232
+ if (this.worker === worker) this.worker = undefined;
233
+ for (const request of [worker.active, ...worker.queued]) if (request) this.settleFailure(request);
234
+ worker.active = undefined;
235
+ worker.queued.length = 0;
236
+ }
237
+
238
+ private dispatch(worker: ServeWorker, request: ServeRequest): void {
239
+ if (worker.dead) { this.settleFailure(request); return; }
240
+ worker.active = request;
241
+ const stdin = worker.child.stdin;
242
+ if (!stdin) { this.killWorker(); return; }
243
+ try { stdin.write(`${JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, op: "play", path: request.snapshot })}\n`); }
244
+ catch { this.killWorker(); }
245
+ }
246
+
247
+ private onServeStdout(worker: ServeWorker, chunk: unknown): void {
248
+ worker.line += String(chunk);
249
+ for (;;) {
250
+ const index = worker.line.indexOf("\n");
251
+ if (index === -1) break;
252
+ const line = worker.line.slice(0, index);
253
+ worker.line = worker.line.slice(index + 1);
254
+ const envelope = parseEnvelope(line);
255
+ if (!envelope) { this.killWorker(); return; } // Protocol violation: `close` settles every pending play.
256
+ const request = worker.active;
257
+ worker.active = undefined;
258
+ if (envelope.ok === true && envelope.played === true) { if (request) this.settleSuccess(request); }
259
+ else if (request) this.settleFailure(request); // A private ok:false leaves the worker serviceable.
260
+ const next = worker.queued.shift();
261
+ if (next) this.dispatch(worker, next);
262
+ }
263
+ if (worker.line.length > MAX_STDOUT) this.killWorker(); // Unterminated envelope line over the bound.
264
+ }
265
+
266
+ /** `gate` (permit or predicate) runs synchronously before any spawn/write; false means no child at all.
267
+ * Plays serialize through one persistent worker; extras wait in a bounded queue. */
152
268
  async play(snapshot: string, signal?: AbortSignal, gate?: (() => boolean) | { permit(): boolean }): Promise<void> {
153
269
  if (!this.supportsTarget()) throw new Error("Native pulse: unsupported platform");
154
270
  if (signal?.aborted) throw abortError();
155
271
  const permitted = typeof gate === "function" ? gate() : gate?.permit();
156
272
  if (permitted === false) throw new NativePulseNotPermittedError();
157
- const result = await this.run("play", snapshot, signal, this.options.playTimeoutMs ?? DEFAULT_PLAY_TIMEOUT_MS);
158
- if (result.aborted) throw abortError();
159
- const envelope = parseEnvelope(result.stdout);
160
- if (result.timedOut || !envelope || envelope.ok !== true || envelope.played !== true) throw new Error("Native pulse playback failed");
273
+ return new Promise<void>((resolve, reject) => {
274
+ const worker = this.ensureWorker();
275
+ const request: ServeRequest = { snapshot, signal, resolve, reject, aborted: false, timedOut: false, settled: false };
276
+ if (worker.active) {
277
+ if (worker.queued.length >= MAX_PENDING_PLAYS) { reject(new Error("Native pulse: too many pending plays")); return; }
278
+ this.armGuards(request);
279
+ worker.queued.push(request);
280
+ return;
281
+ }
282
+ this.armGuards(request);
283
+ this.dispatch(worker, request);
284
+ });
285
+ }
286
+
287
+ /** Owner teardown: SIGKILL the persistent worker once and settle every pending play. */
288
+ dispose(): void {
289
+ const worker = this.worker;
290
+ this.worker = undefined;
291
+ if (!worker) return;
292
+ worker.dead = true;
293
+ try { worker.child.kill("SIGKILL"); } catch { /* the child is already gone */ }
294
+ for (const request of [worker.active, ...worker.queued]) if (request) this.settleFailure(request);
295
+ worker.active = undefined;
296
+ worker.queued.length = 0;
161
297
  }
162
298
  }
@@ -157,6 +157,8 @@ export interface NativeAudioBackend {
157
157
  probe(signal?: AbortSignal): Promise<{ available: boolean; formats: readonly NotificationAudioFormat[] }>;
158
158
  getNativeFormats(): readonly NotificationAudioFormat[];
159
159
  play(snapshot: string, signal?: AbortSignal, gate?: (() => boolean) | { permit(): boolean }): Promise<void>;
160
+ /** Optional owner teardown: a persistent native worker (Linux pulse) kills its child. */
161
+ dispose?(): void;
160
162
  }
161
163
  interface DiscoveredSystem { executable: string; formats: readonly NotificationAudioFormat[]; }
162
164
  interface Discovery { native: readonly NotificationAudioFormat[]; system?: DiscoveredSystem; }
@@ -240,6 +242,8 @@ export class NotificationPlayer {
240
242
  }
241
243
  return undefined;
242
244
  }
245
+ /** Owner teardown: disposes the native backend, killing a persistent worker if one exists. */
246
+ dispose(): void { this.native.dispose?.(); }
243
247
  async play(sound: Exclude<NotificationSound, null>, signal: AbortSignal, permit: PlaybackPermit): Promise<void> {
244
248
  if (signal.aborted) return;
245
249
  const discovered = await this.discover();
@@ -3,8 +3,18 @@
3
3
  * owned Node child (`node --experimental-strip-types --max-old-space-size=32`)
4
4
  * and speaks a single bounded JSON envelope on stdout:
5
5
  * probe -> { schema, ok, available, formats }
6
- * play -> { schema, ok, played }
7
- * Failures are private: a generic `{ schema, ok:false }` and a nonzero exit.
6
+ * Serve protocol (persistent mode): line-delimited JSON requests on stdin,
7
+ * `{ schema, op: "play", path }`, answered by one bounded
8
+ * `{ schema, ok, played }` envelope per play on stdout. Requests carry a
9
+ * snapshot PATH reference, not inline bytes: the worker re-reads the owner's
10
+ * private snapshot with the same bounded nofollow `readSnapshot()` on every
11
+ * play, so the fail-closed validation of snapshots is preserved verbatim and
12
+ * no audio bytes ever cross the stdio channel (request lines stay tiny and
13
+ * bounded). Protocol violations crash the worker with a nonzero exit and the
14
+ * owner respawns it on the next play; stdin EOF (owner death) exits cleanly
15
+ * and an idle worker self-retires, so teardown needs no owner round trip.
16
+ * Failures are private: a generic `{ schema, ok:false }` and, for protocol
17
+ * violations, a nonzero exit.
8
18
  * It reads only argv, the environment and the owner-supplied snapshot; no SDK,
9
19
  * UI, extension, config-owner, cookie-content logging or OS audio surface.
10
20
  */
@@ -15,18 +25,25 @@ import { playPulseWav } from "./notification-pulse-stream.ts";
15
25
 
16
26
  const SCHEMA = "gentle.audio.pulse/v1";
17
27
  const MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024;
28
+ /** Request lines carry only a schema/op/path triple; anything longer is a protocol violation. */
29
+ const MAX_REQUEST_BYTES = 4096;
30
+ /** Bounded request backlog; an overflow is a protocol violation (crash, owner restarts). */
31
+ const MAX_PENDING_REQUESTS = 16;
32
+ /** An idle worker self-retires so one owner process never accumulates workers; unref'd, never busy. */
33
+ const SERVE_IDLE_EXIT_MS = 60000;
18
34
 
19
35
  function emit(payload: Record<string, unknown>): void {
20
36
  process.stdout.write(`${JSON.stringify({ schema: SCHEMA, ...payload })}\n`);
21
37
  }
22
38
 
23
- /** Re-read the trusted owner snapshot with the same bounded nofollow pattern. */
24
- async function readSnapshot(path: string): Promise<Buffer> {
39
+ /** Re-read the trusted owner snapshot with the same bounded nofollow pattern.
40
+ * `buffer` is a caller-owned scratch space so persistent serving never
41
+ * reallocates the 2 MiB read window per play; the returned bytes are a copy. */
42
+ async function readSnapshot(path: string, buffer: Buffer): Promise<Buffer> {
25
43
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26
44
  try {
27
45
  const stat = await handle.stat();
28
46
  if (!stat.isFile() || stat.size < 44 || stat.size > MAX_SNAPSHOT_BYTES) throw new Error("invalid snapshot");
29
- const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
30
47
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
31
48
  if (bytesRead !== stat.size) throw new Error("invalid snapshot");
32
49
  return Buffer.from(buffer.subarray(0, bytesRead));
@@ -35,6 +52,63 @@ async function readSnapshot(path: string): Promise<Buffer> {
35
52
  }
36
53
  }
37
54
 
55
+ /** Protocol violation: one generic envelope so the waiting owner settles, then a crash. */
56
+ function crash(): never {
57
+ emit({ ok: false });
58
+ process.exit(1);
59
+ }
60
+
61
+ async function serve(): Promise<void> {
62
+ const buffer = Buffer.alloc(MAX_SNAPSHOT_BYTES + 1);
63
+ const queue: string[] = [];
64
+ let running = false;
65
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
66
+ const armIdle = () => {
67
+ clearTimeout(idleTimer);
68
+ idleTimer = setTimeout(() => { if (queue.length === 0 && !running) process.exit(0); }, SERVE_IDLE_EXIT_MS);
69
+ idleTimer.unref?.();
70
+ };
71
+ const runOne = async (line: string): Promise<void> => {
72
+ let request: { schema?: unknown; op?: unknown; path?: unknown };
73
+ try {
74
+ const parsed: unknown = JSON.parse(line);
75
+ if (typeof parsed !== "object" || parsed === null) return crash();
76
+ request = parsed as { schema?: unknown; op?: unknown; path?: unknown };
77
+ } catch { return crash(); }
78
+ if (request.schema !== SCHEMA || request.op !== "play" || typeof request.path !== "string" || request.path.length === 0) return crash();
79
+ try {
80
+ const bytes = await readSnapshot(request.path, buffer);
81
+ await playPulseWav(bytes, undefined, { clientOptions: { env: process.env } });
82
+ emit({ ok: true, played: true });
83
+ } catch { emit({ ok: false }); } // A private play failure leaves the healthy worker serviceable.
84
+ };
85
+ const drain = async (): Promise<void> => {
86
+ if (running) return;
87
+ running = true;
88
+ while (queue.length > 0) { const line = queue.shift()!; armIdle(); await runOne(line); }
89
+ running = false;
90
+ armIdle();
91
+ };
92
+ process.stdin.setEncoding("utf8");
93
+ let text = "";
94
+ process.stdin.on("data", (chunk: string) => {
95
+ armIdle();
96
+ text += chunk;
97
+ for (;;) {
98
+ const index = text.indexOf("\n");
99
+ if (index === -1) break;
100
+ const line = text.slice(0, index);
101
+ text = text.slice(index + 1);
102
+ if (line.length > MAX_REQUEST_BYTES || queue.push(line) > MAX_PENDING_REQUESTS) crash();
103
+ }
104
+ if (text.length > MAX_REQUEST_BYTES) crash(); // Unterminated request line over the bound.
105
+ void drain();
106
+ });
107
+ process.stdin.on("end", () => process.exit(0));
108
+ process.stdin.on("error", () => process.exit(1));
109
+ armIdle();
110
+ }
111
+
38
112
  async function main(): Promise<void> {
39
113
  const mode = process.argv[2];
40
114
  if (mode === "probe") {
@@ -46,21 +120,11 @@ async function main(): Promise<void> {
46
120
  }
47
121
  return;
48
122
  }
49
- if (mode === "play") {
50
- const snapshot = process.argv[3];
51
- if (snapshot === undefined) { emit({ ok: false }); process.exitCode = 1; return; }
52
- try {
53
- const bytes = await readSnapshot(snapshot);
54
- await playPulseWav(bytes, undefined, { clientOptions: { env: process.env } });
55
- emit({ ok: true, played: true });
56
- } catch {
57
- emit({ ok: false });
58
- process.exitCode = 1;
59
- }
123
+ if (mode === "serve") {
124
+ await serve();
60
125
  return;
61
126
  }
62
- emit({ ok: false });
63
- process.exitCode = 1;
127
+ crash();
64
128
  }
65
129
 
66
130
  await main();