gentle-pi 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -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 });
@@ -193,6 +274,18 @@ test("bundledUpdatePlan redirects Pi's own update and keeps the extension update
193
274
  }
194
275
  });
195
276
 
277
+ test("bundledUpdatePlan marks Pi's own update with --latest and keeps every other --latest form passing through", () => {
278
+ for (const args of [["update", "--latest"], ["update", "pi", "--latest"], ["update", "--latest", "self"], ["update", "--self", "--latest"]]) {
279
+ assert.deepEqual(bundledUpdatePlan(args), { self: true, latest: true }, args.join(" "));
280
+ }
281
+ assert.deepEqual(bundledUpdatePlan(["update", "--all", "--latest"]), { self: true, latest: true, piArgs: ["update", "--extensions"] });
282
+ assert.deepEqual(bundledUpdatePlan(["update", "--latest", "--self", "--extensions", "-a"]), { self: true, latest: true, piArgs: ["update", "--extensions", "-a"] });
283
+ for (const passthrough of [["update", "--extensions", "--latest"], ["update", "npm:x", "--latest"], ["update", "--models", "--latest"], ["update", "--all", "--self", "--latest"],
284
+ ["update", "pi", "npm:x", "--latest"], ["update", "--latest", "--bogus"], ["update", "--help", "--latest"]]) {
285
+ assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" "));
286
+ }
287
+ });
288
+
196
289
  test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => {
197
290
  assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] });
198
291
  assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 });
@@ -202,6 +295,9 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple
202
295
  const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1);
203
296
  assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]);
204
297
  assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/);
298
+ const latest = bundledUpdateNext({ self: true, latest: true, piArgs: ["update", "--extensions"] }, 1);
299
+ assert.match("note" in latest ? String(latest.note) : "", /latest Pi update did not complete.*extensions/);
300
+ assert.deepEqual(bundledUpdateNext({ self: true, latest: true }, 1), { exitCode: 1 });
205
301
  });
206
302
 
207
303
  test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
@@ -209,6 +305,11 @@ test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell
209
305
  assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
210
306
  assert.match(interactive, /Running 'gentle-shell upgrade'/);
211
307
  assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
308
+ for (const interactive of [true, false]) {
309
+ const lines = bundledUpdateNotice({ piVersion: "1.2.0", interactive });
310
+ assert.equal(lines[0], "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release.");
311
+ assert.equal(lines.at(-1), "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.");
312
+ }
212
313
  });
213
314
 
214
315
  test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
@@ -235,48 +336,56 @@ const posixHost = process.platform === "win32" ? "the bundled prefix fixture use
235
336
  const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
236
337
  const FIXTURE_ID = `${SHELL}-1.2.0`;
237
338
  const FAKE_PI = [
238
- "import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
339
+ "import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
239
340
  "const args = process.argv.slice(2);",
240
341
  "if (args[0] === 'install' && args[1] === 'npm:gentle-engram@0.4.2' && process.env.FIXTURE_INSTALL_CAPABILITY !== '0') {",
241
342
  " const home = process.env.PI_CODING_AGENT_DIR; const metadata = join(home, 'npm/node_modules/gentle-engram/package.json'); mkdirSync(dirname(metadata), {recursive:true});",
242
- " writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1,...(process.env.FIXTURE_OWNED_COMPANION === '0' ? {} : {sessionOwned:1})}}));",
343
+ " writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1}}));",
243
344
  " const path = join(home,'settings.json'); let settings={}; try {settings=JSON.parse(readFileSync(path,'utf8'));} catch {}",
244
345
  " if (process.env.FIXTURE_INSTALL_CAPABILITY !== 'metadata-only') { const packages = settings.packages ?? []; const index = packages.findIndex(p => /^npm:gentle-engram(?:@|$)/.test(typeof p === 'string' ? p : p.source));",
245
346
  " 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
347
  "}",
247
348
  "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
349
  "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
350
  "\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'];",
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');",
351
+ "console.log(JSON.stringify({ cli: process.argv[1], args, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
264
352
  ].join("\n");
353
+ const READY_COMPATIBILITY = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
354
+ instance_id: "0123456789abcdef0123456789abcdef", runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
355
+ // The bundled core: `serve` logs its invocation (FIXTURE_SERVE_LOG) and, like
356
+ // Engram 3.5.2, answers the ready /compatibility contract on a port it publishes
357
+ // in FIXTURE_ENGRAM_PORT_FILE, where the redirected default endpoint finds it.
358
+ // FIXTURE_SERVE=exit fails like a lost bind; race fails after starting the
359
+ // server a concurrent launch would. Every other command only prints a line.
265
360
  const FIXTURE_CORE = [
266
- "#!/bin/sh",
267
- 'if [ "$1" = --help ] && [ "$FIXTURE_OWNED_CORE" != 0 ]; then echo " serve --session-owned Private ephemeral HTTP server"; exit 0; fi',
268
- 'if [ "$1" != instance-id ]; then echo engram; exit 0; fi',
269
- '[ -z "$FIXTURE_PROVISION_ENV" ] || env > "$FIXTURE_PROVISION_ENV"',
270
- '[ -z "$FIXTURE_INSTANCE_ID_FAIL" ] || exit 3',
271
- 'mkdir -p "$HOME/.engram" || exit 1',
272
- '[ -f "$HOME/.engram/.instance-id" ] || printf \'%s\\n\' "${FIXTURE_INSTANCE_ID:-0123456789abcdef0123456789abcdef}" > "$HOME/.engram/.instance-id"',
273
- 'cat "$HOME/.engram/.instance-id"',
361
+ `#!${process.execPath}`,
362
+ "const { appendFileSync, renameSync, writeFileSync } = require('node:fs'); const { spawn } = require('node:child_process');",
363
+ "const args = process.argv.slice(2);",
364
+ "if (args[0] !== 'serve') { console.log('engram'); process.exit(0); }",
365
+ "const mode = process.env.FIXTURE_SERVE ?? 'ready';",
366
+ "const pick = ['HOME', 'GH_TOKEN', 'GITHUB_TOKEN', 'ENGRAM_NO_UPDATE_CHECK', 'ENGRAM_URL', 'ENGRAM_BIN', 'ENGRAM_HTTP_TOKEN', 'ENGRAM_STARTUP_POLICY'];",
367
+ "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');",
368
+ "if (mode === 'exit') process.exit(1);",
369
+ "if (mode === 'race') { spawn(process.execPath, [process.argv[1], 'serve'], { env: { ...process.env, FIXTURE_SERVE: 'late' }, detached: true, stdio: 'ignore' }).unref(); process.exit(1); }",
370
+ `const ready = ${JSON.stringify(JSON.stringify(READY_COMPATIBILITY))};`,
371
+ "setTimeout(() => require('node:http').createServer((request, response) => {",
372
+ " response.writeHead(request.url === '/compatibility' ? 200 : 404, { 'content-type': 'application/json' }); response.end(request.url === '/compatibility' ? ready : '{}');",
373
+ "}).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); }),",
374
+ " mode === 'late' ? 300 : 0);",
375
+ "setTimeout(() => process.exit(0), 60000);",
274
376
  "",
275
377
  ].join("\n");
