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
@@ -16,12 +16,16 @@ import {
16
16
  bundledUpdatePlan,
17
17
  parseBundledUpgradeArgs,
18
18
  withBundledNpmCommand,
19
+ withoutBundledNpmCommand,
20
+ withoutMissingBundledNpmCommand,
19
21
  resolveBundledInstall,
20
22
  setBundledInstallForTesting,
21
23
  type BundledFs,
24
+ isBundledNpmCommand,
22
25
  type BundledPi,
23
26
  } from "../lib/gentle-shell-bundled.ts";
24
27
  import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs";
28
+ import { bundledNpmCommand, prefixLayout } from "../scripts/bundled-install.mjs";
25
29
  import { gentlePiConfigHome } from "../lib/agent-home.ts";
26
30
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
27
31
 
@@ -128,7 +132,7 @@ test("bundledPi fails with the version, the pinned Pi and the way to repair it",
128
132
  assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false);
129
133
  });
130
134
 
131
- const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.4.0/engram" };
135
+ const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.5.2/engram" };
132
136
 
133
137
  test("bundledChildEnv keeps the user's environment and adds only the bundled Pi, the prefix and Engram, no Pi override and no Pi update check", () => {
134
138
  const user = { PATH: "/home/ana/.nvm/bin:/usr/bin", NODE_OPTIONS: "--max-old-space-size=4096", npm_config_registry: "https://npm.example", npm_config_prefix: "/home/ana/.npm-global",
@@ -140,7 +144,7 @@ test("bundledChildEnv keeps the user's environment and adds only the bundled Pi,
140
144
  // folder from the prefix, and Gentle AI 4.0.0 reads that variable as a parent.
141
145
  assert.deepEqual(env, { PATH: user.PATH, NODE_OPTIONS: user.NODE_OPTIONS, npm_config_registry: user.npm_config_registry, npm_config_prefix: user.npm_config_prefix,
142
146
  GENTLE_PI_CONFIG_HOME: user.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_PI: "/g/node /g/cli.js", PI_SKIP_VERSION_CHECK: "1", GENTLE_SHELL_PREFIX: "/g",
143
- ENGRAM_BIN: "/g/runtime/engram-3.4.0/engram" });
147
+ ENGRAM_BIN: "/g/runtime/engram-3.5.2/engram" });
144
148
  // The subagent runner splits that variable on whitespace: a spaced path falls
145
149
  // back to its own default, the running node and cli.js.
146
150
  const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js", ...ISOLATION });
@@ -174,6 +178,83 @@ test("withBundledNpmCommand sets only npmCommand and leaves an already bundled s
174
178
  for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withBundledNpmCommand(malformed, command), { status: "malformed" });
175
179
  });
176
180
 
