gentle-pi 4.3.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 (208) hide show
  1. package/README.md +8 -5
  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 +101 -151
  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 +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  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 +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  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 +2 -0
  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 +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -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/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { mkdtempSync, mkdirSync, symlinkSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, mkdirSync, symlinkSync, linkSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { AutoPermissionRuntime } from "../lib/auto-permission-runtime.ts";
@@ -258,6 +258,90 @@ test("Native consent, human questions and delegation remain with their original
258
258
  f.setMode("auto");assert.equal((await f.execute("custom_tool",{},false)).executions,1);assert.equal(f.requests.length,0);
259
259
  });
260
260
 
261
+ test("Companion update files are not permission authority and still receive ordinary checks", async t=>{
262
+ const f=fixture(t);f.input("Update the installed companion tools from main.");
263
+ for(const mode of ["normal","auto","yolo"]) {
264
+ f.setMode(mode);
265
+ for(const path of ["dev-binary.json","channel.json","main/gentle-ai","tools/go/bin/go"]) {
266
+ const input={path:join(f.cwd,"config",path),content:"updated companion data"};
267
+ for(const toolName of ["write","edit"]) {
268
+ assert.equal(f.runtime.guard({toolName,input},f.ctx),undefined,`${mode}: ${path} is not policy`);
269
+ assert.equal((await f.execute(toolName,input)).executions,1);
270
+ }
271
+ }
272
+ }
273
+ assert.equal(f.requests.length,8,"only Auto requests are classified; other modes keep their own behavior");
274
+ f.setMode("auto");f.setResponse({status:"available",decision:"deny",reason:"Update not authorized"});
275
+ assert.equal((await f.execute("write",{path:join(f.cwd,"config","dev-binary.json"),content:"unapproved"})).executions,0);
276
+ assert.equal(f.requests.length,9,"an update path is not an automatic allow");
277
+ });
278
+
279
+ test("Permission authority, aliases and parent directories remain protected in every mode", async t=>{
280
+ const f=fixture(t);f.input("Update companion tools without changing permissions.");
281
+ const home=join(f.cwd,"config");mkdirSync(home);
282
+ const projectHome=join(f.cwd,".pi","gentle-ai");mkdirSync(projectHome,{recursive:true});
283
+ for(const name of ["permission-mode.json","runtime-guardrails.json"])writeFileSync(join(home,name),"{}");
284
+ writeFileSync(join(projectHome,"runtime-guardrails.json"),"{}");
285
+ symlinkSync(home,join(f.cwd,"config-alias"));
286
+ symlinkSync(join(home,"permission-mode.json"),join(f.cwd,"policy-alias"));
287
+ symlinkSync(join(home,"runtime-guardrails.json"),join(home,"dev-binary.json"));
288
+ for(const mode of ["normal","auto","yolo"]) {
289
+ f.setMode(mode);
290
+ for(const path of [join(home,"permission-mode.json"),join(home,"runtime-guardrails.json"),
291
+ join(f.cwd,"config-alias","permission-mode.json"),join(f.cwd,"policy-alias"),join(home,"dev-binary.json"),
292
+ join(projectHome,"runtime-guardrails.json"),projectHome,home,f.cwd,
293
+ ...["profiles.json","models.json","background-subagents.json","persona.json","builtin-codemode-optout.json",
294
+ "history-capture.json","scratch/session.json","tools/other-tool"].map(path=>join(home,path))]) {
295
+ for(const toolName of ["write","edit"]) {
296
+ const input={path,content:"{}"};
297
+ assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${path} stays protected`);
298
+ assert.equal((await f.execute(toolName,input)).executions,0);
299
+ }
300
+ }
301
+ for(const toolName of ["bash","bash_background","monitor"]) {
302
+ for(const command of [`rm -rf '${home}'`,`rm -rf '${home}/'`,`rm -rf '${home}'/*`,
303
+ `printf '{}' > '${join(home,"permission-mode.json")}'`,`printf '{}' > '${join(home,"runtime-guardrails.json")}'`,
304
+ `printf '{}' > '${join(projectHome,"runtime-guardrails.json")}'`]) {
305
+ assert.equal(f.runtime.guard({toolName,input:{command}},f.ctx)?.block,true);
306
+ assert.equal((await f.execute(toolName,{command})).executions,0);
307
+ }
308
+ }
309
+ }
310
+ assert.equal(f.requests.length,0,"a classifier allow never overrides the policy guard");
311
+ });
312
+
313
+ test("Updater exceptions cannot overwrite authority through hardlinks", async t=>{
314
+ const f=fixture(t);f.input("Update companion tools without changing permission or routing policy.");
315
+ const home=join(f.cwd,"config");mkdirSync(join(home,"main"),{recursive:true});
316
+ for(const [policy,alias] of [["permission-mode.json","dev-binary.json"],["models.json","main/routing-alias"]]) {
317
+ writeFileSync(join(home,policy),"{}");linkSync(join(home,policy),join(home,alias));
318
+ for(const toolName of ["write","edit"]) {
319
+ const input={path:join(home,alias),content:"{}"};
320
+ assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,alias);
321
+ assert.equal((await f.execute(toolName,input)).executions,0);
322
+ }
323
+ }
324
+ assert.equal(f.requests.length,0);
325
+ });
326
+
327
+ test("Dangling updater aliases cannot create absent permission authority", async t=>{
328
+ const f=fixture(t);f.input("Update companion tools without creating or changing permission policy.");
329
+ const home=join(f.cwd,"config");mkdirSync(join(home,"tools"),{recursive:true});
330
+ for(const [alias,policy] of [["dev-binary.json","permission-mode.json"],["channel.json","runtime-guardrails.json"],
331
+ ["main","permission-mode.json"],["tools/go","runtime-guardrails.json"]]) {
332
+ symlinkSync(join(home,policy),join(home,alias));
333
+ for(const mode of ["normal","auto","yolo"]) {
334
+ f.setMode(mode);
335
+ for(const toolName of ["write","edit"]) {
336
+ const input={path:join(home,alias),content:"{}"};
337
+ assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${alias} stays protected`);
338
+ assert.equal((await f.execute(toolName,input)).executions,0);
339
+ }
340
+ }
341
+ }
342
+ assert.equal(f.requests.length,0,"unresolved aliases never reach an allowing classifier");
343
+ });
344
+
261
345
  test("Policy files cannot be modified by model file tools; symlink escapes are reviewed", async t=>{
262
346
  const f=fixture(t);f.input("Edit source code");mkdirSync(join(f.cwd,"config"));
263
347
  assert.equal((await f.execute("write",{path:join(f.cwd,"config","permission-mode.json"),content:"yolo"})).executions,0);
@@ -910,3 +910,23 @@ test("loadRuntimeGuardrailsConfig: autonomousMode:{} (object) in JSON does NOT a
910
910
  rmSync(dir, { recursive: true, force: true });
911
911
  }
912
912
  });
913
+
914
+ test("evaluateSuperYoloCommand: waives data-loss and configured confirmations, keeps only catastrophic denies", () => {
915
+ const { evaluateSuperYoloCommand } = __testing;
916
+ // Ordinary data-loss commands run without confirmation.
917
+ assert.equal(evaluateSuperYoloCommand("rm -rf node_modules build"), "allow");
918
+ assert.equal(evaluateSuperYoloCommand("git rebase origin/main"), "allow");
919
+ assert.equal(evaluateSuperYoloCommand("git branch -D feature/x"), "allow");
920
+ assert.equal(evaluateSuperYoloCommand("npm publish"), "allow");
921
+ assert.equal(evaluateSuperYoloCommand("git push -u origin feature"), "allow");
922
+ // Code-level catastrophic denies survive: root/home removal and hard-coded
923
+ // destructive Git forms (reset --hard, forced clean, force push).
924
+ assert.equal(evaluateSuperYoloCommand("rm -rf /"), "block");
925
+ assert.equal(evaluateSuperYoloCommand("rm -rf ~"), "block");
926
+ assert.equal(evaluateSuperYoloCommand("rm -rf --no-preserve-root /"), "block");
927
+ assert.equal(evaluateSuperYoloCommand("git reset --hard HEAD~1"), "block");
928
+ assert.equal(evaluateSuperYoloCommand("git push --force origin main"), "block");
929
+ assert.equal(evaluateSuperYoloCommand("git clean -fd"), "block");
930
+ // Harmless commands are unaffected.
931
+ assert.equal(evaluateSuperYoloCommand("git status && npm test"), "allow");
932
+ });
@@ -17,7 +17,7 @@ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) =
17
17
  const NODE = "24.21.0";
18
18
  const PNPM = "11.1.1";
19
19
  const NPM = "11.19.0";
20
- const ENGRAM = "3.4.0";
20
+ const ENGRAM = "3.5.2";
21
21
 
22
22
  // Every temporary home this file creates is removed when the file's tests end.
23
23
  const roots: string[] = [];
@@ -373,8 +373,8 @@ posixTest("ensureEngram downloads nothing when the prefix is missing", async ()
373
373
  });
374
374
 
375
375
  test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
376
- const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_linux_amd64.tar.gz" };
377
- const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_windows_amd64.zip" };
376
+ const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_linux_amd64.tar.gz" };
377
+ const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_windows_amd64.zip" };
378
378
  assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
379
379
  const exe = Buffer.from("MZ engram");
380
380
  assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { isWsl, readClipboardImage, sniffImageMimeType, type ClipboardCommandRunner } from "../lib/clipboard-image.ts";
4
+
5
+ // A 2×1 PNG header: signature, IHDR length/type, width 2, height 1.
6
+ const PNG_2X1 = new Uint8Array([
7
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
8
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
9
+ 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
10
+ 0x08, 0x06, 0x00, 0x00, 0x00,
11
+ ]);
12
+
13
+ type Call = { command: string; args: readonly string[] };
14
+ type Reply = Buffer | undefined | ((call: Call) => Buffer | undefined);
15
+
16
+ // Scripted backends keyed by `command firstArg`; anything unscripted is "not installed".
17
+ function runner(replies: Record<string, Reply>): { run: ClipboardCommandRunner; calls: Call[] } {
18
+ const calls: Call[] = [];
19
+ const run: ClipboardCommandRunner = async (command, args) => {
20
+ const call = { command, args };
21
+ calls.push(call);
22
+ const reply = replies[`${command} ${args[0] ?? ""}`.trim()] ?? replies[command];
23
+ return typeof reply === "function" ? reply(call) : reply;
24
+ };
25
+ return { run, calls };
26
+ }
27
+
28
+ const BMP = new Uint8Array([0x42, 0x4d, 0x00, 0x00]);
29
+ const noProc = () => undefined;
30
+
31
+ test("WSL reads a Windows screenshot through PowerShell when the Linux clipboard has none", async () => {
32
+ const { run, calls } = runner({
33
+ "wl-paste --list-types": Buffer.from("text/plain\n"),
34
+ "powershell.exe": Buffer.from(`${Buffer.from(PNG_2X1).toString("base64")}\r\n`),
35
+ });
36
+ const image = await readClipboardImage({ env: { WSL_DISTRO_NAME: "Ubuntu" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
37
+ assert.deepEqual(image && { mimeType: image.mimeType, bytes: [...image.bytes] }, { mimeType: "image/png", bytes: [...PNG_2X1] });
38
+ const powershell = calls.find((call) => call.command === "powershell.exe");
39
+ assert.ok(powershell, "PowerShell was consulted");
40
+ // The image travels on stdout as base64: no temp file path is ever passed.
41
+ const script = powershell.args.at(-1) ?? "";
42
+ assert.match(script, /ToBase64String/);
43
+ assert.doesNotMatch(script, /\$path|Save\('|tmp/i);
44
+ });
45
+
46
+ test("WSL falls back to PowerShell when wl-paste and xclip are not installed", async () => {
47
+ const { run, calls } = runner({ "powershell.exe": Buffer.from(Buffer.from(PNG_2X1).toString("base64")) });
48
+ const image = await readClipboardImage({ env: { WSL_INTEROP: "/run/WSL/1_interop" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
49
+ assert.equal(image?.mimeType, "image/png");
50
+ assert.deepEqual(calls.map((call) => call.command), ["wl-paste", "xclip", "powershell.exe"]);
51
+ });
52
+
53
+ test("Wayland prefers PNG and never asks Windows on a native Linux desktop", async () => {
54
+ const { run, calls } = runner({
55
+ "wl-paste --list-types": Buffer.from("image/jpeg\nimage/png\ntext/plain\n"),
56
+ "wl-paste --type": (call) => call.args[1] === "image/png" ? Buffer.from(PNG_2X1) : undefined,
57
+ });
58
+ const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
59
+ assert.equal(image?.mimeType, "image/png");
60
+ assert.ok(!calls.some((call) => call.command === "powershell.exe"));
61
+ });
62
+
63
+ test("an empty Windows clipboard, an unsupported format, or Termux leave the paste to Pi", async () => {
64
+ const empty = runner({ "powershell.exe": Buffer.from("\r\n") });
65
+ assert.equal(await readClipboardImage({ env: { WSL_DISTRO_NAME: "Ubuntu" }, platform: "linux", run: empty.run, native: () => undefined, procVersion: noProc }), null);
66
+ const bmp = runner({ "xclip -selection": (call) => call.args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(BMP) });
67
+ assert.equal(await readClipboardImage({ env: {}, platform: "linux", run: bmp.run, native: () => undefined, procVersion: noProc }), null);
68
+ const termux = runner({});
69
+ assert.equal(await readClipboardImage({ env: { TERMUX_VERSION: "0.118" }, platform: "linux", run: termux.run, procVersion: noProc }), null);
70
+ assert.equal(termux.calls.length, 0);
71
+ });
72
+
73
+ test("macOS reads the native clipboard but leaves copied files to Pi", async () => {
74
+ const native = (files: string[] | null) => () => ({
75
+ getText: async () => null,
76
+ getImage: async () => PNG_2X1,
77
+ getFilePaths: async () => files,
78
+ });
79
+ const image = await readClipboardImage({ env: {}, platform: "darwin", native: native(null) });
80
+ assert.equal(image?.mimeType, "image/png");
81
+ assert.equal(await readClipboardImage({ env: {}, platform: "darwin", native: native(["/Users/me/shot.png"]) }), null);
82
+ assert.equal(await readClipboardImage({ env: {}, platform: "win32", native: () => undefined }), null);
83
+ });
84
+
85
+ test("a failed wl-paste read falls through to xclip, as Pi does", async () => {
86
+ const { run, calls } = runner({
87
+ "wl-paste --list-types": Buffer.from("image/png\n"),
88
+ "xclip -selection": (call) => call.args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(PNG_2X1),
89
+ });
90
+ const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
91
+ assert.equal(image?.mimeType, "image/png");
92
+ assert.deepEqual(calls.map((call) => call.command), ["wl-paste", "wl-paste", "xclip", "xclip"]);
93
+ });
94
+
95
+ test("files copied in a Linux file manager stay Pi's even when an image is offered too", async () => {
96
+ const { run, calls } = runner({ "wl-paste --list-types": Buffer.from("image/png\ntext/uri-list\n") });
97
+ const native = () => ({ getText: async () => null, getImage: async () => PNG_2X1, getFilePaths: async () => ["/home/me/shot.png"] });
98
+ assert.equal(await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native, procVersion: noProc }), null);
99
+ assert.equal(calls.length, 0);
100
+ });
101
+
102
+ test("image formats are identified by signature", () => {
103
+ assert.equal(sniffImageMimeType(PNG_2X1), "image/png");
104
+ assert.equal(sniffImageMimeType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
105
+ assert.equal(sniffImageMimeType(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])), "image/gif");
106
+ assert.equal(sniffImageMimeType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50])), "image/webp");
107
+ assert.equal(sniffImageMimeType(BMP), undefined);
108
+ });
109
+
110
+ test("WSL is detected from its environment or the kernel version", () => {
111
+ assert.equal(isWsl({ WSL_DISTRO_NAME: "Ubuntu" }, noProc), true);
112
+ assert.equal(isWsl({}, () => "Linux version 6.6.87.2-microsoft-standard-WSL2"), true);
113
+ assert.equal(isWsl({}, () => "Linux version 6.8.0-generic"), false);
114
+ });
@@ -374,7 +374,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
374
374
  "gentle:telemetry",
375
375
  "gentle:dev-binary",
376
376
  ]);
377
- assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission"]);
377
+ assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission", "gentle:voice", "gentle:design"]);
378
378
  assert.deepEqual(byTitle("Diagnostics"), ["gentle:status", "gentle:doctor"]);
379
379
  assert.equal(byTitle("SDD"), undefined);
380
380
  assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
@@ -11,6 +11,8 @@ import {
11
11
  CONTEXT_BACKSTOP_TOKENS_ENV,
12
12
  contextBackstopReached,
13
13
  contextBackstopThreshold,
14
+ createThrottledContextMeasure,
15
+ type MeasuredContext,
14
16
  } from "../lib/context-backstop.ts";
15
17
 
16
18
  // Harness roadmap R1: the parent-context backstop was a fixed "~150k tokens" in prompt text, so the model had to
@@ -63,7 +65,7 @@ test("unknown tokens never count as reaching the backstop", () => {
63
65
  type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
64
66
  type Patch = { content?: Array<{ type: string; text?: string }>; structuredContent?: unknown; isError?: boolean } | undefined;
65
67
 
66
- function session(t: TestContext, child: boolean) {
68
+ function session(t: TestContext, child: boolean, options: { now?: () => number; minIntervalMs?: number } = {}) {
67
69
  const cwd = realpathSync(mkdtempSync(join(tmpdir(), "context-backstop-")));
68
70
  const configHome = mkdtempSync(join(tmpdir(), "context-backstop-config-"));
69
71
  const previous = process.env.GENTLE_PI_CONFIG_HOME;
@@ -76,10 +78,19 @@ function session(t: TestContext, child: boolean) {
76
78
  });
77
79
  const handlers = new Map<string, Handler>();
78
80
  const pi = { on: (name: string, handler: Handler) => { handlers.set(name, handler); }, events: { on() {}, emit() {} }, registerCommand() {}, registerTool() {} } as unknown as ExtensionAPI;
79
- createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: child ? "1" : "0", GENTLE_AI_TELEMETRY: "0" } })(pi);
81
+ // Semantic tests without options keep per-call measuring (minIntervalMs 0); callers that pass
82
+ // options (a fake clock) keep the production window resolution.
83
+ const measureOptions = Object.keys(options).length === 0 ? { minIntervalMs: 0 } : options;
84
+ createGentleAiExtension({
85
+ nativeReviewCli: null,
86
+ processEnv: { GENTLE_PI_AGENTS_CHILD: child ? "1" : "0", GENTLE_AI_TELEMETRY: "0" },
87
+ contextBackstopMeasure: measureOptions,
88
+ })(pi);
80
89
  let usage: ContextUsage | undefined = { tokens: 10_000, contextWindow: 1_000_000, percent: 1 };
90
+ let measures = 0;
81
91
  const sessionManager = { getSessionId: () => "session-backstop", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] };
82
- const ctx = { cwd, hasUI: false, ui: { notify() {} }, sessionManager, getContextUsage: () => usage } as unknown as ExtensionContext;
92
+ const ctx = { cwd, hasUI: false, ui: { notify() {} }, sessionManager, getContextUsage: () => { measures++; return usage; } } as unknown as ExtensionContext;
93
+ const measureCount = () => measures;
83
94
  const setTokens = (tokens: number | null, contextWindow = 1_000_000) => {
84
95
  usage = { tokens, contextWindow, percent: tokens === null ? null : (tokens / contextWindow) * 100 };
85
96
  };
@@ -87,7 +98,7 @@ function session(t: TestContext, child: boolean) {
87
98
  toolName: "read", toolCallId: "call-read", isError: false, input: { path: "a.ts" },
88
99
  content: [{ type: "text", text: "file body" }], structuredContent: { lines: 1 }, ...overrides,
89
100
  }, ctx) as Promise<Patch>;
90
- return { cwd, ctx, handlers, setTokens, read };
101
+ return { cwd, ctx, handlers, setTokens, read, measureCount, options };
91
102
  }
92
103
 
93
104
  test("the primary session gets one notice when its measured context first reaches the backstop", async (t) => {
@@ -157,3 +168,58 @@ test("a parent write result carries the notice after its own bookkeeping", async
157
168
  assert.equal(result?.content?.[0]?.text, "Wrote src/a.ts");
158
169
  assert.match(result?.content?.[1]?.text ?? "", /Context backstop/);
159
170
  });
171
+
172
+ // The measurement behind the backstop (ctx.getContextUsage()) rebuilds the whole session
173
+ // projection, so it is sampled at most once per window per session instead of once per
174
+ // tool result. The window never decides the notice: it only decides when the context is
175
+ // looked at again.
176
+ test("the throttled measure samples once per window and the first sample of a session is immediate", () => {
177
+ let clock = 1_000;
178
+ const measure = createThrottledContextMeasure({ now: () => clock });
179
+ let calls = 0;
180
+ const sample = (value: MeasuredContext | undefined) => measure("session-a", () => { calls++; return value; });
181
+ assert.deepEqual(sample({ tokens: 10_000 }), { tokens: 10_000 }, "the first sample of a session measures immediately");
182
+ assert.equal(calls, 1);
183
+ clock = 2_000;
184
+ sample({ tokens: 10_000 });
185
+ assert.equal(calls, 1, "a sample inside the window skips the measurement");
186
+ clock = 6_001;
187
+ sample({ tokens: 10_000 });
188
+ assert.equal(calls, 2, "the window expired: measure again");
189
+ clock = 6_002;
190
+ sample({ tokens: 10_000 });
191
+ assert.equal(calls, 2, "the window restarts at every measurement");
192
+ });
193
+
194
+ test("the throttled measure is per session and a throwing measure never poisons the window", () => {
195
+ let clock = 0;
196
+ const measure = createThrottledContextMeasure({ now: () => clock });
197
+ assert.ok(measure("session-a", () => ({ tokens: 1 })), "session a measures");
198
+ assert.ok(measure("session-b", () => ({ tokens: 2 })), "session b inside the same window still measures");
199
+ assert.equal(measure("session-a", () => ({ tokens: 3 })), undefined, "session a inside its own window skips");
200
+ clock = 5_001;
201
+ let boom = false;
202
+ assert.equal(measure("session-a", () => { boom = true; throw new Error("boom"); }), undefined, "a throwing measure returns undefined");
203
+ assert.equal(boom, true, "the measure itself actually ran");
204
+ assert.equal(measure("session-a", () => ({ tokens: 4 }))?.tokens, undefined, "the failed measurement still consumed its window");
205
+ clock = 10_002;
206
+ assert.equal(measure("session-a", () => ({ tokens: 4 }))?.tokens, 4, "the next window measures again");
207
+ });
208
+
209
+ test("the extension samples the backstop context at most once per window per session", async (t) => {
210
+ let clock = 0;
211
+ const s = session(t, false, { now: () => clock });
212
+ assert.equal(s.measureCount(), 0);
213
+ await s.read();
214
+ assert.equal(s.measureCount(), 1, "the first tool result measures immediately");
215
+ await s.read();
216
+ await s.read();
217
+ assert.equal(s.measureCount(), 1, "tool results inside the window skip the measurement");
218
+ clock = 5_001;
219
+ s.setTokens(160_000);
220
+ const crossed = await s.read();
221
+ assert.equal(s.measureCount(), 2, "the window expired: measure again");
222
+ assert.match(crossed?.content?.[1]?.text ?? "", /Context backstop/, "a crossing is announced on the next sampled result");
223
+ await s.read();
224
+ assert.equal(s.measureCount(), 2, "and the window restarts after that sample");
225
+ });
@@ -244,9 +244,10 @@ test("T11/S4: the parent hands verify the whole spec, authorizes its probes and
244
244
  // names every line that admits it. The pin still guards against accidental drift.
245
245
  test("AC4/S4: the normative verification rule text is unchanged by the review protocol", () => {
246
246
  const normative = sectionFrom(verification, "## Verification rule (normative)");
247
+ // Re-pinned on purpose: the runtime admission now also accepts a `Risk: item N` sentence (verifier-admission.ts).
247
248
  assert.equal(
248
249
  createHash("sha256").update(normative).digest("hex"),
249
- "b159829cad24a31e23d6309b3bfe0e4cc71eec2316775ba07314fecd1fb6a63f",
250
+ "f052518b2905e4560d629397876057079aa71b45352272646261a611fb4a1e17",
250
251
  "the normative Verification rule section changed",
251
252
  );
252
253
  assert.ok(verification.includes("or a delegated writer returns"), "the module must still load when a delegated writer returns");