276
378
 
277
379
  function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
278
380
  const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
279
- t.after(() => rmSync(base, { recursive: true, force: true }));
381
+ const serveLog = join(base, "engram-serve.log");
382
+ const serves = (): Array<{ args: string[]; mode: string; cwd: string; pid: number; env: Record<string, string | undefined> }> =>
383
+ existsSync(serveLog) ? readFileSync(serveLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [];
384
+ // The detached servers outlive their launcher by design: stop them with the fixture.
385
+ t.after(() => {
386
+ for (const { pid } of serves()) try { process.kill(pid); } catch { /* Already exited. */ }
387
+ rmSync(base, { recursive: true, force: true });
388
+ });
280
389
  const home = join(base, "home");
281
390
  const root = join(home, ".gentle-shell");
282
391
  const version = join(root, "versions", FIXTURE_ID);
@@ -339,33 +448,38 @@ function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
339
448
  mkdirSync(userBin);
340
449
  writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 });
341
450
  // No test contacts the actual default Engram endpoint: connections to 7437
342
- // are redirected to a closed ephemeral port (refused) or to a local fixture
343
- // server (FIXTURE_ENGRAM_PORT). FIXTURE_NO_PROBE makes any probe fail the test.
451
+ // are redirected to a local fixture server (FIXTURE_ENGRAM_PORT), to the
452
+ // server the bundled core's `serve` published (FIXTURE_ENGRAM_PORT_FILE), or
453
+ // else to a closed ephemeral port (refused). FIXTURE_NO_PROBE makes any probe fail the test.
344
454
  const network = join(base, "network.mjs");
345
- writeFileSync(network, `import net from 'node:net'; import { syncBuiltinESMExports } from 'node:module';
455
+ const portFile = join(base, "engram-port");
456
+ writeFileSync(network, `import net from 'node:net'; import { readFileSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
346
457
  const original = net.createConnection;
347
458
  const probe = net.createServer(); await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
348
459
  const closed = probe.address().port; await new Promise((resolve) => probe.close(resolve));
349
460
  net.createConnection = (options, ...rest) => {
350
461
  if (Number(options?.port ?? options) !== 7437) return original(options, ...rest);
351
462
  if (process.env.FIXTURE_NO_PROBE === '1') { process.stderr.write('unexpected Engram probe\\n'); process.exit(97); }
352
- return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || closed) }, ...rest);
463
+ const served = () => { try { return readFileSync(process.env.FIXTURE_ENGRAM_PORT_FILE, 'utf8'); } catch { return ''; } };
464
+ return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || served() || closed) }, ...rest);
353
465
  };
354
466
  syncBuiltinESMExports();`);
355
467
  const metadata = join(home, ".gentle-shell", "agent", "npm", "node_modules", "gentle-engram", "package.json");
356
468
  mkdirSync(dirname(metadata), { recursive: true });
357
469
  writeFileSync(metadata, JSON.stringify({ name: "gentle-engram", version: "0.4.2", engram: { startupPolicy: 1 } }));
358
470
  const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1",
359
- NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example" };
471
+ NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example",
472
+ FIXTURE_SERVE_LOG: serveLog, FIXTURE_ENGRAM_PORT_FILE: portFile };
360
473
  const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
361
474
  const run = (args: string[], extra: Record<string, string> = {}) => spawnSync(process.execPath, [entry, ...args], { env: { ...env, ...extra }, encoding: "utf8", input: "" });
362
- return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, entry: join(shell, "bin", "gentle-shell.mjs") };
475
+ return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, serves, portFile, entry: join(shell, "bin", "gentle-shell.mjs") };
363
476
  }
364
477
 
365
478
  const STORE_ID = "0123456789abcdef0123456789abcdef";
366
479
  const OTHER_STORE_ID = "fedcba9876543210fedcba9876543210";
480
+ const DEFAULT_URL = "http://127.0.0.1:7437";
367
481
  const lastLaunch = (result: { stdout: string }) => JSON.parse(result.stdout.trim().split("\n").at(-1)!);
368
- const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN];
482
+ const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN, lastLaunch(result).env.ENGRAM_URL];
369
483
  const provenancePath = (prefix: { root: string }) => join(prefix.root, "config", "engram-provenance.json");
370
484
  const sha256File = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex");
371
485
  function writeHeader(path: string, userVersion: number, magic = "SQLite format 3\0") {
@@ -390,164 +504,141 @@ async function engramServer(t: TestContext, routes: Record<string, [number, unkn
390
504
  return new Promise((resolve) => child.stdout.once("data", (chunk) => resolve(String(chunk).trim())));
391
505
  }
392
506
 
393
- test("a fresh launch provisions the store identity and finalizes provenance before Pi starts", { skip: posixHost }, (t) => {
507
+ // Whether the server a launch started still answers after the launcher exited.
508
+ async function stillServing(prefix: ReturnType<typeof bundledPrefix>): Promise<boolean> {
509
+ const response = await fetch(`http://127.0.0.1:${readFileSync(prefix.portFile, "utf8")}/compatibility`);
510
+ return response.ok && (await response.json()).status === "ready";
511
+ }
512
+
513
+ 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) => {
394
514
  const prefix = bundledPrefix(t);
395
- const provisioning = join(prefix.home, "provisioning-env");
396
- const result = prefix.run([], { FIXTURE_PROVISION_ENV: provisioning, GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token" });
515
+ const result = prefix.run([], { GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token", ENGRAM_HTTP_TOKEN: "fixture-http" });
397
516
  assert.equal(result.status, 0, result.stderr);
398
- const seen = lastLaunch(result);
399
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined]);
400
- // Already finalized while Pi runs, from the identity file the core wrote.
401
- const record = { schema: 1, dataDir: join(prefix.home, ".engram"), instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) };
402
- assert.deepEqual(seen.provenance, record);
403
- assert.deepEqual(JSON.parse(readFileSync(provenancePath(prefix), "utf8")), record);
404
- // Only the provisioning child skips Engram's update check and loses GitHub tokens; Pi keeps the user's environment.
405
- const child = Object.fromEntries(readFileSync(provisioning, "utf8").trim().split("\n").map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]));
406
- 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]);
407
- assert.deepEqual([seen.env.GH_TOKEN, seen.env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
517
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
518
+ assert.doesNotMatch(result.stderr, /memory disabled/);
519
+ const [serve, ...others] = prefix.serves();
520
+ assert.deepEqual(others, []);
521
+ // Engram's own defaults: no port, data directory or URL chosen for it.
522
+ assert.deepEqual([serve.args, serve.cwd, serve.env.HOME], [["serve"], prefix.home, prefix.home]);
523
+ // S8: no update check, no GitHub tokens and no inherited Engram settings; Pi keeps the user's environment.
524
+ 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],
525
+ ["1", undefined, undefined, undefined, undefined, undefined]);
526
+ assert.deepEqual([lastLaunch(result).env.GH_TOKEN, lastLaunch(result).env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
527
+ // Detached: it outlives the launcher and Pi, and the next launch reuses it without starting another.
528
+ assert.equal(await stillServing(prefix), true);
529
+ const second = prefix.run([]);
530
+ assert.equal(second.status, 0, second.stderr);
531
+ assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
532
+ assert.equal(prefix.serves().length, 1);
408
533
  });
