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
@@ -214,6 +214,7 @@ test("help text documents the bundled upgrade, its rollback and the pinned Pi up
214
214
  assert.ok(text.includes(" gentle-shell upgrade --rollback"));
215
215
  assert.match(text, /bundled install.*release channel only.*--rollback switches back to the previous kept version/s);
216
216
  assert.match(text, /In a bundled install Pi ships pinned: 'update' without a package\n\s+runs 'gentle-shell upgrade'/);
217
+ assert.match(text, /'update --latest' installs the newest published Pi instead,\n\s+without the release's verified lockfile\./);
217
218
  });
218
219
 
219
220
  test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => {
@@ -1890,8 +1891,34 @@ test("quoteForCmdExe leaves a plain token untouched", () => {
1890
1891
  assert.equal(quoteForCmdExe("pi"), "pi");
1891
1892
  });
1892
1893
 
1893
- test("quoteForCmdExe quotes a token with a space and escapes an inner double quote", () => {
1894
- assert.equal(quoteForCmdExe('say "hi" now'), '"say \\"hi\\" now"');
1894
+ test("quoteForCmdExe quotes a token with a space and the other cmd.exe specials", () => {
1895
+ assert.equal(quoteForCmdExe("say hi now"), '"say hi now"');
1896
+ assert.equal(quoteForCmdExe("a&b"), '"a&b"');
1897
+ });
1898
+
1899
+ // cmd.exe has no backslash escape: a `"` inside a quoted token ends the
1900
+ // quoted section, so everything after it is parsed as command syntax. The
1901
+ // simple quoting cannot represent such a token, so it refuses it instead of
1902
+ // emitting a command line that lets the argument inject commands.
1903
+ test("quoteForCmdExe rejects a token with a double quote", () => {
1904
+ for (const token of ['say "hi" now', 'x" & whoami & rem "', '"', 'a"b']) {
1905
+ assert.throws(() => quoteForCmdExe(token), /double quote/);
1906
+ }
1907
+ });
1908
+
1909
+ test("planSpawn refuses a win32 .cmd launch whose argument carries a double quote", () => {
1910
+ const hostile = 'x" & whoami & rem "';
1911
+ for (const command of ["C:\\Program Files\\nodejs\\pi.cmd", "C:\\pi\\pi.BAT"]) {
1912
+ assert.throws(() => planSpawn({ command, args: ["--version", hostile], platform: "win32" }), /double quote/);
1913
+ }
1914
+ assert.throws(() => planSpawn({ command: 'C:\\pi\\a"b.cmd', args: [], platform: "win32" }), /double quote/);
1915
+ });
1916
+
1917
+ test("planSpawn passes a double quote through when no cmd.exe command line is built", () => {
1918
+ const args = ["--prompt", 'say "hi"'];
1919
+ assert.deepEqual(planSpawn({ command: "C:\\pi\\pi.exe", args, platform: "win32" }), { command: "C:\\pi\\pi.exe", args, shell: false });
1920
+ assert.deepEqual(planSpawn({ command: "/usr/bin/pi", args, platform: "linux" }), { command: "/usr/bin/pi", args, shell: false });
1921
+ assert.deepEqual(planSpawn({ command: "/usr/local/bin/pi.cmd", args, platform: "darwin" }), { command: "/usr/local/bin/pi.cmd", args, shell: false });
1895
1922
  });
1896
1923
 
1897
1924
  test("quoteForCmdExe quotes an empty token", () => {
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
  import { parseLauncherArgs } from "../lib/gentle-shell-launcher.ts";
4
- import { findStartupUpdate, shouldCheckStartupUpdate } from "../lib/gentle-shell-startup-update.ts";
4
+ import { findStartupUpdate, parseStartupReleaseCache, shouldCheckStartupUpdate, writeStartupReleaseCache } from "../lib/gentle-shell-startup-update.ts";
5
5
 
6
6
  const eligible = {
7
7
  args: parseLauncherArgs([]), env: {}, bundled: true, inputTTY: true, outputTTY: true,
@@ -83,3 +83,79 @@ test("custom deadline is bounded and settled lookups clear the timeout", async (
83
83
  t.mock.timers.tick(20);
84
84
  assert.equal(settledSignal?.aborted, false);
85
85
  });
86
+
87
+ // A fresh cache keeps the everyday launch off the network entirely: the offer
88
+ // (or its absence) is decided from the recorded lookup, and the live lookup —
89
+ // whose result is the only thing worth caching — runs only when the cache is
90
+ // stale, missing, or malformed.
91
+ test("a fresh cache decides the offer without touching the network", async () => {
92
+ const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
93
+ const fresh = { checkedAt: Date.now() - 60 * 60 * 1000, latest: "2.0.0" };
94
+ assert.deepEqual(await findStartupUpdate({ current: "1.0.0", lookup: offline, cache: fresh, ttlMs: 24 * 60 * 60 * 1000 }), {
95
+ current: "1.0.0", latest: "2.0.0", notesUrl: "https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.0.0",
96
+ });
97
+ assert.equal(await findStartupUpdate({ current: "2.0.0", lookup: offline, cache: fresh, ttlMs: 24 * 60 * 60 * 1000 }), undefined);
98
+ });
99
+
100
+ test("a fresh cache respects the skipped release exactly like a live lookup", async () => {
101
+ const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
102
+ assert.equal(await findStartupUpdate({ current: "1.0.0", skipped: "2.0.0", lookup: offline, cache: { checkedAt: Date.now() - 1000, latest: "2.0.0" } }), undefined);
103
+ assert.equal((await findStartupUpdate({ current: "1.0.0", skipped: "2.0.0", lookup: offline, cache: { checkedAt: Date.now() - 1000, latest: "2.0.1" } }))?.latest, "2.0.1");
104
+ });
105
+
106
+ test("a stale, missing or malformed cache runs the live lookup and records its answer", async () => {
107
+ const written: string[] = [];
108
+ const opts = {
109
+ current: "1.0.0",
110
+ lookup: async () => "2.0.1",
111
+ ttlMs: 1000,
112
+ cacheWrite: (latest: string) => { written.push(latest); },
113
+ };
114
+ // Stale beyond the TTL.
115
+ assert.equal((await findStartupUpdate({ ...opts, cache: { checkedAt: Date.now() - 1001, latest: "2.0.0" } }))?.latest, "2.0.1");
116
+ // No cache at all.
117
+ assert.equal((await findStartupUpdate({ ...opts }))?.latest, "2.0.1");
118
+ // A cache whose latest never parsed: never trusted, refreshed instead.
119
+ assert.equal((await findStartupUpdate({ ...opts, cache: { checkedAt: Date.now() - 1, latest: "garbage" } }))?.latest, "2.0.1");
120
+ assert.deepEqual(written, ["2.0.1", "2.0.1", "2.0.1"]);
121
+ });
122
+
123
+ test("an unanswered lookup is never cached", async () => {
124
+ const written: string[] = [];
125
+ assert.equal(await findStartupUpdate({ current: "1.0.0", lookup: async () => undefined, cacheWrite: (latest) => { written.push(latest); } }), undefined);
126
+ assert.equal(await findStartupUpdate({ current: "1.0.0", lookup: async () => { throw new Error("offline"); }, cacheWrite: (latest) => { written.push(latest); } }), undefined);
127
+ assert.deepEqual(written, []);
128
+ });
129
+
130
+ test("the cache ages against the injected clock, not the wall", async () => {
131
+ const now = 1_700_000_000_000;
132
+ const offline = async (): Promise<string> => { throw new Error("lookup must not run on a fresh cache"); };
133
+ // Exactly at the TTL boundary the cache is already stale: the check errs
134
+ // toward the live lookup, never toward skipping it.
135
+ assert.equal((await findStartupUpdate({ current: "1.0.0", lookup: async () => "2.0.0", ttlMs: 1000, nowMs: now, cache: { checkedAt: now - 1000, latest: "2.0.0" } }))?.latest, "2.0.0");
136
+ assert.deepEqual(await findStartupUpdate({ current: "1.0.0", lookup: offline, ttlMs: 1000, nowMs: now - 1, cache: { checkedAt: now - 1000, latest: "2.0.0" } }).then((offer) => offer?.latest), "2.0.0");
137
+ });
138
+
139
+ test("parseStartupReleaseCache accepts the recorded shape and rejects everything else", () => {
140
+ assert.deepEqual(parseStartupReleaseCache({ checkedAt: "2026-01-01T00:00:00.000Z", latest: "2.0.1" }), { checkedAt: Date.parse("2026-01-01T00:00:00.000Z"), latest: "2.0.1" });
141
+ assert.deepEqual(parseStartupReleaseCache({ checkedAt: 1_700_000_000_000, latest: "2.0.1" }), { checkedAt: 1_700_000_000_000, latest: "2.0.1" });
142
+ for (const bad of [undefined, null, "x", [], {}, { checkedAt: "nope", latest: "2.0.1" }, { checkedAt: 1_700_000_000_000 }, { checkedAt: 1_700_000_000_000, latest: "" }]) {
143
+ assert.equal(parseStartupReleaseCache(bad), undefined);
144
+ }
145
+ });
146
+
147
+ test("writeStartupReleaseCache records one lookup alongside the existing config keys", () => {
148
+ const stored: string[] = [];
149
+ const config = JSON.stringify({ skippedStartupRelease: "9.9.9", other: { nested: true } });
150
+ writeStartupReleaseCache(() => config, (text) => stored.push(text), "2.0.1", 1_700_000_000_000);
151
+ const saved = JSON.parse(stored[0]);
152
+ assert.equal(saved.skippedStartupRelease, "9.9.9");
153
+ assert.deepEqual(saved.other, { nested: true });
154
+ assert.equal(saved.startupReleaseCheck.latest, "2.0.1");
155
+ assert.equal(saved.startupReleaseCheck.checkedAt, new Date(1_700_000_000_000).toISOString());
156
+ // An absent config file starts a minimal one.
157
+ writeStartupReleaseCache(() => undefined, (text) => stored.push(text), "2.0.1");
158
+ assert.deepEqual(JSON.parse(stored[1]).startupReleaseCheck.latest, "2.0.1");
159
+ // An invalid config must fail loudly, never overwrite it silently.
160
+ assert.throws(() => writeStartupReleaseCache(() => "{ broken", () => {}, "2.0.1"));
161
+ });
@@ -977,6 +977,65 @@ test("bundled on Windows: the user Path entry goes first, the running node.exe,
977
977
  } finally { w.cleanup(); }
978
978
  });
979
979
 
980
+ // #2469: a home that self-uninstall keeps must not keep Pi pointed at the npm
981
+ // the uninstall removed: a stale `npmCommand` makes every later `pi install`
982
+ // fail with ENOENT naming the deleted node.exe.
983
+ const bundledNpmCommand = (w: BundledWorld) => [w.node, join(dirname(w.node), "gentle-shell", "npm"), "--update-notifier=false"];
984
+
985
+ const bundledNpmSettings = (w: BundledWorld, npmCommand: unknown) => `${JSON.stringify({ theme: "dark", npmCommand }, null, " ")}\n`;
986
+
987
+ test("bundled: keeping the home removes the npmCommand that names the removed prefix and keeps the rest of settings.json", posixOnly, async () => {
988
+ const w = bundledWorld();
989
+ try {
990
+ const settings = join(w.shared.agent, "settings.json");
991
+ write(settings, bundledNpmSettings(w, bundledNpmCommand(w)));
992
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
993
+ assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")), { theme: "dark" });
994
+ assert.ok(w.out.join("\n").includes(`npmCommand from ${settings}`), w.out.join("\n"));
995
+ } finally { w.cleanup(); }
996
+ });
997
+
998
+ test("bundled: keeping the home leaves settings.json alone when npmCommand does not name the prefix", posixOnly, async () => {
999
+ const w = bundledWorld();
1000
+ try {
1001
+ const settings = join(w.shared.agent, "settings.json");
1002
+ const text = bundledNpmSettings(w, ["/usr/local/bin/node", "--update-notifier=false"]);
1003
+ write(settings, text);
1004
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
1005
+ assert.equal(readFileSync(settings, "utf8"), text);
1006
+ } finally { w.cleanup(); }
1007
+ });
1008
+
1009
+ test("bundled: keeping the home never rewrites a settings.json that is not a JSON object", posixOnly, async () => {
1010
+ const w = bundledWorld();
1011
+ try {
1012
+ const settings = join(w.shared.agent, "settings.json");
1013
+ write(settings, "not json at all\n");
1014
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
1015
+ assert.equal(readFileSync(settings, "utf8"), "not json at all\n");
1016
+ } finally { w.cleanup(); }
1017
+ });
1018
+
1019
+ test("bundled on Windows: keeping the home also removes the npmCommand naming the removed prefix", async () => {
1020
+ const w = bundledWorld({ platform: "win32" });
1021
+ try {
1022
+ const settings = join(w.shared.agent, "settings.json");
1023
+ write(settings, `${JSON.stringify({ npmCommand: bundledNpmCommand(w) }, null, " ")}\n`);
1024
+ assert.equal(await w.uninstall(["--yes"]), 0, w.err.join("\n"));
1025
+ assert.equal(readFileSync(settings, "utf8"), "{}\n");
1026
+ assert.ok(w.out.join("\n").includes(`npmCommand from ${settings}`), w.out.join("\n"));
1027
+ } finally { w.cleanup(); }
1028
+ });
1029
+
1030
+ test("bundled: --include-shared removes the home with its settings, so no npmCommand cleanup is needed", posixOnly, async () => {
1031
+ const w = bundledWorld();
1032
+ try {
1033
+ write(join(w.shared.agent, "settings.json"), bundledNpmSettings(w, bundledNpmCommand(w)));
1034
+ assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n"));
1035
+ assert.equal(existsSync(join(w.shared.agent, "settings.json")), false);
1036
+ } finally { w.cleanup(); }
1037
+ });
1038
+
980
1039
  // #2201: the standalone isolated home keeps its config in ~/.gentle-shell/gentle-ai.
981
1040
  test("Gentle Shell's own config folder next to the isolated home is removed; the shared config home keeps its rule", async () => {
982
1041
  const w = world();
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
4
4
  import { createRequire } from "node:module";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import test, { after, afterEach } from "node:test";
7
+ import test, { after, afterEach, type TestContext } from "node:test";
8
8
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
9
9
  import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
10
10
  import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
@@ -24,6 +24,7 @@ import type { ShellBarTheme } from "../lib/shell-bar.ts";
24
24
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
25
25
  import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
26
26
  import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
27
+ import { isJobsSidebarSnapshot } from "../lib/jobs-sidebar-state.ts";
27
28
  import type { NativeReviewCli } from "../lib/native-review-cli.ts";
28
29
  import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
29
30
  import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
@@ -111,7 +112,7 @@ const renderers = new Map<string, MessageRenderer>();
111
112
  const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
112
113
 
113
114
  const DEFAULT_COMMANDS: SlashCommandInfo[] = [
114
- { name: "gentle:models", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
115
+ { name: "gentle:profiles", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
115
116
  { name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
116
117
  { name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
117
118
  { name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
@@ -163,6 +164,9 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commands
163
164
  if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
164
165
  // Roots outside the worktree list read as a detached HEAD.
165
166
  if (args.includes("symbolic-ref")) return { stdout: "", stderr: "", code: 1, killed: false };
167
+ // The unborn-HEAD ask is cheap metadata, not a scan round: the script
168
+ // models one numstat+porcelain pair per round, exactly like real Git.
169
+ if (args.includes("rev-parse")) return { stdout: "", stderr: "", code: 0, killed: false };
166
170
  const isNumstat = args.includes("diff");
167
171
  const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
168
172
  return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
@@ -500,7 +504,7 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown
500
504
  }
501
505
  });
502
506
 
503
- test("jobs snapshots populate Status, use one compact fallback, and reject stale sessions", async () => {
507
+ test("jobs snapshots feed their own rail card, keep one compact fallback, and reject stale sessions", async () => {
504
508
  const { pi, handlers } = fakePi();
505
509
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
506
510
  const { ctx, ui } = fakeContext();
@@ -512,35 +516,56 @@ test("jobs snapshots populate Status, use one compact fallback, and reject stale
512
516
  const component = factory(tui, plainTheme, data);
513
517
  const state = sidebarState(tui as unknown as TUI);
514
518
  const rail = state.parts.get("footer")!;
519
+ const jobs = state.parts.get("jobs")!;
515
520
  const header = state.parts.get("header")!;
516
521
  const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } });
517
522
  try {
518
523
  publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
519
- assert.match(rail.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
524
+ assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR 1860/, "the Status card never prints jobs; the jobs card owns them");
520
525
  assert.match(rail.render(46).join("\n"), /MCP: 3 servers/);
521
- assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "Jobs replaces the generic integration counter");
526
+ assert.match(jobs.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
527
+ publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }, { id: "job-0", label: "Build runtime", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
528
+ assert.match(jobs.render(60).join("\n"), /1 running · 1 finished/, "finished tails ride the same snapshot");
529
+ assert.match(jobs.render(60).join("\n"), /✓\s+Build runtime/);
530
+ // The compact count is running-only: a finished tail still paints the
531
+ // card, but the header's ⧗ N jobs derives from running entries alone.
532
+ publish("shell-session", [{ id: "job-2", label: "Fresh tail", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
533
+ tui.terminal.columns = 80;
534
+ assert.match(jobs.render(46).join("\n"), /Fresh tail/, "the finished tail still paints the card");
535
+ assert.doesNotMatch(header.render(80).join("\n"), /⧗ 1 job/, "the compact count is running-only");
536
+ tui.terminal.columns = 160;
537
+ publish("shell-session", [{ id: "job-0", label: "Stale tail", startedAt: 1, status: "exited", endedAt: 5, exitCode: 0 }]);
538
+ assert.doesNotMatch(jobs.render(46).join("\n"), /Stale tail/, "a finished job past the TTL paints nothing");
539
+ assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "the jobs counter never joins the integration statuses while display data exists");
522
540
  state.active = true;
523
541
  state.ownsHost = () => true;
524
- assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible Status card owns jobs details");
542
+ assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible rail owns job details");
525
543
  state.active = false;
526
544
  tui.terminal.columns = 80;
527
- assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count");
528
- const before = rail.digest?.();
545
+ publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
546
+ assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count of a running job");
547
+ const before = jobs.render(46).join("\n");
529
548
  publish("foreign", []);
530
549
  publish("shell-session", [{ label: "invalid" }]);
531
- assert.equal(rail.digest?.(), before);
550
+ assert.equal(jobs.render(46).join("\n"), before);
532
551
  publish("shell-session", []);
533
- assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR/);
552
+ assert.doesNotMatch(jobs.render(46).join("\n"), /Jobs|CI for PR/);
534
553
  assert.doesNotMatch(header.render(80).join("\n"), /1 job/);
535
554
  publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]);
536
555
  await fire(handlers, "session_start", ctx);
537
- assert.doesNotMatch(rail.render(46).join("\n"), /Build runtime/);
556
+ assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /Build runtime/);
538
557
  await fire(handlers, "session_shutdown", ctx);
539
558
  publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]);
540
- assert.doesNotMatch(rail.render(46).join("\n"), /late/);
559
+ assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /late/);
541
560
  } finally { component.dispose(); }
542
561
  });
543
562
 
563
+ test("the jobs snapshot validator admits the finished-tail statuses", () => {
564
+ assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-1", label: "Spawn failed", startedAt: 1, status: "failed", endedAt: 60_000, exitCode: null }] }));
565
+ assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-2", label: "Stopped", startedAt: 1, status: "stopped", endedAt: 2 }] }));
566
+ assert.equal(isJobsSidebarSnapshot({ jobs: [{ id: "job-3", label: "Bad", startedAt: 1, status: "exploded" }] }), false);
567
+ });
568
+
544
569
  test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => {
545
570
  const home = scopedDoubleEscCancelConfigHome(t);
546
571
  writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home });
@@ -556,6 +581,9 @@ test("hidden Status leaves only a jobs count in regular bottom and fullscreen he
556
581
  pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
557
582
  assert.equal(component.render(80).join("\n"), "⧗ 1 job");
558
583
  assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/);
584
+ pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }] } });
585
+ assert.doesNotMatch(component.render(80).join("\n"), /⧗/, "a finished-only tail never paints a ⧗ 0 jobs row");
586
+ pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
559
587
  tui.mode = "fullscreen";
