gentle-pi 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,335 @@
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
+ MUSIC_COMMAND_NAME,
13
+ musicTuiArgs,
14
+ parseMusicArgs,
15
+ resolveGentleStream,
16
+ type MusicSpawn,
17
+ } from "../extensions/gentle-music.ts";
18
+
19
+ // /gentle:music hands the terminal to gentle-stream's own TUI, the way Pi's
20
+ // external editor does, and controls the background player with
21
+ // `gentle-stream ctl …` without taking the terminal.
22
+
23
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
24
+ type Command = { description: string; handler(args: string, ctx: ExtensionContext): Promise<void> };
25
+
26
+ function fakePi() {
27
+ const commands = new Map<string, Command>();
28
+ const handlers = new Map<string, Handler[]>();
29
+ const pi = {
30
+ registerCommand: (name: string, registration: Command) => commands.set(name, registration),
31
+ registerShortcut: () => assert.fail("/gentle:music registers no shortcut"),
32
+ on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
33
+ } as unknown as ExtensionAPI;
34
+ const fire = async (event: string, payload: unknown, ctx: ExtensionContext) => {
35
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
36
+ };
37
+ return { pi, commands, fire };
38
+ }
39
+
40
+ function fakeContext(mode = "tui", model: { provider: string; id: string } | null = { provider: "anthropic", id: "claude-sonnet-5-5" }, failing?: "stop" | "start") {
41
+ const notices: Array<[string, string | undefined]> = [];
42
+ const calls: string[] = [];
43
+ let settled = false;
44
+ const tui = {
45
+ stop: () => { calls.push("stop"); if (failing === "stop") throw new Error("terminal busy"); },
46
+ start: () => { calls.push("start"); if (failing === "start") throw new Error("terminal gone"); },
47
+ requestRender: (force?: boolean) => calls.push(`render:${force === true}`),
48
+ };
49
+ const ctx = {
50
+ hasUI: true,
51
+ mode,
52
+ model: model ?? undefined,
53
+ ui: {
54
+ notify: (message: string, type?: string) => notices.push([message, type]),
55
+ custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => unknown) =>
56
+ new Promise((resolve) => {
57
+ factory(tui, {}, {}, (value) => { settled = true; resolve(value); });
58
+ }),
59
+ },
60
+ } as unknown as ExtensionContext;
61
+ return { ctx, notices, calls, settled: () => settled };
62
+ }
63
+
64
+ type Outcome = { code?: number | null; error?: Error; stdout?: string; stderr?: string; hang?: boolean };
65
+
66
+ function fakeSpawn(outcomes: Outcome[], calls: string[] = []) {
67
+ const spawned: Array<{ command: string; args: readonly string[]; stdio: unknown }> = [];
68
+ const spawn: MusicSpawn = (command, args, options) => {
69
+ spawned.push({ command, args, stdio: options.stdio });
70
+ calls.push(`spawn:${args.join(" ")}`);
71
+ const outcome = outcomes.shift() ?? { code: 0 };
72
+ const child = Object.assign(new EventEmitter(), {
73
+ stdout: new EventEmitter(),
74
+ stderr: new EventEmitter(),
75
+ kill: () => { calls.push("kill"); child.emit("close", null); return true; },
76
+ });
77
+ if (!outcome.hang) {
78
+ setImmediate(() => {
79
+ if (outcome.error) { child.emit("error", outcome.error); return; }
80
+ if (outcome.stdout) child.stdout.emit("data", Buffer.from(outcome.stdout));
81
+ if (outcome.stderr) child.stderr.emit("data", Buffer.from(outcome.stderr));
82
+ child.emit("close", outcome.code ?? 0);
83
+ });
84
+ }
85
+ return child;
86
+ };
87
+ return { spawn, spawned };
88
+ }
89
+
90
+ const onPath = (names: Record<string, string>) => (name: string) => names[name];
91
+
92
+ function fakeSignals() {
93
+ const listeners = new Set<() => void>();
94
+ return {
95
+ listeners,
96
+ signals: { on: (_signal: "SIGINT", listener: () => void) => listeners.add(listener), off: (_signal: "SIGINT", listener: () => void) => listeners.delete(listener) },
97
+ };
98
+ }
99
+
100
+ test("executable resolution prefers gentle-stream, then uvx, then uv, else nothing", () => {
101
+ assert.deepEqual(resolveGentleStream(onPath({ "gentle-stream": "/bin/gentle-stream", uvx: "/bin/uvx" })), { command: "/bin/gentle-stream", prefix: [] });
102
+ assert.deepEqual(resolveGentleStream(onPath({ uvx: "/bin/uvx", uv: "/bin/uv" })), { command: "/bin/uvx", prefix: ["--from", GENTLE_STREAM_SPEC, "gentle-stream"] });
103
+ assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" })), { command: "/bin/uv", prefix: ["tool", "run", "--from", GENTLE_STREAM_SPEC, "gentle-stream"] });
104
+ assert.equal(resolveGentleStream(onPath({})), undefined);
105
+ });
106
+
107
+ test("findOnPath finds executable files only, honoring PATHEXT on Windows", (t: TestContext) => {
108
+ const root = mkdtempSync(join(tmpdir(), "gentle-music-path-"));
109
+ t.after(() => rmSync(root, { recursive: true, force: true }));
110
+ const a = join(root, "a");
111
+ const b = join(root, "b");
112
+ mkdirSync(a);
113
+ mkdirSync(b);
114
+ writeFileSync(join(a, "gentle-stream"), "not executable");
115
+ chmodSync(join(a, "gentle-stream"), 0o644);
116
+ writeFileSync(join(b, "gentle-stream"), "#!/bin/sh\n");
117
+ chmodSync(join(b, "gentle-stream"), 0o755);
118
+ mkdirSync(join(a, "uvx"));
119
+ const env = { PATH: [a, b].join(delimiter) };
120
+ if (process.platform !== "win32") assert.equal(findOnPath("gentle-stream", env, "darwin"), join(b, "gentle-stream"));
121
+ assert.equal(findOnPath("uvx", env, "darwin"), undefined, "a directory is not an executable");
122
+ assert.equal(findOnPath("missing", env, "darwin"), undefined);
123
+ assert.equal(findOnPath("gentle-stream", {}, "darwin"), undefined);
124
+ writeFileSync(join(a, "uv.EXE"), "");
125
+ assert.equal(findOnPath("uv", { Path: a, PATHEXT: ".COM;.EXE" }, "win32"), join(a, "uv.EXE"));
126
+ });
127
+
128
+ test("TUI arguments carry the session model and the mood as one argument", () => {
129
+ 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"]);
130
+ assert.deepEqual(musicTuiArgs({ model: { provider: "anthropic", id: "claude-sonnet-5-5" } }), ["--model", "anthropic/claude-sonnet-5-5"]);
131
+ assert.deepEqual(musicTuiArgs({ model: undefined, mood: "calm" }), ["--mood", "calm"]);
132
+ assert.deepEqual(musicTuiArgs({ model: { provider: "", id: "x" } }), []);
133
+ });
134
+
135
+ test("only an exact single control word is a subcommand; anything else is a mood", () => {
136
+ assert.deepEqual(parseMusicArgs(""), { kind: "tui" });
137
+ assert.deepEqual(parseMusicArgs(" "), { kind: "tui" });
138
+ for (const command of ["next", "pause", "play", "stop", "status"]) assert.deepEqual(parseMusicArgs(` ${command} `), { kind: "ctl", command });
139
+ assert.deepEqual(parseMusicArgs("next to the sea"), { kind: "tui", mood: "next to the sea" });
140
+ assert.deepEqual(parseMusicArgs("toggle"), { kind: "tui", mood: "toggle" });
141
+ assert.deepEqual(parseMusicArgs(" dark synthwave "), { kind: "tui", mood: "dark synthwave" });
142
+ });
143
+
144
+ test("status output becomes a one-line summary", () => {
145
+ assert.equal(formatMusicStatus('{"running": false}\n'), "nothing is playing");
146
+ assert.equal(formatMusicStatus('{"running": true, "playing": true, "name": "Neon Rain", "genre": "synthwave", "mood": "night"}'), "♪ Neon Rain · synthwave · playing");
147
+ assert.equal(formatMusicStatus('{"running": true, "playing": false, "name": "Neon Rain", "genre": "synthwave", "mood": ""}'), "♪ Neon Rain · synthwave · paused");
148
+ assert.equal(formatMusicStatus('{"running": true, "playing": true, "name": "", "genre": ""}'), "♪ playing");
149
+ assert.equal(formatMusicStatus("garbage"), undefined);
150
+ assert.equal(formatMusicStatus("[]"), undefined);
151
+ });
152
+
153
+ test("registers /gentle:music without a shortcut", () => {
154
+ const { pi, commands } = fakePi();
155
+ gentleMusic(pi, { which: onPath({}) });
156
+ assert.equal(MUSIC_COMMAND_NAME, "gentle:music");
157
+ assert.match(commands.get(MUSIC_COMMAND_NAME)!.description, /gentle-stream/);
158
+ });
159
+
160
+ test("non-TUI modes get a notice and spawn nothing", async () => {
161
+ const { pi, commands } = fakePi();
162
+ const { spawn, spawned } = fakeSpawn([]);
163
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn });
164
+ for (const mode of ["rpc", "json", "print"]) {
165
+ const context = fakeContext(mode);
166
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
167
+ assert.deepEqual(context.notices, [["/gentle:music needs the interactive terminal.", "warning"]]);
168
+ assert.deepEqual(context.calls, []);
169
+ }
170
+ assert.equal(spawned.length, 0);
171
+ });
172
+
173
+ test("without gentle-stream or uv the command explains how to install and does nothing else", async () => {
174
+ const { pi, commands } = fakePi();
175
+ const { spawn, spawned } = fakeSpawn([]);
176
+ gentleMusic(pi, { which: onPath({}), spawn });
177
+ const context = fakeContext();
178
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx);
179
+ assert.equal(spawned.length, 0);
180
+ assert.deepEqual(context.calls, []);
181
+ assert.equal(context.notices.length, 1);
182
+ const [message, type] = context.notices[0]!;
183
+ assert.equal(type, "warning");
184
+ assert.match(message, /https:\/\/docs\.astral\.sh\/uv/);
185
+ assert.ok(message.includes(`uv tool install '${GENTLE_STREAM_SPEC}'`));
186
+ });
187
+
188
+ test("the TUI gets the terminal, then Pi is restarted with a forced repaint", async () => {
189
+ const { pi, commands } = fakePi();
190
+ const context = fakeContext();
191
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
192
+ const { listeners, signals } = fakeSignals();
193
+ let sigintWhileRunning = 0;
194
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: (command, args, options) => { sigintWhileRunning = listeners.size; return spawn(command, args, options); }, signals });
195
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("rainy night", context.ctx);
196
+ assert.deepEqual(context.calls, ["stop", "spawn:--model anthropic/claude-sonnet-5-5 --mood rainy night", "start", "render:true"]);
197
+ assert.deepEqual(spawned[0], { command: "/bin/gentle-stream", args: ["--model", "anthropic/claude-sonnet-5-5", "--mood", "rainy night"], stdio: "inherit" });
198
+ assert.equal(sigintWhileRunning, 1, "Pi ignores Ctrl+C while the TUI runs");
199
+ assert.equal(listeners.size, 0, "the SIGINT guard is removed afterwards");
200
+ assert.deepEqual(context.notices, []);
201
+ assert.equal(context.settled(), true);
202
+ });
203
+
204
+ test("the uvx fallback runs gentle-stream from the published spec", async () => {
205
+ const { pi, commands } = fakePi();
206
+ const context = fakeContext("tui", null);
207
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
208
+ gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, signals: fakeSignals().signals });
209
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
210
+ assert.deepEqual(spawned[0]!.args, ["--from", GENTLE_STREAM_SPEC, "gentle-stream"]);
211
+ });
212
+
213
+ test("Pi is restored and the failure reported when the TUI exits non-zero or cannot start", async () => {
214
+ for (const [outcome, pattern] of [[{ code: 2 }, /exited with code 2/], [{ error: new Error("spawn ENOENT") }, /could not start.*spawn ENOENT/]] as const) {
215
+ const { pi, commands } = fakePi();
216
+ const context = fakeContext();
217
+ const { spawn } = fakeSpawn([outcome], context.calls);
218
+ const { listeners, signals } = fakeSignals();
219
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals });
220
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
221
+ assert.deepEqual(context.calls.filter((call) => !call.startsWith("spawn")), ["stop", "start", "render:true"]);
222
+ assert.equal(listeners.size, 0);
223
+ assert.equal(context.notices.length, 1);
224
+ assert.match(context.notices[0]![0], pattern);
225
+ assert.equal(context.notices[0]![1], "error");
226
+ }
227
+ });
228
+
229
+ test("Pi is restored even when spawn itself throws", async () => {
230
+ const { pi, commands } = fakePi();
231
+ const context = fakeContext();
232
+ const { listeners, signals } = fakeSignals();
233
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: () => { throw new Error("EPERM"); }, signals });
234
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
235
+ assert.deepEqual(context.calls, ["stop", "start", "render:true"]);
236
+ assert.equal(listeners.size, 0);
237
+ assert.match(context.notices[0]![0], /could not start.*EPERM/);
238
+ });
239
+
240
+ test("control words run ctl with captured output and never take the terminal", async () => {
241
+ const { pi, commands } = fakePi();
242
+ const context = fakeContext();
243
+ const { spawn, spawned } = fakeSpawn([
244
+ { code: 0, stdout: '{"running": true, "playing": true, "name": "Neon Rain", "genre": "synthwave", "mood": "night"}\n' },
245
+ { code: 0 },
246
+ { code: 1, stderr: "gentle-stream: nothing is playing\n" },
247
+ { code: 0, stdout: "not json" },
248
+ ], context.calls);
249
+ gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn });
250
+ const run = (args: string) => commands.get(MUSIC_COMMAND_NAME)!.handler(args, context.ctx);
251
+ await run("status");
252
+ await run("next");
253
+ await run("pause");
254
+ await run("status");
255
+ assert.deepEqual(spawned.map((entry) => entry.args.slice(3)), [["ctl", "status"], ["ctl", "next"], ["ctl", "pause"], ["ctl", "status"]]);
256
+ assert.ok(spawned.every((entry) => JSON.stringify(entry.stdio) === JSON.stringify(["ignore", "pipe", "pipe"])));
257
+ assert.ok(!context.calls.includes("stop"), "ctl never stops the TUI");
258
+ assert.deepEqual(context.notices, [
259
+ ["♪ Neon Rain · synthwave · playing", "info"],
260
+ ["♪ next track", "info"],
261
+ ["gentle-stream: nothing is playing", "warning"],
262
+ ["gentle-stream status was not readable: not json", "warning"],
263
+ ]);
264
+ });
265
+
266
+ test("a control word without gentle-stream or uv explains how to install", async () => {
267
+ const { pi, commands } = fakePi();
268
+ const { spawn, spawned } = fakeSpawn([]);
269
+ gentleMusic(pi, { which: onPath({}), spawn });
270
+ const context = fakeContext();
271
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("next", context.ctx);
272
+ assert.equal(spawned.length, 0);
273
+ assert.match(context.notices[0]![0], /uv tool install/);
274
+ });
275
+
276
+ test("quitting Pi stops the music only after /gentle:music was used", async () => {
277
+ const unused = fakePi();
278
+ const quiet = fakeSpawn([]);
279
+ gentleMusic(unused.pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn: quiet.spawn, signals: fakeSignals().signals, usage: { used: false } });
280
+ await unused.fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, fakeContext().ctx);
281
+ assert.equal(quiet.spawned.length, 0);
282
+
283
+ const usage = { used: false };
284
+ const { pi, commands, fire } = fakePi();
285
+ const context = fakeContext();
286
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }, { code: 0 }], context.calls);
287
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals: fakeSignals().signals, usage });
288
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
289
+ await fire("session_shutdown", { type: "session_shutdown", reason: "new" }, context.ctx);
290
+ assert.equal(spawned.length, 1, "switching sessions keeps the music playing");
291
+ await fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, context.ctx);
292
+ assert.deepEqual(spawned[1], { command: "/bin/gentle-stream", args: ["ctl", "stop"], stdio: ["ignore", "pipe", "pipe"] });
293
+ });
294
+
295
+ test("the shutdown stop never blocks longer than its budget", async () => {
296
+ const { pi, commands, fire } = fakePi();
297
+ const context = fakeContext();
298
+ const { spawn } = fakeSpawn([{ code: 0 }, { hang: true }], context.calls);
299
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals: fakeSignals().signals, usage: { used: false }, shutdownTimeoutMs: 20 });
300
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("status", context.ctx);
301
+ const started = Date.now();
302
+ await fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, context.ctx);
303
+ assert.ok(Date.now() - started < 1000);
304
+ assert.ok(context.calls.includes("kill"), "the hung ctl is killed");
305
+ });
306
+
307
+ test("a failing terminal handover still settles the command, reports it and removes the Ctrl+C guard", async () => {
308
+ for (const failing of ["stop", "start"] as const) {
309
+ const { pi, commands } = fakePi();
310
+ const context = fakeContext("tui", undefined, failing);
311
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
312
+ const { listeners, signals } = fakeSignals();
313
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "/bin/gentle-stream" }), spawn, signals });
314
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
315
+ assert.equal(context.settled(), true, `done is called when tui.${failing}() throws`);
316
+ assert.equal(listeners.size, 0, "the SIGINT guard is removed");
317
+ assert.equal(spawned.length, failing === "stop" ? 0 : 1);
318
+ assert.equal(context.notices.length, 1);
319
+ assert.match(context.notices[0]![0], failing === "stop" ? /terminal busy/ : /terminal gone/);
320
+ }
321
+ });
322
+
323
+ test("Windows .cmd and .bat shims are not picked, so resolution falls back to uvx", (t: TestContext) => {
324
+ const dir = mkdtempSync(join(tmpdir(), "gentle-music-win-"));
325
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
326
+ writeFileSync(join(dir, "gentle-stream.CMD"), "@echo off\n");
327
+ writeFileSync(join(dir, "uvx.BAT"), "@echo off\n");
328
+ writeFileSync(join(dir, "uv.EXE"), "");
329
+ const env = { Path: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
330
+ assert.equal(findOnPath("gentle-stream", env, "win32"), undefined);
331
+ assert.equal(findOnPath("uvx", env, "win32"), undefined);
332
+ assert.equal(findOnPath("uv", env, "win32"), join(dir, "uv.EXE"));
333
+ const resolved = resolveGentleStream((name) => findOnPath(name, env, "win32"));
334
+ assert.equal(resolved?.command, join(dir, "uv.EXE"));
335
+ });
@@ -661,6 +661,123 @@ test("first isolated run bootstraps the home, writes fullscreen, and prints the
661
661
  assert.doesNotMatch(second.stderr, /using a separate home at/);
662
662
  });
