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
@@ -1,7 +1,7 @@
1
1
  import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import { request as httpRequest } from "node:http";
3
3
  import { createConnection } from "node:net";
4
- import { dirname, posix, win32 } from "node:path";
4
+ import { dirname, isAbsolute, posix, win32 } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  // The launcher in a bundled install (S5, S8): pure decisions over an injected
@@ -27,6 +27,46 @@ export interface BundledInstall {
27
27
 
28
28
  type Env = Record<string, string | undefined>;
29
29
 
30
+ /**
31
+ * Builds the detached shared server's environment without retaining session scratch.
32
+ * Windows Node can reintroduce an omitted TEMP from its parent, so use the
33
+ * original stable ancestor there; POSIX omits scratch temp settings instead.
34
+ */
35
+ export function sharedEngramEnv(base: Env, platform: string = process.platform): Env {
36
+ const path = pathFor(platform);
37
+ const env: Env = {};
38
+ const scratch = (value: string) => /^(gp|ga)[A-Za-z0-9]{6}$/.test(path.basename(path.normalize(value)));
39
+ let stableTemp: string | undefined;
40
+ let tempKey: string | undefined;
41
+ for (const [key, value] of Object.entries(base)) {
42
+ const name = platform === "win32" ? key.toUpperCase() : key;
43
+ if (name.startsWith("ENGRAM_") || name === "GH_TOKEN" || name === "GITHUB_TOKEN") continue;
44
+ if (["TMPDIR", "TMP", "TEMP"].includes(name) && value !== undefined && scratch(value)) {
45
+ if (platform !== "win32") continue;
46
+ // A drive-relative or root-relative path depends on ambient state.
47
+ const root = win32.parse(value).root;
48
+ if (!/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]?)$/.test(root) || value.split(/[\\/]/).includes("..")) {
49
+ throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
50
+ }
51
+ let stable = win32.normalize(value);
52
+ while (scratch(stable)) {
53
+ const parent = win32.dirname(stable);
54
+ if (parent === stable) throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
55
+ stable = parent;
56
+ }
57
+ env[key] = stable;
58
+ stableTemp ??= stable;
59
+ } else {
60
+ env[key] = value;
61
+ }
62
+ if (name === "TEMP") tempKey = key;
63
+ }
64
+ // Keep an existing custom TEMP, even if TMP/TMPDIR chose another root.
65
+ if (stableTemp !== undefined && (tempKey === undefined || env[tempKey] === undefined)) env[tempKey ?? "TEMP"] = stableTemp;
66
+ env.ENGRAM_NO_UPDATE_CHECK = "1";
67
+ return env;
68
+ }
69
+
30
70
  // The same rules scripts/bundled-install.mjs writes versions and markers with.
31
71
  const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
32
72
  const VERSION_MARKER = ".gentle-shell-version";
@@ -175,6 +215,29 @@ export function bundledGentlePiConfigHome(env: Env): string | undefined {
175
215
  return prefix === undefined ? undefined : bundledPiConfigDir(prefix);
176
216
  }
177
217
 
218
+ // #2201: the standalone launcher gives Pi this variable in its isolated home
219
+ // only, naming the config folder next to the agent dir (standaloneConfigHome:
220
+ // ~/.gentle-shell/gentle-ai by default). It is launcher-owned, never
221
+ // GENTLE_PI_CONFIG_HOME: Gentle AI 4.0.0 reads that one as the parent of its
222
+ // own gentle-ai folder, and the agent's shell would pass it to any gentle-ai.
223
+ export const GENTLE_SHELL_CONFIG_HOME_ENV = "GENTLE_SHELL_CONFIG_HOME";
224
+
225
+ // gentle-pi's config home, the one rule every reader shares: a bundled
226
+ // prefix's folder, then an explicit GENTLE_PI_CONFIG_HOME, then the launcher's
227
+ // marker (absolute only), then ~/.pi/gentle-ai. Empty values count as unset.
228
+ // `launcherMarker: false` is for install-level state written outside a session
229
+ // (the main channel's channel.json, builds and dev-binary.json): the installer
230
+ // and `gentle-shell upgrade` never see the marker, so their readers ignore it.
231
+ export function resolveGentlePiConfigHome(env: Env, home: string, options: { launcherMarker?: boolean; platform?: string } = {}): string {
232
+ const path = pathFor(options.platform ?? process.platform);
233
+ const bundled = bundledGentlePiConfigHome(env);
234
+ if (bundled !== undefined) return bundled;
235
+ if (env.GENTLE_PI_CONFIG_HOME) return env.GENTLE_PI_CONFIG_HOME;
236
+ const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
237
+ if (options.launcherMarker !== false && marker && path.isAbsolute(marker)) return marker;
238
+ return path.join(home, ".pi", "gentle-ai");
239
+ }
240
+
178
241
  // The exact Pi of the version (S8): the Pi the version's package.json pins,