409
534
 
410
- test("a session that never creates a database keeps Shell's identity; a foreign store with another identity is never adopted", { skip: posixHost }, (t) => {
535
+ test("a server that fails to start or never reports ready disables memory for that launch and Shell still starts Pi", { skip: posixHost }, (t) => {
411
536
  const prefix = bundledPrefix(t);
412
- assert.equal(prefix.run([]).status, 0);
413
- const data = join(prefix.home, ".engram");
414
- assert.deepEqual(readdirSync(data).sort(), [".instance-id"]);
415
- assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
416
- // Another Engram client replaces the shared directory with its own store.
417
- rmSync(data, { recursive: true });
418
- mkdirSync(data);
419
- writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
420
- writeHeader(join(data, "engram.db"), 1);
421
- const foreign = prefix.run([]);
422
- assert.deepEqual(memoryOf(foreign), ["disabled", prefix.guard]);
423
- assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
424
- });
425
- test("a later launch reuses Shell's own store, including a stale WAL header, without rewriting provenance", { skip: posixHost }, (t) => {
426
- const prefix = bundledPrefix(t);
427
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0);
428
- const recorded = readFileSync(provenancePath(prefix), "utf8");
429
- for (const userVersion of [0, 1]) {
430
- writeHeader(join(prefix.home, ".engram", "engram.db"), userVersion);
431
- const second = prefix.run([]);
432
- assert.equal(second.status, 0, second.stderr);
433
- const seen = lastLaunch(second);
434
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined], String(userVersion));
435
- }
436
- assert.equal(readFileSync(provenancePath(prefix), "utf8"), recorded);
437
- });
438
-
439
- test("provenance mismatches disable memory behind the guard: instance, core digest, newer or malformed header", { skip: posixHost }, (t) => {
440
- const mismatches: Array<[string, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
441
- ["instance", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), `${OTHER_STORE_ID}\n`)],
442
- ["invalid identity", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), "not-an-id\n")],
443
- ["core digest", (prefix) => writeFileSync(prefix.engram, "#!/bin/sh\necho upgraded engram\n", { mode: 0o700 })],
444
- ["newer schema", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 2)],
445
- ["malformed header", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 1, "not sqlite at all")],
446
- ["symlinked database", (prefix) => {
447
- const db = join(prefix.home, ".engram", "engram.db");
448
- renameSync(db, `${db}.real`);
449
- symlinkSync(`${db}.real`, db);
450
- }],
451
- ["corrupt provenance", (prefix) => writeFileSync(provenancePath(prefix), "{")],
452
- ];
453
- for (const [name, change] of mismatches) {
454
- const prefix = bundledPrefix(t);
455
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
456
- change(prefix);
457
- const result = prefix.run([]);
458
- assert.equal(result.status, 0, `${name}: ${result.stderr}`);
459
- const seen = lastLaunch(result);
460
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
461
- assert.match(result.stderr, name === "core digest" ? /memory disabled.*bundled core changed.*memory migrate --confirm-quiescent/s : /memory disabled.*could not verify.*compatibility.*Back up/s, name);
462
- }
537
+ const result = prefix.run([], { FIXTURE_SERVE: "exit" });
538
+ assert.equal(result.status, 0, result.stderr);
539
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
540
+ assert.match(result.stderr, /memory disabled.*could not start the shared Engram server/s);
541
+ assert.equal(lastLaunch(result).cli.endsWith(join("dist", "bundle", "cli.js")), true);
542
+ assert.equal(prefix.serves().length, 1);
463
543
  });
464
544
 
465
- function migrationFixture(t: TestContext, outcome = "success") {
545
+ test("concurrent starts converge: a failed bind followed by a ready /compatibility counts as started", { skip: posixHost }, (t) => {
466
546
  const prefix = bundledPrefix(t);
467
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0);
468
- const source = join(prefix.home, ".engram");
469
- writeFileSync(join(source, "config.json"), '{"preserved":true}\n');
470
- const originalRecord = readFileSync(provenancePath(prefix), "utf8");
471
- const sourceBytes = readFileSync(join(source, "engram.db"));
472
- const invocation = join(prefix.home, "migration-invoked");
473
- 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\n serve --session-owned Private ephemeral HTTP server")});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 });
474
- return { ...prefix, source, originalRecord, sourceBytes, invocation };
475
- }
547
+ const result = prefix.run([], { FIXTURE_SERVE: "race" });
548
+ assert.equal(result.status, 0, result.stderr);
549
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
550
+ assert.doesNotMatch(result.stderr, /memory disabled/);
551
+ assert.deepEqual(prefix.serves().map(({ mode }) => mode), ["race", "late"]);
552
+ });
476
553
 