663
663
 
664
+ // --- home import-pi ----------------------------------------------------------
665
+
666
+ function importPiFixture(t: test.TestContext) {
667
+ const f = fixture(t);
668
+ const env = { ...f.env, PI_CODING_AGENT_DIR: undefined };
669
+ const piHome = join(f.home, ".pi", "agent");
670
+ mkdirSync(piHome, { recursive: true });
671
+ writeFileSync(join(piHome, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", refresh: "dummy-refresh" } }), { mode: 0o600 });
672
+ writeFileSync(join(piHome, "mcp.json"), JSON.stringify({ mcpServers: {} }));
673
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ defaultProvider: "anthropic", defaultModel: "m", packages: ["npm:foreign"], extensions: ["./x.ts"] }));
674
+ writeFileSync(join(piHome, "mcp-cache.json"), "{}");
675
+ return { ...f, env, piHome };
676
+ }
677
+
678
+ test("first isolated run without a pi home keeps the plain --link hint", (t) => {
679
+ const f = fixture(t);
680
+ const first = run({ ...f.env, PI_CODING_AGENT_DIR: undefined }, []);
681
+ assert.equal(first.status, 0, first.stderr);
682
+ assert.match(first.stderr, /Run 'gentle-shell --link' to reuse your pi sign-ins and chats\.\n/);
683
+ assert.doesNotMatch(first.stderr, /import-pi/);
684
+ });
685
+
686
+ test("first isolated run with a pi sign-in also mentions home import-pi", (t) => {
687
+ const f = importPiFixture(t);
688
+ const first = run(f.env, []);
689
+ assert.equal(first.status, 0, first.stderr);
690
+ assert.match(first.stderr, /'gentle-shell --link' to reuse your pi sign-ins and chats, or 'gentle-shell home import-pi'/);
691
+ });
692
+
693
+ test("--help lists home import-pi", (t) => {
694
+ const f = fixture(t);
695
+ const result = run(f.env, ["--help"]);
696
+ assert.match(result.stdout, /gentle-shell \[home selectors\] home import-pi \[--dry-run\]/);
697
+ });
698
+
699
+ test("home import-pi copies sign-ins, MCP servers and default model into the isolated home only", (t) => {
700
+ const f = importPiFixture(t);
701
+ const result = run(f.env, ["home", "import-pi"]);
702
+ assert.equal(result.status, 0, result.stderr);
703
+ assert.match(result.stdout, /auth\.json: copied \(0600\)/);
704
+ assert.match(result.stdout, /settings\.json defaultProvider: copied/);
705
+ assert.doesNotMatch(result.stdout + result.stderr, /dummy-refresh/);
706
+ assert.ok(existsSync(join(f.gentleShellHome, "auth.json")));
707
+ assert.equal(existsSync(join(f.gentleShellHome, "mcp-cache.json")), false);
708
+ if (process.platform !== "win32") assert.equal(statSync(join(f.gentleShellHome, "auth.json")).mode & 0o777, 0o600);
709
+ const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
710
+ assert.equal(settings.defaultProvider, "anthropic");
711
+ assert.equal(settings.tuiMode, "fullscreen", "the home is bootstrapped as a normal first launch would");
712
+ assert.equal(settings.packages, undefined);
713
+ assert.equal(settings.extensions, undefined);
714
+ assert.ok(existsSync(join(f.gentleShellHome, ".gentle-shell-home")));
715
+
716
+ const again = run(f.env, ["home", "import-pi"]);
717
+ assert.equal(again.status, 0, again.stderr);
718
+ assert.match(again.stdout, /auth\.json: already present, kept/);
719
+ assert.match(again.stdout, /Nothing to import/);
720
+ });
721
+
722
+ test("home import-pi --dry-run prints the plan and creates nothing", (t) => {
723
+ const f = importPiFixture(t);
724
+ const result = run(f.env, ["home", "import-pi", "--dry-run"]);
725
+ assert.equal(result.status, 0, result.stderr);
726
+ assert.match(result.stdout, /auth\.json: would copy/);
727
+ assert.equal(existsSync(f.gentleShellHome), false);
728
+ assert.equal(existsSync(join(f.home, ".gentle-shell")), false);
729
+ });
730
+
731
+ test("home import-pi refuses link mode and a target that is the pi home itself", (t) => {
732
+ const f = importPiFixture(t);
733
+ const before = readFileSync(join(f.piHome, "settings.json"), "utf8");
734
+ for (const argv of [["--link", "home", "import-pi"], ["--home", f.piHome, "home", "import-pi"]]) {
735
+ const result = run(f.env, argv);
736
+ assert.equal(result.status, 1, argv.join(" "));
737
+ assert.match(result.stderr, /gentle-shell home import-pi: .*nothing to import/);
738
+ }
739
+ assert.equal(run(f.env, ["home", "link"]).status, 0);
740
+ assert.equal(run(f.env, ["home", "import-pi"]).status, 1);
741
+ assert.equal(readFileSync(join(f.piHome, "settings.json"), "utf8"), before);
742
+ assert.equal(run(f.env, ["home", "import-pi", "--force"]).status, 2);
743
+ });
744
+
745
+ test("a --home filled by home import-pi is still auto-provisioned as gentle-shell's own", (t) => {
746
+ const f = importPiFixture(t);
747
+ const fresh = join(f.root, "fresh-home");
748
+ const empty = join(f.root, "empty-home");
749
+ mkdirSync(empty);
750
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
751
+ const counterPath = join(f.root, "gentle-ai-runs.log");
752
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
753
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
754
+ for (const [index, target] of [fresh, empty].entries()) {
755
+ const imported = run(f.env, ["--home", target, "home", "import-pi"]);
756
+ assert.equal(imported.status, 0, imported.stderr);
757
+ assert.match(imported.stdout, /auth\.json: copied/);
758
+ const launch = run(env, ["--home", target, "--mode", "rpc"]);
759
+ assert.equal(launch.status, 0, launch.stderr);
760
+ assert.doesNotMatch(launch.stderr, /already has content and was not set up by gentle-shell/);
761
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, index + 1, `auto-provisioning ran for ${target}`);
762
+ }
763
+ });
764
+
765
+ test("home import-pi exits 1 and names the item when a write fails", { skip: process.platform === "win32" }, (t) => {
766
+ const f = importPiFixture(t);
767
+ mkdirSync(f.gentleShellHome, { recursive: true });
768
+ writeFileSync(join(f.gentleShellHome, "settings.json"), "{}");
769
+ writeFileSync(join(f.gentleShellHome, ".gentle-shell-home"), "{}");
770
+ chmodSync(f.gentleShellHome, 0o500);
771
+ let result;
772
+ try {
773
+ result = run(f.env, ["home", "import-pi"]);
774
+ } finally {
775
+ chmodSync(f.gentleShellHome, 0o700); // before the fixture's own cleanup
776
+ }
777
+ assert.equal(result.status, 1);
778
+ assert.match(result.stdout, /auth\.json: failed/);
779
+ });
780
+
664
781
  test("forwarded args reach pi after the injected extension flags, in order", (t) => {
665
782
  const f = fixture(t);
666
783
  const result = run(f.env, ["--mode", "rpc", "-p", "hi"]);
@@ -815,6 +932,17 @@ test("gentle-shell install npm:<pkg> forwards the subcommand and its argument ve
815
932
  assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
816
933
  });
817
934
 
935
+ // #2140: pi dispatches `mcp` on argv[0] only, so an injected -e ahead of it
936
+ // turned `gentle-shell mcp list` into an interactive prompt.
937
+ test("gentle-shell mcp add <server> -- <cmd> forwards to pi's own mcp verbatim, with no injected extension flags", (t) => {
938
+ const f = fixture(t);
939
+ const result = run(f.env, ["mcp", "add", "demo", "--", "npx", "-y", "demo-server"]);
940
+ assert.equal(result.status, 0, result.stderr);
941
+ const payload = JSON.parse(result.stdout);
942
+ assert.deepEqual(payload.args, ["mcp", "add", "demo", "--", "npx", "-y", "demo-server"]);
943
+ assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
944
+ });
945
+
818
946
  test("a too-old pi exits 1 naming both versions", (t) => {
819
947
  const f = fixture(t);
820
948
  writePiScript(f.piScript, "0.80.0");
@@ -2603,6 +2731,13 @@ test("--help mentions the setup subcommand", (t) => {
2603
2731
  assert.match(result.stdout, /\bsetup\b/);
2604
2732
  });
2605
2733
 
2734
+ test("--help lists the mcp subcommand", (t) => {
2735
+ const f = fixture(t);
2736
+ const result = run(f.env, ["--help"]);
2737
+ assert.equal(result.status, 0, result.stderr);
2738
+ assert.match(result.stdout, /gentle-shell mcp /);
2739
+ });
2740
+
2606
2741
  // --- --link takeover of a conflicting path package -----------------------
2607
2742
  //
2608
2743
  // Regression coverage for the settings.json shape that triggered the tool
@@ -3350,3 +3485,31 @@ test("on a TTY without colors the gentle-shell line has no ANSI styling", { skip
3350
3485
  const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_NO_COLOR: "1" }, ["--link"]);
3351
3486
  assert.ok(out.endsWith(`${PI_HINT}To resume in gentle-shell: gentle-shell --link --session abc\r\n`), JSON.stringify(out));
3352
3487
  });
3488
+
3489
+ // #2143: a Gentle AI that honors GENTLE_AI_PI_EXCLUDE_PACKAGES (gentle-ai#5503)
3490
+ // never declares npm:gentle-pi, so setup neither runs `pi remove` nor prints
3491
+ // the contradictory "removing npm:gentle-pi" line.
3492
+ test("gentle-shell setup asks Gentle AI to skip npm:gentle-pi, so nothing is installed only to be removed", (t) => {
3493
+ const f = fixture(t);
3494
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
3495
+ writeFileSync(gentleAiScript, [
3496
+ "#!/usr/bin/env node",
3497
+ "import { readFileSync, writeFileSync } from 'node:fs';",
3498
+ "import { join } from 'node:path';",
3499
+ "const excluded = (process.env.GENTLE_AI_PI_EXCLUDE_PACKAGES ?? '').split(',').map((entry) => entry.trim());",
3500
+ "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
3501
+ "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
3502
+ "settings.packages = ['npm:gentle-pi', 'npm:pi-btw'].filter((source) => !excluded.includes(source));",
3503
+ "writeFileSync(settingsPath, JSON.stringify(settings));",
3504
+ "process.stdout.write(JSON.stringify({ args: process.argv.slice(2), excluded: process.env.GENTLE_AI_PI_EXCLUDE_PACKAGES ?? null }) + '\\n');",
3505
+ "",
3506
+ ].join("\n"));
3507
+ chmodSync(gentleAiScript, 0o755);
3508
+ const result = run({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }, ["setup"]);
3509
+ assert.equal(result.status, 0, result.stderr);
3510
+ assert.doesNotMatch(result.stderr, /removing npm:gentle-pi/);
3511
+ const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
3512
+ assert.equal(lines.length, 1, "no pi remove ran: " + result.stdout);
3513
+ assert.deepEqual(JSON.parse(lines[0]), { args: ["install", "--agent", "pi", "--scope", "global"], excluded: "npm:gentle-pi" });
3514
+ });
3515
+