179
242
  // installed inside the version folder with Pi's own bin entry. Never another Pi.
180
243
  export function bundledPi(install: BundledInstall, input: { platform: string; fs: BundledFs }): BundledPi {
@@ -209,13 +272,14 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
209
272
  // Gentle AI's variables, never set here because the agent's shell would pass
210
273
  // them to a gentle-ai the user runs), and the published Engram as
211
274
  // gentle-engram's ENGRAM_BIN unless the user chose one. With a memory decision
212
- // (S2, S6): the startup policy, and ENGRAM_BIN is the verified core only when
213
- // memory is allowed locally; otherwise the Shell-owned guard, so no provider,
214
- // whatever loaded it, runs an Engram binary against an unverified store.
275
+ // (S2, S6): the startup policy, and ENGRAM_BIN is the Shell-owned guard, so no
276
+ // provider, whatever loaded it, runs an Engram binary against a store; allowed
277
+ // memory reaches the shared server through ENGRAM_URL only.
215
278
  export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
216
- export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
279
+ export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server on 127.0.0.1:7437 with verified /compatibility evidence; Shell never stops or replaces a server already listening there. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
280
+ export const ENGRAM_START_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not start the shared Engram server, or it did not report ready in time. Restart Shell to retry; if another program holds 127.0.0.1:7437, stop it first. Shell remains usable without memory.";
281
+ export const ENGRAM_NEWER_SCHEMA_NOTICE = "Engram memory disabled: ~/.engram was migrated by a newer Engram than Shell's bundled core, so Shell will not start a server on it. Start that newer Engram's server yourself (engram serve); Shell reuses a running server with verified /compatibility evidence. Back up the store; do not delete or downgrade it. Shell remains usable without memory.";
217
282
  // `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
218
- export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
219
283
  export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
220
284
  // The first Engram release whose server answers GET /compatibility (PR #1792).
221
285
  export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
@@ -236,13 +300,14 @@ function outdatedServerVersion(version: string | undefined): string | undefined
236
300
  // The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
237
301
  export const ENGRAM_DEFAULT_PORT = 7437;
238
302
  export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
239
- const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"];
303
+ const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL", "ENGRAM_SERVER_MODE"];
240
304
  const HEX32 = /^[a-f0-9]{32}$/;
241
305
 
242
306
  export type EngramProbe = { kind: "refused" } | { kind: "unknown" } | { kind: "listening"; compatible: boolean; healthInstanceId?: string; healthVersion?: string };
243
- // `fresh`: Shell provisions a new store this run and may record its provenance.
244
- // `url`: a running server verified through /compatibility, reached explicitly.
245
- export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; fresh?: true };
307
+ // `url`: the shared server, reached explicitly once /compatibility verified it.
308
+ // `spawn`: nothing listens yet; Shell starts the bundled core's server and must
309
+ // see it verified before Pi starts, or memory is disabled for that launch.
310
+ export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; spawn?: true };
246
311
 
247
312
  // Without an invalid policy or an explicit Engram selection, the default
248
313
  // endpoint may be probed; an explicit selection is never contacted.
@@ -252,26 +317,24 @@ export function engramProbeAllowed(env: Env): boolean {
252
317
  }
