gentle-pi 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +8 -5
  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 +101 -151
  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 +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  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 +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  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 +2 -0
  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 +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -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/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -1,11 +1,17 @@
1
- import { chmod, constants, link, lstat, mkdir, open, opendir, realpath, unlink, writeFile } from "node:fs/promises";
1
+ import { chmod, constants, link, lstat, mkdir, open, opendir, readdir, realpath, unlink, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { createConnection, createServer, type Server, type Socket } from "node:net";
5
5
 
6
6
  const DIR_MODE = 0o700, FILE_MODE = 0o600, MAX_RECORD_BYTES = 8192, MAX_ENTRIES = 64, MAX_SOCKET_PATH_BYTES = 100;
7
+ // Over-cap debris collection bounds: a presence directory may hold at most
8
+ // this many schema-matching records before sweeping is refused, and each
9
+ // liveness probe gets a short bounded connect attempt. A genuine flood of
10
+ // live listeners stays over the cap and keeps failing busy, unchanged.
11
+ const SWEEP_MAX_FILES = 4096, SWEEP_PROBE_TIMEOUT_MS = 1_000, SWEEP_PROBE_CONCURRENCY = 8;
7
12
  const RUNTIME_TMP = "/tmp", RUNTIME_PREFIX = "gentle-pi-", PROFILE_HASH_HEX_LENGTH = 32, SOCKET_NAME_BYTES = 27;
8
13
  const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, TOKEN = /^[A-Za-z0-9_-]{22}$/;
14
+ const PRESENCE_FILE = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/;
9
15
  const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
10
16
  type Code = "invalid_session" | "not_found" | "invalid_presence" | "unsafe_path" | "io_error" | "busy";
11
17
 
@@ -131,7 +137,25 @@ export class SessionPresenceRegistry {
131
137
  return (await this.listActivations(excludeSessionId)).map(({ sessionId }) => Object.freeze({ sessionId, reachability: "unknown" as const }));
132
138
  }
133
139
 
134
- async listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
140
+ // An over-cap failure is stale debris until proven otherwise: sessions that
141
+ // exit uncleanly never remove their record, so a long-lived profile accrues
142
+ // them until every scan fails busy (gentle-shell#2501: 133 records, oldest
143
+ // four weeks old, 126 provably dead). Sweep once — bounded, liveness-based —
144
+ // and retry; a flood of live listeners stays over the cap and still fails.
145
+ private async withStaleSweep<T>(scan: () => Promise<T>): Promise<T> {
146
+ try { return await scan(); }
147
+ catch (error) {
148
+ if (!(error instanceof SessionPresenceError) || error.code !== "busy") throw error;
149
+ if (!await this.sweepStaleRecords().catch(() => false)) throw error;
150
+ return scan();
151
+ }
152
+ }
153
+
154
+ listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
155
+ return this.withStaleSweep(() => this.scanActivations(excludeSessionId));
156
+ }
157
+
158
+ private async scanActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
135
159
  const excluded = excludeSessionId === undefined ? undefined : sessionId(excludeSessionId);
136
160
  await privateDirectory(this.paths.root);
137
161
  await privateDirectory(this.paths.presence);
