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
@@ -28,7 +28,6 @@ import {
28
28
  import { constants as osConstants, homedir, tmpdir } from "node:os";
29
29
  import { delimiter, dirname, join, resolve as resolvePath } from "node:path";
30
30
  import { spawn, spawnSync } from "node:child_process";
31
- import { createHash } from "node:crypto";
32
31
  import { fileURLToPath } from "node:url";
33
32
  import {
34
33
  buildPiInvocation,
@@ -67,24 +66,18 @@ import {
67
66
  import { formatImportPiReport, importPi } from "../runtime/gentle-shell-import-pi.mjs";
68
67
  import {
69
68
  BUNDLED_VERIFY_ENV,
70
- bundledEngramSchema,
71
69
  classifyEngramStartup,
72
70
  ENGRAM_COMPANION_SOURCE,
73
- ENGRAM_CORE_CHANGED_NOTICE,
74
- ENGRAM_PRIVATE_REQUIRED_NOTICE,
75
- ENGRAM_PROVISION_FAILED_NOTICE,
71
+ ENGRAM_DEFAULT_URL,
76
72
  ENGRAM_RECOVERY_NOTICE,
77
- engramHeaderAllows,
73
+ ENGRAM_START_FAILED_NOTICE,
78
74
  GENTLE_SHELL_CONFIG_HOME_ENV,
79
75
  engramProbeAllowed,
80
- engramProvenanceMatches,
81
- engramProvenancePath,
82
- parseEngramProvenance,
76
+ sharedEngramEnv,
83
77
  piVersionOrHelpRequested,
84
78
  probeEngramEndpoint,
85
79
  sqliteHeaderUserVersion,
86
80
  supportsEngramStartupPolicy,
87
- supportsEngramSessionOwned,
88
81
  bundledChildEnv,
89
82
  bundledPi,
90
83
  bundledSetupEnv,
@@ -95,6 +88,7 @@ import {
95
88
  parseBundledUpgradeArgs,
96
89
  resolveBundledInstall,
97
90
  withBundledNpmCommand,
91
+ withoutMissingBundledNpmCommand,
98
92
  } from "../runtime/gentle-shell-bundled.mjs";
99
93
  import {
100
94
  parseResumeHandoff,
@@ -106,8 +100,8 @@ import {
106
100
  import { bundledGentleAiPaths, GENTLE_AI_VERSION, gentleAiBinaryPath, gentleAiIsolationEnv, PackageLocalGentleAiBinaryMissingError } from "../runtime/gentle-ai-binary.mjs";
107
101
  import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";
108
102
  import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs";
109
- import { migrateBundledEngram, migrationPending, supportsBundledSessionOwned, MIGRATION_PENDING_NOTICE, MIGRATION_USAGE } from "../scripts/bundled-engram-migration.mjs";
110
- import { shouldCheckStartupUpdate, findStartupUpdate } from "../runtime/gentle-shell-startup-update.mjs";
103
+ import { migrateBundledEngram, migrationPending, MIGRATION_PENDING_NOTICE, MIGRATION_USAGE } from "../scripts/bundled-engram-migration.mjs";
104
+ import { findStartupUpdate, parseStartupReleaseCache, shouldCheckStartupUpdate, writeStartupReleaseCache } from "../runtime/gentle-shell-startup-update.mjs";
111
105
  import { promptStartupUpdate } from "../runtime/gentle-shell-update-panel.mjs";
112
106
  import { applyStartupChoice, omitStartupVersion, resolveStartupRelaunch } from "../runtime/gentle-shell-startup-flow.mjs";
113
107
 
@@ -204,19 +198,6 @@ function verifiedBundledCore() {
204
198
  return undefined;
205
199
  }
206
200
 
207
- function sha256File(path) {
208
- return createHash("sha256").update(readFileSync(path)).digest("hex");
209
- }
210
-
211
- function readSmallRegularFile(path, limit) {
212
- try {
213
- const info = lstatSync(path);
214
- return info.isFile() && info.size <= limit ? readFileSync(path, "utf8") : undefined;
215
- } catch {
216
- return undefined;
217
- }
218
- }
219
-
220
201
  // The database's raw SQLite header, read as a plain file (never through
221
202
  // SQLite): a regular, unlinked engram.db whose opened identity is the one seen.
222
203
  function readSqliteUserVersion(path) {
@@ -236,112 +217,49 @@ function readSqliteUserVersion(path) {
236
217
  }
237
218
  }
238
219
 
239
- // The default data directory without opening it: presence, canonical path,
240
- // the .instance-id file and the database header. A link is never followed.
241
- function observeEngramStore() {
242
- const dir = join(homedir(), ".engram");
243
- let dataDir;
244
- try {
245
- if (!lstatSync(dir).isDirectory()) return { store: "unknown" };
246
- dataDir = realpathSync(dir);
247
- } catch (error) {
248
- return { store: error.code === "ENOENT" ? "absent" : "unknown" };
249
- }
250
- return { store: "present", dir, dataDir, instanceId: readSmallRegularFile(join(dir, ".instance-id"), 128)?.trim() };
251
- }
252
-
253
- // Only the identity files Engram's `instance-id` writes: a store Shell
254
- // provisioned whose session has not created its database yet.
255
- function identityOnlyStore(dir) {
256
- try {
257
- return readdirSync(dir).every((name) => name === ".instance-id" || name === ".instance-id.lock" || name.startsWith(".instance-id-"));
258
- } catch {
259
- return false;
260
- }
261
- }
262
-
263
- // Shell's own store: the record Shell wrote when it provisioned this exact
264
- // directory's identity, the same core digest, and a database header no newer
265
- // than the bundled schema (or no database yet).
266
- function ownEngramStore(observed, core) {
267
- let record;
268
- try {
269
- record = parseEngramProvenance(readJsonIfExists(engramProvenancePath(bundledInstall.root, process.platform)));
270
- } catch {
271
- return undefined;
272
- }
273
- if (record === undefined || observed.store !== "present" || core === undefined) return undefined;
274
- if (!engramProvenanceMatches(record, { dataDir: observed.dataDir, instanceId: observed.instanceId, coreSha256: sha256File(core) })) return undefined;
275
- const headerAllows = engramHeaderAllows(readSqliteUserVersion(join(observed.dir, "engram.db")), bundledEngramSchema(engramPinVersion));
276
- return headerAllows || identityOnlyStore(observed.dir) ? { instanceId: record.instanceId } : undefined;
277
- }
278
-
279
- // Evidence only: file metadata, the raw header and a bounded read-only probe
280
- // of the default endpoint. No Engram executable, SQLite, identity repair, or
281
- // contact with an explicitly selected endpoint.
220
+ // Evidence only: the default store's raw header and a bounded read-only probe
221
+ // of the default endpoint. No Engram executable, SQLite, or contact with an
222
+ // explicitly selected endpoint. A `spawn` decision starts nothing here.
282
223
  async function classifyBundledMemory() {
283
224
  if (migrationPending(bundledInstall.root)) return { policy: "disabled", notice: MIGRATION_PENDING_NOTICE };
284
- const core = verifiedBundledCore();
285
- const decision = { env: process.env, core: core !== undefined, coreVersion: engramPinVersion };
286
- if (!engramProbeAllowed(process.env)) return classifyEngramStartup({ ...decision, store: "unknown", endpoint: { kind: "unknown" } });
287
- const observed = observeEngramStore();
225
+ const decision = { env: process.env, core: verifiedBundledCore() !== undefined, coreVersion: engramPinVersion };
226
+ if (!engramProbeAllowed(process.env)) return classifyEngramStartup({ ...decision, endpoint: { kind: "unknown" } });
288
227
  const endpoint = await probeEngramEndpoint();
289
- const result = classifyEngramStartup({ ...decision, store: observed.store, endpoint, provenance: ownEngramStore(observed, core) });
290
- if (result.policy === "disabled" && endpoint.kind === "refused" && observed.store === "present" && core) {
291
- // Wording only: an unreadable record or core keeps the generic notice and
292
- // must never abort startup.
293
- try {
294
- const record = parseEngramProvenance(readSmallRegularFile(engramProvenancePath(bundledInstall.root, process.platform), 4096));
295
- if (record?.dataDir === observed.dataDir && record.instanceId === observed.instanceId && record.coreSha256 !== sha256File(core)) {
296
- return { ...result, notice: ENGRAM_CORE_CHANGED_NOTICE };
297
- }
298
- } catch { /* Keep the generic disabled notice. */ }
299
- }
300
- return result;
301
- }
302
-
303
- // Verified with the published Engram v3.4.0 darwin-arm64 binary in an isolated
304
- // HOME: `instance-id` creates only `.instance-id.lock` and `.instance-id`,
305
- // without a database. Keep update checks suppressed and GitHub tokens removed
306
- // for this provisioning invocation. The timeout stays below the identity lock
307
- // wait: a held lock means a concurrent creator, so memory stays off that launch.
308
- const ENGRAM_PROVISION_TIMEOUT_MS = 15000;
309
-
310
- // A fresh launch's store identity, provisioned by Shell itself with the verified
311
- // core before Pi starts, then recorded. False when any step fails.
312
- function provisionEngramStore() {
228
+ const userVersion = endpoint.kind === "refused" ? readSqliteUserVersion(join(homedir(), ".engram", "engram.db")) : undefined;
229
+ return classifyEngramStartup({ ...decision, endpoint, userVersion });
230
+ }
231
+
232
+ // Like other agents' hooks, Shell starts the shared server when nothing
233
+ // listens: the verified core's plain `serve` on Engram's own default port and
234
+ // data directory, detached so it outlives Pi. Its environment drops the user's
235
+ // Engram settings and GitHub tokens and skips the update check. Ready means the
236
+ // same /compatibility contract any reused server must answer, so a concurrent
237
+ // launch that wins the bind converges too: after the child exits, polling
238
+ // continues for a short grace within the overall bound.
239
+ const ENGRAM_START_TIMEOUT_MS = 10000;
240
+ const ENGRAM_START_EXIT_GRACE_MS = 2000;
241
+
242
+ async function startSharedEngram() {
243
+ const failed = { policy: "disabled", notice: ENGRAM_START_FAILED_NOTICE };
313
244
  const core = verifiedBundledCore();
314
- if (core === undefined || observeEngramStore().store !== "absent") return false;
315
- let target;
245
+ if (core === undefined) return failed;
246
+ let deadline = Date.now() + ENGRAM_START_TIMEOUT_MS;
247
+ const exited = () => { deadline = Math.min(deadline, Date.now() + ENGRAM_START_EXIT_GRACE_MS); };
316
248
  try {
317
- target = join(realpathSync(homedir()), ".engram");
249
+ const env = sharedEngramEnv(process.env);
250
+ const child = spawn(core, ["serve"], { cwd: homedir(), env, detached: true, stdio: "ignore", windowsHide: true });
251
+ child.once("error", exited);
252
+ child.once("exit", exited);
253
+ child.unref();
318
254
  } catch {
319
- return false;
255
+ return failed;
320
256
  }
321
- // The provider's own environment, except for this one invocation: no update
322
- // check and no GitHub tokens. ENGRAM_DATA_DIR is never set here.
323
- const env = { ...process.env, ENGRAM_NO_UPDATE_CHECK: "1" };
324
- delete env.GH_TOKEN;
325
- delete env.GITHUB_TOKEN;
326
- const result = spawnSync(core, ["instance-id"], { env, encoding: "utf8", timeout: ENGRAM_PROVISION_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
327
- const observed = observeEngramStore();
328
- if (result.status !== 0 || observed.store !== "present" || observed.dataDir !== target || !/^[a-f0-9]{32}$/.test(observed.instanceId ?? "") ||
329
- String(result.stdout).trim() !== observed.instanceId) return false;
330
- return writeEngramProvenance({ schema: 1, dataDir: target, instanceId: observed.instanceId, coreSha256: sha256File(core) });
331
- }
332
-
333
- function writeEngramProvenance(record) {
334
- try {
335
- const path = engramProvenancePath(bundledInstall.root, process.platform);
336
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
337
- const temporary = `${path}.${process.pid}.tmp`;
338
- writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" });
339
- renameSync(temporary, path);
340
- return true;
341
- } catch (error) {
342
- process.stderr.write(`gentle-shell: could not record Engram provenance (${error.message})\n`);
343
- return false;
257
+ while (Date.now() < deadline) {
258
+ const endpoint = await probeEngramEndpoint({ timeoutMs: 500 });
259
+ if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
260
+ await new Promise((resolve) => setTimeout(resolve, 100));
344
261
  }
262
+ return failed;
345
263
  }
346
264
 
347
265
  // Use Pi's supported configuration-only install command, never an alternate
@@ -357,7 +275,6 @@ async function ensureEngramCompanion(home, runtime, stdio, timeoutMs) {
357
275
  if (!Array.isArray(packages)) return { ok: false, exitCode: 1, message: "gentle-shell: cannot verify Engram startupPolicy from non-array packages" };
358
276
  let pinned = false;
359
277
  let local = false;
360
- let localOwned = true;
361
278
  let npmSelected = false;
362
279
  for (const entry of packages) {
363
280
  const source = typeof entry === "string" ? entry : entry?.source;
@@ -371,10 +288,9 @@ async function ensureEngramCompanion(home, runtime, stdio, timeoutMs) {
371
288
  return { ok: false, exitCode: 1, message: `gentle-shell: selected companion ${source} lacks engram.startupPolicy=1; update it and recheck (selection preserved)` };
372
289
  }
373
290
  local = true;
374
- localOwned &&= supportsEngramSessionOwned(readJsonIfExists(join(selected, "package.json")));
375
291
  }
376
- if (local && !npmSelected) return { ok: true, sessionOwned: localOwned };
377
- if (pinned && supportsEngramStartupPolicy(readJsonIfExists(metadata))) return { ok: true, sessionOwned: localOwned && supportsEngramSessionOwned(readJsonIfExists(metadata)) };
292
+ if (local && !npmSelected) return { ok: true };
293
+ if (pinned && supportsEngramStartupPolicy(readJsonIfExists(metadata))) return { ok: true };
378
294
  process.stderr.write(`gentle-shell: installing policy-capable companion ${ENGRAM_COMPANION_SOURCE} through Pi\n`);
379
295
  const result = await spawnAndWait(runtime.command, [...runtime.args, "install", ENGRAM_COMPANION_SOURCE], buildSetupEnv(home, runtime), stdio, timeoutMs);
380
296
  let declared = false;
@@ -385,7 +301,7 @@ async function ensureEngramCompanion(home, runtime, stdio, timeoutMs) {
385
301
  if (!result.ok || result.timedOut || !declared || !supportsEngramStartupPolicy(readJsonIfExists(metadata))) {
386
302
  return { ok: false, exitCode: 1, message: "gentle-shell: companion installation did not establish engram.startupPolicy=1; the Engram guard still keeps unverified stores closed. Repair gentle-engram, then restart Shell." };
387
303
  }
388
- return { ok: true, sessionOwned: localOwned && supportsEngramSessionOwned(readJsonIfExists(metadata)) };
304
+ return { ok: true };
389
305
  }
390
306
 
391
307
  // The bundled children's environment (bundledChildEnv), with the pinned Engram
@@ -1210,7 +1126,7 @@ async function runSetupFlow(home, runtime, { dryRun, stdio, timeoutMs }) {
1210
1126
  if (!engram.ok) return engram;
1211
1127
  }
1212
1128
  // The memory decision never skips setup: it only selects the Engram
1213
- // environment (core or guard, policy, ENGRAM_URL) setup's children and the
1129
+ // environment (guard, policy, ENGRAM_URL) setup's children and the
1214
1130
  // launched Pi get. Gentle AI's `install --agent pi` and `pi-engram init` open
1215
1131
  // no store. Reclassified now that the verified core may just be published.
1216
1132
  if (bundledInstall) {
@@ -1688,7 +1604,9 @@ async function packageManagerAdapters() {
1688
1604
  fs: await import("node:fs/promises"),
1689
1605
  which: async (name) => findOnPath(name) ?? null,
1690
1606
  ...(invocation ? { invocation } : {}),
1691
- run: (command, argv, options = {}) => run(command, argv, { env: options.env ?? process.env, cwd: options.cwd, deadlineMs: options.deadlineMs ?? 20 * 60_000 }),
1607
+ // stderrTail: a caller that asks for it, such as a failed `update` install, gets the bounded stderr.
1608
+ run: (command, argv, options = {}) => run(command, argv, { env: options.env ?? process.env, cwd: options.cwd, deadlineMs: options.deadlineMs ?? 20 * 60_000,
1609
+ ...(options.stderrTail === undefined ? {} : { stderrTail: options.stderrTail }) }),
1692
1610
  };
1693
1611
  }
1694
1612
 
@@ -1766,6 +1684,32 @@ async function handleBundledUpgrade(bundled, commandArgs) {
1766
1684
  }
1767
1685
  }
1768
1686
 
1687
+ // `gentle-shell update --latest` in a bundled install: the newest published
1688
+ // Pi installed with this Gentle Shell beside the active version, without the
1689
+ // release's verified lockfile (after a warning), verified, then `current`
1690
+ // switched. Returns the exit code like handleBundledUpgrade; a failure leaves
1691
+ // `current` as it was.
1692
+ async function handleBundledPiLatest(bundled) {
1693
+ const { prefixLayout, publishedPi, upgradePiLatest } = await import("../scripts/bundled-install.mjs");
1694
+ const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root });
1695
+ try {
1696
+ await upgradePiLatest({
1697
+ layout,
1698
+ home: homedir(),
1699
+ shell: ownPackageVersion(),
1700
+ platform: process.platform,
1701
+ arch: process.arch,
1702
+ env: process.env,
1703
+ adapters: { latestPi: () => publishedPi(layout, { platform: process.platform, env: process.env }) },
1704
+ out: (line) => process.stdout.write(`${line}\n`),
1705
+ });
1706
+ return 0;
1707
+ } catch (error) {
1708
+ process.stderr.write(`gentle-shell update: ${error.message}${error.stderr ? `\n${error.stderr}` : ""}\n`);
1709
+ return 1;
1710
+ }
1711
+ }
1712
+
1769
1713
  // The bundled npm as the `npmCommand` of a home Gentle Shell owns (the isolated
1770
1714
  // home, or a --home it created or provisioned; never --link or a foreign home),
1771
1715
  // so Pi installs packages with our npm while its tools keep the user's PATH.
@@ -1787,6 +1731,26 @@ async function ensureBundledNpmCommand(bundled, home, homeHadContentBeforeBootst
1787
1731
  }
1788
1732
  }
1789
1733
 
1734
+ // The npmCommand a bundled install wrote into a home it owns must not outlive
1735
+ // it (#2469): Pi runs it for every package install, so a key naming a deleted
1736
+ // node.exe fails them all with ENOENT. A main-channel bootstrap drops the key
1737
+ // when it has the shape a bundled install writes and its node is gone; a
1738
+ // command the user set is never touched, and neither are an existing
1739
+ // executable, a link home and a foreign home.
1740
+ async function removeMissingNpmCommand(home, homeHadContentBeforeBootstrap) {
1741
+ if (home.mode === "link") return;
1742
+ const previous = provisionedEntry(readRawConfig(resolveConfigPath()), safeRealpath(home.dir));
1743
+ if (homeIsForeign(home, previous, homeHadContentBeforeBootstrap) || !isDirectory(home.dir)) return;
1744
+ const settingsPath = join(home.dir, "settings.json");
1745
+ const edit = withoutMissingBundledNpmCommand(readJsonIfExists(settingsPath), (path) => existsSync(path));
1746
+ if (edit.status === "malformed") {
1747
+ process.stderr.write(`gentle-shell: ${settingsPath} is not a JSON object, so a stale npmCommand in it is not repaired\n`);
1748
+ } else if (edit.status === "removed") {
1749
+ writeFileAtomically(settingsPath, edit.text);
1750
+ process.stderr.write(`gentle-shell: removed the bundled npmCommand naming a missing executable from ${settingsPath}; Pi installs packages with the npm on your PATH\n`);
1751
+ }
1752
+ }
1753
+
1790
1754
  // `gentle-shell self-uninstall` in a bundled install (S6, S7): the prefix's own
1791
1755
  // folders and exactly the PATH entry its receipt records; on Windows the running
1792
1756
  // node.exe and launcher are removed by a detached process after this one exits.
@@ -1844,11 +1808,27 @@ async function startupUpdate(bundled, args, startupEnv) {
1844
1808
  if (!shouldCheckStartupUpdate({ args, env: startupEnv, bundled: Boolean(bundled),
1845
1809
  inputTTY: process.stdin.isTTY === true, outputTTY: process.stdout.isTTY === true })) return undefined;
1846
1810
  const configPath = resolveConfigPath();
1847
- let skipped;
1848
- try { skipped = readRawConfig(configPath).skippedStartupRelease; } catch { /* A notice must not prevent opening. */ }
1811
+ let skipped, cache;
1812
+ try {
1813
+ const raw = readRawConfig(configPath);
1814
+ skipped = raw.skippedStartupRelease;
1815
+ // The last recorded lookup answers today's check without the network; a
1816
+ // stale or malformed record just means this launch looks again.
1817
+ cache = parseStartupReleaseCache(raw.startupReleaseCheck);
1818
+ } catch { /* A notice must not prevent opening. */ }
1849
1819
  const { latestRelease } = await import("../scripts/main-channel.mjs");
1850
- const offer = await findStartupUpdate({ current: ownPackageVersion(), skipped,
1851
- lookup: (signal) => latestRelease((url, options) => fetch(url, { ...options, signal })) });
1820
+ const offer = await findStartupUpdate({ current: ownPackageVersion(), skipped, cache,
1821
+ lookup: (signal) => latestRelease((url, options) => fetch(url, { ...options, signal })),
1822
+ // Only a live lookup reaches this: persisting it keeps the next launches
1823
+ // off the network. A failed write is retried by tomorrow's stale cache.
1824
+ cacheWrite: (latest) => {
1825
+ try {
1826
+ writeStartupReleaseCache(() => readJsonIfExists(configPath), (text) => {
1827
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
1828
+ writeFileAtomically(configPath, text, { mode: 0o600 });
1829
+ }, latest);
1830
+ } catch { /* A notice must not prevent opening. */ }
1831
+ }, });
1852
1832
  if (!offer) return undefined;
1853
1833
  let choice;
1854
1834
  try { choice = await promptStartupUpdate(offer, { color: process.stdout.hasColors?.() === true }); }
@@ -1886,7 +1866,7 @@ async function main() {
1886
1866
  if (args.error !== undefined) fail(`${args.error}\nRun 'gentle-shell --help' for usage.`, 2);
1887
1867
  if (args.help) {
1888
1868
  process.stdout.write("Pi updates: update reinstalls the verified supplier of the Pi this launcher runs (a global Pi, or the\nShell package whose adjacent peer it is, at the same Shell version) and reads the resulting Pi version back.\nUnknown ownership refuses without installing; update --extensions-only stays Pi-only.\nPrefix-bundled Pi updates with gentle-shell upgrade.\n");
1889
- process.stdout.write(`${helpText()}\nBundled Engram safety: memory opens only a fresh, verified or Shell-provisioned store; otherwise ENGRAM_BIN\nis Shell's guard and memory stays off. Matching versions or updating alone do not prove compatibility.\nReuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity.\nBack up existing data before manual changes; do not delete it or bypass the safety check.\nShell rechecks on every launch and remains usable without memory.\nAn explicit ENGRAM_BIN or ENGRAM_URL is kept and bypasses the guard.\n`);
1869
+ process.stdout.write(`${helpText()}\nBundled Engram memory: Shell shares the global Engram server on 127.0.0.1:7437, like other agents' hooks.\nA running server is reused only with verified /compatibility evidence; when nothing listens, Shell starts the\nbundled core's engram serve (default ~/.engram) and waits for that evidence. Otherwise ENGRAM_BIN is Shell's\nguard and memory stays off for that launch. Matching versions or updating alone do not prove compatibility.\nBack up existing data before manual changes; do not delete it or bypass the safety check.\nShell rechecks on every launch and remains usable without memory.\nAn explicit ENGRAM_BIN or ENGRAM_URL is kept and bypasses the guard.\n`);
1890
1870
  process.exit(0);
1891
1871
  }
1892
1872
  if (args.command === "home") {
@@ -1933,7 +1913,7 @@ async function main() {
1933
1913
  if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) };
1934
1914
 
1935
1915
  // Version and help runs (Shell's own or forwarded to Pi) never probe Engram,
1936
- // ensure the companion, print memory notices or touch provenance.
1916
+ // ensure the companion, print memory notices or start a server.
1937
1917
  const engramQuiet = args.version || piVersionOrHelpRequested(args.passthrough);
1938
1918
  let runtime;
1939
1919
  if (bundled) {
@@ -1979,14 +1959,21 @@ async function main() {
1979
1959
 
1980
1960
  // S5: in a bundled install Pi updates only with Gentle Shell, so Pi's own
1981
1961
  // update becomes `gentle-shell upgrade` (run when interactive, named
1982
- // otherwise); package updates still reach Pi.
1962
+ // otherwise), or with `--latest` the newest published Pi without the
1963
+ // release's lockfile (run even when not interactive: it was asked for);
1964
+ // package updates still reach Pi.
1983
1965
  let passthrough = args.passthrough;
1984
1966
  if (bundled && args.piSubcommand === "update") {
1985
1967
  const plan = bundledUpdatePlan(args.passthrough);
1986
1968
  if (plan.self) {
1987
- const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
1988
- for (const line of bundledUpdateNotice({ piVersion: versionCheck.version, interactive })) process.stdout.write(`${line}\n`);
1989
- const code = interactive ? await handleBundledUpgrade(bundled, []) : undefined;
1969
+ let code;
1970
+ if (plan.latest) {
1971
+ code = await handleBundledPiLatest(bundled);
1972
+ } else {
1973
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
1974
+ for (const line of bundledUpdateNotice({ piVersion: versionCheck.version, interactive })) process.stdout.write(`${line}\n`);
1975
+ code = interactive ? await handleBundledUpgrade(bundled, []) : undefined;
1976
+ }
1990
1977
  const next = bundledUpdateNext(plan, code);
1991
1978
  if ("exitCode" in next) process.exit(next.exitCode);
1992
1979
  if (next.note) process.stderr.write(`${next.note}\n`);
@@ -1996,7 +1983,7 @@ async function main() {
1996
1983
  const { managedUpdatePlan, updateSelectedPi } = await import("../scripts/pi-update.mjs");
1997
1984
  const plan = managedUpdatePlan(args.passthrough);
1998
1985
  if (plan.self) {
1999
- process.exitCode = await updateSelectedPi({ runtime, packageRoot, platform: process.platform, currentVersion: versionCheck.version, cwd: tmpdir(),
1986
+ process.exitCode = await updateSelectedPi({ runtime, packageRoot, platform: process.platform, currentVersion: versionCheck.version, cwd: tmpdir(), env: process.env, home: homedir(),
2000
1987
  adapters: await packageManagerAdapters(), out: (line) => process.stdout.write(`${line}\n`) });
2001
1988
  if (plan.piArgs === undefined) return;
2002
1989
  }
@@ -2044,6 +2031,12 @@ async function main() {
2044
2031
  } catch (error) {
2045
2032
  process.stderr.write(`gentle-shell: could not give Pi the bundled npm in ${join(home.dir, "settings.json")} (${error.message}); continuing\n`);
2046
2033
  }
2034
+ } else {
2035
+ try {
2036
+ await removeMissingNpmCommand(home, homeHadContentBeforeBootstrap);
2037
+ } catch (error) {
2038
+ process.stderr.write(`gentle-shell: could not repair the npmCommand in ${join(home.dir, "settings.json")} (${error.message}); continuing\n`);
2039
+ }
2047
2040
  }
2048
2041
 
2049
2042
  if (args.command === "setup") {
@@ -2177,7 +2170,7 @@ async function main() {
2177
2170
 
2178
2171
  if (bundled) {
2179
2172
  // Recheck after setup and before provider loading: never carry a stale
2180
- // fresh-store decision across an installer that could create data.
2173
+ // decision across an installer that could create data.
2181
2174
  if (args.piSubcommand === undefined && !engramQuiet) {
2182
2175
  bundledMemory = await classifyBundledMemory();
2183
2176
  bundledEnv = bundledProcessEnv();
@@ -2185,19 +2178,9 @@ async function main() {
2185
2178
  // could not be verified is reported, never a reason to stop Shell.
2186
2179
  const companion = await ensureEngramCompanion(home, runtime, ["ignore", 2, 2], 10000);
2187
2180
  if (!companion.ok) process.stderr.write(`${companion.message}\n`);
2188
- if (bundledMemory.policy === "allow" && !bundledMemory.url) {
2189
- const mode = process.env.ENGRAM_SERVER_MODE;
2190
- if (mode !== undefined && mode !== "session-owned") {
2191
- bundledMemory = { policy: "disabled", notice: "Engram memory disabled: explicit ENGRAM_SERVER_MODE is invalid for a local bundled server; unset it or select exactly session-owned. Your selection has not been overridden." };
2192
- } else {
2193
- bundledMemory = companion.ok && companion.sessionOwned && supportsBundledSessionOwned(verifiedBundledCore(), process.env)
2194
- ? { ...bundledMemory, owned: true }
2195
- : { policy: "disabled", notice: ENGRAM_PRIVATE_REQUIRED_NOTICE };
2196
- }
2197
- }
2198
- // A fresh store's identity and provenance exist before Pi starts, or
2199
- // memory stays off: Pi never runs with allow without a written record.
2200
- if (bundledMemory.fresh && !provisionEngramStore()) bundledMemory = { policy: "disabled", notice: ENGRAM_PROVISION_FAILED_NOTICE };
2181
+ // Only this final decision starts the shared server, and Pi gets its
2182
+ // URL only once the server verified.
2183
+ if (bundledMemory.spawn) bundledMemory = await startSharedEngram();
2201
2184
  if (bundledMemory.notice) process.stderr.write(`gentle-shell: ${bundledMemory.notice}\n`);
2202
2185
  }
2203
2186
  bundledEnv = bundledProcessEnv();