253
318
 
254
319
  // Evidence only: never run an executable, open SQLite or infer compatibility
255
- // from versions. `provenance` means Shell's own record matched the store
256
- // (directory, .instance-id, core digest) and its header showed no newer schema.
257
- // A server version only selects the wording of a disabled decision.
258
- export function classifyEngramStartup(input: { env: Env; store: "absent" | "present" | "unknown"; endpoint: EngramProbe; core: boolean;
259
- coreVersion: string; provenance?: { instanceId: string } }): EngramStartup {
320
+ // from versions. Like other agents' hooks, Shell shares the global server: a
321
+ // verified listener is reused, and with nothing listening the verified bundled
322
+ // core starts one on Engram's defaults, unless the default store's header
323
+ // (`userVersion`) shows a newer schema than that core's. A listener that does
324
+ // not verify is never replaced. A server version only selects the wording.
325
+ export function classifyEngramStartup(input: { env: Env; endpoint: EngramProbe; core: boolean; coreVersion: string; userVersion?: number }): EngramStartup {
260
326
  if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
261
327
  const endpoint = input.endpoint;
262
- const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
263
- const unknown: EngramStartup = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
264
328
  if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
265
- if (endpoint.kind === "refused" && input.store === "absent") {
266
- return input.core ? { policy: "allow", fresh: true } : { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
329
+ if (endpoint.kind === "refused") {
330
+ if (!input.core) return { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
331
+ if (input.userVersion !== undefined && !engramHeaderAllows(input.userVersion, bundledEngramSchema(input.coreVersion))) {
332
+ return { policy: "disabled", notice: ENGRAM_NEWER_SCHEMA_NOTICE };
333
+ }
334
+ return { policy: "allow", url: ENGRAM_DEFAULT_URL, spawn: true };
267
335
  }
268
- const own = input.provenance;
269
- if (own === undefined || !input.core || input.store !== "present") return unknown;
270
- // Shell's own store, created by this exact core: offline, the provider starts
271
- // that core; a running server must be that store and that exact core.
272
- if (endpoint.kind === "refused") return { policy: "allow" };
273
- if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow" };
274
- return unknown;
336
+ const outdated = endpoint.kind === "listening" ? outdatedServerVersion(endpoint.healthVersion) : undefined;
337
+ return { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
275
338
  }
276
339
 
277
340
  // The exact contract a startup-verified store reports (Engram GET /compatibility).
@@ -331,8 +394,8 @@ export async function probeEngramEndpoint(input: { port?: number; timeoutMs?: nu
331
394
  return { kind: "listening", compatible: false, healthInstanceId: identified ? body.instance_id : undefined, healthVersion: identified ? body.version : undefined };
332
395
  }
333
396
 
334
- // Shell's own record of a store it provisioned fresh with the verified core.
335
- // Written only after Shell itself provisioned the store's identity, before Pi starts.
397
+ // Shell's record of a store an earlier Shell provisioned with its bundled core,
398
+ // read only by the explicit `gentle-shell memory migrate` recovery.
336
399
  export type EngramProvenance = { schema: 1; dataDir: string; instanceId: string; coreSha256: string };
337
400
 
338
401
  export function engramProvenancePath(prefix: string, platform: string = process.platform): string {
@@ -346,14 +409,10 @@ export function parseEngramProvenance(text: string | undefined): EngramProvenanc
346
409
  return { schema: 1, dataDir: record.dataDir, instanceId: record.instanceId, coreSha256: record.coreSha256 };
347
410
  }
348
411
 
349
- export function engramProvenanceMatches(record: EngramProvenance | undefined, observed: { dataDir: string; instanceId?: string; coreSha256?: string }): boolean {
350
- return record !== undefined && record.dataDir === observed.dataDir && record.instanceId === observed.instanceId && record.coreSha256 === observed.coreSha256;
351
- }
352
-
353
412
  // The bundled core's own schema (SQLite user_version), verified from the
354
- // tagged sources v3.3.2 and v3.4.0: internal/store/store.go `const schemaVersion = 1`.
413
+ // tagged sources v3.3.2, v3.4.0 and v3.5.2: internal/store/store.go `const schemaVersion = 1`.
355
414
  // A pin without a verified entry leaves the header check unavailable.
356
- const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1 };
415
+ const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1, "3.5.2": 1 };
357
416
 
358
417
  export function bundledEngramSchema(coreVersion: string): number | undefined {
359
418
  return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
@@ -423,8 +482,7 @@ export function bundledChildEnv(base: Env, input: { node: string; cli: string; p
423
482
  return env;
424
483
  }
425
484
  if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
426
- const local = memory.policy === "allow" && memory.url === undefined ? input.engram : undefined;
427
- env.ENGRAM_BIN = local ?? input.guard;
485
+ env.ENGRAM_BIN = input.guard;
428
486
  return env;
429
487
  }
430
488
 
@@ -455,6 +513,58 @@ export function withBundledNpmCommand(settingsText: string | undefined, command:
455
513
  return { status: "write", text: settingsText === undefined || settingsText.endsWith("\n") ? `${serialized}\n` : serialized };
456
514
  }
457
515
 
516
+ export type BundledNpmCommandRemoval = { status: "unchanged" } | { status: "malformed" } | { status: "removed"; text: string };
517
+
518
+ // settings.json without `npmCommand`: every other key kept in order and the
519
+ // file's indentation and final newline kept.
520
+ function settingsWithoutNpmCommand(settingsText: string, settings: Record<string, unknown>): string {
521
+ const indent = settingsText.match(/\{\r?\n([ \t]+)/)?.[1] ?? " ";
522
+ const { npmCommand: _removed, ...rest } = settings;
523
+ const serialized = JSON.stringify(rest, null, indent);
524
+ return settingsText.endsWith("\n") ? `${serialized}\n` : serialized;
525
+ }
526
+
527
+ // The agent home's settings.json without a `npmCommand` naming the prefix a
528
+ // bundled self-uninstall removed (gentle-shell-uninstall.ts): one absolute
529
+ // entry inside `withinRemoved` drops the whole key, every other key kept in
530
+ // order and the file's indentation and final newline kept. A file that is not
531
+ // a JSON object is never rewritten; a command naming nothing inside it stays.
532
+ export function withoutBundledNpmCommand(settingsText: string | undefined, withinRemoved: (path: string) => boolean): BundledNpmCommandRemoval {
533
+ if (settingsText === undefined) return { status: "unchanged" };
534
+ const settings = parseJson(settingsText);
535
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
536
+ const current = settings.npmCommand;
537
+ if (!Array.isArray(current) || !current.some((part) => typeof part === "string" && isAbsolute(part) && withinRemoved(part))) return { status: "unchanged" };
538
+ return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
539
+ }
540
+
541
+ // An `npmCommand` a bundled install wrote (scripts/bundled-install.mjs
542
+ // bundledNpmCommand, with npm-cli.js and --prefix in earlier releases): our
543
+ // node in runtime/node-<v>, then npm's settings and cache flags, and
544
+ // --update-notifier=false last. A command the user chose never matches, even
545
+ // when its program is missing.
546
+ export function isBundledNpmCommand(command: readonly unknown[]): boolean {
547
+ const [node] = command;
548
+ return typeof node === "string" && /[\\/]runtime[\\/]node-[^\\/]+[\\/](node\.exe|bin[\\/]node)$/.test(node)
549
+ && ["--userconfig=", "--globalconfig=", "--cache="].every((flag) => command.some((part) => typeof part === "string" && part.startsWith(flag)))
550
+ && command[command.length - 1] === "--update-notifier=false";
551
+ }
552
+
553
+ // The agent home's settings.json without an `npmCommand` a bundled install
554
+ // wrote whose executable (its first entry) no longer exists, as after a
555
+ // bundled self-uninstall that kept the home (#2469): Pi runs that command for
556
+ // every package install, so a key naming a deleted node.exe fails them all
557
+ // with ENOENT. A command whose executable exists, that names no absolute
558
+ // path, or that the user set themselves is never touched.
559
+ export function withoutMissingBundledNpmCommand(settingsText: string | undefined, exists: (path: string) => boolean): BundledNpmCommandRemoval {
560
+ if (settingsText === undefined) return { status: "unchanged" };
561
+ const settings = parseJson(settingsText);
562
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
563
+ const executable = Array.isArray(settings.npmCommand) ? settings.npmCommand[0] : undefined;
564
+ if (typeof executable !== "string" || !isAbsolute(executable) || exists(executable) || !isBundledNpmCommand(settings.npmCommand as unknown[])) return { status: "unchanged" };
565
+ return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
566
+ }
567
+
458
568
  export interface BundledUpdatePlan {
459
569
  // Pi's own update was asked for: redirect it to `gentle-shell upgrade`.
460
570
  self: boolean;
@@ -0,0 +1,233 @@
1
+ import { chmodSync, closeSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { detectJsonFormatting } from "./gentle-shell-launcher.ts";
4
+
5
+ // `gentle-shell home import-pi`: a one-time, opt-in copy of the user's pi
6
+ // sign-ins and MCP servers into a separate Gentle Shell home, as an
7
+ // alternative to --link. Only the files and settings keys below are read;
8
+ // packages, extensions, caches, AGENTS.md and sessions are never copied, so
9
+ // the target home keeps its own provisioning. Nothing is ever overwritten and
10
+ // the source is only read.
11
+
12
+ // auth.json holds OAuth tokens and API keys: always private in the copy.
13
+ const IMPORTED_FILES: readonly { name: string; mode?: number }[] = [
14
+ { name: "auth.json", mode: 0o600 },
15
+ { name: "mcp.json" },
16
+ ];
17
+
18
+ // Pi 1.2.0's own settings for the default model (SettingsManager
19
+ // get/setDefaultProvider, DefaultModel, DefaultThinkingLevel).
20
+ export const IMPORTED_SETTINGS_KEYS: readonly string[] = ["defaultProvider", "defaultModel", "defaultThinkingLevel"];
21
+
22
+ export interface ImportPiFs {
23
+ // lstat: a symbolic link is "other", never followed.
24
+ kind: (path: string) => "missing" | "file" | "dir" | "other";
25
+ mode: (path: string) => number;
26
+ readText: (path: string) => string;
27
+ // Creates `to` with exactly `mode`; throws when it already exists.
28
+ copyNew: (from: string, to: string, mode: number) => void;
29
+ // Temp file next to `path`, then a rename onto it.
30
+ writeAtomic: (path: string, text: string) => void;
31
+ }
32
+
33
+ export type ImportPiStatus = "copied" | "would copy" | "present" | "not found" | "skipped" | "failed";
34
+
35
+ export interface ImportPiItem {
36
+ item: string;
37
+ status: ImportPiStatus;
38
+ // The copy's mode, or why the item was skipped or failed. Never file content.
39
+ detail?: string;
40
+ }
41
+
42
+ export interface ImportPiResult {
43
+ items: ImportPiItem[];
44
+ failed: boolean;
45
+ }
46
+
47
+ function tempPathFor(path: string): string {
48
+ return join(dirname(path), `.${basename(path)}.gentle-shell-${process.pid}.tmp`);
49
+ }
50
+
51
+ function removeQuietly(path: string): void {
52
+ try {
53
+ unlinkSync(path);
54
+ } catch {
55
+ // Nothing left to clean up.
56
+ }
57
+ }
58
+
59
+ function writeTemp(path: string, data: string | Buffer, mode: number): string {
60
+ const temp = tempPathFor(path);
61
+ const fd = openSync(temp, "wx", mode);
62
+ try {
63
+ try {
64
+ writeFileSync(fd, data);
65
+ } finally {
66
+ closeSync(fd);
67
+ }
68
+ chmodSync(temp, mode); // the umask must not change the mode
69
+ } catch (error) {
70
+ // A partial copy may hold credentials: never leave it behind.
71
+ removeQuietly(temp);
72
+ throw error;
73
+ }
74
+ return temp;
75
+ }
76
+
77
+ export const nodeImportPiFs: ImportPiFs = {
78
+ kind: (path) => {
79
+ try {
80
+ const stat = lstatSync(path);
81
+ if (stat.isSymbolicLink()) return "other";
82
+ return stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "other";
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
85
+ throw error;
86
+ }
87
+ },
88
+ mode: (path) => lstatSync(path).mode & 0o777,
89
+ readText: (path) => readFileSync(path, "utf8"),
90
+ copyNew: (from, to, mode) => {
91
+ const temp = writeTemp(to, readFileSync(from), mode);
92
+ try {
93
+ linkSync(temp, to); // EEXIST instead of replacing a file that appeared meanwhile
94
+ } finally {
95
+ removeQuietly(temp);
96
+ }
97
+ },
98
+ writeAtomic: (path, text) => {
99
+ const mode = lstatSync(path, { throwIfNoEntry: false })?.mode;
100
+ const temp = writeTemp(path, text, mode === undefined ? 0o600 : mode & 0o777);
101
+ try {
102
+ renameSync(temp, path);
103
+ } catch (error) {
104
+ removeQuietly(temp);
105
+ throw error;
106
+ }
107
+ },
108
+ };
109
+
110
+ function reason(error: unknown): string {
111
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
112
+ return typeof code === "string" ? code : error instanceof Error ? error.message : String(error);
113
+ }
114
+
115
+ function parseObject(text: string): Record<string, unknown> | undefined {
116
+ try {
117
+ const value: unknown = JSON.parse(text.replace(/^\uFEFF/, ""));
118
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ function importFile(name: string, mode: number | undefined, input: { source: string; target: string; dryRun: boolean; fs: ImportPiFs }): ImportPiItem {
125
+ const { fs } = input;
126
+ const from = join(input.source, name);
127
+ const to = join(input.target, name);
128
+ // Classification can throw too (ENOTDIR under a file): one failed item, never an aborted report.
129
+ try {
130
+ const kind = fs.kind(from);
131
+ if (kind === "missing") return { item: name, status: "not found" };
132
+ if (kind !== "file") return { item: name, status: "skipped", detail: "not a regular file" };
133
+ if (fs.kind(to) !== "missing") return { item: name, status: "present" };
134
+ const copyMode = mode ?? fs.mode(from);
135
+ const detail = `${copyMode.toString(8).padStart(4, "0")}`;
136
+ if (input.dryRun) return { item: name, status: "would copy", detail };
137
+ fs.copyNew(from, to, copyMode);
138
+ return { item: name, status: "copied", detail };
139
+ } catch (error) {
140
+ return { item: name, status: "failed", detail: reason(error) };
141
+ }
142
+ }
143
+
144
+ function importSettings(input: { source: string; target: string; dryRun: boolean; fs: ImportPiFs }): ImportPiItem[] {
145
+ try {
146
+ return planAndWriteSettings(input);
147
+ } catch (error) {
148
+ // An unreadable settings.json stays one failed item, never an aborted report.
149
+ return [{ item: "settings.json", status: "failed", detail: reason(error) }];
150
+ }
151
+ }
152
+
153
+ function planAndWriteSettings(input: { source: string; target: string; dryRun: boolean; fs: ImportPiFs }): ImportPiItem[] {
154
+ const { fs } = input;
155
+ const from = join(input.source, "settings.json");
156
+ const to = join(input.target, "settings.json");
157
+ const sourceKind = fs.kind(from);
158
+ if (sourceKind === "missing") return [{ item: "settings.json", status: "not found" }];
159
+ if (sourceKind !== "file") return [{ item: "settings.json", status: "skipped", detail: "not a regular file" }];
160
+ const source = parseObject(fs.readText(from));
161
+ if (source === undefined) return [{ item: "settings.json", status: "skipped", detail: `${from} is not a JSON object` }];
162
+ const targetKind = fs.kind(to);
163
+ if (targetKind !== "missing" && targetKind !== "file") return [{ item: "settings.json", status: "skipped", detail: `${to} is not a regular file` }];
164
+ const targetText = targetKind === "missing" ? undefined : fs.readText(to);
165
+ const target = targetText === undefined ? {} : parseObject(targetText);
166
+ if (target === undefined) return [{ item: "settings.json", status: "skipped", detail: `${to} is not a JSON object` }];
167
+
168
+ const items: ImportPiItem[] = [];
169
+ const added: Record<string, unknown> = {};
170
+ for (const key of IMPORTED_SETTINGS_KEYS) {
171
+ const item = `settings.json ${key}`;
172
+ if (!Object.hasOwn(source, key)) items.push({ item, status: "not found" });
173
+ else if (Object.hasOwn(target, key)) items.push({ item, status: "present" });
174
+ else if (typeof source[key] !== "string") items.push({ item, status: "skipped", detail: "not a string" });
175
+ else {
176
+ added[key] = source[key];
177
+ items.push({ item, status: input.dryRun ? "would copy" : "copied" });
178
+ }
179
+ }
180
+ if (input.dryRun || Object.keys(added).length === 0) return items;
181
+ try {
182
+ // Keep the target's own indentation and trailing-newline style.
183
+ const style = targetText === undefined ? { indent: undefined, trailingNewline: true } : detectJsonFormatting(targetText);
184
+ const body = JSON.stringify({ ...target, ...added }, null, style.indent ?? 2);
185
+ fs.writeAtomic(to, style.trailingNewline ? `${body}\n` : body);
186
+ return items;
187
+ } catch (error) {
188
+ const detail = reason(error);
189
+ return items.map((entry) => (entry.status === "copied" ? { ...entry, status: "failed", detail } : entry));
190
+ }
191
+ }
192
+
193
+ // Copies what the target lacks. A dry run reads the same files and writes
194
+ // nothing. The caller has already made sure `target` is a home this launcher
195
+ // owns (and exists, unless this is a dry run).
196
+ export function importPi(input: { source: string; target: string; dryRun: boolean; fs?: ImportPiFs }): ImportPiResult {
197
+ const options = { ...input, fs: input.fs ?? nodeImportPiFs };
198
+ const items = IMPORTED_FILES.map((file) => importFile(file.name, file.mode, options));
199
+ items.push(...importSettings(options));
200
+ return { items, failed: items.some((item) => item.status === "failed") };
201
+ }
202
+
203
+ function describe(item: ImportPiItem): string {
204
+ switch (item.status) {
205
+ case "copied":
206
+ case "would copy":
207
+ return item.detail !== undefined && item.item === "auth.json" ? `${item.status} (${item.detail})` : item.status;
208
+ case "present":
209
+ return "already present, kept";
210
+ case "not found":
211
+ return "not found";
212
+ case "skipped":
213
+ return `skipped: ${item.detail}`;
214
+ case "failed":
215
+ return `failed: ${item.detail}`;
216
+ }
217
+ }
218
+
219
+ export function formatImportPiReport(result: ImportPiResult, context: { source: string; target: string; dryRun: boolean }): string[] {
220
+ const lines = [
221
+ context.dryRun
222
+ ? `Dry run: would import from ${context.source} into ${context.target} (nothing is written)`
223
+ : `Importing from ${context.source} into ${context.target}`,
224
+ ];
225
+ for (const item of result.items) lines.push(` ${item.item}: ${describe(item)}`);
226
+ const count = (...statuses: ImportPiStatus[]) => result.items.filter((item) => statuses.includes(item.status)).length;
227
+ const imported = count("copied", "would copy");
228
+ const rest = `${count("present")} already present, ${count("not found")} not found, ${count("skipped")} skipped`;
229
+ if (result.failed) lines.push(`Import failed for ${count("failed")} item(s); ${count("copied")} copied and kept. ${rest}.`);
230
+ else if (imported === 0) lines.push(`Nothing to import: ${rest}.`);
231
+ else lines.push(`${context.dryRun ? "Would import" : "Imported"} ${imported} item(s); ${rest}.`);
232
+ return lines;
233
+ }