gentle-pi 4.3.0 → 4.5.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 (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import * as fsPromises from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join, win32 } from "node:path";
@@ -7,6 +7,7 @@ import test from "node:test";
7
7
  import {
8
8
  CHANNEL_SCHEMA,
9
9
  GENTLE_AI_MAIN_PACKAGE,
10
+ GENTLE_AI_MODULE,
10
11
  MainChannelError,
11
12
  buildMainGentleAi,
12
13
  channelStatePath,
@@ -23,6 +24,8 @@ import { artifactFor } from "../scripts/installer-downloads.mjs";
23
24
 
24
25
  const SHELL_SHA = "6e7e3a18f794223396527a54c7c36d19c7d236c6";
25
26
  const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
27
+ const PSEUDO = "v4.0.1-0.20261008202137-1f9d5e6423e3";
28
+ const LIST_FAILURE = "go: github.com/gentleman-programming/gentle-ai/v4@v4.0.1-0.20261008202137-1f9d5e6423e3: module lookup failed";
26
29
  const posixOnly = { skip: process.platform === "win32" };
27
30
 
28
31
  function sandbox() {
@@ -33,6 +36,38 @@ function sandbox() {
33
36
  }
34
37
  type Call = { command: string; argv: string[]; options: { env?: Record<string, string>; cwd?: string } };
35
38
 
39
+ // Emulates the Go and binary invocations buildMainGentleAi makes, each driven by
40
+ // what `go list -m -json` resolves, what the binary embeds, and what it prints.
41
+ function gentleAiBuildRun(world: {
42
+ commit: string;
43
+ listVersion?: string;
44
+ originHash?: string;
45
+ embeddedVersion?: string;
46
+ probeVersion?: string;
47
+ listFails?: boolean;
48
+ }) {
49
+ const { commit, listVersion = "v4.2.0", originHash = commit, embeddedVersion = listVersion, probeVersion = listVersion.replace(/^v/, ""), listFails = false } = world;
50
+ const calls: Call[] = [];
51
+ const run = async (command: string, argv: string[], options: Call["options"]) => {
52
+ calls.push({ command, argv, options });
53
+ if (argv[0] === "install") {
54
+ mkdirSync(options.env!.GOBIN, { recursive: true });
55
+ writeFileSync(join(options.env!.GOBIN, "gentle-ai"), "#!/bin/sh\n", { mode: 0o755 });
56
+ return { code: 0, stdout: "" };
57
+ }
58
+ if (argv[0] === "list") {
59
+ if (listFails) return { code: 1, stdout: "", stderrTail: `${LIST_FAILURE}\n` };
60
+ return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: listVersion,
61
+ Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: originHash, Ref: "refs/tags/v4.2.0" } }) };
62
+ }
63
+ if (argv[0] === "version" && argv[1] === "-m") {
64
+ return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\t${embeddedVersion}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
65
+ }
66
+ return { code: 0, stdout: `gentle-ai ${probeVersion}\n` };
67
+ };
68
+ return { calls, run };
69
+ }
70
+
36
71
  test("channel state defaults to release and round-trips a main record", async () => {
37
72
  const s = sandbox();
38
73
  try {
@@ -169,18 +204,7 @@ test("Gentle AI main is built from the exact commit through Go's checksum databa
169
204
  const s = sandbox();
170
205
  try {
171
206
  const goPath = join(s.root, "go", "bin", "go");
172
- const calls: Call[] = [];
173
- const run = async (command: string, argv: string[], options: Call["options"]) => {
174
- calls.push({ command, argv, options });
175
- if (argv[0] === "install") {
176
- mkdirSync(options.env!.GOBIN, { recursive: true });
177
- const binary = join(options.env!.GOBIN, "gentle-ai");
178
- writeFileSync(binary, "#!/bin/sh\n");
179
- chmodSync(binary, 0o755);
180
- return { code: 0, stdout: "" };
181
- }
182
- return { code: 0, stdout: "gentle-ai 4.0.1-0.20261008202137-1f9d5e6423e3\n" };
183
- };
207
+ const { calls, run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO });
184
208
  const built = await buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath, run, fs: fsPromises });
185
209
  const install = calls[0];
186
210
  assert.equal(install.command, goPath);
@@ -194,19 +218,107 @@ test("Gentle AI main is built from the exact commit through Go's checksum databa
194
218
  } finally { s.cleanup(); }
195
219
  });
196
220
 
