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
@@ -13,8 +13,11 @@ import {
13
13
  staleTurns,
14
14
  TODO_DETAILS_KEY,
15
15
  TODO_GLYPH,
16
+ TODO_PROGRESS_EVENT,
16
17
  TODO_TOOL_NAME,
17
18
  todoCardTone,
19
+ TODO_CLOSED_STATE,
20
+ todoProgress,
18
21
  todoPromptBlock,
19
22
  todoStateMessage,
20
23
  todoSummary,
@@ -26,8 +29,9 @@ import {
26
29
  // Gentle Shell card above the editor. Three things keep it current that a
27
30
  // static tool description cannot: `write` replaces the whole list in one
28
31
  // call, each run's system prompt carries a byte-stable rules block while the
29
- // open tasks reach the model as a request-local trailing message (so list
30
- // changes never invalidate the cached prompt prefix, gentle-shell#1662), and
32
+ // open tasks reach the model as a hidden transcript message appended when the
33
+ // list changes (so list changes never invalidate the cached prompt prefix and
34
+ // requests stay append-only, gentle-shell#1662, #2195), and
31
35
  // a list that goes untouched while tasks stay open is marked stale for the
32
36
  // human. Long runs also receive one request-local reminder after several
33
37
  // tool-use turns, without waking the agent or changing tasks.
@@ -87,6 +91,10 @@ interface TodoSession {
87
91
  collapsed: boolean;
88
92
  /** A finished list stays on screen for the turn it finished in, then clears. */
89
93
  clearOnNextTurn: boolean;
94
+ /** The last state snapshot sent to the transcript. */
95
+ lastSent: string | undefined;
96
+ /** A snapshot queued during the current turn; Pi appends it at turn_end. */
97
+ pending: string | undefined;
90
98
  ui: ExtensionContext["ui"] | undefined;
91
99
  host: { requestRender(): void } | undefined;
92
100
  tui: TUI | undefined;
@@ -107,12 +115,21 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
107
115
  const key = sessionKey(ctx);
108
116
  let current = sessions.get(key);
109
117
  if (!current) {
110
- current = { state: emptyTodo(), turn: 0, toolTurnsSinceWrite: 0, reminded: false, wroteThisTurn: false, collapsed: false, clearOnNextTurn: false, ui: undefined, host: undefined, tui: undefined };
118
+ current = { state: emptyTodo(), turn: 0, toolTurnsSinceWrite: 0, reminded: false, wroteThisTurn: false, collapsed: false, clearOnNextTurn: false, lastSent: undefined, pending: undefined, ui: undefined, host: undefined, tui: undefined };
111
119
  sessions.set(key, current);
112
120
  }
113
121
  return current;
114
122
  };
115
123
 
124
+ // While the agent streams, Pi appends this hidden message at turn_end, after
125
+ // the tool results and before the next model request; it never starts a turn.
126
+ const persistState = (current: TodoSession, state: string) => {
127
+ if (!pi.sendMessage) return;
128
+ pi.sendMessage({ customType: "gentle-todo-state", content: state, display: false }, { triggerTurn: false });
129
+ current.lastSent = state;
130
+ current.pending = state;
131
+ };
132
+
116
133
  const toggle = (current: TodoSession) => {
117
134
  current.collapsed = !current.collapsed;
118
135
  if (current.tui) invalidateSidebar(current.tui);
@@ -265,6 +282,11 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
265
282
  current.toolTurnsSinceWrite = 0;
266
283
  current.reminded = false;
267
284
  current.wroteThisTurn = true;
285
+ // A closed list persists the closing marker, so the last open
286
+ // snapshot in the transcript never reads as current work.
287
+ const state = todoStateMessage(current.state) ?? (current.lastSent === undefined ? undefined : TODO_CLOSED_STATE);
288
+ if (state !== undefined && state !== current.lastSent) persistState(current, state);
289
+ pi.events?.emit(TODO_PROGRESS_EVENT, todoProgress(current.state, ctx.sessionManager.getSessionId()));
268
290
  }
269
291
  if (current.tui) invalidateSidebar(current.tui);
270
292
  }
@@ -303,6 +325,8 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
303
325
  current.toolTurnsSinceWrite = 0;
304
326
  current.reminded = false;
305
327
  current.wroteThisTurn = false;
328
+ current.lastSent = undefined;
329
+ current.pending = undefined;
306
330
  current.ui = ctx.hasUI ? ctx.ui : undefined;
307
331
  show(current);
308
332
  });
@@ -335,17 +359,27 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
335
359
  current.toolTurnsSinceWrite += 1;
336
360
  }
337
361
  current.wroteThisTurn = false;
362
+ // Pi flushes the queued snapshot right after this event.
363
+ current.pending = undefined;
338
364
  });
339
365
 