477
- test("bundled local memory selects a verified session-owned provider instead of a detached shared server", { skip: posixHost }, (t) => {
554
+ 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) => {
478
555
  const prefix = bundledPrefix(t);
479
- const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
556
+ const result = prefix.run([]);
480
557
  assert.equal(result.status, 0, result.stderr);
481
- assert.deepEqual(memoryOf(result), ["allow", prefix.engram]);
482
- const seen = lastLaunch(result);
483
- assert.equal(seen.env.ENGRAM_SERVER_MODE, "session-owned");
484
- assert.equal(seen.env.ENGRAM_URL, undefined);
485
- assert.equal(seen.env.ENGRAM_SOCKET, undefined);
486
- assert.equal(seen.provenance.instanceId, STORE_ID);
558
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
559
+ assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, undefined);
560
+ assert.doesNotMatch(result.stderr, /session-owned/);
561
+ assert.deepEqual(JSON.parse(readFileSync(prefix.metadata, "utf8")).engram, { startupPolicy: 1 });
487
562
  });
488
563
 
489
- test("missing private capabilities never silently fall back or provision a store", { skip: posixHost }, (t) => {
490
- for (const env of [{ FIXTURE_OWNED_CORE: "0" }, { FIXTURE_OWNED_COMPANION: "0" }]) {
564
+ test("an explicit Engram server mode is a selection: kept, never probed or started, memory disabled", { skip: posixHost }, (t) => {
565
+ for (const mode of ["session-owned", "bogus"]) {
491
566
  const prefix = bundledPrefix(t);
492
- const result = prefix.run([], { ...env, FIXTURE_CREATE_STORE: STORE_ID });
567
+ const result = prefix.run([], { ENGRAM_SERVER_MODE: mode, FIXTURE_NO_PROBE: "1" });
493
568
  assert.equal(result.status, 0, result.stderr);
494
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
495
- assert.match(result.stderr, /session-owned.*supporting published/s);
496
- assert.equal(existsSync(join(prefix.home, ".engram")), false);
497
- assert.equal(existsSync(provenancePath(prefix)), false);
569
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined], mode);
570
+ assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, mode);
571
+ assert.deepEqual(prefix.serves(), [], mode);
498
572
  }
499
573
  });
500
574
 
501
- test("every selected companion must advertise private support, including mixed local and npm selections", { skip: posixHost }, (t) => {
575
+ test("a mixed local and npm companion selection is preserved and memory still uses the shared server", { skip: posixHost }, (t) => {
502
576
  const prefix = bundledPrefix(t);
503
577
  const local = join(prefix.home, "legacy-companion");
504
578
  mkdirSync(local);
505
579
  writeFileSync(join(local, "package.json"), JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1 } }));
506
- writeFileSync(prefix.metadata, JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1, sessionOwned: 1 } }));
507
580
  const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
508
581
  const selected = JSON.stringify({ packages: [local, "npm:gentle-engram@0.4.2"] });
509
582
  writeFileSync(settings, selected);
510
- const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
583
+ const result = prefix.run([]);
511
584
  assert.equal(result.status, 0, result.stderr);
512
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
513
- assert.match(result.stderr, /session-owned.*supporting published/s);
585
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL]);
514
586
  assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")).packages, JSON.parse(selected).packages);
515
- assert.equal(existsSync(join(prefix.home, ".engram")), false);
516
- assert.equal(existsSync(provenancePath(prefix)), false);
517
587
  });
518
588
 
519
- test("explicit empty or conflicting private modes remain invalid without provisioning", { skip: posixHost }, (t) => {
520
- for (const mode of ["", "shared", " "]) {
521
- const prefix = bundledPrefix(t);
522
- const result = prefix.run([], { ENGRAM_SERVER_MODE: mode, FIXTURE_CREATE_STORE: STORE_ID });
589
+ test("an existing store, including one the global CLI created, starts the shared server and Shell never touches it", { skip: posixHost }, (t) => {
590
+ const prefix = bundledPrefix(t);
591
+ const data = join(prefix.home, ".engram");
592
+ mkdirSync(data);
593
+ writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
594
+ for (const userVersion of [0, 1]) {
595
+ writeHeader(join(data, "engram.db"), userVersion);
596
+ const before = readFileSync(join(data, "engram.db"));
597
+ const result = prefix.run([]);
523
598
  assert.equal(result.status, 0, result.stderr);
524
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
525
- assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, mode);
526
- assert.match(result.stderr, /explicit ENGRAM_SERVER_MODE.*unset.*session-owned/s);
527
- assert.equal(existsSync(join(prefix.home, ".engram")), false);
528
- assert.equal(existsSync(provenancePath(prefix)), false);
599
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], String(userVersion));
600
+ assert.deepEqual(readFileSync(join(data, "engram.db")), before);
529
601
  }
602
+ assert.deepEqual(readdirSync(data).sort(), [".instance-id", "engram.db"]);
603
+ assert.equal(existsSync(provenancePath(prefix)), false);
530
604
  });
531
605
 
