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
@@ -10,6 +10,12 @@ export const AUTO_MODE_NOTICE = "Auto uses additional model calls with separate
10
10
 
11
11
  export const YOLO_STATUS_KEY = "gentle:yolo";
12
12
  export const YOLO_STATUS_TEXT = "🚀 YOLO ON 🔥 — destructive confirmations remain";
13
+ export const SUPER_YOLO_STATUS_TEXT = "💥 SUPER YOLO ON — no permission prompts; private paths stay blocked";
14
+ export const SUPER_YOLO_DIRECTIVE = `<gentle-super-yolo-session>
15
+ SUPER YOLO session standing permission is ON, authorized by the human for this live primary session and canonical workspace. No permission questions are asked in this mode: proceed through ordinary and destructive shell work (including rm, resets, force operations and configured-guard confirmations) without confirmation dialogs, and make implementation and delivery choices directly.
16
+ The ONLY hard line is private material: never read, print, copy, transmit or commit secrets or private credentials — .env files, .ssh, keychains, *.pem/*.key/*.p12/*.pfx, secrets/ directories, cloud credential files. If a task seems to need them, stop and ask the human.
17
+ This does NOT override explicit human restrictions in the live conversation, and never auto-answers ask_user tools, provider consent, maintenance/recovery authorization or opaque-token decisions. Children remain bounded by their delegated task; they receive no independent delivery grant or Super Yolo inheritance.
18
+ </gentle-super-yolo-session>`;
13
19
  export const YOLO_DIRECTIVE = `<gentle-yolo-session>
14
20
  YOLO session standing permission is ON, authorized by the human for this live primary session and canonical workspace (directly or through a trusted global default).
15
21
  Qualify the default commit, push and PR confirmation clauses: for ordinary already-scoped implementation, checks, commits, non-force pushes and PR creation, this activation supplies standing permission instead of repeated permission questions. Make ordinary reversible implementation choices without needless interviews.
@@ -40,12 +46,14 @@ export class YoloSessionPolicy {
40
46
  }
41
47
  }
42
48
 
43
- /** Remove our exact block as well as adding it: bridge options can be reused. */
44
- export function updateYoloPrompt(options: AppendableSystemPromptOptions | null | undefined, active: boolean): void {
49
+ /** Remove our exact blocks as well as adding them: bridge options can be reused. */
50
+ export function updateYoloPrompt(options: AppendableSystemPromptOptions | null | undefined, active: boolean, superActive = false): void {
45
51
  if (!options) return;
46
52
  options.appendSystemPrompt = (options.appendSystemPrompt ?? "")
47
- .replace(`\n\n${YOLO_DIRECTIVE}`, "").replace(YOLO_DIRECTIVE, "");
48
- if (active) appendSystemPromptOnce(options, YOLO_DIRECTIVE);
53
+ .replace(`\n\n${YOLO_DIRECTIVE}`, "").replace(YOLO_DIRECTIVE, "")
54
+ .replace(`\n\n${SUPER_YOLO_DIRECTIVE}`, "").replace(SUPER_YOLO_DIRECTIVE, "");
55
+ if (superActive) appendSystemPromptOnce(options, SUPER_YOLO_DIRECTIVE);
56
+ else if (active) appendSystemPromptOnce(options, YOLO_DIRECTIVE);
49
57
  }
50
58
  const HUMAN_ACTION = { on: "on", off: "off", toggle: "" } as const;
51
59
  type HumanAction = (typeof HUMAN_ACTION)[keyof typeof HUMAN_ACTION];
@@ -86,7 +94,8 @@ export function discoverYoloUiAdapter(pi: ExtensionAPI, context: ExtensionContex
86
94
  }
87
95
  export interface YoloSessionController {
88
96
  active(context: ExtensionContext): Promise<boolean>;
89
- mode(context: ExtensionContext): Promise<PermissionMode>;
97
+ superActive(context: ExtensionContext): Promise<boolean>;
98
+ mode(context: ExtensionContext, admission?: { toolCallId: string }): Promise<PermissionMode>;
90
99
  setMode(mode: PermissionMode, context: ExtensionContext): Promise<boolean>;
91
100
  defaultMode(): PermissionMode;
92
101
  saveDefault(mode: PermissionMode, context: ExtensionContext): Promise<boolean>;
@@ -101,7 +110,7 @@ interface ModeIdentity {
101
110
  }
102
111
  /** UI/slash authoring only. Defaults are read from the dedicated trusted user store. */
103
112
  export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessEnv,
104
- options: { onChange?: () => void } = {}): YoloSessionController {
113
+ options: { onChange?: () => void; resolveIdentity?: typeof resolveCanonicalGitRepositoryIdentity } = {}): YoloSessionController {
105
114
  const store = { gentlePiConfigHome: gentlePiConfigHome(env) };
106
115
  let generation = 0;
107
116
  let ownerGeneration = 0;
@@ -112,6 +121,15 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
112
121
  const seen = new WeakMap<object, Set<string>>();
113
122
  const observers = new Set<() => void>();
114
123
  const pendingCaptures = new Set<{ manager: ModeIdentity["manager"]; id: string }>();
124
+ // Identity is re-captured for every tool admission: freshness is a security
125
+ // invariant, so cross-admission reuse is never allowed (a mid-lookup mode
126
+ // reselection must invalidate). Within one admission the redundant mode()
127
+ // lookups of the same tool call share at most ONE capture, keyed by that
128
+ // admission's tool call id; a different or absent id re-captures, and a
129
+ // generation bump or cwd change still invalidates a shared slot.
130
+ let capturedMode: (ModeIdentity & { cwd: string; generation: number; admission: string }) | undefined;
131
+ // Rendered strings last published for each context; identical repaints are skipped.
132
+ const published = new WeakMap<ExtensionContext, { yolo: string | undefined; modeText: string }>();
115
133
  const primary = () => env.GENTLE_PI_AGENTS_CHILD !== "1";
116
134
  const interactive = (ctx: ExtensionContext): boolean => {
117
135
  if (!primary() || !ctx.hasUI) return false;
@@ -129,10 +147,15 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
129
147
  const ids = seen.get(scope.manager) ?? new Set<string>(); ids.add(scope.id); seen.set(scope.manager, ids);
130
148
  };
131
149
  const publish = (ctx: ExtensionContext): void => {
132
- const yolo = liveMode === "yolo";
133
- try { ctx.ui.setStatus(YOLO_STATUS_KEY, yolo ? YOLO_STATUS_TEXT : undefined); } catch { /* display only */ }
134
- try { ctx.ui.setWidget(YOLO_STATUS_KEY, yolo ? [YOLO_STATUS_TEXT] : undefined); } catch { /* display only */ }
135
- try { ctx.ui.setStatus(MODE_STATUS_KEY, `Permissions: ${liveMode.toUpperCase()}`); } catch { /* display only */ }
150
+ const yolo = liveMode === "yolo" ? YOLO_STATUS_TEXT : liveMode === "super-yolo" ? SUPER_YOLO_STATUS_TEXT : undefined;
151
+ const modeText = `Permissions: ${liveMode.toUpperCase()}`;
152
+ const previous = published.get(ctx);
153
+ if (previous && previous.yolo === yolo && previous.modeText === modeText) return;
154
+ let painted = true;
155
+ try { ctx.ui.setStatus(YOLO_STATUS_KEY, yolo); } catch { painted = false; /* display only */ }
156
+ try { ctx.ui.setWidget(YOLO_STATUS_KEY, yolo ? [yolo] : undefined); } catch { painted = false; /* display only */ }
157
+ try { ctx.ui.setStatus(MODE_STATUS_KEY, modeText); } catch { painted = false; /* display only */ }
158
+ if (painted) published.set(ctx, { yolo, modeText });
136
159
  };
137
160
  const changed = (ctx: ExtensionContext): void => {
138
161
  generation += 1; publish(ctx);
@@ -162,17 +185,25 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
162
185
  try {
163
186
  const canonical = await realpath(cwd);
164
187
  if (!current()) return undefined;
165
- const clone = await resolveCanonicalGitRepositoryIdentity(canonical);
188
+ const clone = await (options.resolveIdentity ?? resolveCanonicalGitRepositoryIdentity)(canonical);
166
189
  if (!current()) return undefined;
167
190
  return { ...scope, workspace: clone ?? `cwd:${canonical}` };
168
191
  } catch { return undefined; }
169
192
  finally { pendingCaptures.delete(scope); }
170
193
  };
171
194
  const defaultMode = (): PermissionMode => resolvePermissionModePolicy(store).mode;
172
- const mode = async (ctx: ExtensionContext): Promise<PermissionMode> => {
173
- const epoch = generation;
174
- const identity = await capture(ctx);
175
- if (epoch !== generation) return "normal";
195
+ const mode = async (ctx: ExtensionContext, admission?: { toolCallId: string }): Promise<PermissionMode> => {
196
+ const scope = session(ctx);
197
+ const admissionId = admission?.toolCallId;
198
+ let identity: ModeIdentity | undefined;
199
+ if (admissionId !== undefined && scope && capturedMode &&
200
+ capturedMode.admission === admissionId && capturedMode.generation === generation &&
201
+ capturedMode.manager === scope.manager && capturedMode.id === scope.id && capturedMode.cwd === ctx.cwd) identity = capturedMode;
202
+ if (!identity) {
203
+ const epoch = generation;
204
+ identity = await capture(ctx);
205
+ if (epoch !== generation) return "normal";
206
+ }
176
207
  if (binding && (!identity || binding.manager !== identity.manager || binding.id !== identity.id ||
177
208
  binding.workspace !== identity.workspace || (interactiveGrant && !interactive(ctx)))) {
178
209
  binding = undefined; liveMode = "normal"; interactiveGrant = false; changed(ctx);
@@ -182,12 +213,17 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
182
213
  remember(identity); binding = identity;
183
214
  if (fresh) { liveMode = defaultMode(); interactiveGrant = false; changed(ctx); }
184
215
  }
216
+ // Record after the binding pass: internal changed() bumps above must not strand
217
+ // a cache entry stamped with a superseded generation. Only admission-keyed
218
+ // lookups record; the slot is overwritten when a different admission id arrives.
219
+ if (identity && scope && admissionId !== undefined) capturedMode = { ...identity, cwd: ctx.cwd, generation, admission: admissionId };
185
220
  publish(ctx);
186
221
  return identity && binding ? liveMode : "normal";
187
222
  };
188
223
  const active = async (ctx: ExtensionContext): Promise<boolean> => await mode(ctx) === "yolo";
224
+ const superActive = async (ctx: ExtensionContext): Promise<boolean> => await mode(ctx) === "super-yolo";
189
225
  const reset = (ctx: ExtensionContext): void => {
190
- ownerGeneration += 1;
226
+ ownerGeneration += 1; capturedMode = undefined;
191
227
  // Initial session_start remains fresh; shutdown/reload of an observed session revokes.
192
228
  if (binding) remember(binding);
193
229
  const scope = session(ctx);
@@ -228,7 +264,7 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
228
264
  };
229
265
  const humanAction = async (action: HumanAction, ctx: ExtensionContext, current = () => true): Promise<boolean> => {
230
266
  if (!current()) return false;
231
- const enable = action === HUMAN_ACTION.on || (action === HUMAN_ACTION.toggle && liveMode !== "yolo");
267
+ const enable = action === HUMAN_ACTION.on || (action === HUMAN_ACTION.toggle && liveMode !== "yolo" && liveMode !== "super-yolo");
232
268
  const pending = setMode(enable ? "yolo" : "normal", ctx, current);
233
269
  const epoch = generation; // setMode revokes synchronously, then publishes once on success.
234
270
  const result = await pending;
@@ -253,7 +289,7 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
253
289
  const epoch = generation, value = await mode(ctx);
254
290
  if (!current()) return YOLO_DISPLAY.unavailable;
255
291
  if (epoch !== generation) return read();
256
- return binding ? value === "yolo" ? YOLO_DISPLAY.on : YOLO_DISPLAY.off : YOLO_DISPLAY.unavailable;
292
+ return binding ? value === "yolo" || value === "super-yolo" ? YOLO_DISPLAY.on : YOLO_DISPLAY.off : YOLO_DISPLAY.unavailable;
257
293
  };
258
294
  request.respond({
259
295
  read,
@@ -276,7 +312,7 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
276
312
  } catch { /* obsolete trusted context: discovery expires closed */ }
277
313
  });
278
314
  pi.registerCommand("gentle:mode", {
279
- description: "Permissions: normal|auto|yolo|status; default normal|auto|yolo saves for new primary sessions.",
315
+ description: "Permissions: normal|auto|yolo|super-yolo|status; default normal|auto|yolo|super-yolo saves for new primary sessions.",
280
316
  handler: async (args, ctx) => {
281
317
  const words = args.trim().split(/\s+/);
282
318
  if (!args.trim() || args.trim() === "status") {
@@ -290,7 +326,7 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
290
326
  else notify(ctx, "Mode unchanged — interactive primary session required or the session changed.", true);
291
327
  return;
292
328
  }
293
- notify(ctx, "Use /gentle:mode normal|auto|yolo|status or /gentle:mode default normal|auto|yolo. State unchanged.", true);
329
+ notify(ctx, "Use /gentle:mode normal|auto|yolo|super-yolo|status or /gentle:mode default normal|auto|yolo|super-yolo. State unchanged.", true);
294
330
  },
295
331
  });
296
332
  pi.registerCommand("gentle:yolo", {
@@ -301,7 +337,7 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
301
337
  if (!args.trim() && ctx.hasUI && typeof ctx.ui.select === "function") {
302
338
  current = bindCurrent(ctx);
303
339
  const state = await active(ctx) ? YOLO_DISPLAY.on : YOLO_DISPLAY.off;
304
- const selected = await ctx.ui.select(`🚀 Gentle YOLO 🔥 — full speed, destructive actions still ask (current: ${state})`, ["enable", "disable", "status"]);
340
+ const selected = await ctx.ui.select(`🚀 Gentle YOLO 🔥 — standing permission (current: ${state})`, ["enable", "disable", "status"]);
305
341
  if (selected === undefined) return;
306
342
  action = selected;
307
343
  }
@@ -313,5 +349,5 @@ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessE
313
349
  notify(ctx, "YOLO unchanged — the session changed while the menu was open.", true);
314
350
  },
315
351
  });
316
- return { active, mode, setMode, defaultMode, saveDefault, epoch: () => generation, humanAction, reset };
352
+ return { active, superActive, mode, setMode, defaultMode, saveDefault, epoch: () => generation, humanAction, reset };
317
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "description": "Turn Pi into el Gentleman: an ODD development harness with focused subagents, configured TDD evidence, native review, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
3
  import { request as httpRequest } from "node:http";
4
4
  import { createConnection } from "node:net";
5
- import { dirname, posix, win32 } from "node:path";
5
+ import { dirname, isAbsolute, posix, win32 } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  // The launcher in a bundled install (S5, S8): pure decisions over an injected
@@ -28,6 +28,46 @@ import { fileURLToPath } from "node:url";
28
28
 
29
29
 
30
30
 
31
+ /**
32
+ * Builds the detached shared server's environment without retaining session scratch.
33
+ * Windows Node can reintroduce an omitted TEMP from its parent, so use the
34
+ * original stable ancestor there; POSIX omits scratch temp settings instead.
35
+ */
36
+ export function sharedEngramEnv(base , platform = process.platform) {
37
+ const path = pathFor(platform);
38
+ const env = {};
39
+ const scratch = (value ) => /^(gp|ga)[A-Za-z0-9]{6}$/.test(path.basename(path.normalize(value)));
40
+ let stableTemp ;
41
+ let tempKey ;
42
+ for (const [key, value] of Object.entries(base)) {
43
+ const name = platform === "win32" ? key.toUpperCase() : key;
44
+ if (name.startsWith("ENGRAM_") || name === "GH_TOKEN" || name === "GITHUB_TOKEN") continue;
45
+ if (["TMPDIR", "TMP", "TEMP"].includes(name) && value !== undefined && scratch(value)) {
46
+ if (platform !== "win32") continue;
47
+ // A drive-relative or root-relative path depends on ambient state.
48
+ const root = win32.parse(value).root;
49
+ if (!/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]?)$/.test(root) || value.split(/[\\/]/).includes("..")) {
50
+ throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
51
+ }
52
+ let stable = win32.normalize(value);
53
+ while (scratch(stable)) {
54
+ const parent = win32.dirname(stable);
55
+ if (parent === stable) throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
56
+ stable = parent;
57
+ }
58
+ env[key] = stable;
59
+ stableTemp ??= stable;
60
+ } else {
61
+ env[key] = value;
62
+ }
63
+ if (name === "TEMP") tempKey = key;
64
+ }
65
+ // Keep an existing custom TEMP, even if TMP/TMPDIR chose another root.
66
+ if (stableTemp !== undefined && (tempKey === undefined || env[tempKey] === undefined)) env[tempKey ?? "TEMP"] = stableTemp;
67
+ env.ENGRAM_NO_UPDATE_CHECK = "1";
68
+ return env;
69
+ }
70
+
31
71
  // The same rules scripts/bundled-install.mjs writes versions and markers with.
32
72
  const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
33
73
  const VERSION_MARKER = ".gentle-shell-version";
@@ -233,15 +273,14 @@ export function bundledPi(install , input
233
273
  // Gentle AI's variables, never set here because the agent's shell would pass
234
274
  // them to a gentle-ai the user runs), and the published Engram as
235
275
  // gentle-engram's ENGRAM_BIN unless the user chose one. With a memory decision
236
- // (S2, S6): the startup policy, and ENGRAM_BIN is the verified core only when
237
- // memory is allowed locally; otherwise the Shell-owned guard, so no provider,
238
- // whatever loaded it, runs an Engram binary against an unverified store.
276
+ // (S2, S6): the startup policy, and ENGRAM_BIN is the Shell-owned guard, so no
277
+ // provider, whatever loaded it, runs an Engram binary against a store; allowed
278
+ // memory reaches the shared server through ENGRAM_URL only.
239
279
  export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
240
- export const ENGRAM_PRIVATE_REQUIRED_NOTICE = "Engram memory disabled: session-owned servers require a supporting published bundled core and companion. This bundle does not establish both capabilities; it will not fall back to a detached shared server. Install a supporting published bundle and restart Shell. Existing shared services are not stopped or adopted as session-owned.";
241
- export const ENGRAM_CORE_CHANGED_NOTICE = "Engram memory disabled: the bundled core changed since Shell recorded this store. Install a published bundle supporting migrate-copy, stop every Engram client, retain a complete backup, then run gentle-shell memory migrate --confirm-quiescent. The command verifies and migrates a copy before updating provenance; older bundles refuse safely. Do not edit coreSha256 or delete the original store. Shell remains usable without memory.";
242
- export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
280
+ export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server on 127.0.0.1:7437 with verified /compatibility evidence; Shell never stops or replaces a server already listening there. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
281
+ export const ENGRAM_START_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not start the shared Engram server, or it did not report ready in time. Restart Shell to retry; if another program holds 127.0.0.1:7437, stop it first. Shell remains usable without memory.";
282
+ export const ENGRAM_NEWER_SCHEMA_NOTICE = "Engram memory disabled: ~/.engram was migrated by a newer Engram than Shell's bundled core, so Shell will not start a server on it. Start that newer Engram's server yourself (engram serve); Shell reuses a running server with verified /compatibility evidence. Back up the store; do not delete or downgrade it. Shell remains usable without memory.";
243
283
  // `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