340
366
  pi.on("context", (event, ctx) => {
341
367
  const current = session(ctx);
342
- // Context transforms are request-local: no transcript entry, UI
343
- // notification, continuation or provider-specific wake is needed. The
344
- // current list trails the transcript (gentle-shell#1662), then the
345
- // one-shot reminder when it is due.
368
+ // gentle-shell#1662, #2195: providers cache the request prefix up to the
369
+ // last message (Anthropic) or send only the delta after the previous input
370
+ // (openai-codex), so a trailing message that the next request drops breaks
371
+ // both. The list is persisted when it changes; this transform only covers a
372
+ // transcript that lacks the current snapshot (compaction, branch navigation,
373
+ // sessions recorded earlier): it trails this one request and persists the
374
+ // snapshot once, so the following requests are append-only again.
346
375
  const trailing: Array<{ role: "custom"; customType: string; content: string; display: false; timestamp: number }> = [];
347
- const state = todoStateMessage(current.state);
348
- if (state !== undefined) trailing.push({ role: "custom", customType: "gentle-todo-state", content: state, display: false, timestamp: Date.now() });
376
+ const last = [...event.messages].reverse().find((message) => (message as { customType?: string }).customType === "gentle-todo-state") as { content?: unknown } | undefined;
377
+ const seen = typeof last?.content === "string" ? last.content : undefined;
378
+ const state = todoStateMessage(current.state) ?? (seen === undefined || seen === TODO_CLOSED_STATE ? undefined : TODO_CLOSED_STATE);
379
+ if (state !== undefined && seen !== state) {
380
+ trailing.push({ role: "custom", customType: "gentle-todo-state", content: state, display: false, timestamp: Date.now() });
381
+ if (current.pending !== state) persistState(current, state);
382
+ }
349
383
  const actionable = current.state.tasks.some((task) => task.status === "pending" || task.status === "in_progress");
350
384
  if (!current.reminded && current.toolTurnsSinceWrite >= REMIND_AFTER_TOOL_TURNS && actionable) {
351
385
  current.reminded = true;
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerVoiceCommand, type VoiceCommandDeps } from "../lib/voice-command.ts";
3
+
4
+ // Gentle Voice: /gentle:voice owns optional local dictation (docs/voice.md).
5
+ // Loading this extension registers the command and, when the push-to-talk
6
+ // toggle is on, a hold-Space listener at session start. Nothing is probed,
7
+ // downloaded, loaded or recorded until the user runs the command or holds Space.
8
+
9
+ export default function gentleVoice(pi: ExtensionAPI, deps: VoiceCommandDeps = {}): void {
10
+ registerVoiceCommand(pi, deps);
11
+ }
@@ -795,7 +795,7 @@ export function createQuietToolRenderer(
795
795
  safeResult,
796
796
  options,
797
797
  theme,
798
- sanitizedRenderContext(renderContext) as any,
798
+ { ...sanitizedRenderContext(renderContext), lastComponent: undefined } as any,
799
799
  ), false);
800
800
  }