532
- test("an explicit Engram socket remains untouched and is never probed", { skip: posixHost }, (t) => {
606
+ test("a store a newer Engram migrated keeps the server stopped and memory disabled", { skip: posixHost }, (t) => {
533
607
  const prefix = bundledPrefix(t);
534
- const result = prefix.run([], { ENGRAM_SOCKET: "/fixture/selected.sock", FIXTURE_NO_PROBE: "1" });
608
+ const data = join(prefix.home, ".engram");
609
+ mkdirSync(data);
610
+ writeHeader(join(data, "engram.db"), 2);
611
+ const result = prefix.run([]);
535
612
  assert.equal(result.status, 0, result.stderr);
536
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
537
- assert.equal(lastLaunch(result).env.ENGRAM_SOCKET, "/fixture/selected.sock");
538
- assert.equal(existsSync(join(prefix.home, ".engram")), false);
613
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
614
+ assert.match(result.stderr, /memory disabled.*newer Engram/s);
615
+ assert.deepEqual(prefix.serves(), []);
539
616
  });
540
617
 
541
- test("a stopped Shell store with a changed core gets migration guidance, not a server upgrade guess", { skip: posixHost }, (t) => {
542
- const prefix = migrationFixture(t);
543
- const result = prefix.run([]);
618
+ function migrationFixture(t: TestContext, outcome = "success") {
619
+ const prefix = bundledPrefix(t);
620
+ // A store an earlier Shell provisioned and recorded with the bundled core.
621
+ const source = join(prefix.home, ".engram");
622
+ mkdirSync(source);
623
+ writeFileSync(join(source, ".instance-id"), `${STORE_ID}\n`);
624
+ writeHeader(join(source, "engram.db"), 1);
625
+ writeFileSync(join(source, "config.json"), '{"preserved":true}\n');
626
+ mkdirSync(dirname(provenancePath(prefix)), { recursive: true });
627
+ writeFileSync(provenancePath(prefix), `${JSON.stringify({ schema: 1, dataDir: source, instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) }, null, 2)}\n`);
628
+ const originalRecord = readFileSync(provenancePath(prefix), "utf8");
629
+ const sourceBytes = readFileSync(join(source, "engram.db"));
630
+ const invocation = join(prefix.home, "migration-invoked");
631
+ 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 });
632
+ return { ...prefix, source, originalRecord, sourceBytes, invocation };
633
+ }
634
+
635
+ test("an explicit Engram socket remains untouched and is never probed", { skip: posixHost }, (t) => {
636
+ const prefix = bundledPrefix(t);
637
+ const result = prefix.run([], { ENGRAM_SOCKET: "/fixture/selected.sock", FIXTURE_NO_PROBE: "1" });
544
638
  assert.equal(result.status, 0, result.stderr);
545
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
546
- assert.match(result.stderr, /bundled core changed/);
547
- assert.match(result.stderr, /gentle-shell memory migrate --confirm-quiescent/);
548
- assert.equal(existsSync(prefix.invocation), false);
549
- assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
550
- assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
639
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
640
+ assert.equal(lastLaunch(result).env.ENGRAM_SOCKET, "/fixture/selected.sock");
641
+ assert.deepEqual(prefix.serves(), []);
551
642
  });
552
643
 
553
644
  test("memory migrate explicitly promotes a verified copy and retains the original store", { skip: posixHost }, (t) => {
@@ -568,7 +659,6 @@ test("memory migrate explicitly promotes a verified copy and retains the origina
568
659
  assert.equal(invoked.token, undefined);
569
660
  assert.equal(invoked.cloud, undefined);
570
661
  assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
571
- assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
572
662
  });
573
663
 
574
664
  test("memory migrate rejects missing confirmation and malformed flags without invoking Engram", { skip: posixHost }, (t) => {
@@ -608,18 +698,17 @@ test("memory migrate refuses provider failure and mismatched evidence without ch
608
698
  }
609
699
  });
610
700
 
611
- test("an interrupted memory promotion blocks fresh provisioning until recovery", { skip: posixHost }, (t) => {
701
+ test("an interrupted memory promotion keeps the server stopped until recovery", { skip: posixHost }, (t) => {
612
702
  const prefix = bundledPrefix(t);
613
703
  mkdirSync(join(prefix.root, "config"), { recursive: true });
614
704
  const marker = join(prefix.root, "config", "engram-migration.json");
615
705
  writeFileSync(marker, '{"interrupted":true}\n');
616
706
  const result = prefix.run([], { FIXTURE_NO_PROBE: "1" });
617
707
  assert.equal(result.status, 0, result.stderr);
618
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
708
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
619
709
  assert.match(result.stderr, /incomplete memory migration/);
620
- assert.equal(existsSync(join(prefix.home, ".engram")), false);
710
+ assert.deepEqual(prefix.serves(), []);
621
711
  assert.equal(readFileSync(marker, "utf8"), '{"interrupted":true}\n');
622
- assert.equal(existsSync(provenancePath(prefix)), false);
623
712
  });
624
713
 
625
714
  test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
@@ -629,41 +718,21 @@ test("public help explains compatibility evidence without promising update recov
629
718
  assert.equal(result.stderr, "");
630
719
  assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
631
720
  assert.match(result.stdout, /\/compatibility/);
632
- assert.doesNotMatch(result.stdout, /update Engram, restart/);
721
+ assert.match(result.stdout, /shares the global Engram server.*nothing listens.*engram serve/s);
722
+ assert.doesNotMatch(result.stdout, /update Engram, restart|Shell-provisioned|session-owned/);
633
723
  });
634
724
 
635
- test("a foreign pre-existing store stays disabled and never receives provenance", { skip: posixHost }, (t) => {
725
+ test("a listener without the ready contract is never replaced, even reporting the bundled core version", { skip: posixHost }, async (t) => {
636
726
  const prefix = bundledPrefix(t);
637
- const data = join(prefix.home, ".engram");
638
- mkdirSync(data);
639
- writeFileSync(join(data, ".instance-id"), `${STORE_ID}\n`);
640
- writeHeader(join(data, "engram.db"), 1);
641
- const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
727
+ const port = await engramServer(t, { "/health": [200, { status: "ok", instance_id: STORE_ID, version: engramPinVersion }] });
728
+ const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
642
729
  assert.equal(result.status, 0, result.stderr);
643
- assert.deepEqual([lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN], ["disabled", prefix.guard]);
644
- assert.equal(existsSync(provenancePath(prefix)), false);
730
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
731
+ assert.match(result.stderr, /memory disabled.*could not verify/s);
732
+ assert.deepEqual(prefix.serves(), []);
645
733
  });
646
734
 
647
- 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) => {
648
- const cases: Array<[string, unknown, string]> = [
649
- ["match", { status: "ok", instance_id: STORE_ID, version: engramPinVersion }, "allow"],
650
- ["other instance", { status: "ok", instance_id: OTHER_STORE_ID, version: engramPinVersion }, "disabled"],
651
- ["other version", { status: "ok", instance_id: STORE_ID, version: "9.9.9" }, "disabled"],
652
- ];
653
- for (const [name, health, policy] of cases) {
654
- const prefix = bundledPrefix(t);
655
- assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
656
- const port = await engramServer(t, { "/health": [200, health] });
657
- const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
658
- assert.equal(result.status, 0, `${name}: ${result.stderr}`);
659
- const seen = lastLaunch(result);
660
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL],
661
- [policy, prefix.guard, policy === "allow" ? "http://127.0.0.1:7437" : undefined], name);
662
- assert.equal(seen.env.ENGRAM_SERVER_MODE, undefined, name);
663
- }
664
- });
665
-
666
- test("a verified /compatibility server is reused through an explicit URL and the guard, without provenance", { skip: posixHost }, async (t) => {
735
+ test("a verified /compatibility server is reused unchanged through an explicit URL and the guard", { skip: posixHost }, async (t) => {
667
736
  const ready = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
668
737
  instance_id: STORE_ID, runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
669
738
  const prefix = bundledPrefix(t);
@@ -675,11 +744,12 @@ test("a verified /compatibility server is reused through an explicit URL and the
675
744
  assert.equal(result.status, 0, result.stderr);
676
745
  const seen = lastLaunch(result);
677
746
  assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_URL, seen.env.ENGRAM_BIN], ["allow", "http://127.0.0.1:7437", prefix.guard]);
678
- assert.equal(existsSync(provenancePath(prefix)), false);
747
+ assert.deepEqual(prefix.serves(), []);
679
748
  const unready = bundledPrefix(t);
680
749
  mkdirSync(join(unready.home, ".engram"));
681
750
  const notReady = await engramServer(t, { "/compatibility": [200, { ...ready, status: "unknown", active_reuse_authorized: false }] });
682
751
  assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
752
+ assert.deepEqual(unready.serves(), []);
683
753
  });
684
754
 
685
755
  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) => {
@@ -696,34 +766,10 @@ test("a foreign store behind a 3.3.2 server stays guarded and the launch names t
696
766
  assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
697
767
  assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
698
768
  assert.deepEqual(readFileSync(join(data, "engram.db")), before);
699
- assert.equal(existsSync(provenancePath(prefix)), false);
700
- });
701
-
702
- test("a fresh launch whose launcher is killed during Pi already has finalized provenance, and the next launch allows memory", { skip: posixHost }, (t) => {
703
- const prefix = bundledPrefix(t);
704
- const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID, FIXTURE_KILL_LAUNCHER: "1" });
705
- assert.equal(first.signal, "SIGKILL");
706
- assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
707
- assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
708
- });
709
- test("a failed identity provisioning disables memory for that launch and records nothing", { skip: posixHost }, (t) => {
710
- const cases: Array<[string, Record<string, string>, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
711
- ["instance-id failure", { FIXTURE_INSTANCE_ID_FAIL: "1" }, () => {}],
712
- ["invalid identity file", { FIXTURE_INSTANCE_ID: "not-an-id" }, () => {}],
713
- ["provenance write failure", {}, (prefix) => mkdirSync(provenancePath(prefix), { recursive: true })],
714
- ];
715
- for (const [name, extra, prepare] of cases) {
716
- const prefix = bundledPrefix(t);
717
- prepare(prefix);
718
- const result = prefix.run([], extra);
719
- assert.equal(result.status, 0, `${name}: ${result.stderr}`);
720
- assert.deepEqual(memoryOf(result), ["disabled", prefix.guard], name);
721
- assert.match(result.stderr, /could not provision/, name);
722
- assert.equal(lastLaunch(result).provenance, null, name);
723
- }
769
+ assert.deepEqual(prefix.serves(), []);
724
770
  });