560
588
  const header = sidebarState(tui as unknown as TUI).parts.get("header")!;
561
589
  assert.match(header.render(160).join("\n"), /⧗ 1 job/);
@@ -2048,7 +2076,7 @@ test("gentleShell shows working while the agent runs and queued when messages wa
2048
2076
  const editor = installedPrompt(ctx, ui, handlers);
2049
2077
 
2050
2078
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
2051
- assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ working… ─+╮$/);
2079
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working… ─+╮$/);
2052
2080
  pending.value = true;
2053
2081
  assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
2054
2082
  for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
@@ -2165,7 +2193,7 @@ test("vim NORMAL slash uses Pi's command and skill completion without displacing
2165
2193
  fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false,
2166
2194
  now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => INSTALLED_PI,
2167
2195
  });
2168
- const entries = ["gentle:vim", "gentle:models", "skill:example"];
2196
+ const entries = ["gentle:vim", "gentle:profiles", "skill:example"];
2169
2197
  const requests: string[] = [];
2170
2198
  editor.setAutocompleteProvider({
2171
2199
  async getSuggestions(lines: string[], line: number, col: number) {
@@ -2821,7 +2849,7 @@ test("vim selector exposes enable, disable and status; status and cancellation d
2821
2849
  } finally { editor.dispose(); }
2822
2850
  });
2823
2851
 
2824
- test("animations command reports without writing and switches the live pulse", async (t) => {
2852
+ test("animations command reports without writing and switches the live watchdog", async (t) => {
2825
2853
  const configHome = scopedDoubleEscCancelConfigHome(t);
2826
2854
  const delays: number[] = [];
2827
2855
  let active = 0;
@@ -2839,9 +2867,9 @@ test("animations command reports without writing and switches the live pulse", a
2839
2867
  assert.match(ui.notices.at(-1)!, /animations: quality/);
2840
2868
  assert.equal(existsSync(join(configHome, "animations.json")), false);
2841
2869
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
2842
- assert.deepEqual(delays, [2000, 80]);
2870
+ assert.deepEqual(delays, [2000, 1000]);
2843
2871
  await command.handler("performance", ctx);
2844
- assert.deepEqual(delays, [2000, 80, 1000]);
2872
+ assert.deepEqual(delays, [2000, 1000, 1000]);
2845
2873
  assert.equal(active, 2);
2846
2874
  await command.handler("potato", ctx);
2847
2875
  assert.equal(active, 1);
@@ -2850,7 +2878,7 @@ test("animations command reports without writing and switches the live pulse", a
2850
2878
  await command.handler("invalid", ctx);
2851
2879
  assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
2852
2880
  await command.handler("quality", ctx);
2853
- assert.deepEqual(delays, [2000, 80, 1000, 80]);
2881
+ assert.deepEqual(delays, [2000, 1000, 1000, 1000]);
2854
2882
  for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
2855
2883
  assert.equal(active, 1);
2856
2884
  editor.dispose();
@@ -2939,7 +2967,7 @@ test("animations with no argument opens a selectable menu and applies the chosen
2939
2967
  assert.equal(existsSync(join(dismissHome, "animations.json")), false);
2940
2968
  });
2941
2969
 
2942
- test("prompt uses the compact banner cadence and releases its unref timer at settlement", async (t) => {
2970
+ test("prompt installs the silence watchdog unref'd and releases it at settlement", async (t) => {
2943
2971
  const configHome = scopedDoubleEscCancelConfigHome(t);
2944
2972
  writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
2945
2973
  const delays: number[] = [];
@@ -2957,7 +2985,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
2957
2985
  const editor = installedPrompt(ctx, ui, handlers);
2958
2986
  assert.equal(active, 1);
2959
2987
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
2960
- assert.deepEqual(delays, [2000, 80]);
2988
+ assert.deepEqual(delays, [2000, 1000]);
2961
2989
  assert.equal(unrefs, 2);
2962
2990
  for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
2963
2991
  assert.equal(active, 1);
@@ -2966,6 +2994,109 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
2966
2994
  assert.equal(active, 0);
2967
2995
  });
2968
2996
 
2997
+ // gentle-shell#994: the working animation is render-driven. Frames the host
2998
+ // already paints while streaming advance the petal for free; a low-cadence
2999
+ // watchdog repaints only silent phases (long tool runs with no output).
3000
+ interface RecordedInterval { tick: () => void; delay: number; stopped: boolean }
3001
+
3002
+ function recordIntervals(t: TestContext): RecordedInterval[] {
3003
+ const intervals: RecordedInterval[] = [];
3004
+ t.mock.method(globalThis, "setInterval", (tick: () => void, delay: number) => {
3005
+ const timer: RecordedInterval = { tick, delay, stopped: false };
3006
+ intervals.push(timer);
3007
+ return { unref() {}, timer };
3008
+ });
3009
+ t.mock.method(globalThis, "clearInterval", (handle: { timer: RecordedInterval }) => { handle.timer.stopped = true; });
3010
+ return intervals;
3011
+ }
3012
+
3013
+ function petalGlyphOf(editor: GentlePromptEditor): string {
3014
+ return stripAnsi(editor.render(60)[0]).match(/[✿❀❁✾]/)?.[0] ?? "";
3015
+ }
3016
+
3017
+ test("streaming frames advance the working petal and the quality watchdog stays silent while renders flow", (t) => {
3018
+ const configHome = scopedDoubleEscCancelConfigHome(t);
3019
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
3020
+ const intervals = recordIntervals(t);
3021
+ const renders = t.mock.method(fakeTui, "requestRender", () => {});
3022
+ const { pi, handlers } = fakePi();
3023
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }, { now: () => 1_000_000 });
3024
+ const { ctx, ui } = fakeContext();
3025
+ const editor = installedPrompt(ctx, ui, handlers);
3026
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
3027
+ const watchdog = intervals.find((entry) => entry.delay === 1000);
3028
+ assert.ok(watchdog, "working installs the 1000ms silence watchdog");
3029
+ const glyphs = [0, 1, 2, 3].map(() => petalGlyphOf(editor));
3030
+ assert.ok(new Set(glyphs).size > 1, `each streamed frame must advance the petal, got ${glyphs.join("")}`);
3031
+ const before = renders.mock.callCount();
3032
+ watchdog!.tick();
3033
+ assert.equal(renders.mock.callCount(), before, "renders are already flowing, so the watchdog must not force one");
3034
+ editor.dispose();
3035
+ assert.equal(watchdog!.stopped, true);
3036
+ });
3037
+
3038
+ test("silent working phases repaint only through the 1000ms watchdog window", (t) => {
3039
+ const configHome = scopedDoubleEscCancelConfigHome(t);
3040
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
3041
+ const intervals = recordIntervals(t);
3042
+ const renders = t.mock.method(fakeTui, "requestRender", () => {});
3043
+ let clock = 1_000_000;
3044
+ const { pi, handlers } = fakePi();
3045
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }, { now: () => clock });
3046
+ const { ctx, ui } = fakeContext();
3047
+ const editor = installedPrompt(ctx, ui, handlers);
3048
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
3049
+ const watchdog = intervals.find((entry) => entry.delay === 1000);
3050
+ assert.ok(watchdog, "working installs the 1000ms silence watchdog");
3051
+ const baseline = petalGlyphOf(editor);
3052
+ const before = renders.mock.callCount();
3053
+ watchdog!.tick();
3054
+ assert.equal(renders.mock.callCount(), before, "no frame painted within the window, but the window has not elapsed yet");
3055
+ clock += 1000;
3056
+ watchdog!.tick();
3057
+ assert.equal(renders.mock.callCount(), before + 1, "silence past the window forces exactly one repaint");
3058
+ assert.notEqual(petalGlyphOf(editor), baseline, "the watchdog advanced the petal tick");
3059
+ const afterForced = renders.mock.callCount();
3060
+ watchdog!.tick();
3061
+ assert.equal(renders.mock.callCount(), afterForced, "the watchdog's own repaint counts as render traffic for the next window");
3062
+ editor.dispose();
3063
+ });
3064
+
3065
+ test("potato installs no working watchdog and the petal never advances", (t) => {
3066
+ const configHome = scopedDoubleEscCancelConfigHome(t);
3067
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"potato"}');
3068
+ const intervals = recordIntervals(t);
3069
+ const { pi, handlers } = fakePi();
3070
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
3071
+ const { ctx, ui } = fakeContext();
3072
+ const editor = installedPrompt(ctx, ui, handlers);
3073
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
3074
+ assert.ok(!intervals.some((entry) => entry.delay !== 2000), "potato must not animate: no watchdog interval at all");
3075
+ const glyphs = [0, 1, 2].map(() => petalGlyphOf(editor));
3076
+ assert.deepEqual(glyphs, ["✿", "✿", "✿"], "potato keeps the petal static");
3077
+ editor.dispose();
3078
+ });
3079
+
3080
+ test("the working watchdog is cleared on settlement and on dispose", (t) => {
3081
+ const configHome = scopedDoubleEscCancelConfigHome(t);
3082
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
3083
+ const intervals = recordIntervals(t);
3084
+ const { pi, handlers } = fakePi();
3085
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
3086
+ const { ctx, ui } = fakeContext();
3087
+ const editor = installedPrompt(ctx, ui, handlers);
3088
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
3089
+ const watchdog = intervals.filter((entry) => entry.delay === 1000).at(-1);
3090
+ assert.ok(watchdog && !watchdog.stopped, "working keeps the watchdog armed");
3091
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
3092
+ assert.equal(watchdog!.stopped, true, "settlement must clear the watchdog");
3093
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
3094
+ const rearmWatchdog = intervals.filter((entry) => entry.delay === 1000).at(-1);
3095
+ assert.ok(rearmWatchdog && rearmWatchdog !== watchdog && !rearmWatchdog.stopped, "the next turn re-arms a fresh watchdog");
3096
+ editor.dispose();
3097
+ assert.equal(rearmWatchdog!.stopped, true, "dispose must clear the watchdog");
3098
+ });
3099
+
2969
3100
  // ---------------------------------------------------------------------------
2970
3101
  // double-esc-cancel (issue #1163): opt-in, off by default. While the prompt
2971
3102
  // is working and autocomplete is hidden, the first Esc is swallowed and the
@@ -4811,6 +4942,9 @@ test("new sessions do not inherit another session's worktrees", async () => {
4811
4942
  const first=fakeContext();
4812
4943
  await fire(h.handlers,"session_start",first.ctx);
4813
4944
  await h.tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,first.ctx);
4945
+ // The registration kicks off its own async scan; settle it so its Git calls
4946
+ // cannot land after the next session clears the shared call log.
4947
+ await changesSettled();
4814
4948
  await fire(h.handlers,"session_shutdown",first.ctx);
4815
4949
  const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
4816
4950
  (next.ctx.sessionManager as any).getSessionId=()=>"new-session";
@@ -5559,11 +5693,11 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei
5559
5693
  assert.match(rendered, /Diagnostics/);
5560
5694
  assert.match(rendered, /Skills/);
5561
5695
  assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
5562
- assert.match(rendered, /Assign models and effort/);
5696
+ assert.match(rendered, /Models and profiles/);
5563
5697
  assert.match(rendered, /Browse changes/);
5564
5698
  assert.match(rendered, /Gentle AI status/);
5565
5699
  assert.match(rendered, /Refresh skill registry/);
5566
- assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
5700
+ assert.doesNotMatch(rendered, /gentle:profiles|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
5567
5701
  assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
5568
5702
  assert.doesNotMatch(rendered, /skill:foo/);
5569
5703
  const changesLine = lines.find((line) => line.includes("Browse changes"));
@@ -5578,13 +5712,13 @@ test("selecting a command from the palette by its label sends the underlying com
5578
5712
  const { ctx, ui, overlayReady } = fakeContext();
5579
5713
  const opened = commands.get("gentle:commands")!.handler("", ctx);
5580
5714
  await overlayReady;
5581
- // "mod" only matches the "Assign models and effort" label (it contains
5582
- // "mod" via "models"); nothing else in the fixture does.
5715
+ // "mod" only matches the "Models and profiles" label (it contains "mod"
5716
+ // via "Models"); nothing else in the fixture does.
5583
5717
  for (const ch of "mod") ui.overlayView!.handleInput(ch);
5584
- assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
5718
+ assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
5585
5719
  ui.overlayView!.handleInput("\r");
5586
5720
  await opened;
5587
- assert.deepEqual(sentMessages, [{ content: "/gentle:models", options: { expandPromptTemplates: true } }]);
5721
+ assert.deepEqual(sentMessages, [{ content: "/gentle:profiles", options: { expandPromptTemplates: true } }]);
5588
5722
  });
5589
5723
 
5590
5724
  test("escaping the palette sends no message", async () => {
@@ -5627,7 +5761,7 @@ test("the alt+k shortcut opens the same command palette as the command", async (
5627
5761
  assert.ok(shortcut, "alt+k not registered");
5628
5762
  const opened = shortcut.handler(ctx);
5629
5763
  await overlayReady;
5630
- assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
5764
+ assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
5631
5765
  ui.overlayView!.handleInput("\x1b");
5632
5766
  await opened;
5633
5767
  });
@@ -6376,3 +6510,69 @@ for (const kind of ["unset", "empty", "bundled"] as const) {
6376
6510
  assert.equal(read(), "other (repo)", "the pinned profile exists only in that config home's store");
6377
6511
  });
6378
6512
  }
6513
+
6514
+ // gentle-shell#2464: a pasted image is an atomic chip in the draft and a real
6515
+ // image attachment on submit, never a temp-file path the model must `read`.
6516
+ const CHIP_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01]);
6517
+ const chipKeybindings = { matches: (data: string, action: string) => action === "app.clipboard.pasteImage" && data === "\x1bv" };
6518
+
6519
+ async function pastedChipPrompt(model?: ExtensionContext["model"]) {
6520
+ const { pi, handlers } = fakePi();
6521
+ gentleShell(pi, {}, { readClipboardImage: async () => ({ bytes: CHIP_PNG, mimeType: "image/png" }) });
6522
+ const { ctx, ui } = fakeContext();
6523
+ if (model) (ctx as { model: unknown }).model = model;
6524
+ const editor = installedPrompt(ctx, ui, handlers, chipKeybindings);
6525
+ editor.onPasteImage = () => assert.fail("Pi's path-inserting paste must not run when the clipboard holds an image");
6526
+ editor.setText("what is this?");
6527
+ editor.handleInput("\x1bv");
6528
+ await settle();
6529
+ const input = (text: string, source = "interactive") => handlers.get("input")?.[0]?.({ type: "input", text, source }, ctx);
6530
+ // Pi's real order: the editor empties its draft, then onSubmit hands the
6531
+ // text to session.prompt(), which runs the input handlers.
6532
+ const submit = () => {
6533
+ let sent = "";
6534
+ editor.onSubmit = (text) => { sent = text; };
6535
+ editor.handleInput("\r");
6536
+ return input(sent);
6537
+ };
6538
+ return { editor, ui, input, submit };
6539
+ }
6540
+
6541
+ test("pasted image chips are submitted as real image attachments (gentle-shell#2464)", async () => {
6542
+ const { editor, ui, input, submit } = await pastedChipPrompt();
6543
+ assert.equal(editor.getText(), "what is this? [Image#1]");
6544
+ const strip = (ui.widgets.get("gentle-shell-image-attachments") as (tui: unknown, theme: unknown) => { render(width: number): string[] })(fakeTui, plainTheme);
6545
+ assert.equal(stripAnsi(strip.render(80)[0] ?? ""), "[Image#1] png · 2×1 · 24 B");
6546
+ assert.deepEqual(submit(), {
6547
+ action: "transform",
6548
+ text: "what is this? [Image#1]",
6549
+ images: [{ type: "image", data: Buffer.from(CHIP_PNG).toString("base64"), mimeType: "image/png" }],
6550
+ });
6551
+ // Sent images are released: recalling the same text later is ordinary text.
6552
+ assert.equal(editor.getText(), "");
6553
+ assert.deepEqual(strip.render(80), []);
6554
+ assert.deepEqual(input("what is this? [Image#1]"), { action: "continue" });
6555
+ });
6556
+
6557
+ test("a pasted image chip deleted before submit is never sent (gentle-shell#2464)", async () => {
6558
+ const { editor, submit } = await pastedChipPrompt();
6559
+ editor.handleInput("\x7f");
6560
+ assert.equal(editor.getText(), "what is this? ");
6561
+ assert.deepEqual(submit(), { action: "continue" });
6562
+ });
6563
+
6564
+ test("pasted image chips leave non-interactive input and deleted chips untouched (gentle-shell#2464)", async () => {
6565
+ const { input } = await pastedChipPrompt();
6566
+ assert.deepEqual(input("[Image#1] from an extension", "extension"), { action: "continue" });
6567
+ assert.deepEqual(input("the chip was deleted"), { action: "continue" });
6568
+ });
6569
+
6570
+ test("pasted image chips warn instead of sending images a text-only model rejects (gentle-shell#2464)", async () => {
6571
+ const { ui, input } = await pastedChipPrompt({ id: "text-only", provider: "acme", input: ["text"] } as unknown as ExtensionContext["model"]);
6572
+ assert.deepEqual(input("what is this? [Image#1]"), {
6573
+ action: "transform",
6574
+ text: "what is this? [Image#1 omitted: the current model does not accept images]",
6575
+ images: [],
6576
+ });
6577
+ assert.ok(ui.notices.some((notice) => /1 pasted image was not sent: the current model does not accept images/.test(notice)));
6578
+ });
@@ -498,6 +498,17 @@ test("the system prompt carries only a byte-stable todo block; the current list
498
498
  assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
499
499
  });
500
500
 
501
+ // weak-model-economy S1: small tasks skip the todo list (bench C-econ).
502
+ test("the todo block exempts small tasks before the tracking rules", async () => {
503
+ const { pi, fire } = fakePi();
504
+ gentleTodo(pi, {});
505
+ const { ctx } = fakeContext();
506
+ await fire("session_start", ctx);
507
+ const event = promptEvent();
508
+ await fire("before_agent_start", ctx, event);
509
+ assert.ok(event.systemPromptOptions.appendSystemPrompt.startsWith("## Todo list\nA small task (Task Size) uses no todo list: do it and report. For tracked or multi-step work, keep it current with the `todo` tool: mark a task in_progress before starting it,"));
510
+ });
511
+
501
512
  /**
502
513
  * Drives the extension the way Pi's agent loop does (#2195): every model request
503
514
  * runs the context handlers over the persisted transcript, and a custom message