@@ -142,9 +166,12 @@ export class SessionPresenceRegistry {
142
166
  try {
143
167
  dir = await opendir(this.paths.presence);
144
168
  for await (const entry of dir) {
145
- if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
146
- const match = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/.exec(entry.name);
169
+ const match = PRESENCE_FILE.exec(entry.name);
170
+ // Foreign-schema records (orchestrator headers, activity, and discovery
171
+ // sidecars) share this directory without ever being activations, so
172
+ // only schema-matching entries count toward the spam guard.
147
173
  if (!match) continue;
174
+ if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
148
175
  try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
149
176
  try {
150
177
  const loaded = await this.read(join(this.paths.presence, entry.name), match[1], match[2]);
@@ -160,7 +187,11 @@ export class SessionPresenceRegistry {
160
187
  return [...newest.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId));
161
188
  }
162
189
 
163
- async resolve(id: string): Promise<PresenceRecord> {
190
+ resolve(id: string): Promise<PresenceRecord> {
191
+ return this.withStaleSweep(() => this.scanResolve(id));
192
+ }
193
+
194
+ private async scanResolve(id: string): Promise<PresenceRecord> {
164
195
  id = sessionId(id);
165
196
  await privateDirectory(this.paths.root);
166
197
  await privateDirectory(this.paths.presence);
@@ -170,9 +201,11 @@ export class SessionPresenceRegistry {
170
201
  try {
171
202
  dir = await opendir(this.paths.presence);
172
203
  for await (const entry of dir) {
173
- if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
174
204
  const match = new RegExp(`^${id}\\.([A-Za-z0-9_-]{22})\\.json$`).exec(entry.name);
205
+ // Same shared-directory rule as listActivations: foreign-schema
206
+ // records are skipped before counting toward the cap.
175
207
  if (!match) continue;
208
+ if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
176
209
  try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
177
210
  try {
178
211
  const loaded = await this.read(join(this.paths.presence, entry.name), id, match[1]);
@@ -215,6 +248,68 @@ export class SessionPresenceRegistry {
215
248
  }
216
249
  }
217
250
 
251
+ // One bounded connect attempt against a candidate's own socket endpoint.
252
+ // Endpoints reaching this probe were validated into this registry's sockets
253
+ // directory and lstat-verified as same-user sockets by advertises(), so the
254
+ // probe cannot reach foreign services. Only an active refusal proves the
255
+ // record dead: an ambiguous outcome (timeout, unexpected error) keeps it.
256
+ private liveness(endpoint: string): Promise<"live" | "dead" | "unclear"> {
257
+ return new Promise((resolveProbe) => {
258
+ const socket = createConnection(endpoint);
259
+ const settle = (verdict: "live" | "dead" | "unclear") => {
260
+ clearTimeout(timer);
261
+ socket.destroy();
262
+ resolveProbe(verdict);
263
+ };
264
+ const timer = setTimeout(() => settle("unclear"), SWEEP_PROBE_TIMEOUT_MS);
265
+ socket.once("connect", () => settle("live"));
266
+ socket.once("error", (error: NodeJS.ErrnoException) => settle(error.code === "ETIMEDOUT" ? "unclear" : "dead"));
267
+ });
268
+ }
269
+
270
+ // Garbage-collects provably dead records once the directory is over cap:
271
+ // no live endpoint (advertises false, i.e. the socket is gone) or a refused
272
+ // connect. Removes the record files only — socket files belong to their
273
+ // owning session. Returns whether anything was removed, so the caller
274
+ // retries the scan exactly once; nothing here runs on a healthy directory.
275
+ private async sweepStaleRecords(): Promise<boolean> {
276
+ const names = (await readdir(this.paths.presence)).filter((name) => PRESENCE_FILE.test(name));
277
+ if (names.length === 0 || names.length > SWEEP_MAX_FILES) return false;
278
+ let removed = 0;
279
+ const remove = async (file: string) => {
280
+ try { await unlink(file); removed += 1; }
281
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") removed += 1; }
282
+ };
283
+ const live: PresenceRecord[] = [];
284
+ for (const name of names) {
285
+ const match = PRESENCE_FILE.exec(name);
286
+ if (!match) continue;
287
+ try {
288
+ const loaded = await this.read(join(this.paths.presence, name), match[1], match[2]);
289
+ const record = loaded?.record;
290
+ // A record that no longer validates (malformed, pruned endpoint) is
291
+ // debris; an unsafe_path one is left for its owner, never touched.
292
+ if (!record) { await remove(join(this.paths.presence, name)); continue; }
293
+ if (await this.advertises(record)) live.push(record);
294
+ else await remove(join(this.paths.presence, name));
295
+ } catch (error) {
296
+ // Malformed content is debris from an interrupted write and is
297
+ // collected; unsafe-path records are left for their owner, never
298
+ // touched by the sweep.
299
+ if (error instanceof SessionPresenceError && error.code === "invalid_presence") await remove(join(this.paths.presence, name));
300
+ }
301
+ }
302
+ let next = 0;
303
+ const worker = async () => {
304
+ while (next < live.length) {
305
+ const record = live[next++];
306
+ if (await this.liveness(record.endpoint) === "dead") await remove(this.presencePath(record));
307
+ }
308
+ };
309
+ await Promise.all(Array.from({ length: Math.min(SWEEP_PROBE_CONCURRENCY, live.length) }, worker));
310
+ return removed > 0;
311
+ }
312
+
218
313
  private token(endpoint: unknown) {
219
314
  if (typeof endpoint !== "string" || dirname(endpoint) !== this.paths.sockets || resolve(endpoint) !== endpoint || Buffer.byteLength(endpoint) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
220
315
  const name = basename(endpoint), token = name.endsWith(".sock") ? name.slice(0, -5) : "";
@@ -1,6 +1,7 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { isFinished, TASK_STATUS, type TaskRecord, type TaskStatus } from "./agents-protocol.ts";
3
3
  import { formatCost, formatTokens } from "./shell-bar.ts";
4
+ import { paintHoverable } from "./shell-hover.ts";
4
5
  import { CARD_TONE, panelExtraRows, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
5
6
 
6
7
  // Gentle Agents widget: the card above the editor. Reads task records only
@@ -17,6 +18,10 @@ export interface AgentsWidgetOptions {
17
18
  // "… N more" line so the card never pushes the editor off the screen.
18
19
  maxRows?: number;
19
20
  viewKey?: string;
21
+ // Paint the clickable "▾ Collapse" / "▸ Expand" control in the header, the
22
+ // same control the Todos card carries; hovered paints its hover role.
23
+ collapseControl?: boolean;
24
+ hovered?: boolean;
20
25
  }
21
26
 
22
27
  interface StatusLook {
@@ -297,6 +302,11 @@ function batchElapsed(tasks: readonly TaskRecord[], now: number): string | undef
297
302
  return formatElapsed(end - Math.min(...starts));
298
303
  }
299
304
 
305
+ /** The tone the card paints for these tasks, so a host can locate its header row. */
306
+ export function agentsCardTone(tasks: readonly TaskRecord[], now: number): CardTone {
307
+ return tone(widgetTasks(tasks, now));
308
+ }
309
+
300
310
  export function renderAgentsCard(tasks: readonly TaskRecord[], theme: CardTheme, width: number, now: number, options: AgentsWidgetOptions): string[] {
301
311
  const shown = widgetTasks(tasks, now);
302
312
  if (shown.length === 0) return [];
@@ -310,8 +320,11 @@ export function renderAgentsCard(tasks: readonly TaskRecord[], theme: CardTheme,
310
320
  const hint = options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : shown.length > 1 ? batchElapsed(shown, now) : undefined;
311
321
  const body = listed.flatMap((task) => row(task, theme, cols, now, options.maxRows === undefined));
312
322
  if (hidden > 0) body.push(overflowRow(hidden, theme, options.viewKey));
323
+ const icon = options.collapsed ? "▸" : "▾";
324
+ const control = `${icon} ${width >= 28 ? (options.collapsed ? "Expand" : "Collapse") : ""}`.trimEnd();
325
+ const title = options.collapseControl ? `Agents ${paintHoverable(theme, control, options.hovered ?? false, "accent")}` : "Agents";
313
326
  return renderCard(
314
- { title: "Agents", subtitle: counts(shown), body, tone: tone(shown), glyph: AGENTS_GLYPH },
327
+ { title, subtitle: counts(shown), body, tone: tone(shown), glyph: AGENTS_GLYPH },
315
328
  theme,
316
329
  width,
317
330
  { expanded: true, hint, panel: true },
@@ -6,23 +6,41 @@ export interface AutoPermissionMemoryContext {
6
6
  project: string;
7
7
  cwd: string;
8
8
  }
9
+ /** First failed provenance predicate, in evaluation order. */
10
+ export type AutoPermissionMemorySourceRejection = "tool-missing" | "tool-ambiguous" | "metadata-missing" | "origin" | "source-format" | "path";
11
+ /** Closed, content-free observation of the latest binder decision; never an authorization input. */
12
+ export type AutoPermissionMemoryDiagnostic =
13
+ | {stage:"admission" | "write"; reason:"untrusted-source"; sourceRejection:AutoPermissionMemorySourceRejection}
14
+ | ({sourceRejection?:undefined} & (
15
+ | {stage:"admission"; reason:"admitted" | "invalid-request" | "unexpected-error"}
16
+ | {stage:"resolution"; reason:"bound" | "provider-error" | "stale-identity" | "invalid-request" | "invalid-result" | "unexpected-error"}
17
+ | {stage:"write"; reason:"attached" | "stale-binding" | "source-mismatch" | "destination-mismatch" | "invalid-arguments" | "unexpected-error"}
18
+ | {stage:"clear"; reason:"binding-cleared"}));
9
19
  type Call = Pick<ToolCallEvent,"toolName"|"input"|"toolCallId">;
10
20
  type Identity = {manager: ExtensionContext["sessionManager"]; id:string; cwd:string; epoch:number; anchor:string; source:string};
21
+ type Source = {source:string; rejection?:undefined} | {source?:undefined; rejection:AutoPermissionMemorySourceRejection};
11
22
  /** Live native-provider evidence only: never restore bindings from transcript text. */
12
23
  export class AutoPermissionMemory {
13
24
  private readonly tools: () => ToolInfo[];
14
25
  constructor(tools: () => ToolInfo[]) { this.tools=tools; }
15
26
  private pending?: Identity & {callId:string};
16
27
  private binding?: Identity & {project:string};
17
- clear(): void { this.pending=undefined;this.binding=undefined; }
18
- private source(name:string):string|undefined {
28
+ private last?: AutoPermissionMemoryDiagnostic;
29
+ /** Snapshot of the latest decision; mutating it never affects the binder. */
30
+ diagnostic(): AutoPermissionMemoryDiagnostic | undefined { return this.last && {...this.last}; }
31
+ clear(): void { this.reset();this.last={stage:"clear",reason:"binding-cleared"}; }
32
+ private reset(): void { this.pending=undefined;this.binding=undefined; }
33
+ private inspect(name:string):Source {
19
34
  const matches=this.tools().filter(tool=>tool.name===name);
20
- if(matches.length!==1)return undefined;
35
+ if(matches.length!==1)return {rejection:matches.length ? "tool-ambiguous" : "tool-missing"};
21
36
  const s=matches[0].sourceInfo;
22
- if(!s || s.origin!=="package" || !/^npm:gentle-engram(?:@[^\s/]+)?$/.test(s.source) ||
23
- typeof s.path!=="string" || !s.path || s.path.startsWith("builtin:") || s.path.startsWith("<"))return undefined;
24
- return JSON.stringify([s.path,s.source,s.scope,s.origin,s.baseDir ?? null]);
37
+ if(!s)return {rejection:"metadata-missing"};
38
+ if(s.origin!=="package")return {rejection:"origin"};
39
+ if(!/^npm:gentle-engram(?:@[^\s/]+)?$/.test(s.source))return {rejection:"source-format"};
40
+ if(typeof s.path!=="string" || !s.path || s.path.startsWith("builtin:") || s.path.startsWith("<"))return {rejection:"path"};
41
+ return {source:JSON.stringify([s.path,s.source,s.scope,s.origin,s.baseDir ?? null])};
25
42
  }
43
+ private source(name:string):string|undefined { return this.inspect(name).source; }
26
44
  private valid(value:Identity,ctx:ExtensionContext,epoch:number):boolean {
27
45
  return value.manager===ctx.sessionManager && value.id===ctx.sessionManager.getSessionId() && value.cwd===ctx.cwd &&
28
46
  value.epoch===epoch && this.source("mem_current_project")===value.source &&
@@ -30,40 +48,52 @@ export class AutoPermissionMemory {
30
48
  }
31
49
  admitted(event:Call,ctx:ExtensionContext,epoch:number):void {
32
50
  if(event.toolName!=="mem_current_project")return;
33
- this.clear();
51
+ this.reset();
34
52
  try {
35
- const source=this.source(event.toolName),id=ctx.sessionManager.getSessionId();
53
+ const {source,rejection}=this.inspect(event.toolName),id=ctx.sessionManager.getSessionId();
36
54
  const args=event.input as Record<string,unknown>;
37
55
  const anchor=ctx.sessionManager.getBranch().at(-1)?.id;
56
+ if(rejection){this.last={stage:"admission",reason:"untrusted-source",sourceRejection:rejection};return;}
38
57
  if(!source || !id || !anchor || !args || args.cwd!==ctx.cwd ||
39
- Object.keys(args).some(key=>key!=="cwd"))return;
58
+ Object.keys(args).some(key=>key!=="cwd")){this.last={stage:"admission",reason:"invalid-request"};return;}
40
59
  this.pending={manager:ctx.sessionManager,id,cwd:ctx.cwd,epoch,anchor,source,callId:event.toolCallId};
41
- } catch { this.clear(); }
60
+ this.last={stage:"admission",reason:"admitted"};
61
+ } catch { this.reset();this.last={stage:"admission",reason:"unexpected-error"}; }
42
62
  }
43
63
  result(event:ToolResultEvent,ctx:ExtensionContext,epoch:number):void {
44
64
  if(event.toolName!=="mem_current_project")return;
45
65
  const pending=this.pending;
46
66
  if(!pending || event.toolCallId!==pending.callId)return;
47
- this.clear();
67
+ this.reset();
48
68
  try {
49
- if(event.isError || !this.valid(pending,ctx,epoch) || (event.input as Record<string,unknown>)?.cwd!==ctx.cwd)return;
69
+ if(event.isError){this.last={stage:"resolution",reason:"provider-error"};return;}
70
+ if(!this.valid(pending,ctx,epoch)){this.last={stage:"resolution",reason:"stale-identity"};return;}
71
+ if((event.input as Record<string,unknown>)?.cwd!==ctx.cwd){this.last={stage:"resolution",reason:"invalid-request"};return;}
50
72
  const data=(event.details as {data?:Record<string,unknown>} | undefined)?.data;
51
73
  if(!data || data.cwd!==ctx.cwd || typeof data.project!=="string" || !data.project.trim() || data.project.length>256 ||
52
74
  /[\p{Cc}\p{Cf}\p{Cs}]/u.test(data.project) || typeof data.project_source!=="string" || !["git_remote","dir_basename"].includes(data.project_source) ||
53
75
  (data.available_projects!=null && (!Array.isArray(data.available_projects) || data.available_projects.length>0)) ||
54
- Object.keys(data).some(key=>/error|warning|fallback/i.test(key)))return;
76
+ Object.keys(data).some(key=>/error|warning|fallback/i.test(key))){this.last={stage:"resolution",reason:"invalid-result"};return;}
55
77
  this.binding={...pending,project:data.project};
56
- } catch { this.clear(); }
78
+ this.last={stage:"resolution",reason:"bound"};
79
+ } catch { this.reset();this.last={stage:"resolution",reason:"unexpected-error"}; }
57
80
  }
81
+ /** Without a binding the diagnostic keeps the cause that prevented one. */
58
82
  context(event:Pick<Call,"toolName"|"input">,ctx:ExtensionContext,epoch:number):AutoPermissionMemoryContext|undefined {
59
83
  try {
60
84
  if(event.toolName!=="mem_save" && event.toolName!=="mem_session_summary")return undefined;
61
85
  const binding=this.binding,args=event.input as Record<string,unknown>;
62
- if(!binding || !this.valid(binding,ctx,epoch) || this.source(event.toolName)!==binding.source ||
63
- !args || args.cwd!==ctx.cwd || args.project!==binding.project || (args.scope!==undefined && args.scope!=="project"))return undefined;
86
+ if(!binding)return undefined;
87
+ if(!this.valid(binding,ctx,epoch)){this.last={stage:"write",reason:"stale-binding"};return undefined;}
88
+ const {source,rejection}=this.inspect(event.toolName);
89
+ if(rejection){this.last={stage:"write",reason:"untrusted-source",sourceRejection:rejection};return undefined;}
90
+ if(source!==binding.source){this.last={stage:"write",reason:"source-mismatch"};return undefined;}
91
+ if(!args || args.cwd!==ctx.cwd || args.project!==binding.project || (args.scope!==undefined && args.scope!=="project")){
92
+ this.last={stage:"write",reason:"destination-mismatch"};return undefined;}
64
93
  const allowed=event.toolName==="mem_save" ? ["cwd","project","title","content","type","scope","topic_key","capture_prompt"] : ["cwd","project","content"];
65
- if(Object.keys(args).some(key=>!allowed.includes(key)))return undefined;
94
+ if(Object.keys(args).some(key=>!allowed.includes(key))){this.last={stage:"write",reason:"invalid-arguments"};return undefined;}
95
+ this.last={stage:"write",reason:"attached"};
66
96
  return {provider:"gentle-engram",operation:event.toolName,project:binding.project,cwd:binding.cwd};
67
- } catch { return undefined; }
97
+ } catch { this.last={stage:"write",reason:"unexpected-error"};return undefined; }
68
98
  }
69
99
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, realpathSync } from "node:fs";
1
+ import { lstatSync, realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext, InputEvent, ToolCallEvent, ToolCallEventResult, ToolResultEvent } from "@earendil-works/pi-coding-agent";
4
4
  import { gentlePiConfigHome } from "./agent-home.ts";
@@ -18,7 +18,7 @@ export function updateAutoPrompt(options: AppendableSystemPromptOptions | undefi
18
18
  if (active) appendSystemPromptOnce(options, AUTO_DIRECTIVE);
19
19
  }
20
20
 
21
- type ModeController = {mode(ctx: ExtensionContext): Promise<string>; epoch(): number};
21
+ type ModeController = {mode(ctx: ExtensionContext, admission?: { toolCallId: string }): Promise<string>; epoch(): number};
22
22
  type Verdict = {status: string; decision?: string; reason?: string; code?: string; usage?: unknown; usageKnown?: boolean; actualModel?: unknown; requestedModel?: unknown; diagnostic?: unknown};
23
23
  /** Project a closed metadata schema; injected classifiers cannot add payloads to diagnostics. */
24
24
  function safeDiagnostic(value: unknown): AutoPermissionDiagnostic | undefined {
@@ -96,7 +96,8 @@ function within(path: string, root: string): boolean {
96
96
  function canonical(path: string): string {
97
97
  const absolute = resolve(path);
98
98
  let ancestor = absolute;
99
- while (!existsSync(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) break; ancestor = parent; }
99
+ // A dangling symlink exists as a directory entry: let realpath fail closed.
100
+ while (!lstatSync(ancestor,{throwIfNoEntry:false})) { const parent = dirname(ancestor); if (parent === ancestor) break; ancestor = parent; }
100
101
  return resolve(realpathSync(ancestor), relative(ancestor, absolute));
101
102
  }
102
103
  /** True only for an existing symlink-free in-project regular file larger than the reader accepts.
@@ -368,8 +369,22 @@ export class AutoPermissionRuntime {
368
369
  private protectedMutation(event: Pick<ToolCallEvent,"toolName"|"input">, ctx: ExtensionContext): boolean {
369
370
  const args=event.input as Record<string,unknown>;
370
371
  if (!args || typeof args!=="object") return false;
372
+ const projectHome=resolve(ctx.cwd,".pi","gentle-ai");
371
373
  if ((event.toolName==="write" || event.toolName==="edit") && typeof args.path==="string") {
372
- try { return within(canonical(resolve(ctx.cwd,args.path)),canonical(this.configHome)); } catch { return true; }
374
+ try {
375
+ const target=canonical(resolve(ctx.cwd,args.path)),home=canonical(this.configHome);
376
+ // Keep policy aliases and containing directories protected, including project restrictions.
377
+ if([resolve(this.configHome,"permission-mode.json"),resolve(this.configHome,"runtime-guardrails.json"),
378
+ resolve(projectHome,"runtime-guardrails.json")].some(path=>within(canonical(path),target)))return true;
379
+ if(!within(target,home))return false;
380
+ // A shared inode can alias policy without changing the canonical path.
381
+ if((lstatSync(target,{throwIfNoEntry:false})?.nlink ?? 0)>1)return true;
382
+ // Only updater-owned data returns to ordinary permission checks. Compare the
383
+ // resolved target to literal destinations under home: canonicalizing exemptions
384
+ // would let a dev-binary.json symlink exempt the authority file it points at.
385
+ return !["dev-binary.json","channel.json"].some(path=>target===resolve(home,path)) &&
386
+ !["main","tools/go"].some(path=>within(target,resolve(home,path)));
387
+ } catch { return true; }
373
388
  }
374
389
  if (["bash","bash_background","monitor"].includes(event.toolName) && typeof args.command==="string") {
375
390
  // Best-effort shell recognition supplements classification, not OS containment.
@@ -395,7 +410,7 @@ export class AutoPermissionRuntime {
395
410
  if(this.protectedMutation(event,ctx))return block("Permission configuration is human-controlled. Use the mode settings UI; model tools cannot change it.");
396
411
  // Revocation while identity lookup is pending must not become a fresh grant.
397
412
  const generation=this.generation, epoch=this.modes.epoch(), owner=this.owner;
398
- const mode=await this.modes.mode(ctx);
413
+ const mode=await this.modes.mode(ctx,{toolCallId:event.toolCallId});
399
414
  if(generation!==this.generation || epoch!==this.modes.epoch() || owner!==this.owner || ctx.signal?.aborted)
400
415
  return block("Auto decision expired during mode lookup. The action was not run.");
401
416
  if(mode!=="auto") { this.memory.admitted(event,ctx,epoch);return undefined; }
@@ -452,7 +467,7 @@ export class AutoPermissionRuntime {
452
467
  if(result.status==="available" || result.code!=="provider-error" || controller.signal.aborted)break;
453
468
  }
454
469
  }finally{ctx.signal?.removeEventListener("abort",onAbort);this.active.delete(controller);}
455
- const stillAuto=await this.modes.mode(ctx)==="auto";
470
+ const stillAuto=await this.modes.mode(ctx,{toolCallId:event.toolCallId})==="auto";
456
471
  if(!current() || controller.signal.aborted || !stillAuto)return block("Auto decision expired because the action, mode, request or session changed. The action was not run.");
457
472
  if(result.status!=="available" || !["allow","deny"].includes(result.decision ?? "")){
458
473
  // Contention and revocation are not failed safety judgements or provider outages.
@@ -0,0 +1,147 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { getNativeClipboard, type NativeClipboard } from "@earendil-works/pi-tui";
4
+
5
+ // Clipboard image reader for the prompt's attachment chips (gentle-shell#2464).
6
+ // Pi reads the same backends but does not export its reader, and its handler
7
+ // writes the image to a temp file and inserts the path. This reader keeps the
8
+ // bytes in memory and returns null whenever Pi's own handler should decide
9
+ // instead: no image, an unsupported format, or files copied in a file manager.
10
+
11
+ export interface ClipboardImage {
12
+ bytes: Uint8Array;
13
+ mimeType: string;
14
+ }
15
+
16
+ /** Resolves to stdout, or undefined when the command is missing, fails, or times out. */
17
+ export type ClipboardCommandRunner = (command: string, args: readonly string[], options?: { timeoutMs?: number }) => Promise<Buffer | undefined>;
18
+
19
+ export interface ClipboardImageOptions {
20
+ env?: NodeJS.ProcessEnv;
21
+ platform?: NodeJS.Platform;
22
+ run?: ClipboardCommandRunner;
23
+ native?: () => NativeClipboard | undefined;
24
+ procVersion?: () => string | undefined;
25
+ }
26
+
27
+ export const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
28
+
29
+ const LIST_TIMEOUT_MS = 1000;
30
+ const READ_TIMEOUT_MS = 5000;
31
+ const POWERSHELL_TIMEOUT_MS = 10_000;
32
+ const MAX_CLIPBOARD_BYTES = 64 * 1024 * 1024;
33
+
34
+ // Encodes the Windows clipboard image as PNG on stdout, so nothing touches disk.
35
+ const POWERSHELL_SCRIPT = [
36
+ "Add-Type -AssemblyName System.Windows.Forms",
37
+ "Add-Type -AssemblyName System.Drawing",
38
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
39
+ "if (-not $img) { exit 0 }",
40
+ "$ms = New-Object System.IO.MemoryStream",
41
+ "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
42
+ "[Convert]::ToBase64String($ms.ToArray())",
43
+ ].join("; ");
44
+
45
+ export const runClipboardCommand: ClipboardCommandRunner = (command, args, options) => new Promise((resolve) => {
46
+ execFile(command, [...args], { encoding: "buffer", timeout: options?.timeoutMs ?? READ_TIMEOUT_MS, maxBuffer: MAX_CLIPBOARD_BYTES, windowsHide: true }, (error, stdout) => {
47
+ resolve(error ? undefined : stdout);
48
+ });
49
+ });
50
+
51
+ /** Identify a supported image by its signature, never by a clipboard-declared type. */
52
+ export function sniffImageMimeType(bytes: Uint8Array): string | undefined {
53
+ const starts = (...signature: number[]) => signature.every((byte, index) => bytes[index] === byte);
54
+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
55
+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
56
+ if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
57
+ if (starts(0x52, 0x49, 0x46, 0x46) && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return "image/webp";
58
+ return undefined;
59
+ }
60
+
61
+ export function isWsl(env: NodeJS.ProcessEnv, procVersion: () => string | undefined = readProcVersion): boolean {
62
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
63
+ return /microsoft|wsl/i.test(procVersion() ?? "");
64
+ }
65
+
66
+ function readProcVersion(): string | undefined {
67
+ try { return readFileSync("/proc/version", "utf8"); } catch { return undefined; }
68
+ }
69
+
70
+ function supported(bytes: Uint8Array | null): ClipboardImage | null {
71
+ if (!bytes || bytes.length === 0) return null;
72
+ const mimeType = sniffImageMimeType(bytes);
73
+ return mimeType ? { bytes, mimeType } : null;
74
+ }
75
+
76
+ // A failed read is "backend unavailable", exactly as Pi treats it, so the next backend still runs.
77
+ function supportedRead(bytes: Buffer | undefined): ClipboardImage | null | undefined {
78
+ return bytes === undefined ? undefined : supported(bytes);
79
+ }
80
+
81
+ function preferredImageType(listing: Buffer): string | undefined {
82
+ const types = listing.toString("utf8").split(/\r?\n/).map((type) => type.trim()).filter(Boolean);
83
+ return SUPPORTED_IMAGE_MIME_TYPES.find((wanted) => types.some((type) => type.split(";")[0]?.trim().toLowerCase() === wanted));
84
+ }
85
+
86
+ // undefined: backend unavailable, try the next one. null: backend answered, no usable image.
87
+ async function viaWlPaste(run: ClipboardCommandRunner): Promise<ClipboardImage | null | undefined> {
88
+ const listing = await run("wl-paste", ["--list-types"], { timeoutMs: LIST_TIMEOUT_MS });
89
+ if (listing === undefined) return undefined;
90
+ const type = preferredImageType(listing);
91
+ if (!type) return null;
92
+ return supportedRead(await run("wl-paste", ["--type", type, "--no-newline"]));
93
+ }
94
+
95
+ async function viaXclip(run: ClipboardCommandRunner): Promise<ClipboardImage | null | undefined> {
96
+ const listing = await run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], { timeoutMs: LIST_TIMEOUT_MS });
97
+ if (listing === undefined) return undefined;
98
+ const type = preferredImageType(listing);
99
+ if (!type) return null;
100
+ return supportedRead(await run("xclip", ["-selection", "clipboard", "-t", type, "-o"]));
101
+ }
102
+
103
+ async function viaPowerShell(run: ClipboardCommandRunner): Promise<ClipboardImage | null> {
104
+ const output = await run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-Command", POWERSHELL_SCRIPT], { timeoutMs: POWERSHELL_TIMEOUT_MS });
105
+ const base64 = output?.toString("utf8").trim();
106
+ if (!base64) return null;
107
+ return supported(new Uint8Array(Buffer.from(base64, "base64")));
108
+ }
109
+
110
+ // Files copied in a file manager stay Pi's: it pastes their paths.
111
+ async function hasCopiedFiles(native: NativeClipboard | undefined): Promise<boolean> {
112
+ const files = await native?.getFilePaths?.().catch(() => undefined);
113
+ return Boolean(files && files.length > 0);
114
+ }
115
+
116
+ async function viaNative(native: NativeClipboard | undefined): Promise<ClipboardImage | null> {
117
+ if (!native) return null;
118
+ return supported((await native.getImage().catch(() => undefined)) ?? null);
119
+ }
120
+
121
+ /**
122
+ * Read a supported image from the clipboard, mirroring Pi's backend order:
123
+ * Wayland, then X11, then the Windows clipboard on WSL, then the native
124
+ * clipboard. Returns null when Pi's own paste handler should run instead.
125
+ */
126
+ export async function readClipboardImage(options: ClipboardImageOptions = {}): Promise<ClipboardImage | null> {
127
+ const env = options.env ?? process.env;
128
+ const platform = options.platform ?? process.platform;
129
+ const run = options.run ?? runClipboardCommand;
130
+ const native = options.native ?? getNativeClipboard;
131
+ if (env.TERMUX_VERSION) return null;
132
+ try {
133
+ const nativeClipboard = native();
134
+ if (await hasCopiedFiles(nativeClipboard)) return null;
135
+ if (platform !== "linux") return await viaNative(nativeClipboard);
136
+ const wsl = isWsl(env, options.procVersion);
137
+ let image: ClipboardImage | null | undefined;
138
+ if (env.WAYLAND_DISPLAY || env.XDG_SESSION_TYPE === "wayland" || wsl) image = await viaWlPaste(run);
139
+ if (image === undefined) image = await viaXclip(run);
140
+ // The Linux clipboard never receives Windows screenshots (Win+Shift+S).
141
+ if (!image && wsl) image = (await viaPowerShell(run)) ?? image;
142
+ if (image === undefined) image = await viaNative(nativeClipboard);
143
+ return image ?? null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
@@ -42,6 +42,8 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
42
42
  { command: "gentle:jobs", label: "Background jobs" },
43
43
  { command: "gentle:usage", label: "Subscription usage" },
44
44
  { command: "gentle:review-session-permission", label: "Review session permission" },
45
+ { command: "gentle:voice", label: "Voice dictation · local Parakeet" },
46
+ { command: "gentle:design", label: "Design · HTML artboards" },
45
47
  ],
46
48
  },
47
49
  {
@@ -45,7 +45,7 @@ type Git = (args: string[], cwd: string) => Promise<string | undefined>;
45
45
 
46
46
  // Asynchronous, so the parent stays responsive while git answers.
47
47
  const runGit: Git = (args, cwd) => new Promise((resolve) => {
48
- execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000 }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
48
+ execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000, windowsHide: true }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
49
49
  });
50
50
 
51
51
  async function defaultBranch(cwd: string, git: Git): Promise<string | undefined> {
@@ -12,6 +12,9 @@ export const CONTEXT_BACKSTOP_TOKENS = 150_000;
12
12
  export const CONTEXT_BACKSTOP_FRACTION = 0.15;
13
13
  export const CONTEXT_BACKSTOP_TOKENS_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_TOKENS";
14
14
  export const CONTEXT_BACKSTOP_FRACTION_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_FRACTION";
15
+ // Sampling window for the measured context (see createThrottledContextMeasure).
16
+ export const CONTEXT_BACKSTOP_MEASURE_MS_ENV = "GENTLE_PI_CONTEXT_BACKSTOP_MEASURE_MS";
17
+ export const CONTEXT_BACKSTOP_MEASURE_MS_DEFAULT = 5_000;
15
18
 
16
19
  /** The part of Pi's `ctx.getContextUsage()` the backstop reads; null tokens are unknown (e.g. right after compaction). */
17
20
  export interface MeasuredContext {
@@ -69,6 +72,39 @@ export function createContextBackstopSignal(env: Env = process.env): (session: u
69
72
  };
70
73
  }
71
74
 
75
+ export interface ThrottledContextMeasureOptions {
76
+ now?: () => number;
77
+ /** Injected window in ms; 0 samples on every call (tests). Omitted resolves the env, then the 5 s default. */
78
+ minIntervalMs?: number;
79
+ env?: Env;
80
+ }
81
+
82
+ /**
83
+ * One measured context per session per window. `ctx.getContextUsage()` rebuilds the whole session projection
84
+ * (O(session) per call), so the backstop samples it instead of paying that on every tool result. The first
85
+ * sample of a session is immediate — a resumed session already past the backstop must not wait a window — and
86
+ * every call inside the window skips the measurement entirely, so the backstop sees `undefined` (unknown
87
+ * tokens change nothing). A throwing measure returns `undefined`; the failed attempt still consumed its
88
+ * window, so a persistently failing host cannot turn every tool result into a retry.
89
+ */
90
+ export function createThrottledContextMeasure(options: ThrottledContextMeasureOptions = {}): (session: unknown, measure: () => MeasuredContext | undefined) => MeasuredContext | undefined {
91
+ const now = options.now ?? (() => Date.now());
92
+ const env = options.env ?? process.env;
93
+ const minIntervalMs = options.minIntervalMs ?? envNumber(env, CONTEXT_BACKSTOP_MEASURE_MS_ENV, positive, CONTEXT_BACKSTOP_MEASURE_MS_DEFAULT);
94
+ const lastMeasuredAt = new Map<unknown, number>();
95
+ return (session, measure) => {
96
+ const previous = lastMeasuredAt.get(session);
97
+ const at = now();
98
+ if (previous !== undefined && at - previous < minIntervalMs) return undefined;
99
+ lastMeasuredAt.set(session, at);
100
+ try {
101
+ return measure();
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ };
106
+ }
107
+
72
108
  /** Appends the notice after the (possibly already patched) tool output, keeping structured content and the error flag. */
73
109
  export function appendContextBackstopNotice(
74
110
  event: Pick<ToolResultEvent, "content" | "structuredContent">,