181
+ // #2469: the npmCommand a bundled self-uninstall left behind names a node.exe
182
+ // the uninstall removed, so Pi package installs fail with ENOENT until it is
183
+ // gone; a command naming nothing inside the removed prefix stays.
184
+ test("withoutBundledNpmCommand removes the key when an entry names the removed prefix and keeps the rest", () => {
185
+ const text = '{\n "theme": "gentle",\n "npmCommand": ["/p/runtime/node.exe", "--update-notifier=false"]\n}\n';
186
+ const removed = withoutBundledNpmCommand(text, (path) => path === "/p/runtime/node.exe");
187
+ assert.ok(removed.status === "removed", removed.status);
188
+ assert.deepEqual(JSON.parse(removed.text), { theme: "gentle" });
189
+ assert.match(removed.text, /^\{\n {4}"theme"/);
190
+ assert.ok(removed.text.endsWith("}\n"));
191
+ assert.deepEqual(withoutBundledNpmCommand(undefined, () => true), { status: "unchanged" });
192
+ assert.deepEqual(withoutBundledNpmCommand('{"npmCommand":"npm"}', () => true), { status: "unchanged" });
193
+ assert.deepEqual(withoutBundledNpmCommand('{"npmCommand":["node"]}', () => true), { status: "unchanged" });
194
+ assert.deepEqual(withoutBundledNpmCommand('{"npmCommand":["/other/node"]}', () => false), { status: "unchanged" });
195
+ assert.deepEqual(withoutBundledNpmCommand('{"theme":"gentle"}', () => true), { status: "unchanged" });
196
+ for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withoutBundledNpmCommand(malformed, () => true), { status: "malformed" });
197
+ });
198
+
199
+ test("withoutMissingBundledNpmCommand removes only a bundled npmCommand whose executable is an absolute path that is gone", () => {
200
+ const text = '{\n "theme": "gentle",\n "npmCommand": ["/p/runtime/node-24.21.0/bin/node", "--userconfig=/p/pnpm/config/npmrc", "--globalconfig=/p/npm/npmrc", "--cache=/p/npm/cache", "--update-notifier=false"]\n}\n';
201
+ const removed = withoutMissingBundledNpmCommand(text, (path) => path !== "/p/runtime/node-24.21.0/bin/node");
202
+ assert.ok(removed.status === "removed", removed.status);
203
+ assert.deepEqual(JSON.parse(removed.text), { theme: "gentle" });
204
+ // An executable that exists, a PATH command, a non-array value, no key, no file.
205
+ assert.deepEqual(withoutMissingBundledNpmCommand(text, () => true), { status: "unchanged" });
206
+ assert.deepEqual(withoutMissingBundledNpmCommand('{"npmCommand":["npm"]}', () => false), { status: "unchanged" });
207
+ assert.deepEqual(withoutMissingBundledNpmCommand('{"npmCommand":"npm"}', () => false), { status: "unchanged" });
208
+ assert.deepEqual(withoutMissingBundledNpmCommand('{"theme":"gentle"}', () => false), { status: "unchanged" });
209
+ assert.deepEqual(withoutMissingBundledNpmCommand(undefined, () => false), { status: "unchanged" });
210
+ for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withoutMissingBundledNpmCommand(malformed, () => false), { status: "malformed" });
211
+ });
212
+
213
+ // A missing program alone is not proof the command is ours: a user's
214
+ // npmCommand on an offline drive must survive the launcher repair, so only the
215
+ // shape a bundled install writes is ever dropped (PR #2472 review).
216
+ test("withoutMissingBundledNpmCommand never drops a user's npmCommand, even with a missing program", () => {
217
+ // The posix form the launcher sees on this platform is dropped. The Windows
218
+ // form is covered by isBundledNpmCommand: the launcher's isAbsolute is
219
+ // platform-specific, so a C:\ path is only absolute on Windows.
220
+ const posix = "/home/ana/.gentle-shell";
221
+ const flags = (root: string, sep: string) => [`--userconfig=${root}${sep}pnpm${sep}config${sep}npmrc`, `--globalconfig=${root}${sep}npm${sep}npmrc`, `--cache=${root}${sep}npm${sep}cache`];
222
+ const bundledForm = [`${posix}/runtime/node-24.21.0/bin/node`, `${posix}/runtime/node-24.21.0/lib/node_modules/npm/bin/npm-cli.js`, ...flags(posix, "/"), `--prefix=${posix}/npm/prefix`, "--update-notifier=false"];
223
+ {
224
+ const command = bundledForm;
225
+ const text = JSON.stringify({ theme: "gentle", npmCommand: command });
226
+ const removed = withoutMissingBundledNpmCommand(text, () => false);
227
+ assert.ok(removed.status === "removed", `${command[0]}: ${removed.status}`);
228
+ }
229
+ // What a user writes is never ours, even with a missing program.
230
+ const userCommands: unknown[][] = [
231
+ ["npm"],
232
+ ["C:\\tools\\node.exe", "C:\\tools\\npm-cli.js"],
233
+ ["C:\\Users\\ana\\AppData\\Local\\gentle-shell\\runtime\\node-24.21.0\\node.exe", "npm-cli.js"],
234
+ ["/opt/runtime/node-20.0.0/bin/node", "/opt/npm-cli.js", "--update-notifier=false"],
235
+ ];
236
+ for (const command of userCommands) {
237
+ const text = JSON.stringify({ theme: "gentle", npmCommand: command });
238
+ assert.deepEqual(withoutMissingBundledNpmCommand(text, () => false), { status: "unchanged" }, JSON.stringify(command));
239
+ }
240
+ });
241
+
242
+ test("isBundledNpmCommand recognizes only the npmCommand a bundled install writes, in every released form", () => {
243
+ const win = "C:\\Users\\ana\\AppData\\Local\\gentle-shell";
244
+ const posix = "/home/ana/.gentle-shell";
245
+ const flags = (root: string, sep: string) => [`--userconfig=${root}${sep}pnpm${sep}config${sep}npmrc`, `--globalconfig=${root}${sep}npm${sep}npmrc`, `--cache=${root}${sep}npm${sep}cache`];
246
+ // What the installer writes today on each platform, and its two released forms.
247
+ assert.equal(isBundledNpmCommand(bundledNpmCommand(prefixLayout({ platform: "win32", home: "C:\\Users\\ana", root: win }))), true);
248
+ assert.equal(isBundledNpmCommand(bundledNpmCommand(prefixLayout({ platform: "darwin", home: "/home/ana" }))), true);
249
+ assert.equal(isBundledNpmCommand([`${win}\\runtime\\node-24.21.0\\node.exe`, `${win}\\runtime\\node-24.21.0\\gentle-shell\\npm`, ...flags(win, "\\"), "--update-notifier=false"]), true);
250
+ assert.equal(isBundledNpmCommand([`${posix}/runtime/node-24.21.0/bin/node`, `${posix}/runtime/node-24.21.0/lib/node_modules/npm/bin/npm-cli.js`, ...flags(posix, "/"), `--prefix=${posix}/npm/prefix`, "--update-notifier=false"]), true);
251
+ // What a user writes is never ours, even with a missing program.
252
+ for (const command of [["npm"], ["C:\\tools\\node.exe", "C:\\tools\\npm-cli.js"], [`${win}\\runtime\\node-24.21.0\\node.exe`, "npm-cli.js"],
253
+ ["/opt/runtime/node-20.0.0/bin/node", "/opt/npm-cli.js", "--update-notifier=false"]]) {
254
+ assert.equal(isBundledNpmCommand(command), false, JSON.stringify(command));
255
+ }
256
+ });
257
+
177
258
  test("bundledUpdatePlan redirects Pi's own update and keeps the extension updates", () => {
178
259
  assert.deepEqual(bundledUpdatePlan(["update"]), { self: true });
179
260
  assert.deepEqual(bundledUpdatePlan(["update", "--self"]), { self: true });
@@ -235,7 +316,7 @@ const posixHost = process.platform === "win32" ? "the bundled prefix fixture use
235
316
  const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
236
317
  const FIXTURE_ID = `${SHELL}-1.2.0`;
237
318
  const FAKE_PI = [
238
- "import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
319
+ "import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
239
320
  "const args = process.argv.slice(2);",
240
321
  "if (args[0] === 'install' && args[1] === 'npm:gentle-engram@0.4.2' && process.env.FIXTURE_INSTALL_CAPABILITY !== '0') {",
241
322
  " const home = process.env.PI_CODING_AGENT_DIR; const metadata = join(home, 'npm/node_modules/gentle-engram/package.json'); mkdirSync(dirname(metadata), {recursive:true});",
@@ -245,37 +326,46 @@ const FAKE_PI = [
245
326
  " if (index < 0) packages.push(args[1]); else packages[index] = typeof packages[index] === 'string' ? args[1] : {...packages[index], source:args[1]}; settings.packages = packages; writeFileSync(path, JSON.stringify(settings)); }",
246
327
  "}",
247
328
  "if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
248
- // Stands in for gentle-engram creating the store through the bundled core during the session.
249
- "if (process.env.FIXTURE_CREATE_STORE && args[0] !== 'install' && args[0] !== 'list') {",
250
- " const data = join(process.env.HOME, '.engram');",
251
- " if (process.env.ENGRAM_BIN?.includes('engram-guard') !== true) { mkdirSync(data, {recursive:true});",
252
- " if (!existsSync(join(data, '.instance-id'))) writeFileSync(join(data, '.instance-id'), process.env.FIXTURE_CREATE_STORE + '\\n');",
253
- " const header = Buffer.alloc(4096); header.write('SQLite format 3\\0', 0, 'latin1'); header.writeUInt16BE(4096, 16); header[18] = 2; header[19] = 2;",
254
- " writeFileSync(join(data, 'engram.db'), header); writeFileSync(join(data, 'engram.db-wal'), '');",
255
- " }",
256
- "}",
257
329
  "const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR',",
258
- "\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];",
259
- "const record = join(process.env.GENTLE_SHELL_PREFIX ?? '', 'config', 'engram-provenance.json');",
260
- "const provenance = (() => { try { return JSON.parse(readFileSync(record, 'utf8')); } catch { return null; } })();",
261
- "console.log(JSON.stringify({ cli: process.argv[1], args, provenance, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
262
- // Stands in for a crash, kill or power loss of the launcher before its exit handler runs.
263
- "if (process.env.FIXTURE_KILL_LAUNCHER && args[0] !== 'install') process.kill(process.ppid, 'SIGKILL');",
330
+ "\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];",
331
+ "console.log(JSON.stringify({ cli: process.argv[1], args, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
264
332
  ].join("\n");
333
+ const READY_COMPATIBILITY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
334
+ instance_id: "0123456789abcdef0123456789abcdef", runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
335
+ // The bundled core: `serve` logs its invocation (FIXTURE_SERVE_LOG) and, like
336
+ // Engram 3.5.2, answers the ready /compatibility contract on a port it publishes
337
+ // in FIXTURE_ENGRAM_PORT_FILE, where the redirected default endpoint finds it.
338
+ // FIXTURE_SERVE=exit fails like a lost bind; race fails after starting the
339
+ // server a concurrent launch would. Every other command only prints a line.
265
340
  const FIXTURE_CORE = [
266
- "#!/bin/sh",
267
- 'if [ "$1" != instance-id ]; then echo engram; exit 0; fi',
268
- '[ -z "$FIXTURE_PROVISION_ENV" ] || env > "$FIXTURE_PROVISION_ENV"',
269
- '[ -z "$FIXTURE_INSTANCE_ID_FAIL" ] || exit 3',
270
- 'mkdir -p "$HOME/.engram" || exit 1',
271
- '[ -f "$HOME/.engram/.instance-id" ] || printf \'%s\\n\' "${FIXTURE_INSTANCE_ID:-0123456789abcdef0123456789abcdef}" > "$HOME/.engram/.instance-id"',
272
- 'cat "$HOME/.engram/.instance-id"',
341
+ `#!${process.execPath}`,
342
+ "const { appendFileSync, renameSync, writeFileSync } = require('node:fs'); const { spawn } = require('node:child_process');",
343
+ "const args = process.argv.slice(2);",
344
+ "if (args[0] !== 'serve') { console.log('engram'); process.exit(0); }",
345
+ "const mode = process.env.FIXTURE_SERVE ?? 'ready';",
346
+ "const pick = ['HOME', 'GH_TOKEN', 'GITHUB_TOKEN', 'ENGRAM_NO_UPDATE_CHECK', 'ENGRAM_URL', 'ENGRAM_BIN', 'ENGRAM_HTTP_TOKEN', 'ENGRAM_STARTUP_POLICY'];",
347
+ "appendFileSync(process.env.FIXTURE_SERVE_LOG, JSON.stringify({ args, mode, cwd: process.cwd(), pid: process.pid, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }) + '\\n');",
348
+ "if (mode === 'exit') process.exit(1);",
349
+ "if (mode === 'race') { spawn(process.execPath, [process.argv[1], 'serve'], { env: { ...process.env, FIXTURE_SERVE: 'late' }, detached: true, stdio: 'ignore' }).unref(); process.exit(1); }",
350
+ `const ready = ${JSON.stringify(JSON.stringify(READY_COMPATIBILITY))};`,
351
+ "setTimeout(() => require('node:http').createServer((request, response) => {",
352
+ " response.writeHead(request.url === '/compatibility' ? 200 : 404, { 'content-type': 'application/json' }); response.end(request.url === '/compatibility' ? ready : '{}');",
353
+ "}).listen(0, '127.0.0.1', function () { const file = process.env.FIXTURE_ENGRAM_PORT_FILE; writeFileSync(file + '.tmp', String(this.address().port)); renameSync(file + '.tmp', file); }),",
354
+ " mode === 'late' ? 300 : 0);",
355
+ "setTimeout(() => process.exit(0), 60000);",
273
356
  "",
274
357
  ].join("\n");
275
358
 
276
359
  function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
277
360
  const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
278
- t.after(() => rmSync(base, { recursive: true, force: true }));
361
+ const serveLog = join(base, "engram-serve.log");
362
+ const serves = (): Array<{ args: string[]; mode: string; cwd: string; pid: number; env: Record<string, string | undefined> }> =>
363
+ existsSync(serveLog) ? readFileSync(serveLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [];
364
+ // The detached servers outlive their launcher by design: stop them with the fixture.
365
+ t.after(() => {
366
+ for (const { pid } of serves()) try { process.kill(pid); } catch { /* Already exited. */ }
367
+ rmSync(base, { recursive: true, force: true });
368
+ });
279
369
  const home = join(base, "home");
280
370
  const root = join(home, ".gentle-shell");
281
371
  const version = join(root, "versions", FIXTURE_ID);
@@ -338,33 +428,38 @@ function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
338
428
  mkdirSync(userBin);
339
429
  writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 });
340
430
  // No test contacts the actual default Engram endpoint: connections to 7437
341
- // are redirected to a closed ephemeral port (refused) or to a local fixture
342
- // server (FIXTURE_ENGRAM_PORT). FIXTURE_NO_PROBE makes any probe fail the test.
431
+ // are redirected to a local fixture server (FIXTURE_ENGRAM_PORT), to the
432
+ // server the bundled core's `serve` published (FIXTURE_ENGRAM_PORT_FILE), or
433
+ // else to a closed ephemeral port (refused). FIXTURE_NO_PROBE makes any probe fail the test.
343
434
  const network = join(base, "network.mjs");
344
- writeFileSync(network, `import net from 'node:net'; import { syncBuiltinESMExports } from 'node:module';
435
+ const portFile = join(base, "engram-port");
436
+ writeFileSync(network, `import net from 'node:net'; import { readFileSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
345
437
  const original = net.createConnection;
346
438
  const probe = net.createServer(); await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
347
439
  const closed = probe.address().port; await new Promise((resolve) => probe.close(resolve));
348
440
  net.createConnection = (options, ...rest) => {
349
441
  if (Number(options?.port ?? options) !== 7437) return original(options, ...rest);
350
442
  if (process.env.FIXTURE_NO_PROBE === '1') { process.stderr.write('unexpected Engram probe\\n'); process.exit(97); }
351
- return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || closed) }, ...rest);
443
+ const served = () => { try { return readFileSync(process.env.FIXTURE_ENGRAM_PORT_FILE, 'utf8'); } catch { return ''; } };
444
+ return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || served() || closed) }, ...rest);
352
445
  };