801
801
  let output = formatToolResultOutput(toolName, safeResult, {
@@ -0,0 +1,384 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { lstatSync, realpathSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, resolve } from "node:path";
5
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
6
+ import {
7
+ checkArtifact,
8
+ CLEAN_RECENT_MS,
9
+ createSessionScratch,
10
+ deleteLedger,
11
+ directorySize,
12
+ formatSize,
13
+ gitArtifactTargets,
14
+ isProcessAlive,
15
+ isProtectedPath,
16
+ isSessionScratchName,
17
+ isWithin,
18
+ LEDGER_ARTIFACTS_CAP,
19
+ LEDGER_VERSION,
20
+ ledgerPath,
21
+ listLedgers,
22
+ listLedgersSync,
23
+ liveSubagentScratch,
24
+ processStartToken,
25
+ readLedger,
26
+ realTempRoot,
27
+ removeArtifact,
28
+ removeSessionScratch,
29
+ scratchRefusal,
30
+ SWEEP_GRACE_MS,
31
+ type ArtifactCheck,
32
+ type ArtifactTarget,
33
+ type ScratchArtifact,
34
+ type ScratchLedger,
35
+ type StoredLedger,
36
+ writeLedger,
37
+ writeLedgerSync,
38
+ } from "../lib/session-scratch.ts";
39
+
40
+ // Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
41
+ // the primary session gets its own TMPDIR, the runtime records the clones and
42
+ // worktrees its bash commands create, removes the scratch at shutdown (and the
43
+ // scratch of crashed sessions at the next start), and /gentle:scratch lists or
44
+ // cleans the rest. Delegated children (GENTLE_PI_AGENTS_CHILD=1) do none of it:
45
+ // their own TMPDIR comes from the runner and nests under the parent's scratch.
46
+
47
+ export const SCRATCH_COMMAND_USAGE = [
48
+ "Usage: /gentle:scratch [list|clean]",
49
+ " list this session's scratch dir with its size, and the clones and worktrees recorded by this and ended sessions",
50
+ " clean remove scratch dirs; after one confirmation, remove recorded worktrees and clones with no uncommitted changes and no unpushed commits",
51
+ ].join("\n");
52
+
53
+ export interface SessionScratchOptions {
54
+ env?: NodeJS.ProcessEnv;
55
+ configHome?: string;
56
+ now?: () => number;
57
+ isProcessAlive?: (pid: number) => boolean;
58
+ /** When a process started (epoch ms), or undefined when it cannot be read. */
59
+ processStartToken?: (pid: number) => string | undefined;
60
+ graceMs?: number;
61
+ platform?: NodeJS.Platform;
62
+ }
63
+
64
+ interface ActiveSession {
65
+ sessionId: string;
66
+ path: string;
67
+ ledger: ScratchLedger;
68
+ }
69
+
70
+ // Keyed by the environment the session scratch was applied to. A /reload that
71
+ // re-runs this factory in the same module instance finds the live session here;
72
+ // one that re-evaluates the module (Pi's loader) recovers it from the ledger.
73
+ const activeSessions = new WeakMap<NodeJS.ProcessEnv, ActiveSession>();
74
+
75
+ export function createSessionScratchExtension(options: SessionScratchOptions = {}): (pi: ExtensionAPI) => void {
76
+ const env = options.env ?? process.env;
77
+ const now = options.now ?? Date.now;
78
+ const alive = options.isProcessAlive ?? isProcessAlive;
79
+ const startToken = options.processStartToken ?? processStartToken;
80
+ // A live pid is the ledger's process only if it has the start token the ledger
81
+ // recorded: the OS may have reused it. A token that is missing or cannot be
82
+ // read keeps the ledger live.
83
+ const isLive = (ledger: ScratchLedger) => {
84
+ if (!alive(ledger.pid)) return false;
85
+ if (ledger.processStartToken === undefined) return true;
86
+ let current: string | undefined;
87
+ try { current = startToken(ledger.pid); } catch { current = undefined; }
88
+ return current === undefined || current === ledger.processStartToken;
89
+ };
90
+ // This process's own token, or undefined (omitted from the ledger JSON) when it cannot be read.
91
+ const safeStartToken = () => { try { return startToken(process.pid); } catch { return undefined; } };
92
+ // Real paths of this process's live and quarantined subagent TMPDIRs (lib/agents-runner.ts).
93
+ const subagentDirs = () => liveSubagentScratch().flatMap((dir) => realTempRoot(dir) ?? []);
94
+ const graceMs = options.graceMs ?? SWEEP_GRACE_MS;
95
+ const tempVariables = (options.platform ?? process.platform) === "win32" ? ["TMPDIR", "TMP", "TEMP"] : ["TMPDIR"];
96
+ const configHome = () => options.configHome ?? gentlePiConfigHome(env);
97
+
98
+ const persist = async (path: string, ledger: ScratchLedger) => {
99
+ try {
100
+ if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
101
+ else await writeLedger(path, ledger);
102
+ } catch { /* Best effort: an unwritable config home keeps the session running. */ }
103
+ };
104
+ // The shutdown path stays synchronous: its callers (tests/agents-runner.ts, and any
105
+ // handler consumer that does not await) read its post-conditions right after it runs.
106
+ const persistSync = (path: string, ledger: ScratchLedger) => {
107
+ try {
108
+ if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
109
+ else writeLedgerSync(path, ledger);
110
+ } catch { /* Best effort: an unwritable config home keeps the session running. */ }
111
+ };
112
+ // Paths the runtime never records or removes, whatever a ledger says.
113
+ const protectedDirs = (ledger: ScratchLedger) => [configHome(), ledger.cwd, process.cwd()];
114
+
115
+ // The temp root the asking session trusts inside the home: its own, as recorded.
116
+ const ownRoot = () => activeSessions.get(env)?.ledger.tempRoot ?? realTempRoot(env.TMPDIR?.trim() || tmpdir());
117
+
118
+ // S9: a scratch name no longer carries its session id, so a scratch dir that a
119
+ // live session's ledger records stays out of reach of every other ledger. This
120
+ // session is live whatever the process check says. Returns, per ledger path,
121
+ // what removal through that ledger must keep: its artifacts and others' live scratch.
122
+ const heldBy = (ledgers: readonly StoredLedger[]) => {
123
+ const own = activeSessions.get(env);
124
+ const live = ledgers.filter(({ ledger }) => ledger.scratchDir !== null && isLive(ledger)).map(({ path, ledger }) => ({ path, dir: ledger.scratchDir! }));
125
+ if (own?.ledger.scratchDir) live.push({ path: own.path, dir: own.ledger.scratchDir });
126
+ return (path: string, ledger: ScratchLedger) => [
127
+ ...ledger.artifacts.map((artifact) => artifact.path),
128
+ ...live.filter((entry) => entry.path !== path).map((entry) => entry.dir),
129
+ ];
130
+ };
131
+
132
+ const endSession = (session: ActiveSession) => {
133
+ const { ledger } = session;
134
+ // A running or quarantined subagent's TMPDIR stays; the scratch then stays in the ledger for a later sweep.
135
+ const removal = removeSessionScratch(ledger, heldBy(listLedgersSync(configHome()))(session.path, ledger), { ownRoot: ledger.tempRoot ?? undefined, liveSubagentDirs: subagentDirs() });
136
+ if (removal === "removed" || removal === "absent") ledger.scratchDir = null;
137
+ for (const [key, value] of Object.entries(ledger.inherited)) {
138
+ if (value === null) delete env[key];
139
+ else env[key] = value;
140
+ }
141
+ persistSync(session.path, ledger);
142
+ activeSessions.delete(env);
143
+ pending.clear();
144
+ };
145
+
146
+ // S5(b): scratch of ended sessions past the grace period; their artifacts stay for the command.
147
+ // The startup sweep stays synchronous: session_start's callers (tests/agents-runner.ts) read
148
+ // its post-conditions right after it runs, without awaiting the handler.
149
+ const sweep = (ownPath: string) => {
150
+ const ledgers = listLedgersSync(configHome());
151
+ const held = heldBy(ledgers);
152
+ for (const { path, ledger, mtimeMs } of ledgers) {
153
+ if (path === ownPath || isLive(ledger) || now() - mtimeMs < graceMs) continue;
154
+ const artifacts = ledger.artifacts.filter((artifact) => { try { lstatSync(artifact.path); return true; } catch { return false; } });
155
+ const removal = ledger.scratchDir === null ? "absent" : removeSessionScratch(ledger, held(path, { ...ledger, artifacts }), { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
156
+ persistSync(path, { ...ledger, scratchDir: removal === "removed" || removal === "absent" ? null : ledger.scratchDir, artifacts });
157
+ }
158
+ };
159
+
160
+ // The live scratch of this process: its ledger names this pid and a scratch it
161
+ // created that TMPDIR still points into, as after a reload that lost module state.
162
+ const isLiveScratch = (ledger: ScratchLedger | undefined): boolean => {
163
+ if (!ledger?.scratchDir || ledger.pid !== process.pid || scratchRefusal(ledger, ledger.tempRoot ?? undefined) !== undefined) return false;
164
+ const current = env.TMPDIR?.trim();
165
+ return !!current && [resolve(current), realTempRoot(current)].some((dir) => dir !== undefined && isWithin(dir, ledger.scratchDir!));
166
+ };
167
+
168
+ // The startup path stays synchronous, like the shutdown path: its callers
169
+ // (tests/agents-runner.ts) read its post-conditions without awaiting the handler.
170
+ const startSession = (ctx: ExtensionContext, reason: string) => {
171
+ const sessionId = ctx.sessionManager.getSessionId?.();
172
+ if (!sessionId) return;
173
+ const current = activeSessions.get(env);
174
+ if (current?.sessionId === sessionId && reason === "reload") return;
175
+ if (current) endSession(current);
176
+ const path = ledgerPath(configHome(), sessionId);
177
+ const previous = readLedger(path);
178
+ if (previous && isLiveScratch(previous)) {
179
+ activeSessions.set(env, { sessionId, path, ledger: previous });
180
+ persistSync(path, previous);
181
+ sweep(path);
182
+ return;
183
+ }
184
+ // A reload that could not recover the scratch finds TMPDIR pointing at it:
185
+ // the inherited values are the ones this process's ledger recorded, else
186
+ // TMPDIR as it is unless it is a session scratch, whose parent it was made in.
187
+ const reloaded = reason === "reload" && previous?.pid === process.pid ? previous.inherited : undefined;
188
+ const inheritedValue = (key: string) => {
189
+ if (reloaded && key in reloaded) return reloaded[key];
190
+ const value = env[key];
191
+ if (reason === "reload" && value?.trim() && isSessionScratchName(value.trim())) return dirname(value.trim());
192
+ return value ?? null;
193
+ };
194
+ const inherited = Object.fromEntries(tempVariables.map((key) => [key, inheritedValue(key)]));
195
+ // Restored first, so tmpdir() never answers with the old scratch.
196
+ for (const [key, value] of Object.entries(inherited)) {
197
+ if (value === null) delete env[key];
198
+ else env[key] = value;
199
+ }
200
+ const inheritedTmp = inherited.TMPDIR?.trim();
201
+ const created = createSessionScratch(inheritedTmp ? inheritedTmp : tmpdir());
202
+ // TMPDIR keeps the inherited spelling (S9: socket path length); the ledger keeps real paths.
203
+ if (created) for (const key of tempVariables) env[key] = created.path;
204
+ const scratchDir = created?.realPath;
205
+ // A resumed session keeps the artifacts an earlier run of it recorded.
206
+ const ledger: ScratchLedger = {
207
+ version: LEDGER_VERSION,
208
+ sessionId,
209
+ pid: process.pid,
210
+ processStartedAt: new Date(now() - process.uptime() * 1000).toISOString(),
211
+ processStartToken: safeStartToken(),
212
+ cwd: ctx.cwd,
213
+ tempRoot: scratchDir ? dirname(scratchDir) : null,
214
+ inherited,
215
+ scratchDir: scratchDir ?? null,
216
+ artifacts: previous?.artifacts ?? [],
217
+ };
218
+ // A scratch of this process is never removed at startup: it may still be in use.
219
+ if (previous?.scratchDir && previous.scratchDir !== scratchDir && previous.pid !== process.pid && !isLive(previous)) {
220
+ const held = heldBy(listLedgersSync(configHome()))(path, ledger);
221
+ removeSessionScratch(previous, scratchDir ? [...held, scratchDir] : held, { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
222
+ }
223
+ activeSessions.set(env, { sessionId, path, ledger });
224
+ persistSync(path, ledger);
225
+ sweep(path);
226
+ };
227
+
228
+ // S4: destinations a foreground bash command is about to create, by tool call
229
+ // id. A destination that already exists belongs to someone else.
230
+ const pending = new Map<string, ArtifactTarget[]>();
231
+
232
+ const note = (toolCallId: string, command: string, ctx: ExtensionContext) => {
233
+ if (!activeSessions.get(env)) return;
234
+ const absent = gitArtifactTargets(command, ctx.cwd).filter((target) => {
235
+ try { lstatSync(target.path); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
236
+ });
237
+ if (absent.length > 0) pending.set(toolCallId, absent);
238
+ };
239
+
240
+ // S4: the noted destinations that a successful command created. The ledger keeps
241
+ // only the most recent artifacts, evicting the oldest first, so a long session's
242
+ // ledger file stays bounded.
243
+ const capture = async (targets: readonly ArtifactTarget[], command: string) => {
244
+ const session = activeSessions.get(env);
245
+ if (!session) return;
246
+ const { ledger } = session;
247
+ let changed = false;
248
+ for (const target of targets) {
249
+ let path: string;
250
+ try {
251
+ if (!lstatSync(target.path).isDirectory()) continue;
252
+ path = realpathSync(target.path);
253
+ } catch { continue; }
254
+ if (isProtectedPath(path, protectedDirs(ledger)) || ledger.artifacts.some((artifact) => artifact.path === path)) continue;
255
+ ledger.artifacts.push({ kind: target.kind, path, command, recordedAt: new Date(now()).toISOString() });
256
+ changed = true;
257
+ }
258
+ if (!changed) return;
259
+ if (ledger.artifacts.length > LEDGER_ARTIFACTS_CAP) ledger.artifacts = ledger.artifacts.slice(-LEDGER_ARTIFACTS_CAP);
260
+ await persist(session.path, ledger);
261
+ };
262
+
263
+ const endedLedgers = async (ownPath: string | undefined) =>
264
+ (await listLedgers(configHome())).filter(({ path, ledger }) => path !== ownPath && !isLive(ledger));
265
+
266
+ const describe = (artifact: ScratchArtifact) => `${artifact.kind} ${artifact.path}`;
267
+
268
+ const list = async (): Promise<string> => {
269
+ const session = activeSessions.get(env);
270
+ const lines: string[] = [];
271
+ const own = session?.ledger;
272
+ lines.push(own?.scratchDir
273
+ ? `Session scratch: ${own.scratchDir} (${formatSize(directorySize(own.scratchDir))})`
274
+ : `Session scratch: none (TMPDIR ${env.TMPDIR ?? tmpdir()})`);
275
+ const ended = await endedLedgers(session?.path);
276
+ for (const { ledger } of ended) if (ledger.scratchDir) lines.push(`Ended-session scratch: ${ledger.scratchDir} (session ${ledger.sessionId})`);
277
+ const artifacts = [
278
+ ...(own?.artifacts ?? []).map((artifact) => ` ${describe(artifact)} (this session)`),
279
+ ...ended.flatMap(({ ledger }) => ledger.artifacts.map((artifact) => ` ${describe(artifact)} (session ${ledger.sessionId}, ended)`)),
280
+ ];
281
+ lines.push(artifacts.length > 0 ? "Recorded clones and worktrees:" : "Recorded clones and worktrees: none", ...artifacts);
282
+ return lines.join("\n");
283
+ };
284
+
285
+ const clean = async (ctx: ExtensionContext): Promise<string> => {
286
+ // Entries of the live scratch changed since this moment may belong to a running command.
287
+ const recentSince = now() - CLEAN_RECENT_MS;
288
+ const session = activeSessions.get(env);
289
+ const groups = [
290
+ ...(session ? [{ path: session.path, ledger: session.ledger, own: true }] : []),
291
+ ...(await endedLedgers(session?.path)).map(({ path, ledger }) => ({ path, ledger, own: false })),
292
+ ];
293
+ const lines: string[] = [];
294
+ const entries = groups.flatMap((group) => group.ledger.artifacts.map((artifact) => ({ group, artifact })));
295
+ const drop = (entry: (typeof entries)[number]) => {
296
+ entry.group.ledger.artifacts = entry.group.ledger.artifacts.filter((artifact) => artifact !== entry.artifact);
297
+ };
298
+ if (entries.length > 0 && !ctx.hasUI) {
299
+ lines.push("Recorded clones and worktrees kept (no UI to confirm their removal):", ...entries.map(({ artifact }) => ` ${describe(artifact)}`));
300
+ } else if (entries.length > 0) {
301
+ const checked = await Promise.all(entries.map(async (entry) => ({ ...entry, check: await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger)) })));
302
+ const removable: Array<(typeof entries)[number] & { check: Extract<ArtifactCheck, { state: "removable" }> }> = [];
303
+ for (const entry of checked) {
304
+ if (entry.check.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); }
305
+ else if (entry.check.state === "kept") lines.push(`Kept ${describe(entry.artifact)}: ${entry.check.reason}`);
306
+ else removable.push(entry as (typeof removable)[number]);
307
+ }
308
+ if (removable.length > 0) {
309
+ const confirmed = await ctx.ui.confirm(
310
+ `Remove ${removable.length} recorded clone${removable.length === 1 ? "" : "s"}/worktree${removable.length === 1 ? "" : "s"}?`,
311
+ `No uncommitted changes and no unpushed commits; ignored files inside these paths are deleted too:\n${removable.map(({ artifact }) => ` ${describe(artifact)}`).join("\n")}`,
312
+ );
313
+ for (const entry of removable) {
314
+ if (!confirmed) { lines.push(`Kept ${describe(entry.artifact)}: not confirmed`); continue; }
315
+ // The clone may have changed while the confirmation was open: check it again.
316
+ const recheck = await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger));
317
+ if (recheck.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); continue; }
318
+ if (recheck.state === "kept") { lines.push(`Kept ${describe(entry.artifact)}: ${recheck.reason}`); continue; }
319
+ if (recheck.identity?.dev !== entry.check.identity?.dev || recheck.identity?.ino !== entry.check.identity?.ino) { lines.push(`Kept ${describe(entry.artifact)}: replaced after the check`); continue; }
320
+ const failure = await removeArtifact(entry.artifact, recheck);
321
+ if (failure) lines.push(`Kept ${describe(entry.artifact)}: ${failure}`);
322
+ else { drop(entry); lines.push(`Removed ${describe(entry.artifact)}`); }
323
+ }
324
+ }
325
+ }
326
+ // Scratch goes without asking; the live session keeps its TMPDIR and empties it.
327
+ const held = heldBy(await listLedgers(configHome()));
328
+ for (const group of groups) {
329
+ const { ledger } = group;
330
+ if (!ledger.scratchDir) continue;
331
+ const dir = ledger.scratchDir;
332
+ const removal = removeSessionScratch(ledger, held(group.path, ledger), { contentsOnly: group.own, ownRoot: ownRoot(), liveSubagentDirs: subagentDirs(), keepChangedSince: group.own ? recentSince : undefined });
333
+ if (!group.own && (removal === "removed" || removal === "absent")) ledger.scratchDir = null;
334
+ if (removal === "refused") lines.push(`Kept scratch ${dir}: not a directory this runtime created`);
335
+ else if (removal === "kept") lines.push(`${group.own ? "Emptied" : "Kept"} scratch ${dir}${group.own ? " except subagent scratch, live sessions' scratch, recorded artifacts, and entries changed in the last minute" : ": holds a recorded artifact, a live session's scratch, or a live subagent's scratch"}`);
336
+ else lines.push(`${group.own ? "Emptied" : "Removed"} scratch ${dir}`);
337
+ }
338
+ for (const group of groups) await persist(group.path, group.ledger);
339
+ return lines.length > 0 ? lines.join("\n") : "Nothing to clean.";
340
+ };
341
+
342
+ return (pi: ExtensionAPI) => {
343
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") return;
344
+ pi.on("session_start", (event, ctx) => {
345
+ try { startSession(ctx, (event as { reason?: string }).reason ?? "startup"); } catch { /* The session keeps the inherited TMPDIR. */ }
346
+ });
347
+ pi.on("session_shutdown", (event) => {
348
+ // A reload continues the same session and keeps its scratch.
349
+ if ((event as { reason?: string }).reason === "reload") return;
350
+ const session = activeSessions.get(env);
351
+ try { if (session) endSession(session); } catch { /* best effort */ }
352
+ });
353
+ pi.on("tool_call", (event, ctx) => {
354
+ // Only foreground bash: a background job's result arrives before its clone exists.
355
+ const command = (event.input as { command?: unknown } | undefined)?.command;
356
+ if (event.toolName !== "bash" || typeof command !== "string") return undefined;
357
+ try { note(event.toolCallId, command, ctx); } catch { /* Capture never blocks a tool call. */ }
358
+ return undefined;
359
+ });
360
+ pi.on("tool_result", async (event) => {
361
+ const targets = pending.get(event.toolCallId);
362
+ if (!targets) return undefined;
363
+ pending.delete(event.toolCallId);
364
+ const command = (event.input as { command?: unknown } | undefined)?.command;
365
+ if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string") return undefined;
366
+ try { await capture(targets, command); } catch { /* Capture never changes a tool result. */ }
367
+ return undefined;
368
+ });
369
+ pi.registerCommand("gentle:scratch", {
370
+ description: "List or clean this session's scratch dir and the recorded clones and worktrees (list|clean).",
371
+ getArgumentCompletions: (prefix) => ["list", "clean"].filter((action) => action.startsWith(prefix.trim())).map((action) => ({ value: action, label: action })),
372
+ handler: async (args, ctx) => {
373
+ const action = args.trim() || "list";
374
+ if (action === "list") ctx.ui.notify(await list(), "info");
375
+ else if (action === "clean") ctx.ui.notify(await clean(ctx), "info");
376
+ else ctx.ui.notify(SCRATCH_COMMAND_USAGE, "warning");
377
+ },
378
+ });
379
+ };
380
+ }
381
+
382
+ export default function sessionScratch(pi: ExtensionAPI): void {
383
+ createSessionScratchExtension()(pi);
384
+ }
@@ -25,6 +25,11 @@ const SECTION_MARKER = "## Skills";
25
25
  const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
26
26
  const EXCLUDE_PREFIXES = ["sdd-"];
27
27
  const WATCH_DEBOUNCE_MS = 500;
28
+ // inotify budgets are finite (ENOSPC/EMFILE) and recursive watches degrade
29
+ // silently on exhausted systems -- WSL2 hits this constantly -- so a slow
30
+ // poll is the floor that keeps regeneration working when the OS cannot
31
+ // deliver change events. The fingerprint gate makes a redundant poll cheap.
32
+ const WATCH_FALLBACK_POLL_MS = 2_000;
28
33
  const REGISTRY_SCHEMA_VERSION = 7;
29
34
  const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
30
35
  const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
@@ -98,6 +103,11 @@ function projectSkillDirs(cwd: string): string[] {
98
103
  ];
99
104
  }
