gentle-pi 4.2.0 → 4.3.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 (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -6,6 +6,11 @@ export interface DestructiveCommandMatch {
6
6
  kind: DestructionKind;
7
7
  triggerIndex: number;
8
8
  hardDeny: boolean;
9
+ /**
10
+ * Literal targets of a direct, top-level, unwrapped and unquoted recursive
11
+ * `rm` that runs before every other segment of the command; absent otherwise.
12
+ */
13
+ removalTargets?: readonly string[];
9
14
  }
10
15
  interface Token {
11
16
  value: string;
@@ -14,6 +19,10 @@ interface Token {
14
19
  index: number;
15
20
  }
16
21
  const separators = new Set([";", "&&", "||", "|", "&", "\n", "(", ")"]);
22
+ // Separators after which the next segment starts only once this one finished.
23
+ // Pipes, background jobs and subshell parentheses run segments concurrently or
24
+ // out of textual order.
25
+ const SEQUENTIAL_SEPARATORS = new Set(["", ";", "&&", "||", "\n"]);
17
26
 
18
27
  /** Literal, named heredocs are data, not executable shell segments; offsets are preserved. */
19
28
  function blankHeredocs(command: string): string {
@@ -49,10 +58,14 @@ const WRAPPER_VALUE_OPTIONS: Record<string, readonly string[]> = {
49
58
  xargs: ["-n", "-I", "-P", "--max-args", "--replace", "--max-procs"],
50
59
  };
51
60
  const WRAPPERS = ["env", "sudo", "command", "exec", "nohup", "timeout", "xargs"];
61
+ // gentle-pi#2285: unquoted reserved words that open a compound body or a
62
+ // condition are followed by a command in command position.
63
+ const RESERVED_WORDS = new Set(["then", "else", "elif", "do", "{", "!", "if", "while", "until"]);
52
64
 
53
- /** Index of the executable token after assignments and familiar wrappers. */
65
+ /** Index of the executable token after reserved words, assignments and familiar wrappers. */
54
66
  function headCursor(segment: readonly Token[]): number {
55
67
  let cursor = 0;
68
+ while (cursor < segment.length && segment[cursor].raw === segment[cursor].value && RESERVED_WORDS.has(segment[cursor].value)) cursor++;
56
69
  while (cursor < segment.length) {
57
70
  const value = segment[cursor].value;
58
71
  if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(value)) {
@@ -85,29 +98,63 @@ const DATA_COMMANDS = new Set([
85
98
  const INERT_COMMANDS = new Set(["cd", "pwd", "true", "false", "test", "[", "mkdir", "touch"]);
86
99
  const HEREDOC_RE = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g;
87
100
 
101
+ type Ranges = Array<readonly [number, number]>;
102
+ // A simple `$(...)`: no nested substitution, quoting, expansion or escapes in its body.
103
+ const SIMPLE_SUBSTITUTION_RE = /\$\(([^()'"`$\\]*)\)/g;
104
+
105
+ /**
106
+ * Replace each simple `$(...)` whose body is itself only data and inert
107
+ * commands with a same-length placeholder word, so offsets are preserved.
108
+ * Such a substitution runs nothing guarded; its output is only more words.
109
+ * Returns undefined when any substitution is not provably data, or when a
110
+ * heredoc could expand one in its body.
111
+ */
112
+ function maskDataSubstitutions(command: string): { masked: string; ranges: Ranges } | undefined {
113
+ if (!command.includes("$(")) return { masked: command, ranges: [] };
114
+ if (command.includes("<<")) return undefined;
115
+ const ranges: Ranges = [];
116
+ let opaque = false;
117
+ const masked = command.replace(SIMPLE_SUBSTITUTION_RE, (raw: string, body: string, offset: number) => {
118
+ const inner = dataRanges(body);
119
+ if (inner === undefined) opaque = true;
120
+ else ranges.push(...inner.map(([from, to]) => [offset + 2 + from, offset + 2 + to] as const));
121
+ return "_".repeat(raw.length);
122
+ });
123
+ return opaque || masked.includes("$(") ? undefined : { masked, ranges };
124
+ }
125
+
88
126
  /**
89
127
  * Byte ranges of the command that are plain arguments of a known data command
90
128
  * (gentle-shell#2174). A guarded or denied pattern matching only inside these
91
129
  * ranges is prose, not an operation. Returns no ranges, keeping every lexical
92
- * match, when the command is opaque: command or process substitution, an
130
+ * match, when the command is opaque: a command substitution that is not
131
+ * itself only data commands, process substitution or backticks, an
93
132
  * interpreter or remote shell anywhere, or quoting the tokenizer cannot
94
133
  * account for.
95
134
  */
96
- export function dataArgumentRanges(command: string): Array<readonly [number, number]> {
97
- if (/\$\(|`|[<>]\(/.test(command)) return [];
135
+ export function dataArgumentRanges(command: string): Ranges {
136
+ return dataRanges(command) ?? [];
137
+ }
138
+
139
+ /** Undefined means opaque; an empty list means only inert commands. */
140
+ function dataRanges(original: string): Ranges | undefined {
141
+ const substitutions = maskDataSubstitutions(original);
142
+ if (substitutions === undefined) return undefined;
143
+ const command = substitutions.masked;
144
+ if (/`|[<>]\(/.test(command)) return undefined;
98
145
  // A heredoc counts only when its `<<` starts an unquoted token of the raw
99
146
  // command; quoted text such as `echo '<<x'` must not blank following lines.
100
147
  const rawStarts = new Set(tokenize(command, false).filter((token) => token.raw.startsWith("<<")).map((token) => token.index));
101
148
  const heredocs = [...command.matchAll(HEREDOC_RE)];
102
- if (heredocs.some((heredoc) => !rawStarts.has(heredoc.index))) return [];
149
+ if (heredocs.some((heredoc) => !rawStarts.has(heredoc.index))) return undefined;
103
150
  const blanked = blankHeredocs(command);
104
151
  const tokens = tokenize(command);
105
152
  const covered = new Uint8Array(blanked.length);
106
153
  for (const token of tokens) covered.fill(1, token.index, token.index + token.raw.length);
107
154
  for (let index = 0; index < blanked.length; index++) {
108
- if (!covered[index] && !/\s/.test(blanked[index])) return [];
155
+ if (!covered[index] && !/\s/.test(blanked[index])) return undefined;
109
156
  }
110
- const ranges: Array<readonly [number, number]> = [];
157
+ const ranges: Ranges = [...substitutions.ranges];
111
158
  let start = 0;
112
159
  for (let end = 0; end <= tokens.length; end++) {
113
160
  if (end < tokens.length && !tokens[end].operator) continue;
@@ -120,10 +167,12 @@ export function dataArgumentRanges(command: string): Array<readonly [number, num
120
167
  if (segment[0].value === "env") cursor++;
121
168
  while (cursor < segment.length && /^[A-Za-z_][A-Za-z_0-9]*=/.test(segment[cursor].value)) cursor++;
122
169
  const head = segment[cursor];
123
- if (head === undefined || head.value !== head.raw) return [];
170
+ // A segment of plain assignments only (`f=$(ls)`) runs no command.
171
+ if (head === undefined && cursor > 0 && segment[0].value !== "env") continue;
172
+ if (head === undefined || head.value !== head.raw) return undefined;
124
173
  const name = head.value;
125
174
  if (INERT_COMMANDS.has(name)) continue;
126
- if (!DATA_COMMANDS.has(name)) return [];
175
+ if (!DATA_COMMANDS.has(name)) return undefined;
127
176
  ranges.push([head.index + head.raw.length, tokens[end]?.index ?? command.length]);
128
177
  }
129
178
  // A heredoc body belongs to the segment of its `<<` header, which the
@@ -148,12 +197,58 @@ function sqlDestroys(sql: string): boolean {
148
197
  });
149
198
  }
150
199
 
200
+ /** Operands of a plain `rm` invocation, or undefined when any argument is quoted, escaped or disables root protection. */
201
+ function removalTargets(args: readonly Token[]): string[] | undefined {
202
+ const targets: string[] = [];
203
+ let options = true;
204
+ for (const arg of args) {
205
+ if (arg.raw !== arg.value || arg.value === "--no-preserve-root") return undefined;
206
+ if (options && arg.value === "--") options = false;
207
+ else if (options && arg.value.startsWith("-")) continue;
208
+ else targets.push(arg.value);
209
+ }
210
+ return targets.length > 0 ? targets : undefined;
211
+ }
212
+
213
+ /**
214
+ * Bodies of backtick command substitutions outside single quotes, with the
215
+ * backslash escapes that backticks remove, and the offset of each opening
216
+ * backtick. An unterminated substitution is a shell syntax error and runs
217
+ * nothing, so it yields no body. Heredoc bodies stay data, as elsewhere.
218
+ */
219
+ function backtickBodies(command: string): Array<{ body: string; index: number }> {
220
+ const bodies: Array<{ body: string; index: number }> = [];
221
+ const text = blankHeredocs(command);
222
+ let quote: "'" | '"' | undefined;
223
+ for (let index = 0; index < text.length; index++) {
224
+ const char = text[index];
225
+ if (quote === "'") {
226
+ if (char === "'") quote = undefined;
227
+ } else if (char === "\\") {
228
+ index++;
229
+ } else if (char === "'" && quote === undefined) {
230
+ quote = "'";
231
+ } else if (char === '"') {
232
+ quote = quote === '"' ? undefined : '"';
233
+ } else if (char === "`") {
234
+ let end = index + 1;
235
+ while (end < text.length && text[end] !== "`") end += text[end] === "\\" ? 2 : 1;
236
+ if (end >= text.length) break;
237
+ bodies.push({ body: text.slice(index + 1, end).replace(/\\([\\$`])/g, "$1"), index });
238
+ index = end;
239
+ }
240
+ }
241
+ return bodies;
242
+ }
243
+
151
244
  export function recognizeDestructiveCommands(command: string, depth = 0): DestructiveCommandMatch[] {
152
245
  if (depth > 4) return [];
153
246
  const tokens = tokenize(command);
154
247
  const matches: DestructiveCommandMatch[] = [];
155
248
  let previous: Token[] = [];
156
249
  let previousSeparator = "";
250
+ // True while every earlier segment was a leading literal recursive `rm`.
251
+ let leadingRemovals = depth === 0;
157
252
  let start = 0;
158
253
  for (let end = 0; end <= tokens.length; end++) {
159
254
  if (end < tokens.length && !tokens[end].operator) continue;
@@ -172,6 +267,11 @@ export function recognizeDestructiveCommands(command: string, depth = 0): Destru
172
267
  matches.push(...recognizeDestructiveCommands(args[flag + 1], depth + 1).map((match) => ({ ...match, triggerIndex: head.index })));
173
268
  }
174
269
  }
270
+ // `eval` runs its arguments joined by spaces, like a literal `sh -c` payload.
271
+ if (name === "eval") {
272
+ const payload = (args[0] === "--" ? args.slice(1) : args).join(" ");
273
+ matches.push(...recognizeDestructiveCommands(payload, depth + 1).map((match) => ({ ...match, triggerIndex: head.index })));
274
+ }
175
275
  if (["psql", "mysql", "mariadb", "sqlite3"].includes(name)) {
176
276
  const payloads = args.map((arg) => arg.replace(/^(?:--command|--execute)=/, ""));
177
277
  const heredoc = command.slice(head.index).match(/^[^\n]*<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/);
@@ -184,6 +284,13 @@ export function recognizeDestructiveCommands(command: string, depth = 0): Destru
184
284
  if (name === "rm" && args.some((arg) => /^-[^-]*[rR]/.test(arg) || arg === "--recursive")) {
185
285
  const rootTarget = args.some((arg) => /^(?:\/(?:\*+)?$|\.{1,2}\/?$|~(?:\/|$)|\$\{?HOME\}?(?:\/|$))/.test(arg));
186
286
  add(DESTRUCTION_KIND.FILESYSTEM, rootTarget);
287
+ const sequential = SEQUENTIAL_SEPARATORS.has(tokens[end]?.raw ?? "");
288
+ const targets = leadingRemovals && sequential && cursor === 0 && head.raw === "rm" ? removalTargets(segment.slice(1)) : undefined;
289
+ if (targets !== undefined) matches[matches.length - 1].removalTargets = targets;
290
+ else leadingRemovals = false;
291
+ } else if (segment.length > 0 || tokens[end]?.raw !== "\n") {
292
+ // Only blank lines may sit between leading removals.
293
+ leadingRemovals = false;
187
294
  }
188
295
  if (name === "find") {
189
296
  if (args.includes("-delete")) add(DESTRUCTION_KIND.FILESYSTEM);
@@ -216,5 +323,9 @@ export function recognizeDestructiveCommands(command: string, depth = 0): Destru
216
323
  previousSeparator = tokens[end]?.value ?? "";
217
324
  start = end + 1;
218
325
  }
326
+ // Backtick substitutions run like `$(...)`, also inside double quotes.
327
+ for (const { body, index } of backtickBodies(command)) {
328
+ matches.push(...recognizeDestructiveCommands(body, depth + 1).map((match) => ({ ...match, triggerIndex: index })));
329
+ }
219
330
  return matches;
220
331
  }
@@ -0,0 +1,146 @@
1
+ // Gauge theme roles from the installed theme source (issue #2222).
2
+ //
3
+ // Pi's theme schema is closed at every level (colors, export and the theme
4
+ // object itself reject unknown properties) and `theme.fg` throws on unknown
5
+ // tokens, so gauge roles cannot be theme colors or a top-level block. The
6
+ // one open map is `vars` (a free-form record of color values), so themes
7
+ // declare the roles there under reserved `gauge.` keys. Color values resolve
8
+ // like any Pi theme value: an existing theme token name (painted through
9
+ // `theme.fg`), or a direct color (hex, okhsl, or a 256-color index, painted
10
+ // as raw ANSI so the gauges alone recolor). Glyph values are literal single
11
+ // characters. Everything is optional and falls back per role to the gauge's
12
+ // historical roles, so themes without the keys — and invalid values — render
13
+ // exactly as before.
14
+
15
+ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
16
+ import { colorToRgb, parseColor } from "@earendil-works/pi-tui";
17
+ import { visibleWidth } from "@earendil-works/pi-tui";
18
+ import type { GaugePaint } from "./shell-gauge.ts";
19
+
20
+ const MAX_BYTES = 256_000;
21
+ const REFERENCE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
22
+ const COLOR_KEYS = ["ok", "warning", "error", "empty"] as const;
23
+ const GLYPH_KEYS = ["filled", "emptyGlyph"] as const;
24
+ // Reserved `vars` names that carry the gauge customization, e.g. "vars": { "gauge.ok": "#22c55e" }.
25
+ const gaugeVar = (key: string): string => `gauge.${key}`;
26
+
27
+ interface CacheEntry {
28
+ mtimeMs: number;
29
+ size: number;
30
+ paint: GaugePaint | undefined;
31
+ }
32
+
33
+ // The bar and panel render many times a second, so a render pays at most one
34
+ // statSync per call; the file is opened and parsed only when the stat changes,
35
+ // and a failed resolve is cached like any other result so a malformed theme
36
+ // costs no repeated reads on the render path.
37
+ const cache = new Map<string, CacheEntry>();
38
+
39
+ function record(value: unknown): value is Record<string, unknown> {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ }
42
+
43
+ /** One visible terminal column, single code point, no control characters or escape sequences. */
44
+ function isGlyph(value: unknown): value is string {
45
+ return typeof value === "string" && [...value].length === 1 && visibleWidth(value) === 1 && !/[\u0000-\u001f\u007f\x1b]/.test(value);
46
+ }
47
+
48
+ // A color entry resolves like a Pi theme value: variable references expand
49
+ // first, then either an existing theme token keeps its name (so the theme's
50
+ // own resolved ANSI applies) or the value delegates to pi-tui's `parseColor`,
51
+ // the same grammar themes use everywhere else (3 or 6 digit hex, oklch,
52
+ // case-insensitive okhsl), and becomes raw foreground ANSI. Direct colors are
53
+ // what let a theme recolor only the gauges, exactly like the issue's
54
+ // green/yellow/red statusline use case.
55
+ function resolveGaugeColor(value: unknown, colors: Record<string, unknown>, vars: Record<string, unknown>): string | undefined {
56
+ const seen = new Set<unknown>();
57
+ while (typeof value === "string" && REFERENCE.test(value) && Object.hasOwn(vars, value)) {
58
+ if (seen.has(value)) return undefined;
59
+ seen.add(value);
60
+ value = vars[value];
61
+ }
62
+ if (typeof value === "string" && Object.hasOwn(colors, value)) return value;
63
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255) return `\x1b[38;5;${value}m`;
64
+ if (typeof value === "string") {
65
+ try {
66
+ const { r, g, b } = colorToRgb(parseColor(value));
67
+ return `\x1b[38;2;${r};${g};${b}m`;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /** Read only the installed theme source supplied by Pi; never fall back to the active theme. */
76
+ function readThemeSource(sourcePath: string): unknown {
77
+ const resolved = realpathSync(sourcePath);
78
+ const fd = openSync(resolved, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
79
+ let text: string;
80
+ try {
81
+ const stat = fstatSync(fd);
82
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error("Theme source unavailable or too large.");
83
+ text = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(fd));
84
+ } finally {
85
+ closeSync(fd);
86
+ }
87
+ return JSON.parse(text);
88
+ }
89
+
90
+ /**
91
+ * Gauge roles and glyphs declared by the theme source, or undefined when the
92
+ * theme declares none. Invalid entries are dropped individually, never
93
+ * throwing: a bad `gaugeWarning` keeps `warning` rather than disabling the rest.
94
+ */
95
+ export function resolveGaugePaint(sourcePath: string): GaugePaint | undefined {
96
+ const document: unknown = readThemeSource(sourcePath);
97
+ if (!record(document) || !record(document.vars)) return undefined;
98
+ const vars = document.vars;
99
+ const hasGaugeVars = COLOR_KEYS.some((key) => gaugeVar(key) in vars) || GLYPH_KEYS.some((key) => gaugeVar(key) in vars);
100
+ if (!hasGaugeVars) return undefined;
101
+ const colors = record(document.colors) ? document.colors : {};
102
+ const paint: GaugePaint = {};
103
+ for (const key of COLOR_KEYS) {
104
+ const declared = vars[gaugeVar(key)];
105
+ if (declared === undefined) continue;
106
+ const value = resolveGaugeColor(declared, colors, vars);
107
+ if (value !== undefined) paint[key] = value;
108
+ }
109
+ for (const key of GLYPH_KEYS) {
110
+ const value = vars[gaugeVar(key)];
111
+ if (isGlyph(value)) paint[key] = value;
112
+ }
113
+ return paint;
114
+ }
115
+
116
+ /** Memoized `resolveGaugePaint`; undefined whenever the theme source is unavailable or invalid. */
117
+ export function gaugePaintFor(sourcePath: string | undefined): GaugePaint | undefined {
118
+ if (!sourcePath) return undefined;
119
+ let stat;
120
+ try {
121
+ stat = statSync(sourcePath);
122
+ } catch {
123
+ // A missing source stays uncached: recreating it must take effect.
124
+ return undefined;
125
+ }
126
+ const cached = cache.get(sourcePath);
127
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.paint;
128
+ // Validate the stat before any open: a path that is not a regular file (a
129
+ // FIFO, a device) must never reach openSync, and an oversized file must
130
+ // never reach a read. Failures resolve to the historical roles.
131
+ let paint: GaugePaint | undefined;
132
+ if (stat.isFile() && stat.size <= MAX_BYTES) {
133
+ try {
134
+ paint = resolveGaugePaint(sourcePath);
135
+ } catch {
136
+ paint = undefined;
137
+ }
138
+ }
139
+ cache.set(sourcePath, { mtimeMs: stat.mtimeMs, size: stat.size, paint });
140
+ return paint;
141
+ }
142
+
143
+ /** Test hook: drop every cached theme source entry. */
144
+ export function clearGaugePaintCache(): void {
145
+ cache.clear();
146
+ }
@@ -14,7 +14,7 @@ import {
14
14
  resolveGentleAiReleaseAsset,
15
15
  } from "../scripts/gentle-ai-installer.mjs";
16
16
  import { engramPinVersion } from "../scripts/installer-downloads.mjs";
17
- import { bundledGentlePiConfigHome, bundledPiConfigDir, bundledPiConfigParent, runningBundledPrefix } from "./gentle-shell-bundled.ts";
17
+ import { bundledPiConfigDir, bundledPiConfigParent, GENTLE_SHELL_CONFIG_HOME_ENV, resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts";
18
18
  import { fileURLToPath } from "node:url";
19
19
 
20
20
  export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
@@ -86,9 +86,11 @@ export function bundledGentleAiPaths(prefix: string, platform: string = process.
86
86
  * (only when the pinned Engram is published), GENTLE_AI_NO_SELF_UPDATE=1 and
87
87
  * GENTLE_PI_CONFIG_HOME (the parent of GENTLE_AI_PI_CONFIG_DIR)
88
88
  * when this gentle-pi runs from the valid bundled install GENTLE_SHELL_PREFIX
89
- * names (runningBundledPrefix). Otherwise `env` itself, unchanged: a gentle-pi
90
- * installed elsewhere (the user's own, started from the agent's shell, which
91
- * inherits the variable) keeps its own Gentle AI.
89
+ * names (runningBundledPrefix). In the standalone launcher's isolated home
90
+ * (#2201) only the config pair, for the folder its marker names. Otherwise
91
+ * `env` itself, unchanged: a gentle-pi installed elsewhere (the user's own,
92
+ * started from the agent's shell, which inherits the variable) keeps its own
93
+ * Gentle AI.
92
94
  */
93
95
  export function gentleAiIsolationEnv(
94
96
  env: NodeJS.ProcessEnv,
@@ -96,7 +98,7 @@ export function gentleAiIsolationEnv(
96
98
  ): NodeJS.ProcessEnv {
97
99
  const platform = options.platform ?? process.platform;
98
100
  const prefix = runningBundledPrefix(env, { packageRoot: options.packageRoot, platform });
99
- if (prefix === undefined) return env;
101
+ if (prefix === undefined) return standaloneGentleAiEnv(env, platform);
100
102
  const paths = bundledGentleAiPaths(prefix, platform);
101
103
  let engram = false;
102
104
  try {
@@ -125,6 +127,23 @@ export function gentleAiIsolationEnv(
125
127
  };
126
128
  }
127
129
 
130
+ // #2201: the same config pair as a bundled install, for the folder the
131
+ // standalone launcher's marker names, when it decides the config home
132
+ // (resolveGentlePiConfigHome; an explicit GENTLE_PI_CONFIG_HOME keeps its
133
+ // existing meaning for Gentle AI). Gentle AI's own state stays where it was.
134
+ // The parent is given only for a folder named gentle-ai, the one Gentle AI
135
+ // 4.0.0 appends to it.
136
+ function standaloneGentleAiEnv(env: NodeJS.ProcessEnv, platform: string): NodeJS.ProcessEnv {
137
+ const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
138
+ if (marker === undefined || resolveGentlePiConfigHome(env, homedir(), { platform }) !== marker) return env;
139
+ const path = platform === "win32" ? win32 : posix;
140
+ return {
141
+ ...env,
142
+ GENTLE_AI_PI_CONFIG_DIR: marker,
143
+ ...(path.basename(marker) === "gentle-ai" ? { GENTLE_PI_CONFIG_HOME: path.dirname(marker) } : {}),
144
+ };
145
+ }
146
+
128
147
  function sha256(value: Buffer): string {
129
148
  return createHash("sha256").update(value).digest("hex");
130
149
  }
@@ -136,7 +155,9 @@ function sha256(value: Buffer): string {
136
155
  // 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then
137
156
  // 2. the persistent registration file at
138
157
  // <config home>/dev-binary.json (a bundled prefix's gentle-pi, else
139
- // GENTLE_PI_CONFIG_HOME, else ~/.pi/gentle-ai) with the strict
158
+ // GENTLE_PI_CONFIG_HOME, else ~/.pi/gentle-ai, never the standalone
159
+ // launcher's marker: the main channel writes it outside any session,
160
+ // #2201) with the strict
140
161
  // shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}.
141
162
  //
142
163
  // The registration deliberately pins no digest: it is the unpinned field-test
@@ -194,7 +215,7 @@ function ambientDevBinaryEnvironment(): GentleAiDevBinaryEnvironment {
194
215
  }
195
216
 
196
217
  export function gentleAiDevBinaryRegistrationPath(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): string {
197
- const configHome = bundledGentlePiConfigHome(environment.env) ?? environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai");
218
+ const configHome = resolveGentlePiConfigHome(environment.env, environment.home, { launcherMarker: false });
198
219
  return join(configHome, "dev-binary.json");
199
220
  }
200
221
 
@@ -175,6 +175,29 @@ export function bundledGentlePiConfigHome(env: Env): string | undefined {
175
175
  return prefix === undefined ? undefined : bundledPiConfigDir(prefix);
176
176
  }
177
177
 
178
+ // #2201: the standalone launcher gives Pi this variable in its isolated home
179
+ // only, naming the config folder next to the agent dir (standaloneConfigHome:
180
+ // ~/.gentle-shell/gentle-ai by default). It is launcher-owned, never
181
+ // GENTLE_PI_CONFIG_HOME: Gentle AI 4.0.0 reads that one as the parent of its
182
+ // own gentle-ai folder, and the agent's shell would pass it to any gentle-ai.
183
+ export const GENTLE_SHELL_CONFIG_HOME_ENV = "GENTLE_SHELL_CONFIG_HOME";
184
+
185
+ // gentle-pi's config home, the one rule every reader shares: a bundled
186
+ // prefix's folder, then an explicit GENTLE_PI_CONFIG_HOME, then the launcher's
187
+ // marker (absolute only), then ~/.pi/gentle-ai. Empty values count as unset.
188
+ // `launcherMarker: false` is for install-level state written outside a session
189
+ // (the main channel's channel.json, builds and dev-binary.json): the installer
190
+ // and `gentle-shell upgrade` never see the marker, so their readers ignore it.
191
+ export function resolveGentlePiConfigHome(env: Env, home: string, options: { launcherMarker?: boolean; platform?: string } = {}): string {
192
+ const path = pathFor(options.platform ?? process.platform);
193
+ const bundled = bundledGentlePiConfigHome(env);
194
+ if (bundled !== undefined) return bundled;
195
+ if (env.GENTLE_PI_CONFIG_HOME) return env.GENTLE_PI_CONFIG_HOME;
196
+ const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
197
+ if (options.launcherMarker !== false && marker && path.isAbsolute(marker)) return marker;
198
+ return path.join(home, ".pi", "gentle-ai");
199
+ }
200
+
178
201
  // The exact Pi of the version (S8): the Pi the version's package.json pins,
179
202
  // installed inside the version folder with Pi's own bin entry. Never another Pi.
180
203
  export function bundledPi(install: BundledInstall, input: { platform: string; fs: BundledFs }): BundledPi {
@@ -213,6 +236,8 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
213
236
  // memory is allowed locally; otherwise the Shell-owned guard, so no provider,
214
237
  // whatever loaded it, runs an Engram binary against an unverified store.
215
238
  export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
239
+ 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.";
240
+ 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.";
216
241
  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.";
217
242
  // `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
218
243
  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.";
@@ -236,13 +261,13 @@ function outdatedServerVersion(version: string | undefined): string | undefined
236
261
  // The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
237
262
  export const ENGRAM_DEFAULT_PORT = 7437;
238
263
  export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
239
- const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"];
264
+ const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL"];
240
265
  const HEX32 = /^[a-f0-9]{32}$/;
241
266
 
242
267
  export type EngramProbe = { kind: "refused" } | { kind: "unknown" } | { kind: "listening"; compatible: boolean; healthInstanceId?: string; healthVersion?: string };
243
268
  // `fresh`: Shell provisions a new store this run and may record its provenance.
244
269
  // `url`: a running server verified through /compatibility, reached explicitly.
245
- export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; fresh?: true };
270
+ export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; fresh?: true; owned?: true };
246
271
 
247
272
  // Without an invalid policy or an explicit Engram selection, the default
248
273
  // endpoint may be probed; an explicit selection is never contacted.
@@ -270,7 +295,7 @@ export function classifyEngramStartup(input: { env: Env; store: "absent" | "pres
270
295
  // Shell's own store, created by this exact core: offline, the provider starts
271
296
  // that core; a running server must be that store and that exact core.
272
297
  if (endpoint.kind === "refused") return { policy: "allow" };
273
- if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow" };
298
+ if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
274
299
  return unknown;
275
300
  }
276
301
 
@@ -401,6 +426,11 @@ export function supportsEngramStartupPolicy(metadata: string | undefined): boole
401
426
  return pkg?.name === "gentle-engram" && pkg.engram?.startupPolicy === 1;
402
427
  }
403
428
 
429
+ export function supportsEngramSessionOwned(metadata: string | undefined): boolean {
430
+ const pkg = parseJson(metadata);
431
+ return supportsEngramStartupPolicy(metadata) && pkg?.engram?.sessionOwned === 1;
432
+ }
433
+
404
434
  export function bundledChildEnv(base: Env, input: { node: string; cli: string; prefix: string; engram?: string; guard?: string; memory?: EngramStartup }): Env {
405
435
  const env: Env = { ...base, PI_SKIP_VERSION_CHECK: "1", [GENTLE_SHELL_PREFIX_ENV]: input.prefix };
406
436
  delete env.GENTLE_SHELL_PI;
@@ -425,6 +455,12 @@ export function bundledChildEnv(base: Env, input: { node: string; cli: string; p
425
455
  if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
426
456
  const local = memory.policy === "allow" && memory.url === undefined ? input.engram : undefined;
427
457
  env.ENGRAM_BIN = local ?? input.guard;
458
+ if (local && memory.owned) {
459
+ env.ENGRAM_SERVER_MODE = "session-owned";
460
+ delete env.ENGRAM_URL;
461
+ delete env.ENGRAM_PORT;
462
+ delete env.ENGRAM_SOCKET;
463
+ }
428
464
  return env;
429
465
  }
430
466