197
- test("a Gentle AI build whose version names another commit is rejected and never registered", posixOnly, async () => {
221
+ // #2301: when main's HEAD is exactly a tag commit, Go stamps the tag (v4.2.0), not a
222
+ // pseudo-version, so the suffix check alone rejects every tagged release.
223
+ test("a main commit that is exactly a release tag is accepted when Go resolves that tag to the commit", posixOnly, async () => {
198
224
  const s = sandbox();
199
225
  try {
200
- const run = async (_command: string, argv: string[], options: Call["options"]) => {
201
- if (argv[0] === "install") {
202
- mkdirSync(options.env!.GOBIN, { recursive: true });
203
- writeFileSync(join(options.env!.GOBIN, "gentle-ai"), "#!/bin/sh\n", { mode: 0o755 });
204
- }
205
- return { code: 0, stdout: "gentle-ai 4.0.1-0.20261008202137-aaaaaaaaaaaa\n" };
206
- };
226
+ const TAGGED = "4acaf4241beae1e0c977c92dfe406a270f8d40de";
227
+ const { run } = gentleAiBuildRun({ commit: TAGGED, listVersion: "v4.2.0", originHash: TAGGED });
228
+ const built = await buildMainGentleAi({ commit: TAGGED, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises });
229
+ assert.equal(built.binaryPath, join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", TAGGED, "gentle-ai"));
230
+ assert.equal(built.version, "4.2.0");
231
+ assert.deepEqual(JSON.parse(readFileSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")),
232
+ { schema: "gentle-pi.dev-binary/v1", path: built.binaryPath });
233
+ } finally { s.cleanup(); }
234
+ });
235
+
236
+ test("a go list whose Origin.Hash names another commit is rejected, and the build is removed", posixOnly, async () => {
237
+ const s = sandbox();
238
+ try {
239
+ const OTHER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
240
+ const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, originHash: OTHER });
241
+ await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
242
+ (error: MainChannelError) => error.code === "main-gentle-ai-unverified" && error.message.includes(OTHER) && error.message.includes(AI_SHA.slice(0, 12)));
243
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
244
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
245
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", ".build")), false);
246
+ } finally { s.cleanup(); }
247
+ });
248
+
249
+ test("a binary whose embedded module version differs from what Go resolved is rejected and never registered", posixOnly, async () => {
250
+ const s = sandbox();
251
+ try {
252
+ const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, embeddedVersion: "v4.1.2" });
253
+ await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
254
+ (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
255
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
256
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
257
+ } finally { s.cleanup(); }
258
+ });
259
+
260
+ test("a failed go list is rejected as unverified, with its stderr tail as the cause, and nothing is registered", posixOnly, async () => {
261
+ const s = sandbox();
262
+ try {
263
+ const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, listFails: true });
264
+ await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
265
+ (error: MainChannelError) => error.code === "main-gentle-ai-unverified" && error.cause instanceof Error && error.cause.message === LIST_FAILURE);
266
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
267
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
268
+ } finally { s.cleanup(); }
269
+ });
270
+
271
+ // Rebuilding a commit that is already registered (a rerun wizard step, a lost
272
+ // channel record) must not delete the working override when verification fails.
273
+ for (const [name, failure] of Object.entries({
274
+ "go list fails": { listFails: true },
275
+ "the embedded version differs": { embeddedVersion: "v4.1.2" },
276
+ "the reported version differs": { probeVersion: "4.0.1-0.20261008202137-aaaaaaaaaaaa" },
277
+ })) test(`a registered build of the same commit survives a rebuild whose verification fails because ${name}`, posixOnly, async () => {
278
+ const s = sandbox();
279
+ try {
280
+ const directory = join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA);
281
+ const binary = join(directory, "gentle-ai");
282
+ const registration = join(s.home, ".pi", "gentle-ai", "dev-binary.json");
283
+ mkdirSync(directory, { recursive: true });
284
+ writeFileSync(binary, "#!/bin/sh\n# registered\n", { mode: 0o755 });
285
+ const record = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: binary });
286
+ writeFileSync(registration, record);
287
+ const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, ...failure });
288
+ await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
289
+ (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
290
+ assert.equal(readFileSync(binary, "utf8"), "#!/bin/sh\n# registered\n");
291
+ assert.equal(readFileSync(registration, "utf8"), record);
292
+ assert.deepEqual(readdirSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai")), [AI_SHA]);
293
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", ".build")), false);
294
+ } finally { s.cleanup(); }
295
+ });
296
+
297
+ test("go list and go version -m run with the same sealed environment and cwd as go install", posixOnly, async () => {
298
+ const s = sandbox();
299
+ try {
300
+ const goPath = join(s.root, "go", "bin", "go");
301
+ const { calls, run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO });
302
+ await buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath, run, fs: fsPromises });
303
+ const sealed = { GOPROXY: "https://proxy.golang.org", GOSUMDB: "sum.golang.org", GOTOOLCHAIN: "local",
304
+ GOFLAGS: "-modcacherw", GONOSUMDB: "", GOPRIVATE: "", GOINSECURE: "", GOENV: "off", GOWORK: "off" };
305
+ const goCalls = calls.filter((call) => call.command === goPath);
306
+ assert.deepEqual(goCalls.map((call) => call.argv[0]), ["install", "list", "version"]);
307
+ for (const call of goCalls) {
308
+ assert.equal(call.options.cwd, join(s.home, ".pi", "gentle-ai", "main", ".build"), call.argv.join(" "));
309
+ for (const [key, value] of Object.entries(sealed)) assert.equal(call.options.env![key], value, `${key} for ${call.argv.join(" ")}`);
310
+ }
311
+ } finally { s.cleanup(); }
312
+ });
313
+
314
+ test("a Gentle AI build whose reported version differs from the resolved module version is rejected and never registered", posixOnly, async () => {
315
+ const s = sandbox();
316
+ try {
317
+ const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, probeVersion: "4.0.1-0.20261008202137-aaaaaaaaaaaa" });
207
318
  await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
208
319
  (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
209
320
  assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
321
+ assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
210
322
  } finally { s.cleanup(); }
211
323
  });