100
105
 
106
+ function isMissingSkillFileError(error: unknown): boolean {
107
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
108
+ return error.code === "ENOENT" || error.code === "ENOTDIR";
109
+ }
110
+
101
111
  async function findSkillFiles(root: string): Promise<string[]> {
102
112
  if (!(await pathExists(root))) return [];
103
113
  let entries;
@@ -107,7 +117,8 @@ async function findSkillFiles(root: string): Promise<string[]> {
107
117
  return [];
108
118
  }
109
119
 
110
- const out: string[] = [];
120
+ const directSkills: string[] = [];
121
+ const nestedSkills: string[] = [];
111
122
  for (const entry of entries) {
112
123
  const skillDir = join(root, entry.name);
113
124
  let dirInfo;
@@ -121,12 +132,40 @@ async function findSkillFiles(root: string): Promise<string[]> {
121
132
  const candidate = join(skillDir, "SKILL.md");
122
133
  try {
123
134
  const skillInfo = await stat(candidate);
124
- if (skillInfo.isFile()) out.push(candidate);
135
+ if (skillInfo.isFile()) directSkills.push(candidate);
136
+ continue;
137
+ } catch (error) {
138
+ if (!isMissingSkillFileError(error)) continue;
139
+ }
140
+ if (entry.isSymbolicLink() || isExcluded(entry.name)) continue;
141
+
142
+ let nestedEntries;
143
+ try {
144
+ nestedEntries = await readdir(skillDir, { withFileTypes: true });
125
145
  } catch {
126
- // Missing or unreadable skill files are ignored; the registry is best-effort.
146
+ continue;
147
+ }
148
+ for (const nestedEntry of nestedEntries) {
149
+ if (nestedEntry.isSymbolicLink()) continue;
150
+ const nestedSkillDir = join(skillDir, nestedEntry.name);
151
+ let nestedDirInfo;
152
+ try {
153
+ nestedDirInfo = await stat(nestedSkillDir);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (!nestedDirInfo.isDirectory()) continue;
158
+
159
+ const nestedCandidate = join(nestedSkillDir, "SKILL.md");
160
+ try {
161
+ const nestedSkillInfo = await stat(nestedCandidate);
162
+ if (nestedSkillInfo.isFile()) nestedSkills.push(nestedCandidate);
163
+ } catch {
164
+ // Missing or unreadable skill files are ignored; the registry is best-effort.
165
+ }
127
166
  }
128
167
  }
129
- return out.sort();
168
+ return [...directSkills.sort(), ...nestedSkills.sort()];
130
169
  }
131
170
 
132
171
  function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
@@ -346,7 +385,7 @@ async function hasNoGitMetadata(cwd: string): Promise<boolean> {
346
385
  }
347
386
 
348
387
  // Freshness (including forced self-heal) never grants automatic write permission.
349
- async function automaticWriteWarning(
388
+ export async function automaticWriteWarning(
350
389
  cwd: string,
351
390
  targets: string[],
352
391
  remedy = "Run /skill-registry:refresh to deliberately regenerate runtime files.",
@@ -357,6 +396,7 @@ async function automaticWriteWarning(
357
396
  cwd,
358
397
  env: { ...process.env, LC_ALL: "C" },
359
398
  timeout: gitTimeoutMs,
399
+ windowsHide: true,
360
400
  });
361
401
  tracked = stdout.split("\0").filter(Boolean);
362
402
  } catch (error) {
@@ -496,6 +536,7 @@ async function regenerateRegistry(
496
536
  }
497
537
 
498
538
  const watchedCwds = new Set<string>();
539
+ const watcherPolls = new Map<string, ReturnType<typeof setInterval>>();
499
540
  // Startup regeneration runs after session_start returns; watcher refreshes
500
541
  // wait for it so an older startup scan never overwrites a newer refresh.
501
542
  const pendingStartups = new Map<string, Promise<void>>();
@@ -574,6 +615,8 @@ function closeSkillRegistryWatchers(): void {
574
615
  }
575
616
  }
576
617
  activeWatchers.clear();
618
+ for (const poll of watcherPolls.values()) clearInterval(poll);
619
+ watcherPolls.clear();
577
620
  watchedCwds.clear();
578
621
  }
579
622
 
@@ -588,6 +631,9 @@ async function startSkillRegistryWatcher(
588
631
  ...userSkillDirs(),
589
632
  ]);