353
446
  syncBuiltinESMExports();`);
354
447
  const metadata = join(home, ".gentle-shell", "agent", "npm", "node_modules", "gentle-engram", "package.json");
355
448
  mkdirSync(dirname(metadata), { recursive: true });
356
449
  writeFileSync(metadata, JSON.stringify({ name: "gentle-engram", version: "0.4.2", engram: { startupPolicy: 1 } }));
357
450
  const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1",
358
- NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example" };
451
+ NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example",
452
+ FIXTURE_SERVE_LOG: serveLog, FIXTURE_ENGRAM_PORT_FILE: portFile };
359
453
  const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
360
454
  const run = (args: string[], extra: Record<string, string> = {}) => spawnSync(process.execPath, [entry, ...args], { env: { ...env, ...extra }, encoding: "utf8", input: "" });
361
- return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, entry: join(shell, "bin", "gentle-shell.mjs") };
455
+ return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, serves, portFile, entry: join(shell, "bin", "gentle-shell.mjs") };
362
456
  }
363
457
 
364
458
  const STORE_ID = "0123456789abcdef0123456789abcdef";
365
459
  const OTHER_STORE_ID = "fedcba9876543210fedcba9876543210";
460
+ const DEFAULT_URL = "http://127.0.0.1:7437";
366
461
  const lastLaunch = (result: { stdout: string }) => JSON.parse(result.stdout.trim().split("\n").at(-1)!);
367
- const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN];
462
+ const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN, lastLaunch(result).env.ENGRAM_URL];
368
463
  const provenancePath = (prefix: { root: string }) => join(prefix.root, "config", "engram-provenance.json");
369
464
  const sha256File = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex");
370
465
  function writeHeader(path: string, userVersion: number, magic = "SQLite format 3\0") {
@@ -389,78 +484,213 @@ async function engramServer(t: TestContext, routes: Record<string, [number, unkn
389
484
  return new Promise((resolve) => child.stdout.once("data", (chunk) => resolve(String(chunk).trim())));
390
485
  }
391
486
 
392
- test("a fresh launch provisions the store identity and finalizes provenance before Pi starts", { skip: posixHost }, (t) => {
487
+ // Whether the server a launch started still answers after the launcher exited.
488
+ async function stillServing(prefix: ReturnType<typeof bundledPrefix>): Promise<boolean> {
489
+ const response = await fetch(`http://127.0.0.1:${readFileSync(prefix.portFile, "utf8")}/compatibility`);
490
+ return response.ok && (await response.json()).status === "ready";
491
+ }
492
+
493
+ test("with nothing listening Shell starts the bundled core's shared server detached, with a clean environment, and Pi reuses it", { skip: posixHost }, async (t) => {
393
494
  const prefix = bundledPrefix(t);
394
- const provisioning = join(prefix.home, "provisioning-env");
395
- const result = prefix.run([], { FIXTURE_PROVISION_ENV: provisioning, GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token" });
495
+ const result = prefix.run([], { GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token", ENGRAM_HTTP_TOKEN: "fixture-http" });
396
496
  assert.equal(result.status, 0, result.stderr);
397
- const seen = lastLaunch(result);
398
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined]);
399
- // Already finalized while Pi runs, from the identity file the core wrote.
400
- const record = { schema: 1, dataDir: join(prefix.home, ".engram"), instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) };
401
- assert.deepEqual(seen.provenance, record);
402
- assert.deepEqual(JSON.parse(readFileSync(provenancePath(prefix), "utf8")), record);
403
- // Only the provisioning child skips Engram's update check and loses GitHub tokens; Pi keeps the user's environment.
404
- const child = Object.fromEntries(readFileSync(provisioning, "utf8").trim().split("\n").map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]));
405
- assert.deepEqual([child.ENGRAM_NO_UPDATE_CHECK, child.GH_TOKEN, child.GITHUB_TOKEN, child.ENGRAM_DATA_DIR, child.HOME], ["1", undefined, undefined, undefined, prefix.home]);
406
- assert.deepEqual([seen.env.GH_TOKEN, seen.env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
497
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
498
+ assert.doesNotMatch(result.stderr, /memory disabled/);
499
+ const [serve, ...others] = prefix.serves();
500
+ assert.deepEqual(others, []);
501
+ // Engram's own defaults: no port, data directory or URL chosen for it.
502
+ assert.deepEqual([serve.args, serve.cwd, serve.env.HOME], [["serve"], prefix.home, prefix.home]);
503
+ // S8: no update check, no GitHub tokens and no inherited Engram settings; Pi keeps the user's environment.
504
+ assert.deepEqual([serve.env.ENGRAM_NO_UPDATE_CHECK, serve.env.GH_TOKEN, serve.env.GITHUB_TOKEN, serve.env.ENGRAM_HTTP_TOKEN, serve.env.ENGRAM_URL, serve.env.ENGRAM_BIN],
505
+ ["1", undefined, undefined, undefined, undefined, undefined]);
506
+ assert.deepEqual([lastLaunch(result).env.GH_TOKEN, lastLaunch(result).env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
507
+ // Detached: it outlives the launcher and Pi, and the next launch reuses it without starting another.
508
+ assert.equal(await stillServing(prefix), true);
509
+ const second = prefix.run([]);
510
+ assert.equal(second.status, 0, second.stderr);
511
+ assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
512
+ assert.equal(prefix.serves().length, 1);
513
+ });
514
+
515
+ test("a server that fails to start or never reports ready disables memory for that launch and Shell still starts Pi", { skip: posixHost }, (t) => {
516
+ const prefix = bundledPrefix(t);
517
+ const result = prefix.run([], { FIXTURE_SERVE: "exit" });
518
+ assert.equal(result.status, 0, result.stderr);
519
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
520
+ assert.match(result.stderr, /memory disabled.*could not start the shared Engram server/s);
521
+ assert.equal(lastLaunch(result).cli.endsWith(join("dist", "bundle", "cli.js")), true);
522
+ assert.equal(prefix.serves().length, 1);
523
+ });
524
+
525
+ test("concurrent starts converge: a failed bind followed by a ready /compatibility counts as started", { skip: posixHost }, (t) => {
526
+ const prefix = bundledPrefix(t);
527
+ const result = prefix.run([], { FIXTURE_SERVE: "race" });
528
+ assert.equal(result.status, 0, result.stderr);
529
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
530
+ assert.doesNotMatch(result.stderr, /memory disabled/);
531
+ assert.deepEqual(prefix.serves().map(({ mode }) => mode), ["race", "late"]);
532
+ });
533
+
534
+ test("bundled memory never requires or selects a session-owned server, with the 0.4.2 companion and an explicit mode kept", { skip: posixHost }, (t) => {
535
+ const prefix = bundledPrefix(t);
536
+ const result = prefix.run([]);
537
+ assert.equal(result.status, 0, result.stderr);
538
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
539
+ assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, undefined);
540
+ assert.doesNotMatch(result.stderr, /session-owned/);
541
+ assert.deepEqual(JSON.parse(readFileSync(prefix.metadata, "utf8")).engram, { startupPolicy: 1 });
542
+ });
543
+
544
+ test("an explicit Engram server mode is a selection: kept, never probed or started, memory disabled", { skip: posixHost }, (t) => {
545
+ for (const mode of ["session-owned", "bogus"]) {
546
+ const prefix = bundledPrefix(t);
547
+ const result = prefix.run([], { ENGRAM_SERVER_MODE: mode, FIXTURE_NO_PROBE: "1" });
548
+ assert.equal(result.status, 0, result.stderr);
549
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined], mode);
550
+ assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, mode);
551
+ assert.deepEqual(prefix.serves(), [], mode);
552
+ }
407
553
  });