212
324
 
@@ -278,6 +390,13 @@ async function upgradeWorld({ userGo = null as string | null, pinned = false, ac
278
390
  const fetch = async (url: string) => ({ ok: true, status: 200, text: async () => (url.endsWith("/gentle-ai/commits/main") ? NEW_AI : SHELL_SHA) });
279
391
  const run = async (command: string, argv: string[], options: Call["options"]) => {
280
392
  calls.push({ command, argv, options });
393
+ const isGo = command === "/usr/bin/go" || command === pinnedGo;
394
+ const pseudo = (commit: string) => `v4.0.1-0.20261008202137-${commit.slice(0, 12)}`;
395
+ if (isGo && argv[0] === "version" && argv[1] === "-m") return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\t${pseudo(NEW_AI)}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
396
+ if (isGo && argv[0] === "list") {
397
+ const requested = argv[3].slice(`${GENTLE_AI_MODULE}@`.length);
398
+ return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: pseudo(requested), Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: requested } }) };
399
+ }
281
400
  if (command === "/usr/bin/go" && argv[0] === "version") return { code: 0, stdout: `go version ${userGo === "devel" ? "devel go1.26-abc" : `go${userGo}`} darwin/arm64\n` };
282
401
  if (argv[0] === "install") {
283
402
  mkdirSync(options.env!.GOBIN, { recursive: true });
@@ -7,7 +7,7 @@ import test from "node:test";
7
7
  import { applyModelConfig, applyModelConfigAsync, applySavedModelConfig } from "../extensions/gentle-ai.ts";
8
8
 
9
9
  // gentle-ai#4946: the activation sweep re-applies saved routing without user
10
- // consent. Persisted clear entries (an "inherit" saved from /gentle:models is
10
+ // consent. Persisted clear entries (an "inherit" saved from /gentle:profiles is
11
11
  // `{}`) must not delete `model_profiles` entries the user authored in the
12
12
  // agent-home `subagents.json`. Clearing stays owned by the consented flows
13
13
  // that issue it: the panel save and a confirmed profile apply.
@@ -66,7 +66,7 @@ function preservedProfilesFixture(t: test.TestContext) {
66
66
 
67
67
  test("startup sweep keeps user model_profiles entries for persisted clears", async (t) => {
68
68
  const fixture = preservedProfilesFixture(t);
69
- // Saved routing as /gentle:models leaves it after pressing "inherit" on
69
+ // Saved routing as /gentle:profiles leaves it after pressing "inherit" on
70
70
  // worker: a persisted clear entry. The sweep must not delete the entries.
71
71
  writeFileSync(fixture.globalModelsPath, JSON.stringify({ worker: {} }));
72
72
  const result = await applySavedModelConfig(fixture.context);
@@ -3,6 +3,7 @@ import test from "node:test";
3
3
  import type { RefreshModelsContext } from "@earendil-works/pi-ai";
4
4
  import { clampThinkingLevel, createModels, getSupportedThinkingLevels, InMemoryCredentialStore } from "@earendil-works/pi-ai";
5
5
  import type { Provider } from "@earendil-works/pi-ai";
6
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
6
7
  import nanProviderExtension from "../extensions/nan-provider.ts";
7
8
  import { createNanProviderConfig as createNativeProvider, isNanAdaptiveReasoningModel, NAN_PROVIDER_BASE_URL, NAN_PROVIDER_ID } from "../lib/nan-provider.ts";
8
9
 
@@ -195,6 +196,41 @@ test("initial catalog contains all seven documented chat models with configured
195
196
  assert.deepEqual(model?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
196
197
  });
197
198
 
199
+ test("only NaN DeepSeek serializes its unchanged output cap as max_tokens", async () => {
200
+ const models = createNativeProvider().getModels();
201
+ const model = models.find((model) => model.id === "deepseek-v4-flash")!;
202
+ let body: Record<string, unknown> | undefined;
203
+ const result = await completeSimple(model, { messages: [{ role: "user", content: "offline probe", timestamp: 0 }] }, {
204
+ apiKey: "synthetic-key", reasoning: "minimal",
205
+ fetch: async (input, init) => {
206
+ assert.equal(String(input), `${NAN_PROVIDER_BASE_URL}/chat/completions`);
207
+ body = JSON.parse(String(init?.body));
208
+ return new Response('data: {"id":"offline","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', {
209
+ headers: { "content-type": "text/event-stream" },
210
+ });
211
+ },
212
+ });
213
+ assert.equal(result.stopReason, "stop");
214
+ assert.equal(body?.max_tokens, 16_384);
215
+ assert.ok(!Object.hasOwn(body!, "max_completion_tokens"));
216
+ assert.equal(body?.reasoning_effort, "minimal");
217
+ assert.equal(model.maxTokens, 16_384);
218
+ assert.equal(model.compat?.maxTokensField, "max_tokens");
219
+ assert.ok(models.filter((candidate) => candidate.id !== model.id).every((candidate) => candidate.compat === undefined));
220
+ });
221
+
222
+ test("DeepSeek compat is isolated across snapshots, providers, and live refreshes", async () => {
223
+ const provider = createNativeProvider({ fetchImpl: async () => jsonResponse({ data: [{ id: "deepseek-v4-flash" }] }) });
224
+ for (const live of [false, true]) {
225
+ if (live) await provider.refreshModels!(refreshContext({ type: "api_key", key: "synthetic-key" }));
226
+ const model = provider.getModels().find((model) => model.id === "deepseek-v4-flash")!;
227
+ assert.equal(model.compat?.maxTokensField, "max_tokens");
228
+ model.compat!.maxTokensField = "max_completion_tokens";
229
+ assert.equal(provider.getModels().find((candidate) => candidate.id === model.id)!.compat?.maxTokensField, "max_tokens");
230
+ assert.equal(createNativeProvider().getModels().find((model) => model.id === "deepseek-v4-flash")!.compat?.maxTokensField, "max_tokens");
231
+ }
232
+ });
233
+
198
234
  test("GLM models expose exactly NaN's four efforts and no off", () => {
199
235
  const models = createNativeProvider().getModels();
200
236
  for (const id of ["glm5.3", "glm5.3-flash"]) {
@@ -103,9 +103,10 @@ test("2.4.0 repeats 2.2.3 because the START envelope Pi consumes still omits ris
103
103
  // the envelope carries, not a capability it lost.
104
104
  // delivery `review validate --gate pre-commit` still answers
105
105
  // `gentle-ai.review-gate-result/v1` with
106
- // `delivery: "disabled/unmanaged"` at exit 0. Under opt-in RDD
107
- // this is now the DEFAULT delivery answer for an install that
108
- // never enabled review, so the column matters more, not less.
106
+ // `delivery: "disabled/unmanaged"` at exit 0. RDD is on by
107
+ // default again since gentle-ai v4.0.0, so this is the answer
108
+ // for an install that turned review off, and the column still
109
+ // matters.
109
110
  //
110
111
  // riskEvidence and hint stay dark for exactly the reason the 2.2.0 comment
111
112
  // gives, re-proven rather than inherited: the negotiated
@@ -385,11 +386,17 @@ test("4.2.0 explicitly repeats 4.1.1 because capabilities/v2.6 is unchanged desp
385
386
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["4.1.1"] as Record<string, boolean>);
386
387
  });
387
388
 
389
+ test("4.3.1 explicitly repeats 4.2.0 because capabilities/v2.6 and provider contract 1.4.0 are unchanged", () => {
390
+ const contract = NATIVE_CLI_CONTRACTS["4.3.1"] as Record<string, boolean>;
391
+ assert.ok(contract);
392
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["4.2.0"] as Record<string, boolean>);
393
+ });
394
+
388
395
  test("no shipped version key was added beyond the pin bump", () => {
389
396
  // Rows are promises to consumers, so a new key only ever appears in a
390
397
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
391
398
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
392
399
  // so they get no row: a row asserts ground truth measured against a binary
393
400
  // Pi actually ran, and the table only has to be ascending, not gapless.
394
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0", "4.1.1", "4.2.0"]);
401
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0", "4.1.1", "4.2.0", "4.3.1"]);
395
402
  });
@@ -384,7 +384,7 @@ test("review-mode gate retains every off-source continuation and fails closed on
384
384
  for (const [source, expected] of [
385
385
  ["clone_local", /clear this clone-local override/],
386
386
  ["global", /cannot override a global off/],
387
- ["default", /off by default until explicitly enabled/],
387
+ ["default", /RDD is on by default, but this install could not resolve the review mode and failed closed to off/],
388
388
  ] as const) {
389
389
  const native = {
390
390
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: source === "global" ? "off" : "", cloneLocal: source === "clone_local" ? "off" : "", effective: "off" as const, source } }),
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
2
3
  import { EventEmitter } from "node:events";
3
4
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { createServer, type Server } from "node:net";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
7
9
  import { test } from "node:test";
8
10
  import { NativePulsePlayer, NATIVE_PULSE_SCHEMA, type NativePulseOptions, type NativePulseSpawnOptions } from "../lib/notification-audio-native.ts";
9
11
 
@@ -75,9 +77,12 @@ async function startFakeServer(): Promise<FakeServer> {
75
77
  class FakeChild extends EventEmitter {
76
78
  readonly stdout = new EventEmitter();
77
79
  readonly stderr = new EventEmitter();
80
+ readonly stdin = { writes: [] as string[], write(chunk: string): boolean { this.writes.push(chunk); return true; } };
78
81
  readonly kills: string[] = [];
79
82
  kill(signal: string): boolean { this.kills.push(signal); return true; }
80
83
  }
84
+ const okPlay = (): string => `${JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, ok: true, played: true })}\n`;
85
+ const failPlay = (): string => `${JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, ok: false })}\n`;
81
86
  function recorder(platform = "linux", overrides: Partial<NativePulseOptions> = {}) {
82
87
  const spawned: Array<{ executable: string; args: string[]; options: NativePulseSpawnOptions }> = [];
83
88
  const children: FakeChild[] = [];
@@ -135,11 +140,77 @@ test("play gate false prevents spawning and no async runs after it", async () =>
135
140
  const { player: second, spawned: secondSpawned, children } = recorder();
136
141
  const playing = second.play("/tmp/s.wav", undefined, { permit: () => true });
137
142
  assert.equal(secondSpawned.length, 1);
138
- children[0]!.stdout.emit("data", JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, ok: true, played: true }));
139
- children[0]!.emit("close");
143
+ children[0]!.stdout.emit("data", okPlay());
140
144
  await playing;
141
145
  });
142
146
 
147
+ /** N plays must reuse ONE persistent `serve` worker: spawn once, queue, answer one envelope per play. */
148
+ test("persistent serve worker: N plays spawn exactly one worker and each play succeeds", async () => {
149
+ const { player, spawned, children } = recorder();
150
+ const paths = ["/tmp/a.wav", "/tmp/b.wav", "/tmp/c.wav"];
151
+ const plays = paths.map(path => player.play(path));
152
+ assert.equal(spawned.length, 1, "N plays must spawn exactly one serve worker");
153
+ assert.equal(spawned[0]!.args[3], "serve");
154
+ assert.deepEqual(spawned[0]!.options.stdio, ["pipe", "pipe", "pipe"]);
155
+ assert.equal(children[0]!.stdin.writes.length, 1, "queued plays must not write until the active play settles");
156
+ for (const write of children[0]!.stdin.writes) {
157
+ const request = JSON.parse(write) as { schema: string; op: string; path: string };
158
+ assert.equal(request.schema, NATIVE_PULSE_SCHEMA); assert.equal(request.op, "play"); assert.equal(request.path, paths[0]);
159
+ }
160
+ for (let i = 0; i < plays.length; i++) children[0]!.stdout.emit("data", okPlay());
161
+ await Promise.all(plays);
162
+ assert.equal(spawned.length, 1);
163
+ assert.equal(children[0]!.stdin.writes.length, 3);
164
+ assert.deepEqual(children[0]!.stdin.writes.map(write => (JSON.parse(write) as { path: string }).path), paths);
165
+ });
166
+
167
+ test("a crashed serve worker is replaced by a fresh spawn on the next play", async () => {
168
+ const { player, spawned, children } = recorder();
169
+ const first = player.play("/tmp/a.wav");
170
+ children[0]!.stdout.emit("data", okPlay());
171
+ await first;
172
+ children[0]!.emit("close"); // crash while idle
173
+ const second = player.play("/tmp/b.wav");
174
+ assert.equal(spawned.length, 2, "the next play must spawn a fresh worker after a crash");
175
+ assert.equal(spawned[1]!.args[3], "serve");
176
+ children[1]!.stdout.emit("data", okPlay());
177
+ await second;
178
+ });
179
+
180
+ test("a crash with a play in flight fails that play privately and the next play spawns fresh", async () => {
181
+ const { player, spawned, children } = recorder();
182
+ const failing = player.play("/tmp/a.wav");
183
+ children[0]!.emit("close");
184
+ await assert.rejects(failing, /Native pulse/);
185
+ const next = player.play("/tmp/b.wav");
186
+ assert.equal(spawned.length, 2);
187
+ children[1]!.stdout.emit("data", okPlay());
188
+ await next;
189
+ });
190
+
191
+ test("dispose kills the persistent worker, fails pending plays and the next play spawns fresh", async () => {
192
+ const { player, spawned, children } = recorder();
193
+ const playing = player.play("/tmp/a.wav");
194
+ player.dispose();
195
+ assert.deepEqual(children[0]!.kills, ["SIGKILL"]);
196
+ await assert.rejects(playing, /Native pulse/);
197
+ const next = player.play("/tmp/b.wav");
198
+ assert.equal(spawned.length, 2);
199
+ children[1]!.stdout.emit("data", okPlay());
200
+ await next;
201
+ player.dispose(); // a second dispose is a no-op
202
+ });
203
+
204
+ test("pending plays are bounded fail-closed and never grow without bound", async () => {
205
+ const { player, spawned, children } = recorder();
206
+ const plays = Array.from({ length: 10 }, (_, i) => player.play(`/tmp/${i}.wav`).catch(error => error));
207
+ assert.equal(spawned.length, 1);
208
+ assert.match(String(await plays[9]), /too many pending/);
209
+ for (let i = 0; i < 9; i++) children[0]!.stdout.emit("data", okPlay()); // settle active + queued
210
+ const settled = await Promise.all(plays);
211
+ assert.equal(settled.filter(value => value instanceof Error).length, 1);
212
+ });
213
+
143
214
  test("malformed, oversize and error output fail privately and kill at most once", async () => {
144
215
  const malformed = recorder();
145
216
  const malformedProbe = malformed.player.probe();
@@ -158,9 +229,11 @@ test("malformed, oversize and error output fail privately and kill at most once"
158
229
 
159
230
  const failure = recorder();
160
231
  const playing = failure.player.play("/private/snapshot.wav");
161
- failure.children[0]!.stdout.emit("data", JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, ok: false }));
162
- failure.children[0]!.emit("close");
232
+ failure.children[0]!.stdout.emit("data", failPlay());
233
+ // A private ok:false leaves the healthy worker serviceable; no kill, no restart needed.
163
234
  await assert.rejects(playing, (error: Error) => /Native pulse/.test(error.message) && !/snapshot|cookie|\/private/.test(error.message));
235
+ assert.equal(failure.children[0]!.kills.length, 0);
236
+ assert.equal(failure.spawned.length, 1);
164
237
  });
165
238
 
166
239
  test("timeout and abort SIGKILL once and settle only on close", async () => {
@@ -181,6 +254,11 @@ test("timeout and abort SIGKILL once and settle only on close", async () => {
181
254
  const playing = aborted.player.play("/tmp/s.wav", controller.signal);
182
255
  controller.abort();
183
256
  assert.deepEqual(aborted.children[0]!.kills, ["SIGKILL"]);
257
+ let abortSettled = false; void playing.then(() => { abortSettled = true; }, () => { abortSettled = true; });
258
+ aborted.children[0]!.emit("exit", null);
259
+ aborted.children[0]!.emit("error", new Error("kill race")); // Node emits close after error
260
+ await new Promise(resolve => setTimeout(resolve, 0));
261
+ assert.equal(abortSettled, false);
184
262
  aborted.children[0]!.emit("close");
185
263
  await assert.rejects(playing, /aborted|Native pulse/);
186
264
  });
@@ -210,7 +288,8 @@ test("probe never throws for offline/malformed IPC while play rejects and cleans
210
288
  assert.deepEqual(await probing, { available: false, formats: [] });
211
289
  const { player: playPlayer, children: playChildren } = recorder();
212
290
  const playing = playPlayer.play("/tmp/s.wav");
213
- playChildren[0]!.stdout.emit("data", JSON.stringify({ schema: "wrong", ok: true, played: true }));
291
+ playChildren[0]!.stdout.emit("data", `${JSON.stringify({ schema: "wrong", ok: true, played: true })}\n`);
292
+ assert.deepEqual(playChildren[0]!.kills, ["SIGKILL"], "a malformed serve line is a protocol violation: kill once");
214
293
  playChildren[0]!.emit("close");
215
294
  await assert.rejects(playing, /Native pulse/);
216
295
  });
@@ -259,13 +338,13 @@ test("real worker plays a fake PCM snapshot to a fake socket with no OS audio",
259
338
  const snapshot = join(dir, "sound.wav");
260
339
  writeFileSync(snapshot, bytes, { mode: 0o400 });
261
340
  const server = await startFakeServer();
341
+ const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
262
342
  try {
263
- const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
264
343
  await player.play(snapshot);
265
344
  assert.deepEqual(Buffer.concat(server.pcm), dataOf(bytes));
266
345
  assert.ok(server.seen.includes(3) && server.seen.includes(12) && server.seen.includes(4));
267
346
  assert.ok(existsSync(snapshot), "the bridge/worker must not delete the snapshot; the owner does");
268
- } finally { await server.close(); rmSync(dir, { recursive: true, force: true }); }
347
+ } finally { player.dispose(); await server.close(); rmSync(dir, { recursive: true, force: true }); }
269
348
  });
270
349
 
271
350
  test("real worker rejects invalid WAV before opening the socket", async () => {
@@ -273,9 +352,49 @@ test("real worker rejects invalid WAV before opening the socket", async () => {
273
352
  const snapshot = join(dir, "sound.wav");
274
353
  writeFileSync(snapshot, Buffer.alloc(64, 0x00), { mode: 0o400 });
275
354
  const server = await startFakeServer();
355
+ const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
276
356
  try {
277
- const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
278
357
  await assert.rejects(player.play(snapshot), /Native pulse/);
279
358
  assert.deepEqual(server.seen, []);
280
- } finally { await server.close(); rmSync(dir, { recursive: true, force: true }); }
359
+ } finally { player.dispose(); await server.close(); rmSync(dir, { recursive: true, force: true }); }
360
+ });
361
+
362
+ /** Direct serve-protocol check: bounded fail-closed requests, private per-play failures, stdin EOF teardown. */
363
+ test("real serve worker fails closed on protocol violations and exits on stdin EOF", async () => {
364
+ const workerPath = fileURLToPath(new URL("../lib/notification-pulse-worker.ts", import.meta.url));
365
+ const serveEnv = { ...process.env, NODE_NO_WARNINGS: "1", GENTLE_PI_AGENTS_CHILD: "1" };
366
+ const spawnServe = () => spawn(process.execPath, ["--experimental-strip-types", workerPath, "serve"],
367
+ { stdio: ["pipe", "pipe", "pipe"], env: serveEnv });
368
+ const collect = (child: ReturnType<typeof spawn>): string[] => {
369
+ const lines: string[] = []; let text = "";
370
+ child.stdout!.setEncoding("utf8");
371
+ child.stdout!.on("data", (chunk: string) => {
372
+ text += chunk;
373
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n")) { lines.push(text.slice(0, index)); text = text.slice(index + 1); }
374
+ });
375
+ return lines;
376
+ };
377
+ const exited = (child: ReturnType<typeof spawn>): Promise<number | null> => new Promise(resolve => {
378
+ if (child.exitCode !== null || child.signalCode !== null) { resolve(child.exitCode); return; }
379
+ child.once("exit", code => resolve(code));
380
+ });
381
+ // (a) A malformed line is a protocol violation: one generic envelope, then a crash the owner restarts.
382
+ const crashing = spawnServe();
383
+ const crashingLines = collect(crashing);
384
+ crashing.stdin!.write("not json\n");
385
+ assert.equal(await exited(crashing), 1);
386
+ assert.deepEqual(await crashingLines, [JSON.stringify({ schema: "gentle.audio.pulse/v1", ok: false })]);
387
+ // (b) A schema-valid request for a missing snapshot fails privately and the worker keeps serving.
388
+ const serving = spawnServe();
389
+ const servingLines = collect(serving);
390
+ for (const path of ["/nonexistent/gentle-a.wav", "/nonexistent/gentle-b.wav"])
391
+ serving.stdin!.write(`${JSON.stringify({ schema: "gentle.audio.pulse/v1", op: "play", path })}\n`);
392
+ await new Promise(resolve => setTimeout(resolve, 1200));
393
+ assert.deepEqual(servingLines, [
394
+ JSON.stringify({ schema: "gentle.audio.pulse/v1", ok: false }),
395
+ JSON.stringify({ schema: "gentle.audio.pulse/v1", ok: false }),
396
+ ]);
397
+ // (c) stdin EOF (owner death) is a clean exit.
398
+ serving.stdin!.end();
399
+ assert.equal(await exited(serving), 0);
281
400
  });
@@ -10,6 +10,15 @@ import { basename } from "node:path";
10
10
 
11
11
  const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
12
12
 
13
+ /** UI chains settle across a load-dependent number of turns: wait on the
14
+ * observable state instead of assuming a fixed tick count landed the async
15
+ * step (gentle-shell#2498). The deadline keeps a broken chain failing fast. */
16
+ const settleUntil = async (predicate: () => boolean, what: string): Promise<void> => {
17
+ const deadline = Date.now() + 5000;
18
+ while (!predicate() && Date.now() < deadline) await tick();
19
+ assert.ok(predicate(), `timed out waiting for ${what}`);
20
+ };
21
+
13
22
  /** Real packaged WAVs so a valid path uses an existing fixture, never a synthetic name. */
14
23
  const WAV_FIXTURE = fileURLToPath(new URL("../assets/sounds/success.wav", import.meta.url));
15
24
  const WAV_FIXTURES = {
@@ -223,7 +232,7 @@ test("an in-flight local sound validation keeps busy, blocking a second field an
223
232
  assert.match(view.render(160).join("\n"), /Local audio path/);
224
233
  view.handleInput(WAV_FIXTURE);
225
234
  view.handleInput("\r");
226
- await tick();
235
+ await settleUntil(() => validated.length === 1, "the submitted value to start validating");
227
236
  assert.deepEqual(validated, [`file:${WAV_FIXTURE}`], "the submitted value is validated exactly once");
228
237
  // Validation is still pending: `f` must not open a second field and Enter must not write.
229
238
  view.handleInput("f");
@@ -233,11 +242,11 @@ test("an in-flight local sound validation keeps busy, blocking a second field an
233
242
  assert.equal(h.writes, 0, "no write before the deferred validation resolves");
234
243
  // Resolve: a single write, then busy releases for the next edit.
235
244
  resolveValidation(true);
236
- await tick(); await tick();
245
+ await settleUntil(() => h.writes === 1, "the resolved validation to write exactly once");
237
246
  assert.equal(h.writes, 1, "the resolved validation writes exactly once");
238
247
  assert.equal(getNotificationService()!.getState().settings.audio.events["agent.failed"], `file:${WAV_FIXTURE}`);
239
248
  view.handleInput("f");
240
- assert.match(view.render(160).join("\n"), /Local audio path/, "busy is released so the next edit opens a fresh field");
249
+ await settleUntil(() => view.render(160).join("\n").includes("Local audio path"), "busy released so the next edit opens a fresh field");
241
250
  } finally { h.owner.retire(); }
242
251
  });
243
252
 
@@ -262,14 +271,17 @@ test("disposing the card while validation is in flight never writes the stale ch
262
271
  const h = harness(); try {
263
272
  const rows = h.rows();
264
273
  let resolveValidation!: (value: boolean) => void;
265
- getNotificationService()!.validateFile = () => new Promise<boolean>(resolve => { resolveValidation = resolve; });
274
+ let validationStarted = false;
275
+ getNotificationService()!.validateFile = () => { validationStarted = true; return new Promise<boolean>(resolve => { resolveValidation = resolve; }); };
266
276
  const view = customizeView(rows);
267
277
  await expandAndSelect(view, "agent.failed:");
268
278
  view.handleInput("f");
269
279
  view.render(160);
270
280
  view.handleInput(WAV_FIXTURE);
271
281
  view.handleInput("\r");
272
- await tick();
282
+ // Disposing before the validation even starts would never observe the
283
+ // stale-choice guard, so wait until the deferred validation is pending.
284
+ await settleUntil(() => validationStarted, "the in-flight validation to start");
273
285
  view.dispose();
274
286
  resolveValidation(true);
275
287
  await tick(); await tick();
@@ -149,7 +149,8 @@ const ANCHORS: readonly RoutingAnchor[] = [
149
149
  canonical: "run a differential check",
150
150
  mirrors: [
151
151
  { surface: DELEGATION, includes: "**Differential check (PRESERVE):**" },
152
- { surface: EXTENSION, includes: "run a differential check" },
152
+ // weak-model-economy S2: the harness gates the check on shared code an unnamed command uses.
153
+ { surface: EXTENSION, includes: "run one differential check" },
153
154
  ],
154
155
  },
155
156
  {
@@ -33,7 +33,10 @@ const FIXTURE_PATH = join(import.meta.dirname, "fixtures", "orchestrator.pre-die
33
33
  // gentle-shell#1731 T10 (user decision): 8,192 -> 8,400 B for the narrowed high-risk items 1 and 3.
34
34
  // Verification by named risk (escalate-only rule 3) and the writer-rule fix (triggers hold only when
35
35
  // their condition holds; a unit is independent behavior over a size floor), user decision 2026-10-14: 8,400 -> 9,100 B.
36
- const BUDGET_BYTES = 9100;
36
+ // Weak-model economy (small path reads one batch with no todo; item 1 excludes rebuildable dev/test data), measured in
37
+ // gentle-shell-bench 1321c60 (DeepSeek -24-30% time on x2/x5, -70% on a dev-database rename; Sol unchanged), user decision
38
+ // 2026-10-22: 9,100 -> 9,250 B.
39
+ const BUDGET_BYTES = 9250;
37
40
  const MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS = 93;
38
41
 
39
42
  const LAZY_ASSET_NAMES = [
@@ -126,7 +129,7 @@ function measureOrchestratorPromptBytes(assetsDir: string): number {
126
129
  // measuring a shorter render again.
127
130
  const RDD_WORST_CASE_LINE = "Receipt-driven development: unknown (native status unavailable)";
128
131
 
129
- test("getOrchestratorPrompt return value stays within the canonical 9,100 B budget at a short assets root", () => {
132
+ test("getOrchestratorPrompt return value stays within the canonical 9,250 B budget at a short assets root", () => {
130
133
  const rendered = __testing.renderOrchestratorPrompt(representativeProductionAssetsDir);
131
134
  assert.ok(
132
135
  rendered.includes(RDD_WORST_CASE_LINE),