gentle-pi 4.2.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 (334) hide show
  1. package/README.md +340 -138
  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/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -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.
@@ -1,6 +1,7 @@
1
1
  import { realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, parse, relative, resolve } from "node:path";
4
+ import { contextBackstopReached, type MeasuredContext } from "./context-backstop.ts";
4
5
  import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
5
6
 
6
7
  const WRITER_NAMES = ["gentle-ai-worker", "worker", "jd-fix-agent"];
@@ -79,6 +80,56 @@ export function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: tr
79
80
  return { block: true, reason: `${WRITER_EDIT_SURFACE_REJECTION} ${parsed.problem} Resend the same task text unchanged except for that section; never shorten or re-summarize it.` };
80
81
  }
81
82
 
83
+ // Pain-point iteration S16: prompt rules alone did not stop small-task delegation (a two-feature request still
84
+ // started two writers for ~35-line units). A generic writer launch is admitted only for a task of an existing ODD
85
+ // feature document, or when the parent's measured context reaches the context backstop (the same threshold as the
86
+ // runtime notice, lib/context-backstop.ts). Judgment Day fix agents and continuations keep their own protocols.
87
+ // A line that starts with `Spec:` (optionally as a list item), names an odd/tasks document, and names a task
88
+ // `T<n>` later on the same line: covers the canon (`, T2, S3-S4`), the delegation card (`(read until "## Log").
89
+ // Do T2; S3.`), and comma-free forms (`.md T1 S1;`). A mention inside prose does not start the line.
90
+ const FEATURE_DOCUMENT_TASK = /^[ \t]*(?:[-*+][ \t]+)?Spec:[ \t]*`?(odd\/tasks\/[^\s,`()]+\.md)`?[^\n]*?\bT\d+\b/gm;
91
+ export const WRITER_FEATURE_DOCUMENT_REJECTION =
92
+ "Writers run only for a task of an existing ODD feature document (`Spec: odd/tasks/<feature>.md, T<n>` in the task) or when your measured context reaches the context backstop. This unit has neither: write this unit inline yourself now; do not retry the launch, and never create a feature document only to launch writers.";
93
+
94
+ function featureDocumentExists(root: string, relativePath: string): boolean {
95
+ // Resolve symlinks on both sides so a document linked from outside the root, or an odd/tasks directory linked
96
+ // elsewhere, never admits a writer; the document must be a regular file under <root>/odd/tasks.
97
+ try {
98
+ const realRoot = realpathSync(resolve(root));
99
+ const tasksDir = realpathSync(resolve(realRoot, "odd", "tasks"));
100
+ const target = realpathSync(resolve(realRoot, relativePath));
101
+ const inTasks = relative(tasksDir, target);
102
+ if (relative(realRoot, tasksDir).startsWith("..") || !inTasks || inTasks.startsWith("..") || isAbsolute(inTasks)) return false;
103
+ return statSync(target).isFile();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ // True when a string value has a `Spec:` task line naming an existing feature document under `root`. Shared by
110
+ // writer and verifier admission so both accept exactly the same task lines.
111
+ export function namesExistingFeatureDocumentTask(values: readonly unknown[], root: string): boolean {
112
+ for (const value of values) {
113
+ if (typeof value !== "string") continue;
114
+ for (const match of value.matchAll(FEATURE_DOCUMENT_TASK)) {
115
+ if (featureDocumentExists(root, match[1])) return true;
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+
121
+ export function rejectUndocumentedWriterLaunch(
122
+ input: unknown,
123
+ options: { root: string; contextUsage: MeasuredContext | undefined; env?: Readonly<Record<string, string | undefined>> },
124
+ ): { block: true; reason: string } | undefined {
125
+ if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
126
+ const record = input as Record<string, unknown>;
127
+ if (typeof record.agent !== "string" || !isGenericBoundedWriter(record.agent)) return undefined;
128
+ if (namesExistingFeatureDocumentTask([record.task, record.context], options.root)) return undefined;
129
+ if (contextBackstopReached(options.contextUsage, options.env)) return undefined;
130
+ return { block: true, reason: WRITER_FEATURE_DOCUMENT_REJECTION };
131
+ }
132
+
82
133
  // A continuation resumes the same delegated task, so a writer follow-up that
83
134
  // carries no section of its own inherits the surfaces its original launch was
84
135
  // admitted with. A follow-up that carries the heading is validated as written,
@@ -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
+ }
@@ -30,10 +30,6 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
30
30
  { command: "gentle:animations", label: "Animation mode" },
31
31
  { command: "gentle:vim", label: "Vim opt-in · Pi slash commands" },
32
32
  { command: "gentle:telemetry", label: "Telemetry" },
33
- { command: "gentle:banner", label: "Startup banner" },
34
- { command: "gentle:banner-color", label: "Banner color" },
35
- { command: "gentle:toggle-rose", label: "Toggle banner rose" },
36
- { command: "gentle:toggle-text-logo", label: "Toggle banner text logo" },
37
33
  { command: "gentle:dev-binary", label: "Gentle AI dev binary" },
38
34
  ],
39
35
  },
@@ -46,6 +42,8 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
46
42
  { command: "gentle:jobs", label: "Background jobs" },
47
43
  { command: "gentle:usage", label: "Subscription usage" },
48
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" },
49
47
  ],
50
48
  },
51
49
  {
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { ToolResultEvent, ToolResultEventResult } from "@earendil-works/pi-coding-agent";
3
+ import { resolve } from "node:path";
4
+
5
+ // Harness roadmap T4 (R1): ODD slices delivery at about 400 authored changed
6
+ // lines, but the model had to estimate the count. After each parent commit the
7
+ // runtime reports it from Git, so the slicing decision reads a number.
8
+
9
+ export const SLICE_LINES = 400;
10
+
11
+ // Generated files that do not count as authored lines.
12
+ const GENERATED = [/(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|composer\.lock|Gemfile\.lock)$/, /\.min\.(js|css)$/];
13
+
14
+ // `git [-C dir | -c key=value]... commit` at the start of a segment, after any variable assignments.
15
+ const COMMIT = /^\s*(\w+=\S*\s+)*git(\s+-[Cc]\s+\S+)*\s+commit(\s|$)/;
16
+
17
+ /** A commit is a git invocation that starts a shell segment; text that only mentions one (echo, a message) is not. */
18
+ export function isGitCommitCommand(command: string): boolean {
19
+ return command.split(/[;&|(\n]/).some((segment) => COMMIT.test(segment));
20
+ }
21
+
22
+ /** Shell segments joined by `;`, `&&`, `||`, or a newline. */
23
+ export const SHELL_SEGMENT_SEPARATOR = /;|&&|\|\||\n/;
24
+
25
+ const unquote = (value: string) => value.replace(/^["']|["']$/g, "");
26
+
27
+ /** The directory after a shell segment: `cd <dir>` resolved against `base`, else `base`. */
28
+ export function directoryAfterSegment(segment: string, base: string): string {
29
+ const cdDir = /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*$/.exec(segment)?.[1];
30
+ return cdDir ? resolve(base, unquote(cdDir)) : base;
31
+ }
32
+
33
+ /** The directory the commit runs in: `git -C <dir>` under every `cd <dir>` segment before the commit (joined by `;`, `&&`, `||`, or a newline), else the session cwd. */
34
+ export function commitTargetDirectory(command: string, cwd: string): string {
35
+ const gitDir = /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)\s+(?:-[Cc]\s+\S+\s+)*commit\b/.exec(command)?.[1];
36
+ let base = cwd;
37
+ for (const segment of command.split(SHELL_SEGMENT_SEPARATOR)) {
38
+ if (COMMIT.test(segment)) break;
39
+ base = directoryAfterSegment(segment, base);
40
+ }
41
+ return gitDir ? resolve(base, unquote(gitDir)) : base;
42
+ }
43
+
44
+ type Git = (args: string[], cwd: string) => Promise<string | undefined>;
45
+
46
+ // Asynchronous, so the parent stays responsive while git answers.
47
+ const runGit: Git = (args, cwd) => new Promise((resolve) => {
48
+ execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000, windowsHide: true }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
49
+ });
50
+
51
+ async function defaultBranch(cwd: string, git: Git): Promise<string | undefined> {
52
+ const remote = await git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], cwd);
53
+ if (remote) return remote;
54
+ for (const name of ["main", "master"]) {
55
+ if (await git(["rev-parse", "--verify", "-q", `refs/heads/${name}`], cwd) !== undefined) return name;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** One line for the parent after a commit on a feature branch, or undefined when there is no slice to report. */
61
+ export async function commitSliceReport(cwd: string, git: Git = runGit): Promise<string | undefined> {
62
+ const branch = await git(["symbolic-ref", "-q", "--short", "HEAD"], cwd);
63
+ const target = branch === undefined ? undefined : await defaultBranch(cwd, git);
64
+ if (!branch || !target || branch === target || `origin/${branch}` === target) return undefined;
65
+ const base = await git(["merge-base", "HEAD", target], cwd);
66
+ const commits = base ? Number(await git(["rev-list", "--count", `${base}..HEAD`], cwd)) : 0;
67
+ if (!base || !(commits > 0)) return undefined;
68
+ // A failed or timed-out diff is not an empty one: report nothing rather than 0.
69
+ // `-M` counts a move by what it changed; `-z` keeps paths unquoted for the generated-file filter.
70
+ const numstat = await git(["diff", "--numstat", "-z", "-M", base, "HEAD"], cwd);
71
+ if (numstat === undefined) return undefined;
72
+ let lines = 0;
73
+ const fields = numstat.split("\0");
74
+ for (let i = 0; i < fields.length; i++) {
75
+ const [added, deleted, inline] = fields[i].split("\t");
76
+ // A rename leaves the inline path empty and is followed by its old and new paths.
77
+ const path = inline === "" ? fields[i += 2] : inline;
78
+ if (!path || added === "-" || GENERATED.some((pattern) => pattern.test(path))) continue;
79
+ lines += Number(added) + Number(deleted);
80
+ }
81
+ const head = `Commit slice: ${lines} authored changed lines in ${commits} commit${commits === 1 ? "" : "s"} on ${branch} since ${target} (lock files excluded).`;
82
+ return lines > SLICE_LINES
83
+ ? `${head} Past the ~${SLICE_LINES}-line slice: apply the feature's delivery strategy before the next commit (record the slice boundary in the feature document).`
84
+ : `${head} ${SLICE_LINES - lines} lines left before the ~${SLICE_LINES}-line slice.`;
85
+ }
86
+
87
+ /**
88
+ * tool_result patch for a successful parent `git commit`: the slice line appended, structured content kept.
89
+ * Only foreground `bash`: a background job's result arrives before its commit exists.
90
+ */
91
+ export async function commitSliceToolResult(event: Pick<ToolResultEvent, "toolName" | "input" | "isError" | "content" | "structuredContent">, cwd: string, git: Git = runGit): Promise<ToolResultEventResult | undefined> {
92
+ const command = (event.input as { command?: unknown } | undefined)?.command;
93
+ if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string" || !isGitCommitCommand(command)) return undefined;
94
+ const report = await commitSliceReport(commitTargetDirectory(command, cwd), git);
95
+ if (!report) return undefined;
96
+ return {
97
+ content: [...event.content, { type: "text", text: `\n${report}` }],
98
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
99
+ };
100
+ }