725
771
 
726
- test("--version and forwarded Pi version or help flags never probe Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
772
+ test("--version and forwarded Pi version or help flags never probe or start Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
727
773
  const prefix = bundledPrefix(t);
728
774
  writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
729
775
  mkdirSync(join(prefix.home, ".engram"));
@@ -737,7 +783,7 @@ test("--version and forwarded Pi version or help flags never probe Engram, insta
737
783
  assert.equal(readFileSync(settings, "utf8"), before, args.join(" "));
738
784
  assert.equal(JSON.parse(readFileSync(prefix.metadata, "utf8")).version, "0.4.1", args.join(" "));
739
785
  }
740
- assert.equal(existsSync(provenancePath(prefix)), false);
786
+ assert.equal(prefix.serves().length, 1);
741
787
  });
742
788
 
743
789
  test("-v or --help as a prompt or an option value never switches memory off", { skip: posixHost }, (t) => {
@@ -745,8 +791,8 @@ test("-v or --help as a prompt or an option value never switches memory off", {
745
791
  const prefix = bundledPrefix(t);
746
792
  const result = prefix.run(args);
747
793
  assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`);
748
- assert.deepEqual(memoryOf(result), ["allow", prefix.engram], args.join(" "));
749
- assert.equal(lastLaunch(result).provenance.instanceId, STORE_ID, args.join(" "));
794
+ assert.deepEqual(memoryOf(result), ["allow", prefix.guard, DEFAULT_URL], args.join(" "));
795
+ assert.equal(prefix.serves().length, 1, args.join(" "));
750
796
  }
751
797
  });
752
798
  test("a missing bundled core uses the guard, never an engram on PATH", { skip: posixHost }, (t) => {
@@ -771,20 +817,23 @@ function countingGentleAi(prefix: ReturnType<typeof bundledPrefix>) {
771
817
  return { bin, runs: () => (existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).length : 0) };
772
818
  }
773
819
 
774
- test("setup on a fresh machine publishes the bundled core; the next launch is fresh, records provenance and is reused", { skip: posixHost }, (t) => {
820
+ 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) => {
775
821
  const prefix = bundledPrefix(t, { core: false });
776
822
  const installer = countingGentleAi(prefix);
777
823
  const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin });
778
824
  assert.equal(setup.status, 0, setup.stderr);
779
825
  assert.equal(installer.runs(), 1);
780
826
  assert.equal(existsSync(prefix.engram), true);
781
- const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
827
+ // Setup itself never starts a server.
828
+ assert.deepEqual(prefix.serves(), []);
829
+ const first = prefix.run([]);
782
830
  assert.equal(first.status, 0, first.stderr);
783
- assert.deepEqual([lastLaunch(first).env.ENGRAM_STARTUP_POLICY, lastLaunch(first).env.ENGRAM_BIN], ["allow", prefix.engram]);
784
- assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
831
+ assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
785
832
  const second = prefix.run([]);
786
833
  assert.equal(second.status, 0, second.stderr);
787
- assert.deepEqual([lastLaunch(second).env.ENGRAM_STARTUP_POLICY, lastLaunch(second).env.ENGRAM_BIN], ["allow", prefix.engram]);
834
+ assert.deepEqual(memoryOf(second), ["allow", prefix.guard, DEFAULT_URL]);
835
+ assert.equal(prefix.serves().length, 1);
836
+ assert.equal(existsSync(provenancePath(prefix)), false);
788
837
  });
789
838
 
790
839
  test("an explicit ENGRAM_URL alone keeps the verified core, or the guard without it, as ENGRAM_BIN", { skip: posixHost }, (t) => {
@@ -808,12 +857,13 @@ test("a failed companion check after setup is a warning, not a setup failure", {
808
857
  assert.equal(setup.status, 0, setup.stderr);
809
858
  assert.equal(installer.runs(), 1);
810
859
  assert.match(setup.stderr, /startupPolicy/);
811
- assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["disabled", prefix.guard]);
860
+ // The guard, not the companion check, protects memory: the shared server is still reused.
861
+ assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["allow", prefix.guard, DEFAULT_URL]);
812
862
  assert.deepEqual(readdirSync(data), ["engram.db"]);
813
863
  assert.equal(readFileSync(join(data, "engram.db"), "utf8"), "untouched database sentinel");
814
864
  });
815
865
 
816
- test("setup with a foreign existing store runs the installer like the baseline, while memory stays behind the guard", { skip: posixHost }, (t) => {
866
+ 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) => {
817
867
  const prefix = bundledPrefix(t, { core: false });
818
868
  const data = join(prefix.home, ".engram");
819
869
  mkdirSync(data);
@@ -823,13 +873,11 @@ test("setup with a foreign existing store runs the installer like the baseline,
823
873
  assert.equal(setup.status, 0, setup.stderr);
824
874
  assert.equal(installer.runs(), 1);
825
875
  assert.equal(existsSync(prefix.engram), true);
826
- assert.match(setup.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s);
827
- assert.doesNotMatch(setup.stderr, /update Engram and restart/);
828
- assert.doesNotMatch(setup.stderr, /deferred/);
876
+ assert.doesNotMatch(setup.stderr, /memory disabled|deferred/);
829
877
  const guarded = prefix.run([]);
830
878
  assert.equal(guarded.status, 0, guarded.stderr);
831
879
  const env = lastLaunch(guarded).env;
832
- assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
880
+ assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["allow", prefix.guard, DEFAULT_URL]);
833
881
  // Subagents receive the same environment (GENTLE_PI_AGENTS_PI runs the same Pi with it).
834
882
  assert.equal(env.GENTLE_PI_AGENTS_PI.endsWith(join("dist", "bundle", "cli.js")), true);
835
883
  const provider = spawnSync(env.ENGRAM_BIN, ["instance-id"], { env: { ...prefix.env, ...env }, encoding: "utf8" });
@@ -844,15 +892,14 @@ test("setup with a foreign existing store runs the installer like the baseline,
844
892
  assert.equal(existsSync(provenancePath(prefix)), false);
845
893
  });
846
894
 
847
- test("a gentle-pi version change re-provisions Shell's own store once and keeps its memory", { skip: posixHost }, (t) => {
895
+ test("a gentle-pi version change reruns setup once and keeps memory on the shared server", { skip: posixHost }, (t) => {
848
896
  const prefix = bundledPrefix(t, { core: false });
849
897
  const installer = countingGentleAi(prefix);
850
898
  const auto = { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: installer.bin };
851
- const first = prefix.run([], { ...auto, FIXTURE_CREATE_STORE: STORE_ID });
899
+ const first = prefix.run([], auto);
852
900
  assert.equal(first.status, 0, first.stderr);
853
901
  assert.equal(installer.runs(), 1);
854
- assert.deepEqual([lastLaunch(first).env.ENGRAM_STARTUP_POLICY, lastLaunch(first).env.ENGRAM_BIN], ["allow", prefix.engram]);
855
- assert.equal(existsSync(provenancePath(prefix)), true);
902
+ assert.deepEqual(memoryOf(first), ["allow", prefix.guard, DEFAULT_URL]);
856
903
  assert.equal(prefix.run([], auto).status, 0);
857
904
  assert.equal(installer.runs(), 1);
858
905
  const manifest = join(dirname(dirname(prefix.entry)), "package.json");
@@ -861,7 +908,8 @@ test("a gentle-pi version change re-provisions Shell's own store once and keeps
861
908
  assert.equal(changed.status, 0, changed.stderr);
862
909
  assert.equal(installer.runs(), 2);
863
910
  assert.doesNotMatch(changed.stderr, /automatic setup failed|deferred/);
864
- assert.deepEqual([lastLaunch(changed).env.ENGRAM_STARTUP_POLICY, lastLaunch(changed).env.ENGRAM_BIN], ["allow", prefix.engram]);
911
+ assert.deepEqual(memoryOf(changed), ["allow", prefix.guard, DEFAULT_URL]);
912
+ assert.equal(prefix.serves().length, 1);
865
913
  assert.equal(prefix.run([], auto).status, 0);
866
914
  assert.equal(installer.runs(), 2);
867
915
  });
@@ -882,9 +930,7 @@ test("capability readback also requires Pi to declare the selected pinned compan
882
930
  const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "metadata-only" });
883
931
  assert.equal(result.status, 0, result.stderr);
884
932
  assert.match(result.stderr, /startupPolicy/);
885
- // Unverified companion capability cannot authorize even fresh provisioning.
886
- assert.equal(existsSync(join(prefix.home, ".engram")), false);
887
- assert.equal(existsSync(provenancePath(prefix)), false);
933
+ assert.equal(lastLaunch(result).env.ENGRAM_BIN, prefix.guard);
888
934
  });
889
935
 
890
936
  test("pinning preserves resource filters and unrelated settings and does not reinstall next launch", { skip: posixHost }, (t) => {
@@ -900,16 +946,19 @@ test("pinning preserves resource filters and unrelated settings and does not rei
900
946
  assert.doesNotMatch(prefix.run([]).stderr, /installing policy-capable companion/);
901
947
  });
902
948
 
903
- test("final startup rechecks data created during automatic setup and records no provenance for it", { skip: posixHost }, (t) => {
949
+ test("final startup rechecks a store a newer Engram migrated during automatic setup and never starts a server on it", { skip: posixHost }, (t) => {
904
950
  const prefix = bundledPrefix(t);
905
951
  const fake = join(prefix.userBin, "gentle-ai");
906
- 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 });
952
+ const db = join(prefix.home, ".engram", "engram.db");
953
+ writeFileSync(fake, `#!${process.execPath}\nconst { mkdirSync, writeFileSync } = require('node:fs');\nmkdirSync(${JSON.stringify(dirname(db))}, { recursive: true });\n` +
954
+ `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` +
955
+ `writeFileSync(${JSON.stringify(db)}, header);\n`, { mode: 0o755 });
907
956
  const result = prefix.run([], { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: fake });
908
957
  assert.equal(result.status, 0, result.stderr);
909
- const seen = lastLaunch(result);
910
- assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard]);
911
- assert.equal(readFileSync(join(prefix.home, ".engram", "engram.db"), "utf8"), "fixture");
912
- assert.equal(existsSync(provenancePath(prefix)), false);
958
+ assert.equal(existsSync(db), true);
959
+ assert.deepEqual(memoryOf(result), ["disabled", prefix.guard, undefined]);
960
+ assert.match(result.stderr, /memory disabled.*newer Engram/s);
961
+ assert.deepEqual(prefix.serves(), []);
913
962
  });