408
554
 
409
- test("a session that never creates a database keeps Shell's identity; a foreign store with another identity is never adopted", { skip: posixHost }, (t) => {
555
+ test("a mixed local and npm companion selection is preserved and memory still uses the shared server", { skip: posixHost }, (t) => {
556
+ const prefix = bundledPrefix(t);
557
+ const local = join(prefix.home, "legacy-companion");
558
+ mkdirSync(local);
559
+ writeFileSync(join(local, "package.json"), JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1 } }));
560
+ const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
561
+ const selected = JSON.stringify({ packages: [local, "npm:gentle-engram@0.4.2"] });
562
+ writeFileSync(settings, selected);
563
+ const result = prefix.run([]);
564
+ assert.equal(result.status, 0, result.stderr);
565
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
566
+ assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")).packages, JSON.parse(selected).packages);
567
+ });
568
+
569
+ test("an existing store, including one the global CLI created, starts the shared server and Shell never touches it", { skip: posixHost }, (t) => {
410
570
  const prefix = bundledPrefix(t);
411
- assert.equal(prefix.run([]).status, 0);
412
571
  const data = join(prefix.home, ".engram");
413
- assert.deepEqual(readdirSync(data).sort(), [".instance-id"]);
414
- assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
415
- // Another Engram client replaces the shared directory with its own store.
416
- rmSync(data, { recursive: true });
417
572
  mkdirSync(data);
418
573
  writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
419
- writeHeader(join(data, "engram.db"), 1);
420
- const foreign = prefix.run([]);
421
- assert.deepEqual(memoryOf(foreign), ["disabled", prefix.guard]);
422
- assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
574
+ for (const userVersion of [0, 1]) {
575
+ writeHeader(join(data, "engram.db"), userVersion);
576
+ const before = readFileSync(join(data, "engram.db"));
577
+ const result = prefix.run([]);
578
+ assert.equal(result.status, 0, result.stderr);
579
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], String(userVersion));
580
+ assert.deepEqual(readFileSync(join(data, "engram.db")), before);
581
+ }
582
+ assert.deepEqual(readdirSync(data).sort(), [".instance-id", "engram.db"]);
583
+ assert.equal(existsSync(provenancePath(prefix)), false);
423
584
  });
