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
@@ -0,0 +1,452 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { delimiter, join } from "node:path";
6
+ import test, { type TestContext } from "node:test";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import gentleMusic, {
9
+ findOnPath,
10
+ formatMusicStatus,
11
+ GENTLE_STREAM_SPEC,
12
+ installHint,
13
+ MUSIC_COMMAND_NAME,
14
+ MUSIC_INSTALL_OFFER,
15
+ MUSIC_INSTALL_TITLE,
16
+ musicTuiArgs,
17
+ parseMusicArgs,
18
+ resolveGentleStream,
19
+ type BundledMusicPlayer,
20
+ type MusicSpawn,
21
+ } from "../extensions/gentle-music.ts";
22
+
23
+ // /gentle:music hands the terminal to gentle-stream's own TUI, the way Pi's
24
+ // external editor does, and controls the background player with
25
+ // `gentle-stream ctl …` without taking the terminal.
26
+
27
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
28
+ type Command = { description: string; handler(args: string, ctx: ExtensionContext): Promise<void> };
29
+
30
+ function fakePi() {
31
+ const commands = new Map<string, Command>();
32
+ const handlers = new Map<string, Handler[]>();
33
+ const pi = {
34
+ registerCommand: (name: string, registration: Command) => commands.set(name, registration),
35
+ registerShortcut: () => assert.fail("/gentle:music registers no shortcut"),
36
+ on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
37
+ } as unknown as ExtensionAPI;
38
+ const fire = async (event: string, payload: unknown, ctx: ExtensionContext) => {
39
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
40
+ };
41
+ return { pi, commands, fire };
42
+ }
43
+
44
+ function fakeContext(mode = "tui", model: { provider: string; id: string } | null = { provider: "anthropic", id: "claude-sonnet-5-5" }, failing?: "stop" | "start") {
45
+ const notices: Array<[string, string | undefined]> = [];
46
+ const calls: string[] = [];
47
+ let settled = false;
48
+ const tui = {
49
+ stop: () => { calls.push("stop"); if (failing === "stop") throw new Error("terminal busy"); },
50
+ start: () => { calls.push("start"); if (failing === "start") throw new Error("terminal gone"); },
51
+ requestRender: (force?: boolean) => calls.push(`render:${force === true}`),
52
+ };
53
+ const ctx = {
54
+ hasUI: true,
55
+ mode,
56
+ model: model ?? undefined,
57
+ ui: {
58
+ notify: (message: string, type?: string) => notices.push([message, type]),
59
+ custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => unknown) =>
60
+ new Promise((resolve) => {
61
+ factory(tui, {}, {}, (value) => { settled = true; resolve(value); });
62
+ }),
63
+ },
64
+ } as unknown as ExtensionContext;
65
+ return { ctx, notices, calls, settled: () => settled };
66
+ }
67
+
68
+ type Outcome = { code?: number | null; error?: Error; stdout?: string; stderr?: string; hang?: boolean };
69
+
70
+ function fakeSpawn(outcomes: Outcome[], calls: string[] = []) {
71
+ const spawned: Array<{ command: string; args: readonly string[]; stdio: unknown }> = [];
72
+ const spawn: MusicSpawn = (command, args, options) => {
73
+ spawned.push({ command, args, stdio: options.stdio });
74
+ calls.push(`spawn:${args.join(" ")}`);
75
+ const outcome = outcomes.shift() ?? { code: 0 };
76
+ const child = Object.assign(new EventEmitter(), {
77
+ stdout: new EventEmitter(),
78
+ stderr: new EventEmitter(),
79
+ kill: () => { calls.push("kill"); child.emit("close", null); return true; },
80
+ });
81
+ if (!outcome.hang) {
82
+ setImmediate(() => {
83
+ if (outcome.error) { child.emit("error", outcome.error); return; }
84
+ if (outcome.stdout) child.stdout.emit("data", Buffer.from(outcome.stdout));
85
+ if (outcome.stderr) child.stderr.emit("data", Buffer.from(outcome.stderr));
86
+ child.emit("close", outcome.code ?? 0);
87
+ });
88
+ }
89
+ return child;
90
+ };
91
+ return { spawn, spawned };
92
+ }
93
+
94
+ const onPath = (names: Record<string, string>) => (name: string) => names[name];
95
+
96
+ function fakeSignals() {
97
+ const listeners = new Set<() => void>();
98
+ return {
99
+ listeners,
100
+ signals: { on: (_signal: "SIGINT", listener: () => void) => listeners.add(listener), off: (_signal: "SIGINT", listener: () => void) => listeners.delete(listener) },
101
+ };
102
+ }
103
+
104
+ test("executable resolution prefers gentle-stream, then uvx, then uv, else nothing", () => {
105
+ assert.deepEqual(resolveGentleStream(onPath({ "gentle-stream": "/bin/gentle-stream", uvx: "/bin/uvx" })), { command: "/bin/gentle-stream", prefix: [] });
106
+ assert.deepEqual(resolveGentleStream(onPath({ uvx: "/bin/uvx", uv: "/bin/uv" })), { command: "/bin/uvx", prefix: ["--from", GENTLE_STREAM_SPEC, "gentle-stream"] });
107
+ assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" })), { command: "/bin/uv", prefix: ["tool", "run", "--from", GENTLE_STREAM_SPEC, "gentle-stream"] });
108
+ assert.equal(resolveGentleStream(onPath({})), undefined);
109
+ });
110
+
111
+ test("findOnPath finds executable files only, honoring PATHEXT on Windows", (t: TestContext) => {
112
+ const root = mkdtempSync(join(tmpdir(), "gentle-music-path-"));
113
+ t.after(() => rmSync(root, { recursive: true, force: true }));
114
+ const a = join(root, "a");
115
+ const b = join(root, "b");
116
+ mkdirSync(a);
117
+ mkdirSync(b);
118
+ writeFileSync(join(a, "gentle-stream"), "not executable");
119
+ chmodSync(join(a, "gentle-stream"), 0o644);
120
+ writeFileSync(join(b, "gentle-stream"), "#!/bin/sh\n");
121
+ chmodSync(join(b, "gentle-stream"), 0o755);
122
+ mkdirSync(join(a, "uvx"));
123
+ const env = { PATH: [a, b].join(delimiter) };
124
+ if (process.platform !== "win32") assert.equal(findOnPath("gentle-stream", env, "darwin"), join(b, "gentle-stream"));
125
+ assert.equal(findOnPath("uvx", env, "darwin"), undefined, "a directory is not an executable");
126
+ assert.equal(findOnPath("missing", env, "darwin"), undefined);
127
+ assert.equal(findOnPath("gentle-stream", {}, "darwin"), undefined);
128
+ writeFileSync(join(a, "uv.EXE"), "");
129
+ assert.equal(findOnPath("uv", { Path: a, PATHEXT: ".COM;.EXE" }, "win32"), join(a, "uv.EXE"));
130
+ });
131
+
132
+ test("TUI arguments carry the session model and the mood as one argument", () => {
133
+ assert.deepEqual(musicTuiArgs({ model: { provider: "anthropic", id: "claude-sonnet-5-5" }, mood: "rainy \"lo-fi\" night; $HOME" }), ["--model", "anthropic/claude-sonnet-5-5", "--mood", "rainy \"lo-fi\" night; $HOME"]);
134
+ assert.deepEqual(musicTuiArgs({ model: { provider: "anthropic", id: "claude-sonnet-5-5" } }), ["--model", "anthropic/claude-sonnet-5-5"]);
135
+ assert.deepEqual(musicTuiArgs({ model: undefined, mood: "calm" }), ["--mood", "calm"]);
136
+ assert.deepEqual(musicTuiArgs({ model: { provider: "", id: "x" } }), []);
137
+ });
138
+
139
+ test("only an exact single control word is a subcommand; anything else is a mood", () => {
140
+ assert.deepEqual(parseMusicArgs(""), { kind: "tui" });
141
+ assert.deepEqual(parseMusicArgs(" "), { kind: "tui" });
142
+ for (const command of ["next", "pause", "play", "stop", "status"]) assert.deepEqual(parseMusicArgs(` ${command} `), { kind: "ctl", command });
143
+ assert.deepEqual(parseMusicArgs("next to the sea"), { kind: "tui", mood: "next to the sea" });
144
+ assert.deepEqual(parseMusicArgs("toggle"), { kind: "tui", mood: "toggle" });
145
+ assert.deepEqual(parseMusicArgs(" dark synthwave "), { kind: "tui", mood: "dark synthwave" });
146
+ });
147
+
148
+ test("status output becomes a one-line summary", () => {
149
+ assert.equal(formatMusicStatus('{"running": false}\n'), "nothing is playing");
150
+ assert.equal(formatMusicStatus('{"running": true, "playing": true, "name": "Neon Rain", "genre": "synthwave", "mood": "night"}'), "♪ Neon Rain · synthwave · playing");
151
+ assert.equal(formatMusicStatus('{"running": true, "playing": false, "name": "Neon Rain", "genre": "synthwave", "mood": ""}'), "♪ Neon Rain · synthwave · paused");
152
+ assert.equal(formatMusicStatus('{"running": true, "playing": true, "name": "", "genre": ""}'), "♪ playing");
153
+ assert.equal(formatMusicStatus("garbage"), undefined);
154
+ assert.equal(formatMusicStatus("[]"), undefined);
155
+ });
156
+
157
+ test("registers /gentle:music without a shortcut", () => {
158
+ const { pi, commands } = fakePi();
159
+ gentleMusic(pi, { which: onPath({}) });
160
+ assert.equal(MUSIC_COMMAND_NAME, "gentle:music");
161
+ assert.match(commands.get(MUSIC_COMMAND_NAME)!.description, /gentle-stream/);
162
+ });
163
+
164
+ test("non-TUI modes get a notice and spawn nothing", async () => {
165
+ const { pi, commands } = fakePi();
166
+ const { spawn, spawned } = fakeSpawn([]);
167
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn });
168
+ for (const mode of ["rpc", "json", "print"]) {
169
+ const context = fakeContext(mode);
170
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
171
+ assert.deepEqual(context.notices, [["/gentle:music needs the interactive terminal.", "warning"]]);
172
+ assert.deepEqual(context.calls, []);
173
+ }
174
+ assert.equal(spawned.length, 0);
175
+ });
176
+
177
+ test("without gentle-stream or uv the command explains how to install and does nothing else", async () => {
178
+ const { pi, commands } = fakePi();
179
+ const { spawn, spawned } = fakeSpawn([]);
180
+ gentleMusic(pi, { which: onPath({}), spawn });
181
+ const context = fakeContext();
182
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx);
183
+ assert.equal(spawned.length, 0);
184
+ assert.deepEqual(context.calls, []);
185
+ assert.equal(context.notices.length, 1);
186
+ const [message, type] = context.notices[0]!;
187
+ assert.equal(type, "warning");
188
+ assert.match(message, /https:\/\/docs\.astral\.sh\/uv/);
189
+ assert.ok(message.includes(`uv tool install '${GENTLE_STREAM_SPEC}'`));
190
+ });
191
+
192
+ test("the TUI gets the terminal, then Pi is restarted with a forced repaint", async () => {
193
+ const { pi, commands } = fakePi();
194
+ const context = fakeContext();
195
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
196
+ const { listeners, signals } = fakeSignals();
197
+ let sigintWhileRunning = 0;
198
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: (command, args, options) => { sigintWhileRunning = listeners.size; return spawn(command, args, options); }, signals });
199
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("rainy night", context.ctx);
200
+ assert.deepEqual(context.calls, ["stop", "spawn:--model anthropic/claude-sonnet-5-5 --mood rainy night", "start", "render:true"]);
201
+ assert.deepEqual(spawned[0], { command: "/bin/gentle-stream", args: ["--model", "anthropic/claude-sonnet-5-5", "--mood", "rainy night"], stdio: "inherit" });
202
+ assert.equal(sigintWhileRunning, 1, "Pi ignores Ctrl+C while the TUI runs");
203
+ assert.equal(listeners.size, 0, "the SIGINT guard is removed afterwards");
204
+ assert.deepEqual(context.notices, []);
205
+ assert.equal(context.settled(), true);
206
+ });
207
+
208
+ test("the uvx fallback runs gentle-stream from the published spec", async () => {
209
+ const { pi, commands } = fakePi();
210
+ const context = fakeContext("tui", null);
211
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
212
+ gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, signals: fakeSignals().signals });
213
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
214
+ assert.deepEqual(spawned[0]!.args, ["--from", GENTLE_STREAM_SPEC, "gentle-stream"]);
215
+ });
216
+
217
+ test("Pi is restored and the failure reported when the TUI exits non-zero or cannot start", async () => {
218
+ for (const [outcome, pattern] of [[{ code: 2 }, /exited with code 2/], [{ error: new Error("spawn ENOENT") }, /could not start.*spawn ENOENT/]] as const) {
219
+ const { pi, commands } = fakePi();
220
+ const context = fakeContext();
221
+ const { spawn } = fakeSpawn([outcome], context.calls);
222
+ const { listeners, signals } = fakeSignals();
223
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals });
224
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
225
+ assert.deepEqual(context.calls.filter((call) => !call.startsWith("spawn")), ["stop", "start", "render:true"]);
226
+ assert.equal(listeners.size, 0);
227
+ assert.equal(context.notices.length, 1);
228
+ assert.match(context.notices[0]![0], pattern);
229
+ assert.equal(context.notices[0]![1], "error");
230
+ }
231
+ });
232
+
233
+ test("Pi is restored even when spawn itself throws", async () => {
234
+ const { pi, commands } = fakePi();
235
+ const context = fakeContext();
236
+ const { listeners, signals } = fakeSignals();
237
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: () => { throw new Error("EPERM"); }, signals });
238
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
239
+ assert.deepEqual(context.calls, ["stop", "start", "render:true"]);
240
+ assert.equal(listeners.size, 0);
241
+ assert.match(context.notices[0]![0], /could not start.*EPERM/);
242
+ });
243
+
244
+ test("control words run ctl with captured output and never take the terminal", async () => {
245
+ const { pi, commands } = fakePi();
246
+ const context = fakeContext();
247
+ const { spawn, spawned } = fakeSpawn([
248
+ { code: 0, stdout: '{"running": true, "playing": true, "name": "Neon Rain", "genre": "synthwave", "mood": "night"}\n' },
249
+ { code: 0 },
250
+ { code: 1, stderr: "gentle-stream: nothing is playing\n" },
251
+ { code: 0, stdout: "not json" },
252
+ ], context.calls);
253
+ gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn });
254
+ const run = (args: string) => commands.get(MUSIC_COMMAND_NAME)!.handler(args, context.ctx);
255
+ await run("status");
256
+ await run("next");
257
+ await run("pause");
258
+ await run("status");
259
+ assert.deepEqual(spawned.map((entry) => entry.args.slice(3)), [["ctl", "status"], ["ctl", "next"], ["ctl", "pause"], ["ctl", "status"]]);
260
+ assert.ok(spawned.every((entry) => JSON.stringify(entry.stdio) === JSON.stringify(["ignore", "pipe", "pipe"])));
261
+ assert.ok(!context.calls.includes("stop"), "ctl never stops the TUI");
262
+ assert.deepEqual(context.notices, [
263
+ ["♪ Neon Rain · synthwave · playing", "info"],
264
+ ["♪ next track", "info"],
265
+ ["gentle-stream: nothing is playing", "warning"],
266
+ ["gentle-stream status was not readable: not json", "warning"],
267
+ ]);
268
+ });
269
+
270
+ test("a control word without gentle-stream or uv explains how to install", async () => {
271
+ const { pi, commands } = fakePi();
272
+ const { spawn, spawned } = fakeSpawn([]);
273
+ gentleMusic(pi, { which: onPath({}), spawn });
274
+ const context = fakeContext();
275
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("next", context.ctx);
276
+ assert.equal(spawned.length, 0);
277
+ assert.match(context.notices[0]![0], /uv tool install/);
278
+ });
279
+
280
+ test("quitting Pi stops the music only after /gentle:music was used", async () => {
281
+ const unused = fakePi();
282
+ const quiet = fakeSpawn([]);
283
+ gentleMusic(unused.pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: quiet.spawn, signals: fakeSignals().signals, usage: { used: false } });
284
+ await unused.fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, fakeContext().ctx);
285
+ assert.equal(quiet.spawned.length, 0);
286
+
287
+ const usage = { used: false };
288
+ const { pi, commands, fire } = fakePi();
289
+ const context = fakeContext();
290
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }, { code: 0 }], context.calls);
291
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals: fakeSignals().signals, usage });
292
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
293
+ await fire("session_shutdown", { type: "session_shutdown", reason: "new" }, context.ctx);
294
+ assert.equal(spawned.length, 1, "switching sessions keeps the music playing");
295
+ await fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, context.ctx);
296
+ assert.deepEqual(spawned[1], { command: "/bin/gentle-stream", args: ["ctl", "stop"], stdio: ["ignore", "pipe", "pipe"] });
297
+ });
298
+
299
+ test("the shutdown stop never blocks longer than its budget", async () => {
300
+ const { pi, commands, fire } = fakePi();
301
+ const context = fakeContext();
302
+ const { spawn } = fakeSpawn([{ code: 0 }, { hang: true }], context.calls);
303
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals: fakeSignals().signals, usage: { used: false }, shutdownTimeoutMs: 20 });
304
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("status", context.ctx);
305
+ const started = Date.now();
306
+ await fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, context.ctx);
307
+ assert.ok(Date.now() - started < 1000);
308
+ assert.ok(context.calls.includes("kill"), "the hung ctl is killed");
309
+ });
310
+
311
+ test("a failing terminal handover still settles the command, reports it and removes the Ctrl+C guard", async () => {
312
+ for (const failing of ["stop", "start"] as const) {
313
+ const { pi, commands } = fakePi();
314
+ const context = fakeContext("tui", undefined, failing);
315
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
316
+ const { listeners, signals } = fakeSignals();
317
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals });
318
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
319
+ assert.equal(context.settled(), true, `done is called when tui.${failing}() throws`);
320
+ assert.equal(listeners.size, 0, "the SIGINT guard is removed");
321
+ assert.equal(spawned.length, failing === "stop" ? 0 : 1);
322
+ assert.equal(context.notices.length, 1);
323
+ assert.match(context.notices[0]![0], failing === "stop" ? /terminal busy/ : /terminal gone/);
324
+ }
325
+ });
326
+
327
+ test("Windows .cmd and .bat shims are not picked, so resolution falls back to uvx", (t: TestContext) => {
328
+ const dir = mkdtempSync(join(tmpdir(), "gentle-music-win-"));
329
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
330
+ writeFileSync(join(dir, "gentle-stream.CMD"), "@echo off\n");
331
+ writeFileSync(join(dir, "uvx.BAT"), "@echo off\n");
332
+ writeFileSync(join(dir, "uv.EXE"), "");
333
+ const env = { Path: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
334
+ assert.equal(findOnPath("gentle-stream", env, "win32"), undefined);
335
+ assert.equal(findOnPath("uvx", env, "win32"), undefined);
336
+ assert.equal(findOnPath("uv", env, "win32"), join(dir, "uv.EXE"));
337
+ const resolved = resolveGentleStream((name) => findOnPath(name, env, "win32"));
338
+ assert.equal(resolved?.command, join(dir, "uv.EXE"));
339
+ });
340
+
341
+ // S5: a bundled Gentle Shell offers to install the player inside its own
342
+ // folder; any other install keeps the manual hint.
343
+ const PREVIOUS_HINT = "/gentle:music needs gentle-stream, and neither gentle-stream nor uv is on PATH. Install uv (https://docs.astral.sh/uv/getting-started/installation/) and run /gentle:music again, or install gentle-stream with: uv tool install 'gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream'";
344
+
345
+ function withConfirm(context: ReturnType<typeof fakeContext>, answer: boolean) {
346
+ const asked: Array<[string, string]> = [];
347
+ (context.ctx.ui as unknown as { confirm: (title: string, message: string) => Promise<boolean> }).confirm = async (title, message) => {
348
+ asked.push([title, message]);
349
+ return answer;
350
+ };
351
+ return asked;
352
+ }
353
+
354
+ function fakeBundled({ installed = false, fail }: { installed?: boolean; fail?: Error } = {}) {
355
+ const state = { installed, installs: 0 };
356
+ const player: BundledMusicPlayer = {
357
+ gentleStream: "/prefix/music/bin/gentle-stream",
358
+ installed: () => state.installed,
359
+ install: async (progress) => {
360
+ state.installs += 1;
361
+ progress("Downloading uv 0.13.6…");
362
+ if (fail) throw fail;
363
+ state.installed = true;
364
+ return { gentleStream: "/prefix/music/bin/gentle-stream", samples: true };
365
+ },
366
+ };
367
+ return { state, bundled: async () => player };
368
+ }
369
+
370
+ test("the manual hint outside a bundled install is unchanged", async () => {
371
+ assert.equal(installHint(), PREVIOUS_HINT);
372
+ const { pi, commands } = fakePi();
373
+ const { spawn, spawned } = fakeSpawn([]);
374
+ gentleMusic(pi, { which: onPath({}), spawn, bundled: async () => undefined });
375
+ const context = fakeContext();
376
+ const asked = withConfirm(context, true);
377
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
378
+ assert.deepEqual(asked, [], "no offer outside a bundled install");
379
+ assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]);
380
+ assert.equal(spawned.length, 0);
381
+ });
382
+
383
+ test("a bundled install without the player offers it; declining shows the hint and installs nothing", async () => {
384
+ const { pi, commands } = fakePi();
385
+ const { spawn, spawned } = fakeSpawn([]);
386
+ const { state, bundled } = fakeBundled();
387
+ gentleMusic(pi, { which: onPath({}), spawn, bundled });
388
+ const context = fakeContext();
389
+ const asked = withConfirm(context, false);
390
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
391
+ assert.deepEqual(asked, [[MUSIC_INSTALL_TITLE, MUSIC_INSTALL_OFFER]]);
392
+ assert.equal(MUSIC_INSTALL_OFFER, "Install the gentle-stream music player inside Gentle Shell? It downloads uv, Python 3.13 and the player (about 150 MB) into the Gentle Shell folder.");
393
+ assert.equal(state.installs, 0);
394
+ assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]);
395
+ assert.equal(spawned.length, 0);
396
+ });
397
+
398
+ test("accepting the offer installs the player with progress, then starts the music from the prefix", async () => {
399
+ const { pi, commands } = fakePi();
400
+ const context = fakeContext();
401
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
402
+ const { state, bundled } = fakeBundled();
403
+ gentleMusic(pi, { which: onPath({}), spawn, bundled, signals: fakeSignals().signals });
404
+ withConfirm(context, true);
405
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx);
406
+ assert.equal(state.installs, 1);
407
+ assert.deepEqual(context.notices.map(([, type]) => type), ["info", "info"]);
408
+ assert.match(context.notices[0]![0], /Downloading uv/);
409
+ assert.match(context.notices[1]![0], /installed/i);
410
+ assert.equal(spawned[0]!.command, "/prefix/music/bin/gentle-stream");
411
+ assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5", "--mood", "calm"]);
412
+ });
413
+
414
+ test("an installed prefix player is used without asking; PATH gentle-stream still comes first", async () => {
415
+ const { bundled } = fakeBundled({ installed: true });
416
+ for (const [names, expected] of [[{}, "/prefix/music/bin/gentle-stream"], [{ uvx: "/bin/uvx" }, "/prefix/music/bin/gentle-stream"], [{ "gentle-stream": "/bin/gentle-stream" }, "/bin/gentle-stream"]] as const) {
417
+ const { pi, commands } = fakePi();
418
+ const context = fakeContext();
419
+ const asked = withConfirm(context, false);
420
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
421
+ gentleMusic(pi, { which: onPath(names), spawn, bundled, signals: fakeSignals().signals });
422
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
423
+ assert.deepEqual(asked, []);
424
+ assert.equal(spawned[0]!.command, expected);
425
+ assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5"]);
426
+ }
427
+ assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" }), "/prefix/music/bin/gentle-stream"), { command: "/prefix/music/bin/gentle-stream", prefix: [] });
428
+ });
429
+
430
+ test("a bundled install with uv on PATH keeps the uvx route and does not offer", async () => {
431
+ const { pi, commands } = fakePi();
432
+ const context = fakeContext();
433
+ const asked = withConfirm(context, true);
434
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
435
+ gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, bundled: fakeBundled().bundled, signals: fakeSignals().signals });
436
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
437
+ assert.deepEqual(asked, []);
438
+ assert.equal(spawned[0]!.command, "/bin/uvx");
439
+ });
440
+
441
+ test("a failed install is reported and nothing starts", async () => {
442
+ const { pi, commands } = fakePi();
443
+ const { spawn, spawned } = fakeSpawn([]);
444
+ gentleMusic(pi, { which: onPath({}), spawn, bundled: fakeBundled({ fail: new Error("Download integrity mismatch") }).bundled });
445
+ const context = fakeContext();
446
+ withConfirm(context, true);
447
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
448
+ assert.equal(spawned.length, 0);
449
+ const [message, type] = context.notices.at(-1)!;
450
+ assert.equal(type, "error");
451
+ assert.match(message, /could not install gentle-stream.*Download integrity mismatch/);
452
+ });