914
963
 
915
964
  test("explicit endpoints and invalid policies never trigger availability probes or override executables", { skip: posixHost }, (t) => {
@@ -923,6 +972,7 @@ test("explicit endpoints and invalid policies never trigger availability probes
923
972
  const policyOnly = prefix.run([], { FIXTURE_NO_PROBE: "1", ENGRAM_STARTUP_POLICY: "invalid" });
924
973
  assert.equal(policyOnly.status, 0, policyOnly.stderr);
925
974
  assert.equal(lastLaunch(policyOnly).env.ENGRAM_BIN, prefix.guard);
975
+ assert.deepEqual(prefix.serves(), []);
926
976
  });
927
977
 
928
978
  test("startup offer precedes Pi, not-now preserves config and omit suppresses just that release", { skip: posixHost }, (t) => {
@@ -939,15 +989,36 @@ test("startup offer precedes Pi, not-now preserves config and omit suppresses ju
939
989
  env: { ...prefix.env, NODE_OPTIONS: `${prefix.env.NODE_OPTIONS} --import=data:text/javascript,${encodeURIComponent(preload(latest))}` },
940
990
  encoding: "utf8", input, timeout: 10000,
941
991
  });
992
+ // A recorded lookup answers the next launch without the network, so these
993
+ // runs simulate later days by aging the cache back past the TTL; the live
994
+ // lookup then records its own answer again.
995
+ const ageCache = () => {
996
+ const parsed = JSON.parse(readFileSync(config, "utf8"));
997
+ const latest = typeof parsed.startupReleaseCheck?.latest === "string" ? parsed.startupReleaseCheck.latest : "999.0.0";
998
+ parsed.startupReleaseCheck = { checkedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), latest };
999
+ writeFileSync(config, `${JSON.stringify(parsed, null, 2)}\n`);
1000
+ };
942
1001
  const skipped = run("999.0.0", "2\r");
943
1002
  assert.equal(skipped.status, 0, skipped.stderr);
944
1003
  assert.match(skipped.stdout, /Gentle Shell update available/);
945
1004
  assert.ok(skipped.stdout.indexOf("Gentle Shell update available") < skipped.stdout.indexOf('"cli":'));
946
- assert.equal(readFileSync(config, "utf8"), '{"other":"preserved"}\n');
1005
+ // A not-now choice still records no decision, but the live lookup it paid
1006
+ // for lands in the config as the cache tomorrow's launch reuses.
1007
+ const afterSkipped = JSON.parse(readFileSync(config, "utf8"));
1008
+ assert.equal(afterSkipped.other, "preserved");
1009
+ assert.equal(afterSkipped.skippedStartupRelease, undefined);
1010
+ assert.equal(afterSkipped.startupReleaseCheck.latest, "999.0.0");
1011
+ assert.ok(!Number.isNaN(Date.parse(afterSkipped.startupReleaseCheck.checkedAt)));
1012
+ ageCache();
947
1013
  const omitted = run("999.0.0", "3\r");
948
1014
  assert.equal(omitted.status, 0, omitted.stderr);
949
- assert.deepEqual(JSON.parse(readFileSync(config, "utf8")), { other: "preserved", skippedStartupRelease: "999.0.0" });
1015
+ const afterOmit = JSON.parse(readFileSync(config, "utf8"));
1016
+ assert.equal(afterOmit.other, "preserved");
1017
+ assert.equal(afterOmit.skippedStartupRelease, "999.0.0");
1018
+ assert.equal(afterOmit.startupReleaseCheck.latest, "999.0.0");
1019
+ ageCache();
950
1020
  assert.doesNotMatch(run("999.0.0", "").stdout, /Gentle Shell update available/);
1021
+ ageCache();
951
1022
  assert.match(run("999.0.1", "2\r").stdout, /Gentle Shell update available/);
952
1023
  const command = run("999.0.1", "", ["list"]);
953
1024
  assert.equal(command.status, 0, command.stderr);
@@ -973,11 +1044,13 @@ test("the bundled launcher runs only its version's Pi, with the user's environme
973
1044
  assert.equal(seen.env.NODE_OPTIONS, prefix.env.NODE_OPTIONS);
974
1045
  assert.equal(seen.env.npm_config_registry, "https://npm.example");
975
1046
  assert.equal(seen.env.npm_config_prefix, undefined);
976
- // S9: gentle-pi's config folder inside the prefix, the pinned Engram for
977
- // gentle-engram, and none of Gentle AI's own variables.
1047
+ // S9: gentle-pi's config folder inside the prefix, the shared server's URL
1048
+ // behind the guard for gentle-engram, and none of Gentle AI's own variables.
1049
+ // A Pi subcommand never starts the server.
978
1050
  assert.equal(seen.env.GENTLE_SHELL_PREFIX, prefix.root);
979
1051
  assert.equal(seen.env.GENTLE_PI_CONFIG_HOME, undefined);
980
- assert.equal(seen.env.ENGRAM_BIN, prefix.engram);
1052
+ assert.deepEqual([seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], [prefix.guard, DEFAULT_URL]);
1053
+ assert.deepEqual(prefix.serves(), []);
981
1054
  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);
982
1055
  // Pi's own npm is ours, configured only from the prefix, through the isolated home's settings.
983
1056
  const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8"));