424
- test("a later launch reuses Shell's own store, including a stale WAL header, without rewriting provenance", { skip: posixHost }, (t) => {
585
+
586
+ test("a store a newer Engram migrated keeps the server stopped and memory disabled", { skip: posixHost }, (t) => {
425
587
  const prefix = bundledPrefix(t);
426
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0);
427
- const recorded = readFileSync(provenancePath(prefix), "utf8");
428
- for (const userVersion of [0, 1]) {
429
- writeHeader(join(prefix.home, ".engram", "engram.db"), userVersion);
430
- const second = prefix.run([]);
431
- assert.equal(second.status, 0, second.stderr);
432
- const seen = lastLaunch(second);
433
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined], String(userVersion));
588
+ const data = join(prefix.home, ".engram");
589
+ mkdirSync(data);
590
+ writeHeader(join(data, "engram.db"), 2);
591
+ const result = prefix.run([]);
592
+ assert.equal(result.status, 0, result.stderr);
593
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
594
+ assert.match(result.stderr, /memory disabled.*newer Engram/s);
595
+ assert.deepEqual(prefix.serves(), []);
596
+ });
597
+
598
+ function migrationFixture(t: TestContext, outcome = "success") {
599
+ const prefix = bundledPrefix(t);
600
+ // A store an earlier Shell provisioned and recorded with the bundled core.
601
+ const source = join(prefix.home, ".engram");
602
+ mkdirSync(source);
603
+ writeFileSync(join(source, ".instance-id"), `${STORE_ID}\n`);
604
+ writeHeader(join(source, "engram.db"), 1);
605
+ writeFileSync(join(source, "config.json"), '{"preserved":true}\n');
606
+ mkdirSync(dirname(provenancePath(prefix)), { recursive: true });
607
+ writeFileSync(provenancePath(prefix), `${JSON.stringify({ schema: 1, dataDir: source, instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) }, null, 2)}\n`);
608
+ const originalRecord = readFileSync(provenancePath(prefix), "utf8");
609
+ const sourceBytes = readFileSync(join(source, "engram.db"));
610
+ const invocation = join(prefix.home, "migration-invoked");
611
+ writeFileSync(prefix.engram, `#!${process.execPath}\nimport {cpSync,writeFileSync} from 'node:fs';\nconst args=process.argv.slice(2);\nif(args[0]==='--help'){console.log(${JSON.stringify(outcome === "unsupported" ? "Commands: compatibility" : " migrate-copy --data-dir <absolute-source> --output-dir <new-absolute-destination> --confirm-quiescent --json")});process.exit(0);}\nwriteFileSync(${JSON.stringify(invocation)},JSON.stringify({args,token:process.env.GH_TOKEN,cloud:process.env.ENGRAM_CLOUD_TOKEN}));\nif(args[0]!=='migrate-copy') process.exit(2);\nconst source=args[args.indexOf('--data-dir')+1],output=args[args.indexOf('--output-dir')+1];\nif(${JSON.stringify(outcome)}==='failure'){process.stderr.write('migration rejected\\n');process.exit(1);}\ncpSync(source,output,{recursive:true});\nconsole.log(JSON.stringify({source_dir:source,output_dir:output,instance_id:${JSON.stringify(outcome === "wrong-identity" ? OTHER_STORE_ID : STORE_ID)},migration_completed:true}));\n`, { mode: 0o700 });
612
+ return { ...prefix, source, originalRecord, sourceBytes, invocation };
613
+ }
614
+
615
+ test("an explicit Engram socket remains untouched and is never probed", { skip: posixHost }, (t) => {
616
+ const prefix = bundledPrefix(t);
617
+ const result = prefix.run([], { ENGRAM_SOCKET: "/fixture/selected.sock", FIXTURE_NO_PROBE: "1" });
618
+ assert.equal(result.status, 0, result.stderr);
619
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
620
+ assert.equal(lastLaunch(result).env.ENGRAM_SOCKET, "/fixture/selected.sock");
621
+ assert.deepEqual(prefix.serves(), []);
622
+ });
623
+
624
+ test("memory migrate explicitly promotes a verified copy and retains the original store", { skip: posixHost }, (t) => {
625
+ const prefix = migrationFixture(t);
626
+ const result = prefix.run(["memory", "migrate", "--confirm-quiescent"], { FIXTURE_NO_PROBE: "1", GH_TOKEN: "fixture-secret", ENGRAM_CLOUD_TOKEN: "fixture-cloud" });
627
+ assert.equal(result.status, 0, result.stderr);
628
+ assert.equal(result.stderr, "");
629
+ const receipt = JSON.parse(result.stdout);
630
+ assert.deepEqual(Object.keys(receipt).sort(), ["backup_dir", "instance_id", "status"]);
631
+ assert.equal(receipt.status, "migrated");
632
+ assert.equal(receipt.instance_id, STORE_ID);
633
+ assert.deepEqual(readFileSync(join(receipt.backup_dir, "engram.db")), prefix.sourceBytes);
634
+ assert.equal(readFileSync(join(receipt.backup_dir, "config.json"), "utf8"), '{"preserved":true}\n');
635
+ assert.equal(readFileSync(join(prefix.source, "config.json"), "utf8"), '{"preserved":true}\n');
636
+ assert.deepEqual(JSON.parse(readFileSync(provenancePath(prefix), "utf8")), { schema: 1, dataDir: prefix.source, instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) });
637
+ const invoked = JSON.parse(readFileSync(prefix.invocation, "utf8"));
638
+ assert.equal(invoked.args[0], "migrate-copy");
639
+ assert.equal(invoked.token, undefined);
640
+ assert.equal(invoked.cloud, undefined);
641
+ assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
642
+ });
643
+
644
+ test("memory migrate rejects missing confirmation and malformed flags without invoking Engram", { skip: posixHost }, (t) => {
645
+ const prefix = migrationFixture(t);
646
+ for (const args of [["memory", "migrate"], ["memory", "migrate", "--confirm-quiescent", "--unknown"], ["memory", "migrate", "--confirm-quiescent", "--confirm-quiescent"]]) {
647
+ const result = prefix.run(args, { FIXTURE_NO_PROBE: "1" });
648
+ assert.equal(result.status, 2, result.stderr);
649
+ assert.equal(result.stdout, "");
650
+ assert.equal(result.stderr, "Usage: gentle-shell memory migrate --confirm-quiescent\nStop every Engram client before running this command.\n");
651
+ assert.equal(existsSync(prefix.invocation), false);
652
+ assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
653
+ assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
434
654
  }
435
- assert.equal(readFileSync(provenancePath(prefix), "utf8"), recorded);
436
655
  });
437
656
 
438
- test("provenance mismatches disable memory behind the guard: instance, core digest, newer or malformed header", { skip: posixHost }, (t) => {
439
- const mismatches: Array<[string, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
440
- ["instance", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), `${OTHER_STORE_ID}\n`)],
441
- ["invalid identity", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), "not-an-id\n")],
442
- ["core digest", (prefix) => writeFileSync(prefix.engram, "#!/bin/sh\necho upgraded engram\n", { mode: 0o700 })],
443
- ["newer schema", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 2)],
444
- ["malformed header", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 1, "not sqlite at all")],
445
- ["symlinked database", (prefix) => {
446
- const db = join(prefix.home, ".engram", "engram.db");
447
- renameSync(db, `${db}.real`);
448
- symlinkSync(`${db}.real`, db);
449
- }],
450
- ["corrupt provenance", (prefix) => writeFileSync(provenancePath(prefix), "{")],
451
- ];
452
- for (const [name, change] of mismatches) {
453
- const prefix = bundledPrefix(t);
454
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
455
- change(prefix);
456
- const result = prefix.run([]);
457
- assert.equal(result.status, 0, `${name}: ${result.stderr}`);
458
- const seen = lastLaunch(result);
459
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
460
- assert.match(result.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s, name);
657
+ test("memory migrate never sends an unknown command to an older bundled core", { skip: posixHost }, (t) => {
658
+ const prefix = migrationFixture(t, "unsupported");
659
+ const result = prefix.run(["memory", "migrate", "--confirm-quiescent"], { FIXTURE_NO_PROBE: "1" });
660
+ assert.equal(result.status, 1, result.stderr);
661
+ assert.equal(result.stdout, "");
662
+ assert.match(result.stderr, /does not support migrate-copy/);
663
+ assert.equal(existsSync(prefix.invocation), false);
664
+ assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
665
+ assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
666
+ });
667
+
668
+ test("memory migrate refuses provider failure and mismatched evidence without changing provenance or source", { skip: posixHost }, (t) => {
669
+ for (const outcome of ["failure", "wrong-identity"]) {
670
+ const prefix = migrationFixture(t, outcome);
671
+ const result = prefix.run(["memory", "migrate", "--confirm-quiescent"], { FIXTURE_NO_PROBE: "1" });
672
+ assert.equal(result.status, 1, result.stderr);
673
+ assert.equal(result.stdout, "");
674
+ assert.match(result.stderr, /^gentle-shell: memory migration failed:/);
675
+ assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
676
+ assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
677
+ assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
461
678
  }
462
679
  });
463
680
 
681
+ test("an interrupted memory promotion keeps the server stopped until recovery", { skip: posixHost }, (t) => {
682
+ const prefix = bundledPrefix(t);
683
+ mkdirSync(join(prefix.root, "config"), { recursive: true });
684
+ const marker = join(prefix.root, "config", "engram-migration.json");
685
+ writeFileSync(marker, '{"interrupted":true}\n');
686
+ const result = prefix.run([], { FIXTURE_NO_PROBE: "1" });
687
+ assert.equal(result.status, 0, result.stderr);
688
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
689
+ assert.match(result.stderr, /incomplete memory migration/);
690
+ assert.deepEqual(prefix.serves(), []);
691
+ assert.equal(readFileSync(marker, "utf8"), '{"interrupted":true}\n');
692
+ });
693
+
464
694
  test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
465
695
  const prefix = bundledPrefix(t);
466
696
  const result = prefix.run(["--help"]);
@@ -468,40 +698,21 @@ test("public help explains compatibility evidence without promising update recov
468
698
  assert.equal(result.stderr, "");
469
699
  assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
470
700
  assert.match(result.stdout, /\/compatibility/);
471
- assert.doesNotMatch(result.stdout, /update Engram, restart/);
701
+ assert.match(result.stdout, /shares the global Engram server.*nothing listens.*engram serve/s);
702
+ assert.doesNotMatch(result.stdout, /update Engram, restart|Shell-provisioned|session-owned/);
472
703
  });