590
633
  let timer: ReturnType<typeof setTimeout> | undefined;
634
+ // The poll re-asks a state the watchers already answered: without this,
635
+ // an unchanged tracked-target warning would repeat every poll interval.
636
+ let lastWarning: string | undefined;
591
637
  const refresh = () => {
592
638
  if (timer) clearTimeout(timer);
593
639
  timer = setTimeout(() => {
@@ -595,9 +641,14 @@ async function startSkillRegistryWatcher(
595
641
  try {
596
642
  await pendingStartups.get(cwd);
597
643
  const result = await regenerateRegistry(cwd, false);
598
- if (result.warning) notify(result.warning, "warning");
599
- if (result.regenerated) {
600
- notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
644
+ if (result.warning) {
645
+ if (result.warning !== lastWarning) notify(result.warning, "warning");
646
+ lastWarning = result.warning;
647
+ } else {
648
+ lastWarning = undefined;
649
+ if (result.regenerated) {
650
+ notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
651
+ }
601
652
  }
602
653
  } catch (error) {
603
654
  notify(`Skill registry refresh failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -620,12 +671,16 @@ async function startSkillRegistryWatcher(
620
671
  // Some filesystems do not support recursive watches; session_start/manual refresh still work.
621
672
  }
622
673
  }
674
+ // Watchers may be absent (unsupported filesystem) or silently eventless
675
+ // (exhausted inotify budget): the poll keeps regeneration alive either way.
676
+ if (!watcherPolls.has(cwd)) watcherPolls.set(cwd, setInterval(refresh, WATCH_FALLBACK_POLL_MS));
623
677
  }
624
678
 
625
679
  export const __testing = {
626
680
  projectSkillDirs,
627
681
  userSkillDirs,
628
682
  findSkillFiles,
683
+ isMissingSkillFileError,
629
684
  uniqueExistingDirs,
630
685
  dedupeBySkillName,
631
686
  scopeForPath,