244
- export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
245
284
  export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
246
285
  // The first Engram release whose server answers GET /compatibility (PR #1792).
247
286
  export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
@@ -262,12 +301,13 @@ function outdatedServerVersion(version )
262
301
  // The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
263
302
  export const ENGRAM_DEFAULT_PORT = 7437;
264
303
  export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
265
- const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL"];
304
+ const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL", "ENGRAM_SERVER_MODE"];
266
305
  const HEX32 = /^[a-f0-9]{32}$/;
267
306
 
268
307
 
269
- // `fresh`: Shell provisions a new store this run and may record its provenance.
270
- // `url`: a running server verified through /compatibility, reached explicitly.
308
+ // `url`: the shared server, reached explicitly once /compatibility verified it.
309
+ // `spawn`: nothing listens yet; Shell starts the bundled core's server and must
310
+ // see it verified before Pi starts, or memory is disabled for that launch.
271
311
 
272
312
 
273
313
  // Without an invalid policy or an explicit Engram selection, the default
@@ -278,26 +318,24 @@ export function engramProbeAllowed(env ) {
278
318
  }
279
319
 
280
320
  // Evidence only: never run an executable, open SQLite or infer compatibility
281
- // from versions. `provenance` means Shell's own record matched the store
282
- // (directory, .instance-id, core digest) and its header showed no newer schema.
283
- // A server version only selects the wording of a disabled decision.
284
- export function classifyEngramStartup(input
285
- ) {
321
+ // from versions. Like other agents' hooks, Shell shares the global server: a
322
+ // verified listener is reused, and with nothing listening the verified bundled
323
+ // core starts one on Engram's defaults, unless the default store's header
324
+ // (`userVersion`) shows a newer schema than that core's. A listener that does
325
+ // not verify is never replaced. A server version only selects the wording.
326
+ export function classifyEngramStartup(input ) {
286
327
  if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
287
328
  const endpoint = input.endpoint;
288
- const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
289
- const unknown = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
290
329
  if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
291
- if (endpoint.kind === "refused" && input.store === "absent") {
292
- return input.core ? { policy: "allow", fresh: true } : { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
330
+ if (endpoint.kind === "refused") {
331
+ if (!input.core) return { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
332
+ if (input.userVersion !== undefined && !engramHeaderAllows(input.userVersion, bundledEngramSchema(input.coreVersion))) {
333
+ return { policy: "disabled", notice: ENGRAM_NEWER_SCHEMA_NOTICE };
334
+ }
335
+ return { policy: "allow", url: ENGRAM_DEFAULT_URL, spawn: true };
293
336
  }
294
- const own = input.provenance;
295
- if (own === undefined || !input.core || input.store !== "present") return unknown;
296
- // Shell's own store, created by this exact core: offline, the provider starts
297
- // that core; a running server must be that store and that exact core.
298
- if (endpoint.kind === "refused") return { policy: "allow" };
299
- if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
300
- return unknown;
337
+ const outdated = endpoint.kind === "listening" ? outdatedServerVersion(endpoint.healthVersion) : undefined;
338
+ return { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
301
339
  }
302
340
 
303
341
  // The exact contract a startup-verified store reports (Engram GET /compatibility).
@@ -357,8 +395,8 @@ export async function probeEngramEndpoint(input
357
395
  return { kind: "listening", compatible: false, healthInstanceId: identified ? body.instance_id : undefined, healthVersion: identified ? body.version : undefined };
358
396
  }
359
397
 
360
- // Shell's own record of a store it provisioned fresh with the verified core.
361
- // Written only after Shell itself provisioned the store's identity, before Pi starts.
398
+ // Shell's record of a store an earlier Shell provisioned with its bundled core,
399
+ // read only by the explicit `gentle-shell memory migrate` recovery.
362
400
 
363
401
 
364
402
  export function engramProvenancePath(prefix , platform = process.platform) {
@@ -372,14 +410,10 @@ export function parseEngramProvenance(text )
372
410
  return { schema: 1, dataDir: record.dataDir, instanceId: record.instanceId, coreSha256: record.coreSha256 };
373
411
  }
374
412
 
375
- export function engramProvenanceMatches(record , observed ) {
376
- return record !== undefined && record.dataDir === observed.dataDir && record.instanceId === observed.instanceId && record.coreSha256 === observed.coreSha256;
377
- }
378
-
379
413
  // The bundled core's own schema (SQLite user_version), verified from the
380
- // tagged sources v3.3.2 and v3.4.0: internal/store/store.go `const schemaVersion = 1`.
414
+ // tagged sources v3.3.2, v3.4.0 and v3.5.2: internal/store/store.go `const schemaVersion = 1`.
381
415
  // A pin without a verified entry leaves the header check unavailable.
382
- const BUNDLED_ENGRAM_SCHEMA = { "3.3.2": 1, "3.4.0": 1 };
416
+ const BUNDLED_ENGRAM_SCHEMA = { "3.3.2": 1, "3.4.0": 1, "3.5.2": 1 };
383
417
 
384
418
  export function bundledEngramSchema(coreVersion ) {
385
419
  return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
@@ -427,11 +461,6 @@ export function supportsEngramStartupPolicy(metadata )
427
461
  return pkg?.name === "gentle-engram" && pkg.engram?.startupPolicy === 1;
428
462
  }
429
463
 
430
- export function supportsEngramSessionOwned(metadata ) {
431
- const pkg = parseJson(metadata);
432
- return supportsEngramStartupPolicy(metadata) && pkg?.engram?.sessionOwned === 1;
433
- }
434
-
435
464
  export function bundledChildEnv(base , input ) {
436
465
  const env = { ...base, PI_SKIP_VERSION_CHECK: "1", [GENTLE_SHELL_PREFIX_ENV]: input.prefix };
437
466
  delete env.GENTLE_SHELL_PI;
@@ -454,14 +483,7 @@ export function bundledChildEnv(base , input
454
483
  return env;
455
484
  }
456
485
  if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
457
- const local = memory.policy === "allow" && memory.url === undefined ? input.engram : undefined;
458
- env.ENGRAM_BIN = local ?? input.guard;
459
- if (local && memory.owned) {
460
- env.ENGRAM_SERVER_MODE = "session-owned";
461
- delete env.ENGRAM_URL;
462
- delete env.ENGRAM_PORT;
463
- delete env.ENGRAM_SOCKET;
464
- }
486
+ env.ENGRAM_BIN = input.guard;
465
487
  return env;
466
488
  }
467
489
 
@@ -494,6 +516,61 @@ export function withBundledNpmCommand(settingsText , command
494
516
 
495
517
 
496
518
 
519
+ // settings.json without `npmCommand`: every other key kept in order and the
520
+ // file's indentation and final newline kept.
521
+ function settingsWithoutNpmCommand(settingsText , settings ) {
522
+ const indent = settingsText.match(/\{\r?\n([ \t]+)/)?.[1] ?? " ";
523
+ const { npmCommand: _removed, ...rest } = settings;
524
+ const serialized = JSON.stringify(rest, null, indent);
525
+ return settingsText.endsWith("\n") ? `${serialized}\n` : serialized;
526
+ }
527
+
528
+ // The agent home's settings.json without a `npmCommand` naming the prefix a
529
+ // bundled self-uninstall removed (gentle-shell-uninstall.ts): one absolute
530
+ // entry inside `withinRemoved` drops the whole key, every other key kept in
531
+ // order and the file's indentation and final newline kept. A file that is not
532
+ // a JSON object is never rewritten; a command naming nothing inside it stays.
533
+ export function withoutBundledNpmCommand(settingsText , withinRemoved ) {
534
+ if (settingsText === undefined) return { status: "unchanged" };
535
+ const settings = parseJson(settingsText);
536
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
537
+ const current = settings.npmCommand;
538
+ if (!Array.isArray(current) || !current.some((part) => typeof part === "string" && isAbsolute(part) && withinRemoved(part))) return { status: "unchanged" };
539
+ return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
540
+ }
541
+
542
+ // An `npmCommand` a bundled install wrote (scripts/bundled-install.mjs
543
+ // bundledNpmCommand, with npm-cli.js and --prefix in earlier releases): our
544
+ // node in runtime/node-<v>, then npm's settings and cache flags, and
545
+ // --update-notifier=false last. A command the user chose never matches, even
546
+ // when its program is missing.
547
+ export function isBundledNpmCommand(command ) {
548
+ const [node] = command;
549
+ return typeof node === "string" && /[\\/]runtime[\\/]node-[^\\/]+[\\/](node\.exe|bin[\\/]node)$/.test(node)
550
+ && ["--userconfig=", "--globalconfig=", "--cache="].every((flag) => command.some((part) => typeof part === "string" && part.startsWith(flag)))
551
+ && command[command.length - 1] === "--update-notifier=false";
552
+ }
553
+
554
+ // The agent home's settings.json without an `npmCommand` a bundled install
555
+ // wrote whose executable (its first entry) no longer exists, as after a
556
+ // bundled self-uninstall that kept the home (#2469): Pi runs that command for
557
+ // every package install, so a key naming a deleted node.exe fails them all
558
+ // with ENOENT. A command whose executable exists, that names no absolute
559
+ // path, or that the user set themselves is never touched.
560
+ export function withoutMissingBundledNpmCommand(settingsText , exists ) {
561
+ if (settingsText === undefined) return { status: "unchanged" };
562
+ const settings = parseJson(settingsText);
563
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
564
+ const executable = Array.isArray(settings.npmCommand) ? settings.npmCommand[0] : undefined;
565
+ if (typeof executable !== "string" || !isAbsolute(executable) || exists(executable) || !isBundledNpmCommand(settings.npmCommand )) return { status: "unchanged" };
566
+ return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
567
+ }
568
+
569
+
570
+
571
+
572
+
573
+
497
574
 
498
575
 
499
576
 
@@ -505,7 +582,9 @@ export function withBundledNpmCommand(settingsText , command
505
582
  // `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`,
506
583
  // `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part
507
584
  // is redirected; "all" still sends `update --extensions` (with the other
508
- // flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or
585
+ // flags) to Pi. `--latest` marks Pi's own part as the newest published Pi;
586
+ // with any form that is not Pi's own update it is unknown to Pi, so it passes
587
+ // through. Packages, models, help, and forms Pi refuses (conflicting or
509
588
  // unknown options) reach Pi unchanged.
510
589
  export function bundledUpdatePlan(passthrough ) {
511
590
  const passThrough = { self: false, piArgs: passthrough };
@@ -514,8 +593,10 @@ export function bundledUpdatePlan(passthrough ) {
514
593
  let self = false;
515
594
  let extensions = false;
516
595
  let all = false;
596
+ let latest = false;
517
597
  for (const token of passthrough.slice(1)) {
518
598
  if (token === "--self") self = true;
599
+ else if (token === "--latest") latest = true;
519
600
  else if (token === "--extensions") extensions = true;
520
601
  else if (token === "--all") all = true;
521
602
  else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token);
@@ -527,19 +608,21 @@ export function bundledUpdatePlan(passthrough ) {
527
608
  if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough;
528
609
  self = true;
529
610
  }
530
- if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] };
611
+ const marked = latest ? { latest: true } : {};
612
+ if (all || (self && extensions)) return { self: true, ...marked, piArgs: ["update", "--extensions", ...flags] };
531
613
  if (extensions) return passThrough;
532
- return { self: true };
614
+ return { self: true, ...marked };
533
615
  }
534
616
 
535
- // What `gentle-shell update` does after the redirected upgrade (`upgradeCode`
536
- // is undefined when it did not run): with nothing left for Pi it exits with the
537
- // upgrade's code; otherwise Pi still runs, so `update --all` updates the
538
- // extensions even when the upgrade could not complete.
617
+ // What `gentle-shell update` does after the redirected upgrade or the
618
+ // `--latest` Pi update (`upgradeCode` is undefined when it did not run): with
619
+ // nothing left for Pi it exits with that code; otherwise Pi still runs, so
620
+ // `update --all` updates the extensions even when that step could not complete.
539
621
  export function bundledUpdateNext(plan , upgradeCode ) {
540
622
  if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 };
541
623
  if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) {
542
- return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." };
624
+ const step = plan.latest ? "latest Pi update" : "Gentle Shell upgrade";
625
+ return { piArgs: plan.piArgs, note: `gentle-shell: the ${step} did not complete; updating the extensions anyway.` };
543
626
  }
544
627
  return { piArgs: plan.piArgs };
545
628
  }
@@ -550,6 +633,7 @@ export function bundledUpdateNotice(input
550
633
  input.interactive
551
634
  ? "Running 'gentle-shell upgrade'..."
552
635
  : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.",
636
+ "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.",
553
637
  ];
554
638
  }
555
639
 
@@ -1119,12 +1119,17 @@ const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
1119
1119
 
1120
1120
  // cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
1121
1121
  // token in double quotes when it is empty or contains whitespace or any of
1122
- // `"&|<>^%()`, and escape an inner `"` as `\"` — doubling inner quotes is not
1123
- // reliable in cmd.exe, unlike the `\"` convention Node's own Windows spawn
1124
- // helpers use.
1122
+ // `&|<>^%()`. A token with an inner `"` cannot be represented: cmd.exe has no
1123
+ // backslash escape, so `\"` still ends the quoted section and the rest of the
1124
+ // token is parsed as command syntax (`x" & whoami & rem "` would run
1125
+ // `whoami`). Doubling the quote is not reliable either, so such a token is
1126
+ // refused instead of producing a command line it cannot protect.
1125
1127
  export function quoteForCmdExe(token ) {
1128
+ if (token.includes('"')) {
1129
+ throw new Error("cannot pass an argument containing a double quote through cmd.exe: it would end the quoted argument and let the rest run as a command");
1130
+ }
1126
1131
  if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
1127
- return `"${token.replace(/"/g, '\\"')}"`;
1132
+ return `"${token}"`;
1128
1133
  }
1129
1134
 
1130
1135
  const POSIX_SHELL_SPECIAL_CHARS = /[\s"'`\\$&|;<>(){}*?[\]!#~]/;
@@ -1347,6 +1352,8 @@ export function helpText() {
1347
1352
  " gentle-shell update [target] Run pi's own 'update' against the resolved home.",
1348
1353
  " In a bundled install Pi ships pinned: 'update' without a package",
1349
1354
  " runs 'gentle-shell upgrade' instead of updating Pi.",
1355
+ " 'update --latest' installs the newest published Pi instead,",
1356
+ " without the release's verified lockfile.",
1350
1357
  " gentle-shell config Run pi's own 'config' against the resolved home.",
1351
1358
  " gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
1352
1359
  " gentle-shell mcp <command> Run pi's own 'mcp' (add, list, login, logout) against the",