473
704
 
474
- test("a foreign pre-existing store stays disabled and never receives provenance", { skip: posixHost }, (t) => {
705
+ test("a listener without the ready contract is never replaced, even reporting the bundled core version", { skip: posixHost }, async (t) => {
475
706
  const prefix = bundledPrefix(t);
476
- const data = join(prefix.home, ".engram");
477
- mkdirSync(data);
478
- writeFileSync(join(data, ".instance-id"), `${STORE_ID}\n`);
479
- writeHeader(join(data, "engram.db"), 1);
480
- const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
707
+ const port = await engramServer(t, { "/health": [200, { status: "ok", instance_id: STORE_ID, version: engramPinVersion }] });
708
+ const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
481
709
  assert.equal(result.status, 0, result.stderr);
482
- assert.deepEqual([lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN], ["disabled", prefix.guard]);
483
- assert.equal(existsSync(provenancePath(prefix)), false);
484
- });
485
-
486
- test("a listening server is reused for Shell's store only with the recorded /health instance and the exact core version", { skip: posixHost }, async (t) => {
487
- const cases: Array<[string, unknown, string]> = [
488
- ["match", { status: "ok", instance_id: STORE_ID, version: engramPinVersion }, "allow"],
489
- ["other instance", { status: "ok", instance_id: OTHER_STORE_ID, version: engramPinVersion }, "disabled"],
490
- ["other version", { status: "ok", instance_id: STORE_ID, version: "9.9.9" }, "disabled"],
491
- ];
492
- for (const [name, health, policy] of cases) {
493
- const prefix = bundledPrefix(t);
494
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
495
- const port = await engramServer(t, { "/health": [200, health] });
496
- const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
497
- assert.equal(result.status, 0, `${name}: ${result.stderr}`);
498
- const seen = lastLaunch(result);
499
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL],
500
- [policy, policy === "allow" ? prefix.engram : prefix.guard, undefined], name);
501
- }
710
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
711
+ assert.match(result.stderr, /memory disabled.*could not verify/s);
712
+ assert.deepEqual(prefix.serves(), []);
502
713
  });
503
714
 
504
- test("a verified /compatibility server is reused through an explicit URL and the guard, without provenance", { skip: posixHost }, async (t) => {
715
+ test("a verified /compatibility server is reused unchanged through an explicit URL and the guard", { skip: posixHost }, async (t) => {
505
716
  const ready = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
506
717
  instance_id: STORE_ID, runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
507
718
  const prefix = bundledPrefix(t);
@@ -513,11 +724,12 @@ test("a verified /compatibility server is reused through an explicit URL and the
513
724
  assert.equal(result.status, 0, result.stderr);
514
725
  const seen = lastLaunch(result);
515
726
  assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_URL, seen.env.ENGRAM_BIN], ["allow", "http://127.0.0.1:7437", prefix.guard]);
516
- assert.equal(existsSync(provenancePath(prefix)), false);
727
+ assert.deepEqual(prefix.serves(), []);
517
728
  const unready = bundledPrefix(t);
518
729
  mkdirSync(join(unready.home, ".engram"));
519
730
  const notReady = await engramServer(t, { "/compatibility": [200, { ...ready, status: "unknown", active_reuse_authorized: false }] });
520
731
  assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
732
+ assert.deepEqual(unready.serves(), []);
521
733
  });
522
734
 
523
735
  test("a foreign store behind a 3.3.2 server stays guarded and the launch names the server version to update", { skip: posixHost }, async (t) => {
@@ -534,34 +746,10 @@ test("a foreign store behind a 3.3.2 server stays guarded and the launch names t
534
746
  assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
535
747
  assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
536
748
  assert.deepEqual(readFileSync(join(data, "engram.db")), before);
537
- assert.equal(existsSync(provenancePath(prefix)), false);
749
+ assert.deepEqual(prefix.serves(), []);
538
750
  });
539
751
 
540
- test("a fresh launch whose launcher is killed during Pi already has finalized provenance, and the next launch allows memory", { skip: posixHost }, (t) => {
541
- const prefix = bundledPrefix(t);
542
- const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID, FIXTURE_KILL_LAUNCHER: "1" });
543
- assert.equal(first.signal, "SIGKILL");
544
- assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
545
- assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
546
- });
547
- test("a failed identity provisioning disables memory for that launch and records nothing", { skip: posixHost }, (t) => {
548
- const cases: Array<[string, Record<string, string>, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
549
- ["instance-id failure", { FIXTURE_INSTANCE_ID_FAIL: "1" }, () => {}],
550
- ["invalid identity file", { FIXTURE_INSTANCE_ID: "not-an-id" }, () => {}],
551
- ["provenance write failure", {}, (prefix) => mkdirSync(provenancePath(prefix), { recursive: true })],
552
- ];
553
- for (const [name, extra, prepare] of cases) {
554
- const prefix = bundledPrefix(t);
555
- prepare(prefix);
556
- const result = prefix.run([], extra);
557
- assert.equal(result.status, 0, `${name}: ${result.stderr}`);
558
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard], name);
559
- assert.match(result.stderr, /could not provision/, name);
560
- assert.equal(lastLaunch(result).provenance, null, name);
561
- }
562
- });
563
-
564
- test("--version and forwarded Pi version or help flags never probe Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
752
+ test("--version and forwarded Pi version or help flags never probe or start Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
565
753
  const prefix = bundledPrefix(t);
566
754
  writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
567
755
  mkdirSync(join(prefix.home, ".engram"));
@@ -575,7 +763,7 @@ test("--version and forwarded Pi version or help flags never probe Engram, insta
575
763
  assert.equal(readFileSync(settings, "utf8"), before, args.join(" "));
576
764
  assert.equal(JSON.parse(readFileSync(prefix.metadata, "utf8")).version, "0.4.1", args.join(" "));
577
765
  }
578
- assert.equal(existsSync(provenancePath(prefix)), false);
766
+ assert.equal(prefix.serves().length, 1);
579
767
  });
580
768
 
