gentle-pi 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,6 +1,6 @@
1
1
  import { statSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { activitySummary, ActivityPublisher, createActivityColumns, metadataTransport } from "../lib/herdr-activity.ts";
3
+ import { activitySummary, ActivityPublisher, createActivityColumns, herdrDetected, metadataTransport } from "../lib/herdr-activity.ts";
4
4
  import { replayTodo, type TodoState } from "../lib/shell-todo.ts";
5
5
 
6
6
  interface ActivityOptions {
@@ -15,8 +15,7 @@ interface ActivityOptions {
15
15
  export function createHerdrActivityExtension(options: ActivityOptions = {}) {
16
16
  return (pi: ExtensionAPI): void => {
17
17
  const env = options.env ?? process.env;
18
- if (env.GENTLE_PI_AGENTS_CHILD === "1" || env.HERDR_ENV !== "1" ||
19
- !env.HERDR_PANE_ID?.trim() || !env.HERDR_SOCKET_PATH?.trim()) return;
18
+ if (env.GENTLE_PI_AGENTS_CHILD === "1" || !herdrDetected(env)) return;
20
19
  const isSocket = options.isSocket ?? ((path: string) => {
21
20
  try { return statSync(path).isSocket(); } catch { return false; }
22
21
  });
@@ -12,6 +12,7 @@ import { dirname, join, resolve } from "node:path";
12
12
  import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
13
13
  import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type FileRevision, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
14
14
  import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
15
+ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
15
16
  import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
16
17
  import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
17
18
  import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
@@ -21,6 +22,8 @@ import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
21
22
  import { discoverYoloUiAdapter, YOLO_DISPLAY, type YoloDisplay, type YoloUiAdapter } from "../lib/yolo-session-policy.ts";
22
23
  import { VisualCustomizeView, type CustomizeCategory, type CustomizeRow, type ProfileActions } from "../lib/visual-customize-view.ts";
23
24
  import { deleteVisualProfile, getVisualProfile, listVisualProfiles, resetVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
25
+ import { gaugePaintFor } from "../lib/gauge-theme.ts";
26
+ import type { GaugePaint } from "../lib/shell-gauge.ts";
24
27
  import { sourcePalettePreview } from "../lib/theme-customization.ts";
25
28
  import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, STATUS_PLACEMENT, VISUAL_SECTION_KEYS, resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
26
29
  import { BANNER_COLORS, DEFAULT_BANNER_CONFIG, readBannerConfig, readBannerConfigForEdit, writeBannerConfig } from "./startup-banner.ts";
@@ -31,7 +34,6 @@ import { oddPhaseRegistry } from "../lib/odd-phase.ts";
31
34
  import { inferOddPhase } from "../lib/odd-phase-inference.ts";
32
35
  import { isInteractiveMode } from "../lib/rpc-host.ts";
33
36
  import { gentlePiConfigHome } from "../lib/agent-home.ts";
34
- import { bundledGentlePiConfigHome } from "../lib/gentle-shell-bundled.ts";
35
37
  import { isNanAdaptiveReasoningModel } from "../lib/nan-provider.ts";
36
38
  import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
37
39
  import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts";
@@ -180,7 +182,7 @@ export type ActiveProfileReader = (() => string | undefined) & {
180
182
  // Unbound reads retain the global file-identity cache, including atomic replacements.
181
183
  // Bound reads are snapshots: no filesystem or Git work is done during a frame.
182
184
  export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader {
183
- const path = profilesFilePath(bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
185
+ const path = profilesFilePath(gentlePiConfigHome(env));
184
186
  let fingerprint: string | undefined;
185
187
  let name: string | undefined;
186
188
  let bound = false;
@@ -215,7 +217,7 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env):
215
217
  const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({
216
218
  sessionId,
217
219
  cwd,
218
- configHome: bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"),
220
+ configHome: gentlePiConfigHome(env),
219
221
  resolveWorktree: () => identity,
220
222
  env,
221
223
  }));
@@ -334,6 +336,16 @@ export function buildShellBarModel(
334
336
  };
335
337
  }
336
338
 
339
+ // Gauge-specific theme roles (issue #2222): resolved from the installed theme
340
+ // source and memoized by gauge-theme.ts, so the render hot path only stats the
341
+ // theme file. Undefined — and therefore today's shared roles — whenever the theme
342
+ // source is unavailable.
343
+ function gaugePaint(ctx: ExtensionContext): GaugePaint | undefined {
344
+ const themeName = ctx.ui.theme.name;
345
+ const source = ctx.ui.theme.sourcePath ?? ctx.ui.getAllThemes?.().find((item) => item?.name === themeName)?.path;
346
+ return gaugePaintFor(typeof source === "string" ? source : undefined);
347
+ }
348
+
337
349
  export function createShellBarComponent(
338
350
  pi: ExtensionAPI,
339
351
  ctx: ExtensionContext,
@@ -350,7 +362,7 @@ export function createShellBarComponent(
350
362
  });
351
363
  return {
352
364
  render(width: number) {
353
- return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.());
365
+ return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.(), gaugePaint(ctx));
354
366
  },
355
367
  invalidate() {},
356
368
  dispose() {
@@ -1223,8 +1235,6 @@ const CHANGES_SHORTCUT_DEFAULT = "alt+g";
1223
1235
  const CHANGES_POLL_DEFAULT_MS = 2000;
1224
1236
  const GIT_TIMEOUT_MS = 5000;
1225
1237
  const COMMANDS_COMMAND_NAME = "gentle:commands";
1226
- const OVERLAY_HEIGHT_RATIO = 0.8;
1227
- const OVERLAY_MIN_ROWS = 8;
1228
1238
 
1229
1239
  export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile, maxBuffer = Infinity): GitRunner {
1230
1240
  // Pi exec cannot replace the inherited environment. Use argv directly and
@@ -1329,6 +1339,8 @@ interface OverlayDeps {
1329
1339
  }
1330
1340
 
1331
1341
  // The overlay polls each root's Git state while it is open; diffs load lazily.
1342
+ // Like Gentle Agents it takes the whole terminal, and the native fullscreen
1343
+ // wrapper keeps mouse dispatch on the view while it owns the keyboard.
1332
1344
  async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
1333
1345
  let host: ExternalEditorHost | undefined;
1334
1346
  let view: WorktreeChangesView | undefined;
@@ -1346,16 +1358,18 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
1346
1358
  host = tui;
1347
1359
  view = new WorktreeChangesView(deps.worktrees(), {
1348
1360
  theme,
1349
- rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
1361
+ rows: () => Math.max(0, tui.terminal.rows),
1350
1362
  loadDiff: deps.loadDiff,
1351
1363
  onOpen: (root, file) => close({ root, file }),
1352
1364
  onRefresh: () => void refresh().catch(() => {}),
1353
1365
  onClose: () => close(null),
1354
1366
  requestRender: () => tui.requestRender(),
1355
1367
  });
1356
- return view;
1368
+ const interaction = createNativeFullscreenInteraction({ keyboardTarget: view, requestRender: () => tui.requestRender() });
1369
+ interaction.addChild(view);
1370
+ return interaction;
1357
1371
  },
1358
- { overlay: true, overlayOptions: { width: "92%", anchor: "center" } },
1372
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } },
1359
1373
  );
1360
1374
  if (!chosen || !host) return;
1361
1375
  if (!openInExternalEditor(host, chosen.file.path, process.env, spawnSync, chosen.root)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
@@ -1738,6 +1752,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1738
1752
  onClose: () => withOverlayRepaint(tui, done)(null),
1739
1753
  requestRender: () => tui.requestRender(),
1740
1754
  scope: () => ({ providers: usageScope, failed: new Set(usageFailures) }),
1755
+ gauge: () => gaugePaint(ctx),
1741
1756
  });
1742
1757
  // Open promptly: the panel draws whatever the store already holds
1743
1758
  // while the forced refresh runs underneath it, and repaints when
@@ -1924,7 +1939,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1924
1939
  const statusOwner = () => narrowStatusOwner({ mode: (tui as TUI & { mode?: string }).mode, columns: tui.terminal?.columns ?? 0, statusPlacement: visualSettings.statusPlacement, headerPlacement: visualSettings.headerPlacement });
1925
1940
  const statusCardVisible = () => Boolean(tui.terminal && tui.terminal.columns >= SIDEBAR_BREAKPOINT && sidebarState(tui).ownsHost?.() && visualSettings.statusPlacement !== "hidden");
1926
1941
  const belowFloat = (width: number, statuses: boolean) => (tui as TUI & { mode?: string }).mode === "fullscreen"
1927
- ? renderShellBelowInputFloat({ ...footerModel(), ...(statuses ? {} : { statuses: [] }), ...(statusCardVisible() ? { jobs: undefined } : {}) }, theme, width, usageShortcutKey, visualSettings, tracker.model)
1942
+ ? renderShellBelowInputFloat({ ...footerModel(), ...(statuses ? {} : { statuses: [] }), ...(statusCardVisible() ? { jobs: undefined } : {}) }, theme, width, usageShortcutKey, visualSettings, tracker.model, gaugePaint(ctx))
1928
1943
  : undefined;
1929
1944
  // Match sidebarPart's live paint ownership, including an unavailable rail.
1930
1945
  const footerSuppressed = () => {
@@ -1935,7 +1950,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1935
1950
  groupedChangesOwner = (width) => belowFloat(width, !footerSuppressed()) !== undefined;
1936
1951
  const bottomBar = {
1937
1952
  ...bottom,
1938
- render: (width: number) => belowFloat(width, true)?.rows ?? (statusOwner() === STATUS_OWNER.BOTTOM ? renderShellBottomOnlyBar(footerModel(), theme, width, usageShortcutKey, visualSettings) : bottom.render(width)),
1953
+ render: (width: number) => belowFloat(width, true)?.rows ?? (statusOwner() === STATUS_OWNER.BOTTOM ? renderShellBottomOnlyBar(footerModel(), theme, width, usageShortcutKey, visualSettings, undefined, gaugePaint(ctx)) : bottom.render(width)),
1939
1954
  handleMouse(event: Parameters<NonNullable<SidebarRail["handleMouse"]>>[0]) {
1940
1955
  if (footerSuppressed() || event.type !== "click" || event.button !== "left") return undefined;
1941
1956
  const chrome = belowFloat(event.width, true);
@@ -1952,7 +1967,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1952
1967
  // The header row carries everything that ticks every frame (model,
1953
1968
  // effort, context, cost, usage) plus session identity and the jobs count
1954
1969
  // when Status is absent. Opaque statuses and working state stay excluded.
1955
- const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings);
1970
+ const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings, gaugePaint(ctx));
1956
1971
  const disposeHeader = sidebarHeader(tui, {
1957
1972
  digest: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
1958
1973
  render: (width) => headerBar(width).rows,
@@ -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;
@@ -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,365 @@
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_VERSION,
19
+ ledgerPath,
20
+ listLedgers,
21
+ liveSubagentScratch,
22
+ processStartToken,
23
+ readLedger,
24
+ realTempRoot,
25
+ removeArtifact,
26
+ removeSessionScratch,
27
+ scratchRefusal,
28
+ SWEEP_GRACE_MS,
29
+ type ArtifactCheck,
30
+ type ArtifactTarget,
31
+ type ScratchArtifact,
32
+ type ScratchLedger,
33
+ type StoredLedger,
34
+ writeLedger,
35
+ } from "../lib/session-scratch.ts";
36
+
37
+ // Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
38
+ // the primary session gets its own TMPDIR, the runtime records the clones and
39
+ // worktrees its bash commands create, removes the scratch at shutdown (and the
40
+ // scratch of crashed sessions at the next start), and /gentle:scratch lists or
41
+ // cleans the rest. Delegated children (GENTLE_PI_AGENTS_CHILD=1) do none of it:
42
+ // their own TMPDIR comes from the runner and nests under the parent's scratch.
43
+
44
+ export const SCRATCH_COMMAND_USAGE = [
45
+ "Usage: /gentle:scratch [list|clean]",
46
+ " list this session's scratch dir with its size, and the clones and worktrees recorded by this and ended sessions",
47
+ " clean remove scratch dirs; after one confirmation, remove recorded worktrees and clones with no uncommitted changes and no unpushed commits",
48
+ ].join("\n");
49
+
50
+ export interface SessionScratchOptions {
51
+ env?: NodeJS.ProcessEnv;
52
+ configHome?: string;
53
+ now?: () => number;
54
+ isProcessAlive?: (pid: number) => boolean;
55
+ /** When a process started (epoch ms), or undefined when it cannot be read. */
56
+ processStartToken?: (pid: number) => string | undefined;
57
+ graceMs?: number;
58
+ platform?: NodeJS.Platform;
59
+ }
60
+
61
+ interface ActiveSession {
62
+ sessionId: string;
63
+ path: string;
64
+ ledger: ScratchLedger;
65
+ }
66
+
67
+ // Keyed by the environment the session scratch was applied to. A /reload that
68
+ // re-runs this factory in the same module instance finds the live session here;
69
+ // one that re-evaluates the module (Pi's loader) recovers it from the ledger.
70
+ const activeSessions = new WeakMap<NodeJS.ProcessEnv, ActiveSession>();
71
+
72
+ export function createSessionScratchExtension(options: SessionScratchOptions = {}): (pi: ExtensionAPI) => void {
73
+ const env = options.env ?? process.env;
74
+ const now = options.now ?? Date.now;
75
+ const alive = options.isProcessAlive ?? isProcessAlive;
76
+ const startToken = options.processStartToken ?? processStartToken;
77
+ // A live pid is the ledger's process only if it has the start token the ledger
78
+ // recorded: the OS may have reused it. A token that is missing or cannot be
79
+ // read keeps the ledger live.
80
+ const isLive = (ledger: ScratchLedger) => {
81
+ if (!alive(ledger.pid)) return false;
82
+ if (ledger.processStartToken === undefined) return true;
83
+ let current: string | undefined;
84
+ try { current = startToken(ledger.pid); } catch { current = undefined; }
85
+ return current === undefined || current === ledger.processStartToken;
86
+ };
87
+ // This process's own token, or undefined (omitted from the ledger JSON) when it cannot be read.
88
+ const safeStartToken = () => { try { return startToken(process.pid); } catch { return undefined; } };
89
+ // Real paths of this process's live and quarantined subagent TMPDIRs (lib/agents-runner.ts).
90
+ const subagentDirs = () => liveSubagentScratch().flatMap((dir) => realTempRoot(dir) ?? []);
91
+ const graceMs = options.graceMs ?? SWEEP_GRACE_MS;
92
+ const tempVariables = (options.platform ?? process.platform) === "win32" ? ["TMPDIR", "TMP", "TEMP"] : ["TMPDIR"];
93
+ const configHome = () => options.configHome ?? gentlePiConfigHome(env);
94
+
95
+ const persist = (path: string, ledger: ScratchLedger) => {
96
+ try {
97
+ if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
98
+ else writeLedger(path, ledger);
99
+ } catch { /* Best effort: an unwritable config home keeps the session running. */ }
100
+ };
101
+ // Paths the runtime never records or removes, whatever a ledger says.
102
+ const protectedDirs = (ledger: ScratchLedger) => [configHome(), ledger.cwd, process.cwd()];
103
+
104
+ // The temp root the asking session trusts inside the home: its own, as recorded.
105
+ const ownRoot = () => activeSessions.get(env)?.ledger.tempRoot ?? realTempRoot(env.TMPDIR?.trim() || tmpdir());
106
+
107
+ // S9: a scratch name no longer carries its session id, so a scratch dir that a
108
+ // live session's ledger records stays out of reach of every other ledger. This
109
+ // session is live whatever the process check says. Returns, per ledger path,
110
+ // what removal through that ledger must keep: its artifacts and others' live scratch.
111
+ const heldBy = (ledgers: readonly StoredLedger[]) => {
112
+ const own = activeSessions.get(env);
113
+ const live = ledgers.filter(({ ledger }) => ledger.scratchDir !== null && isLive(ledger)).map(({ path, ledger }) => ({ path, dir: ledger.scratchDir! }));
114
+ if (own?.ledger.scratchDir) live.push({ path: own.path, dir: own.ledger.scratchDir });
115
+ return (path: string, ledger: ScratchLedger) => [
116
+ ...ledger.artifacts.map((artifact) => artifact.path),
117
+ ...live.filter((entry) => entry.path !== path).map((entry) => entry.dir),
118
+ ];
119
+ };
120
+
121
+ const endSession = (session: ActiveSession) => {
122
+ const { ledger } = session;
123
+ // A running or quarantined subagent's TMPDIR stays; the scratch then stays in the ledger for a later sweep.
124
+ const removal = removeSessionScratch(ledger, heldBy(listLedgers(configHome()))(session.path, ledger), { ownRoot: ledger.tempRoot ?? undefined, liveSubagentDirs: subagentDirs() });
125
+ if (removal === "removed" || removal === "absent") ledger.scratchDir = null;
126
+ for (const [key, value] of Object.entries(ledger.inherited)) {
127
+ if (value === null) delete env[key];
128
+ else env[key] = value;
129
+ }
130
+ persist(session.path, ledger);
131
+ activeSessions.delete(env);
132
+ pending.clear();
133
+ };
134
+
135
+ // S5(b): scratch of ended sessions past the grace period; their artifacts stay for the command.
136
+ const sweep = (ownPath: string) => {
137
+ const ledgers = listLedgers(configHome());
138
+ const held = heldBy(ledgers);
139
+ for (const { path, ledger, mtimeMs } of ledgers) {
140
+ if (path === ownPath || isLive(ledger) || now() - mtimeMs < graceMs) continue;
141
+ const artifacts = ledger.artifacts.filter((artifact) => { try { lstatSync(artifact.path); return true; } catch { return false; } });
142
+ const removal = ledger.scratchDir === null ? "absent" : removeSessionScratch(ledger, held(path, { ...ledger, artifacts }), { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
143
+ persist(path, { ...ledger, scratchDir: removal === "removed" || removal === "absent" ? null : ledger.scratchDir, artifacts });
144
+ }
145
+ };
146
+
147
+ // The live scratch of this process: its ledger names this pid and a scratch it
148
+ // created that TMPDIR still points into, as after a reload that lost module state.
149
+ const isLiveScratch = (ledger: ScratchLedger | undefined): boolean => {
150
+ if (!ledger?.scratchDir || ledger.pid !== process.pid || scratchRefusal(ledger, ledger.tempRoot ?? undefined) !== undefined) return false;
151
+ const current = env.TMPDIR?.trim();
152
+ return !!current && [resolve(current), realTempRoot(current)].some((dir) => dir !== undefined && isWithin(dir, ledger.scratchDir!));
153
+ };
154
+
155
+ const startSession = (ctx: ExtensionContext, reason: string) => {
156
+ const sessionId = ctx.sessionManager.getSessionId?.();
157
+ if (!sessionId) return;
158
+ const current = activeSessions.get(env);
159
+ if (current?.sessionId === sessionId && reason === "reload") return;
160
+ if (current) endSession(current);
161
+ const path = ledgerPath(configHome(), sessionId);
162
+ const previous = readLedger(path);
163
+ if (previous && isLiveScratch(previous)) {
164
+ activeSessions.set(env, { sessionId, path, ledger: previous });
165
+ persist(path, previous);
166
+ sweep(path);
167
+ return;
168
+ }
169
+ // A reload that could not recover the scratch finds TMPDIR pointing at it:
170
+ // the inherited values are the ones this process's ledger recorded, else
171
+ // TMPDIR as it is unless it is a session scratch, whose parent it was made in.
172
+ const reloaded = reason === "reload" && previous?.pid === process.pid ? previous.inherited : undefined;
173
+ const inheritedValue = (key: string) => {
174
+ if (reloaded && key in reloaded) return reloaded[key];
175
+ const value = env[key];
176
+ if (reason === "reload" && value?.trim() && isSessionScratchName(value.trim())) return dirname(value.trim());
177
+ return value ?? null;
178
+ };
179
+ const inherited = Object.fromEntries(tempVariables.map((key) => [key, inheritedValue(key)]));
180
+ // Restored first, so tmpdir() never answers with the old scratch.
181
+ for (const [key, value] of Object.entries(inherited)) {
182
+ if (value === null) delete env[key];
183
+ else env[key] = value;
184
+ }
185
+ const inheritedTmp = inherited.TMPDIR?.trim();
186
+ const created = createSessionScratch(inheritedTmp ? inheritedTmp : tmpdir());
187
+ // TMPDIR keeps the inherited spelling (S9: socket path length); the ledger keeps real paths.
188
+ if (created) for (const key of tempVariables) env[key] = created.path;
189
+ const scratchDir = created?.realPath;
190
+ // A resumed session keeps the artifacts an earlier run of it recorded.
191
+ const ledger: ScratchLedger = {
192
+ version: LEDGER_VERSION,
193
+ sessionId,
194
+ pid: process.pid,
195
+ processStartedAt: new Date(now() - process.uptime() * 1000).toISOString(),
196
+ processStartToken: safeStartToken(),
197
+ cwd: ctx.cwd,
198
+ tempRoot: scratchDir ? dirname(scratchDir) : null,
199
+ inherited,
200
+ scratchDir: scratchDir ?? null,
201
+ artifacts: previous?.artifacts ?? [],
202
+ };
203
+ // A scratch of this process is never removed at startup: it may still be in use.
204
+ if (previous?.scratchDir && previous.scratchDir !== scratchDir && previous.pid !== process.pid && !isLive(previous)) {
205
+ const held = heldBy(listLedgers(configHome()))(path, ledger);
206
+ removeSessionScratch(previous, scratchDir ? [...held, scratchDir] : held, { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
207
+ }
208
+ activeSessions.set(env, { sessionId, path, ledger });
209
+ persist(path, ledger);
210
+ sweep(path);
211
+ };
212
+
213
+ // S4: destinations a foreground bash command is about to create, by tool call
214
+ // id. A destination that already exists belongs to someone else.
215
+ const pending = new Map<string, ArtifactTarget[]>();
216
+
217
+ const note = (toolCallId: string, command: string, ctx: ExtensionContext) => {
218
+ if (!activeSessions.get(env)) return;
219
+ const absent = gitArtifactTargets(command, ctx.cwd).filter((target) => {
220
+ try { lstatSync(target.path); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
221
+ });
222
+ if (absent.length > 0) pending.set(toolCallId, absent);
223
+ };
224
+
225
+ // S4: the noted destinations that a successful command created.
226
+ const capture = (targets: readonly ArtifactTarget[], command: string) => {
227
+ const session = activeSessions.get(env);
228
+ if (!session) return;
229
+ const { ledger } = session;
230
+ let changed = false;
231
+ for (const target of targets) {
232
+ let path: string;
233
+ try {
234
+ if (!lstatSync(target.path).isDirectory()) continue;
235
+ path = realpathSync(target.path);
236
+ } catch { continue; }
237
+ if (isProtectedPath(path, protectedDirs(ledger)) || ledger.artifacts.some((artifact) => artifact.path === path)) continue;
238
+ ledger.artifacts.push({ kind: target.kind, path, command, recordedAt: new Date(now()).toISOString() });
239
+ changed = true;
240
+ }
241
+ if (changed) persist(session.path, ledger);
242
+ };
243
+
244
+ const endedLedgers = (ownPath: string | undefined) =>
245
+ listLedgers(configHome()).filter(({ path, ledger }) => path !== ownPath && !isLive(ledger));
246
+
247
+ const describe = (artifact: ScratchArtifact) => `${artifact.kind} ${artifact.path}`;
248
+
249
+ const list = (): string => {
250
+ const session = activeSessions.get(env);
251
+ const lines: string[] = [];
252
+ const own = session?.ledger;
253
+ lines.push(own?.scratchDir
254
+ ? `Session scratch: ${own.scratchDir} (${formatSize(directorySize(own.scratchDir))})`
255
+ : `Session scratch: none (TMPDIR ${env.TMPDIR ?? tmpdir()})`);
256
+ const ended = endedLedgers(session?.path);
257
+ for (const { ledger } of ended) if (ledger.scratchDir) lines.push(`Ended-session scratch: ${ledger.scratchDir} (session ${ledger.sessionId})`);
258
+ const artifacts = [
259
+ ...(own?.artifacts ?? []).map((artifact) => ` ${describe(artifact)} (this session)`),
260
+ ...ended.flatMap(({ ledger }) => ledger.artifacts.map((artifact) => ` ${describe(artifact)} (session ${ledger.sessionId}, ended)`)),
261
+ ];
262
+ lines.push(artifacts.length > 0 ? "Recorded clones and worktrees:" : "Recorded clones and worktrees: none", ...artifacts);
263
+ return lines.join("\n");
264
+ };
265
+
266
+ const clean = async (ctx: ExtensionContext): Promise<string> => {
267
+ // Entries of the live scratch changed since this moment may belong to a running command.
268
+ const recentSince = now() - CLEAN_RECENT_MS;
269
+ const session = activeSessions.get(env);
270
+ const groups = [
271
+ ...(session ? [{ path: session.path, ledger: session.ledger, own: true }] : []),
272
+ ...endedLedgers(session?.path).map(({ path, ledger }) => ({ path, ledger, own: false })),
273
+ ];
274
+ const lines: string[] = [];
275
+ const entries = groups.flatMap((group) => group.ledger.artifacts.map((artifact) => ({ group, artifact })));
276
+ const drop = (entry: (typeof entries)[number]) => {
277
+ entry.group.ledger.artifacts = entry.group.ledger.artifacts.filter((artifact) => artifact !== entry.artifact);
278
+ };
279
+ if (entries.length > 0 && !ctx.hasUI) {
280
+ lines.push("Recorded clones and worktrees kept (no UI to confirm their removal):", ...entries.map(({ artifact }) => ` ${describe(artifact)}`));
281
+ } else if (entries.length > 0) {
282
+ const checked = await Promise.all(entries.map(async (entry) => ({ ...entry, check: await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger)) })));
283
+ const removable: Array<(typeof entries)[number] & { check: Extract<ArtifactCheck, { state: "removable" }> }> = [];
284
+ for (const entry of checked) {
285
+ if (entry.check.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); }
286
+ else if (entry.check.state === "kept") lines.push(`Kept ${describe(entry.artifact)}: ${entry.check.reason}`);
287
+ else removable.push(entry as (typeof removable)[number]);
288
+ }
289
+ if (removable.length > 0) {
290
+ const confirmed = await ctx.ui.confirm(
291
+ `Remove ${removable.length} recorded clone${removable.length === 1 ? "" : "s"}/worktree${removable.length === 1 ? "" : "s"}?`,
292
+ `No uncommitted changes and no unpushed commits; ignored files inside these paths are deleted too:\n${removable.map(({ artifact }) => ` ${describe(artifact)}`).join("\n")}`,
293
+ );
294
+ for (const entry of removable) {
295
+ if (!confirmed) { lines.push(`Kept ${describe(entry.artifact)}: not confirmed`); continue; }
296
+ // The clone may have changed while the confirmation was open: check it again.
297
+ const recheck = await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger));
298
+ if (recheck.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); continue; }
299
+ if (recheck.state === "kept") { lines.push(`Kept ${describe(entry.artifact)}: ${recheck.reason}`); continue; }
300
+ 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; }
301
+ const failure = await removeArtifact(entry.artifact, recheck);
302
+ if (failure) lines.push(`Kept ${describe(entry.artifact)}: ${failure}`);
303
+ else { drop(entry); lines.push(`Removed ${describe(entry.artifact)}`); }
304
+ }
305
+ }
306
+ }
307
+ // Scratch goes without asking; the live session keeps its TMPDIR and empties it.
308
+ const held = heldBy(listLedgers(configHome()));
309
+ for (const group of groups) {
310
+ const { ledger } = group;
311
+ if (!ledger.scratchDir) continue;
312
+ const dir = ledger.scratchDir;
313
+ const removal = removeSessionScratch(ledger, held(group.path, ledger), { contentsOnly: group.own, ownRoot: ownRoot(), liveSubagentDirs: subagentDirs(), keepChangedSince: group.own ? recentSince : undefined });
314
+ if (!group.own && (removal === "removed" || removal === "absent")) ledger.scratchDir = null;
315
+ if (removal === "refused") lines.push(`Kept scratch ${dir}: not a directory this runtime created`);
316
+ 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"}`);
317
+ else lines.push(`${group.own ? "Emptied" : "Removed"} scratch ${dir}`);
318
+ }
319
+ for (const group of groups) persist(group.path, group.ledger);
320
+ return lines.length > 0 ? lines.join("\n") : "Nothing to clean.";
321
+ };
322
+
323
+ return (pi: ExtensionAPI) => {
324
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") return;
325
+ pi.on("session_start", (event, ctx) => {
326
+ try { startSession(ctx, (event as { reason?: string }).reason ?? "startup"); } catch { /* The session keeps the inherited TMPDIR. */ }
327
+ });
328
+ pi.on("session_shutdown", (event) => {
329
+ // A reload continues the same session and keeps its scratch.
330
+ if ((event as { reason?: string }).reason === "reload") return;
331
+ const session = activeSessions.get(env);
332
+ try { if (session) endSession(session); } catch { /* best effort */ }
333
+ });
334
+ pi.on("tool_call", (event, ctx) => {
335
+ // Only foreground bash: a background job's result arrives before its clone exists.
336
+ const command = (event.input as { command?: unknown } | undefined)?.command;
337
+ if (event.toolName !== "bash" || typeof command !== "string") return undefined;
338
+ try { note(event.toolCallId, command, ctx); } catch { /* Capture never blocks a tool call. */ }
339
+ return undefined;
340
+ });
341
+ pi.on("tool_result", (event) => {
342
+ const targets = pending.get(event.toolCallId);
343
+ if (!targets) return undefined;
344
+ pending.delete(event.toolCallId);
345
+ const command = (event.input as { command?: unknown } | undefined)?.command;
346
+ if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string") return undefined;
347
+ try { capture(targets, command); } catch { /* Capture never changes a tool result. */ }
348
+ return undefined;
349
+ });
350
+ pi.registerCommand("gentle:scratch", {
351
+ description: "List or clean this session's scratch dir and the recorded clones and worktrees (list|clean).",
352
+ getArgumentCompletions: (prefix) => ["list", "clean"].filter((action) => action.startsWith(prefix.trim())).map((action) => ({ value: action, label: action })),
353
+ handler: async (args, ctx) => {
354
+ const action = args.trim() || "list";
355
+ if (action === "list") ctx.ui.notify(list(), "info");
356
+ else if (action === "clean") ctx.ui.notify(await clean(ctx), "info");
357
+ else ctx.ui.notify(SCRATCH_COMMAND_USAGE, "warning");
358
+ },
359
+ });
360
+ };
361
+ }
362
+
363
+ export default function sessionScratch(pi: ExtensionAPI): void {
364
+ createSessionScratchExtension()(pi);
365
+ }