581
769
  test("-v or --help as a prompt or an option value never switches memory off", { skip: posixHost }, (t) => {
@@ -583,8 +771,8 @@ test("-v or --help as a prompt or an option value never switches memory off", {
583
771
  const prefix = bundledPrefix(t);
584
772
  const result = prefix.run(args);
585
773
  assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`);
586
- assert.deepEqual(memoryOf(result), ["allow", prefix.engram], args.join(" "));
587
- assert.equal(lastLaunch(result).provenance.instanceId, STORE_ID, args.join(" "));
774
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], args.join(" "));
775
+ assert.equal(prefix.serves().length, 1, args.join(" "));
588
776
  }
589
777
  });
590
778
  test("a missing bundled core uses the guard, never an engram on PATH", { skip: posixHost }, (t) => {
@@ -609,20 +797,23 @@ function countingGentleAi(prefix: ReturnType<typeof bundledPrefix>) {
609
797
  return { bin, runs: () => (existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).length : 0) };
610
798
  }
611
799
 
612
- test("setup on a fresh machine publishes the bundled core; the next launch is fresh, records provenance and is reused", { skip: posixHost }, (t) => {
800
+ test("setup on a fresh machine publishes the bundled core; the next launch starts the shared server and later launches reuse it", { skip: posixHost }, (t) => {
613
801
  const prefix = bundledPrefix(t, { core: false });
614
802
  const installer = countingGentleAi(prefix);
615
803
  const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin });
616
804
  assert.equal(setup.status, 0, setup.stderr);
617
805
  assert.equal(installer.runs(), 1);
618
806
  assert.equal(existsSync(prefix.engram), true);
619
- const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
807
+ // Setup itself never starts a server.
808
+ assert.deepEqual(prefix.serves(), []);
809
+ const first = prefix.run([]);
620
810
  assert.equal(first.status, 0, first.stderr);
621
- assert.deepEqual([lastLaunch(first).env.ENGRAM_STARTUP_POLICY, lastLaunch(first).env.ENGRAM_BIN], ["allow", prefix.engram]);
622
- assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
811
+ assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
623
812
  const second = prefix.run([]);
624
813
  assert.equal(second.status, 0, second.stderr);
625
- assert.deepEqual([lastLaunch(second).env.ENGRAM_STARTUP_POLICY, lastLaunch(second).env.ENGRAM_BIN], ["allow", prefix.engram]);
814
+ assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
815
+ assert.equal(prefix.serves().length, 1);
816
+ assert.equal(existsSync(provenancePath(prefix)), false);
626
817
  });
627
818
 
628
819
  test("an explicit ENGRAM_URL alone keeps the verified core, or the guard without it, as ENGRAM_BIN", { skip: posixHost }, (t) => {
@@ -646,12 +837,13 @@ test("a failed companion check after setup is a warning, not a setup failure", {
646
837
  assert.equal(setup.status, 0, setup.stderr);
647
838
  assert.equal(installer.runs(), 1);
648
839
  assert.match(setup.stderr, /startupPolicy/);
649
- assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["disabled", prefix.guard]);
840
+ // The guard, not the companion check, protects memory: the shared server is still reused.
841
+ assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["allow", prefix.guard, DEFAULT_URL]);
650
842
  assert.deepEqual(readdirSync(data), ["engram.db"]);
651
843
  assert.equal(readFileSync(join(data, "engram.db"), "utf8"), "untouched database sentinel");
652
844
  });
653
845
 
654
- test("setup with a foreign existing store runs the installer like the baseline, while memory stays behind the guard", { skip: posixHost }, (t) => {
846
+ test("setup with an existing store runs the installer like the baseline and the launch reuses the shared server behind the guard", { skip: posixHost }, (t) => {
655
847
  const prefix = bundledPrefix(t, { core: false });
656
848
  const data = join(prefix.home, ".engram");
657
849
  mkdirSync(data);
@@ -661,13 +853,11 @@ test("setup with a foreign existing store runs the installer like the baseline,
661
853
  assert.equal(setup.status, 0, setup.stderr);
662
854
  assert.equal(installer.runs(), 1);
663
855
  assert.equal(existsSync(prefix.engram), true);
664
- assert.match(setup.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s);
665
- assert.doesNotMatch(setup.stderr, /update Engram and restart/);
666
- assert.doesNotMatch(setup.stderr, /deferred/);
856
+ assert.doesNotMatch(setup.stderr, /memory disabled|deferred/);
667
857
  const guarded = prefix.run([]);
668
858
  assert.equal(guarded.status, 0, guarded.stderr);
669
859
  const env = lastLaunch(guarded).env;
670
- assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
860
+ assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["allow", prefix.guard, DEFAULT_URL]);
671
861
  // Subagents receive the same environment (GENTLE_PI_AGENTS_PI runs the same Pi with it).
672
862
  assert.equal(env.GENTLE_PI_AGENTS_PI.endsWith(join("dist", "bundle", "cli.js")), true);
673
863
  const provider = spawnSync(env.ENGRAM_BIN, ["instance-id"], { env: { ...prefix.env, ...env }, encoding: "utf8" });
@@ -682,15 +872,14 @@ test("setup with a foreign existing store runs the installer like the baseline,
682
872
  assert.equal(existsSync(provenancePath(prefix)), false);
683
873
  });
684
874
 
685
- test("a gentle-pi version change re-provisions Shell's own store once and keeps its memory", { skip: posixHost }, (t) => {
875
+ test("a gentle-pi version change reruns setup once and keeps memory on the shared server", { skip: posixHost }, (t) => {
686
876
  const prefix = bundledPrefix(t, { core: false });
687
877
  const installer = countingGentleAi(prefix);
688
878
  const auto = { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: installer.bin };
689
- const first = prefix.run([], { ...auto, FIXTURE_CREATE_STORE: STORE_ID });
879
+ const first = prefix.run([], auto);
690
880
  assert.equal(first.status, 0, first.stderr);
691
881
  assert.equal(installer.runs(), 1);
692
- assert.deepEqual([lastLaunch(first).env.ENGRAM_STARTUP_POLICY, lastLaunch(first).env.ENGRAM_BIN], ["allow", prefix.engram]);
693
- assert.equal(existsSync(provenancePath(prefix)), true);
882
+ assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
694
883
  assert.equal(prefix.run([], auto).status, 0);
695
884
  assert.equal(installer.runs(), 1);
696
885
  const manifest = join(dirname(dirname(prefix.entry)), "package.json");
@@ -699,7 +888,8 @@ test("a gentle-pi version change re-provisions Shell's own store once and keeps
699
888
  assert.equal(changed.status, 0, changed.stderr);
700
889
  assert.equal(installer.runs(), 2);
701
890
  assert.doesNotMatch(changed.stderr, /automatic setup failed|deferred/);
702
- assert.deepEqual([lastLaunch(changed).env.ENGRAM_STARTUP_POLICY, lastLaunch(changed).env.ENGRAM_BIN], ["allow", prefix.engram]);
891
+ assert.deepEqual(memoryOf(changed), ["allow", prefix.guard, DEFAULT_URL]);
892
+ assert.equal(prefix.serves().length, 1);
703
893
  assert.equal(prefix.run([], auto).status, 0);
704
894
  assert.equal(installer.runs(), 2);
705
895
  });
@@ -720,8 +910,7 @@ test("capability readback also requires Pi to declare the selected pinned compan
720
910
  const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "metadata-only" });
721
911
  assert.equal(result.status, 0, result.stderr);
722
912
  assert.match(result.stderr, /startupPolicy/);
723
- // Only the identity Shell provisions before Pi: no database is opened.
724
- assert.deepEqual(readdirSync(join(prefix.home, ".engram")), [".instance-id"]);
913
+ assert.equal(lastLaunch(result).env.ENGRAM_BIN, prefix.guard);
725
914
  });
726
915
 
727
916
  test("pinning preserves resource filters and unrelated settings and does not reinstall next launch", { skip: posixHost }, (t) => {
@@ -737,16 +926,19 @@ test("pinning preserves resource filters and unrelated settings and does not rei
737
926
  assert.doesNotMatch(prefix.run([]).stderr, /installing policy-capable companion/);
738
927
  });
739
928
 
740
- test("final startup rechecks data created during automatic setup and records no provenance for it", { skip: posixHost }, (t) => {
929
+ test("final startup rechecks a store a newer Engram migrated during automatic setup and never starts a server on it", { skip: posixHost }, (t) => {
741
930
  const prefix = bundledPrefix(t);
742
931
  const fake = join(prefix.userBin, "gentle-ai");
743
- writeFileSync(fake, `#!/bin/sh\nmkdir -p "$HOME/.engram"\nprintf '${STORE_ID}\\n' > "$HOME/.engram/.instance-id"\nprintf fixture > "$HOME/.engram/engram.db"\n`, { mode: 0o755 });
932
+ const db = join(prefix.home, ".engram", "engram.db");
933
+ writeFileSync(fake, `#!${process.execPath}\nconst { mkdirSync, writeFileSync } = require('node:fs');\nmkdirSync(${JSON.stringify(dirname(db))}, { recursive: true });\n` +
934
+ `const header = Buffer.alloc(4096); header.write('SQLite format 3\\0', 0, 'latin1'); header.writeUInt16BE(4096, 16); header[18] = 2; header[19] = 2; header.writeUInt32BE(2, 60);\n` +
935
+ `writeFileSync(${JSON.stringify(db)}, header);\n`, { mode: 0o755 });
744
936
  const result = prefix.run([], { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: fake });
745
937
  assert.equal(result.status, 0, result.stderr);
746
- const seen = lastLaunch(result);
747
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard]);
748
- assert.equal(readFileSync(join(prefix.home, ".engram", "engram.db"), "utf8"), "fixture");
749
- assert.equal(existsSync(provenancePath(prefix)), false);
938
+ assert.equal(existsSync(db), true);
939
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
940
+ assert.match(result.stderr, /memory disabled.*newer Engram/s);
941
+ assert.deepEqual(prefix.serves(), []);
750
942
  });
751
943
 
752
944
  test("explicit endpoints and invalid policies never trigger availability probes or override executables", { skip: posixHost }, (t) => {
@@ -760,6 +952,7 @@ test("explicit endpoints and invalid policies never trigger availability probes
760
952
  const policyOnly = prefix.run([], { FIXTURE_NO_PROBE: "1", ENGRAM_STARTUP_POLICY: "invalid" });
761
953
  assert.equal(policyOnly.status, 0, policyOnly.stderr);
762
954
  assert.equal(lastLaunch(policyOnly).env.ENGRAM_BIN, prefix.guard);
955
+ assert.deepEqual(prefix.serves(), []);
763
956
  });
764
957
 
765
958
  test("startup offer precedes Pi, not-now preserves config and omit suppresses just that release", { skip: posixHost }, (t) => {
@@ -776,15 +969,36 @@ test("startup offer precedes Pi, not-now preserves config and omit suppresses ju
776
969
  env: { ...prefix.env, NODE_OPTIONS: `${prefix.env.NODE_OPTIONS} --import=data:text/javascript,${encodeURIComponent(preload(latest))}` },
777
970
  encoding: "utf8", input, timeout: 10000,
778
971
  });
972
+ // A recorded lookup answers the next launch without the network, so these
973
+ // runs simulate later days by aging the cache back past the TTL; the live
974
+ // lookup then records its own answer again.
975
+ const ageCache = () => {
976
+ const parsed = JSON.parse(readFileSync(config, "utf8"));
977
+ const latest = typeof parsed.startupReleaseCheck?.latest === "string" ? parsed.startupReleaseCheck.latest : "999.0.0";
978
+ parsed.startupReleaseCheck = { checkedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), latest };
979
+ writeFileSync(config, `${JSON.stringify(parsed, null, 2)}\n`);
980
+ };
779
981
  const skipped = run("999.0.0", "2\r");
780
982
  assert.equal(skipped.status, 0, skipped.stderr);
781
983
  assert.match(skipped.stdout, /Gentle Shell update available/);
782
984
  assert.ok(skipped.stdout.indexOf("Gentle Shell update available") < skipped.stdout.indexOf('"cli":'));
783
- assert.equal(readFileSync(config, "utf8"), '{"other":"preserved"}\n');
985
+ // A not-now choice still records no decision, but the live lookup it paid
986
+ // for lands in the config as the cache tomorrow's launch reuses.
987
+ const afterSkipped = JSON.parse(readFileSync(config, "utf8"));
988
+ assert.equal(afterSkipped.other, "preserved");
989
+ assert.equal(afterSkipped.skippedStartupRelease, undefined);
990
+ assert.equal(afterSkipped.startupReleaseCheck.latest, "999.0.0");
991
+ assert.ok(!Number.isNaN(Date.parse(afterSkipped.startupReleaseCheck.checkedAt)));
992
+ ageCache();
784
993
  const omitted = run("999.0.0", "3\r");
785
994
  assert.equal(omitted.status, 0, omitted.stderr);
786
- assert.deepEqual(JSON.parse(readFileSync(config, "utf8")), { other: "preserved", skippedStartupRelease: "999.0.0" });
995
+ const afterOmit = JSON.parse(readFileSync(config, "utf8"));
996
+ assert.equal(afterOmit.other, "preserved");
997
+ assert.equal(afterOmit.skippedStartupRelease, "999.0.0");
998
+ assert.equal(afterOmit.startupReleaseCheck.latest, "999.0.0");
999
+ ageCache();
787
1000
  assert.doesNotMatch(run("999.0.0", "").stdout, /Gentle Shell update available/);
1001
+ ageCache();
788
1002
  assert.match(run("999.0.1", "2\r").stdout, /Gentle Shell update available/);
789
1003
  const command = run("999.0.1", "", ["list"]);
790
1004
  assert.equal(command.status, 0, command.stderr);
@@ -810,11 +1024,13 @@ test("the bundled launcher runs only its version's Pi, with the user's environme
810
1024
  assert.equal(seen.env.NODE_OPTIONS, prefix.env.NODE_OPTIONS);
811
1025
  assert.equal(seen.env.npm_config_registry, "https://npm.example");
812
1026
  assert.equal(seen.env.npm_config_prefix, undefined);
813
- // S9: gentle-pi's config folder inside the prefix, the pinned Engram for
814
- // gentle-engram, and none of Gentle AI's own variables.
1027
+ // S9: gentle-pi's config folder inside the prefix, the shared server's URL
1028
+ // behind the guard for gentle-engram, and none of Gentle AI's own variables.
1029
+ // A Pi subcommand never starts the server.
815
1030
  assert.equal(seen.env.GENTLE_SHELL_PREFIX, prefix.root);
816
1031
  assert.equal(seen.env.GENTLE_PI_CONFIG_HOME, undefined);
817
- assert.equal(seen.env.ENGRAM_BIN, prefix.engram);
1032
+ assert.deepEqual([seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], [prefix.guard, DEFAULT_URL]);
1033
+ assert.deepEqual(prefix.serves(), []);
818
1034
  for (const name of ["GENTLE_AI_HOME", "GENTLE_AI_PI_CONFIG_DIR", "GENTLE_AI_ENGRAM_BIN", "GENTLE_AI_NO_SELF_UPDATE"]) assert.equal(seen.env[name], undefined, name);
819
1035
  // Pi's own npm is ours, configured only from the prefix, through the isolated home's settings.
820
1036
  const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8"));