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
@@ -0,0 +1,1011 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import test, { type TestContext } from "node:test";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { createSessionScratchExtension, SCRATCH_COMMAND_USAGE } from "../extensions/session-scratch.ts";
9
+ import * as scratchLib from "../lib/session-scratch.ts";
10
+ import { gitArtifactTargets, LEDGER_ARTIFACTS_CAP, LEDGER_VERSION, type ScratchLedger } from "../lib/session-scratch.ts";
11
+
12
+ // Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
13
+ // the runtime owns the parent session's TMPDIR and the clones/worktrees it
14
+ // creates, records them in a per-session ledger, and removes only what it
15
+ // created and recorded. Every fixture lives under one private test root.
16
+
17
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
18
+ type Command = { description?: string; handler: (args: string, ctx: ExtensionContext) => Promise<void> };
19
+
20
+ interface Fixture { root: string; base: string; configHome: string; cwd: string; work: string }
21
+
22
+ function fixture(t: TestContext): Fixture {
23
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-session-scratch-test-")));
24
+ const fx = { root, base: join(root, "tmp"), configHome: join(root, "config"), cwd: join(root, "project"), work: join(root, "work") };
25
+ for (const path of [fx.base, fx.configHome, fx.cwd, fx.work]) mkdirSync(path, { recursive: true });
26
+ t.after(() => rmSync(root, { recursive: true, force: true }));
27
+ return fx;
28
+ }
29
+
30
+ type ExtensionFactory = typeof createSessionScratchExtension;
31
+
32
+ interface Options {
33
+ sessionId?: string; child?: boolean; hasUI?: boolean; confirm?: boolean; isProcessAlive?: (pid: number) => boolean; now?: () => number; platform?: NodeJS.Platform; tmp?: string;
34
+ processStartToken?: (pid: number) => string | undefined;
35
+ /** A shared process environment, as a reload re-runs the extension against the same `process.env`. */
36
+ env?: NodeJS.ProcessEnv;
37
+ /** The factory of a separately loaded module instance. */
38
+ factory?: ExtensionFactory;
39
+ /** Runs while the confirmation is open, before the user answers. */
40
+ duringConfirm?: () => void;
41
+ }
42
+
43
+ interface BashOptions { isError?: boolean; toolName?: string }
44
+
45
+ let toolCallCount = 0;
46
+
47
+ function session(fx: Fixture, options: Options = {}) {
48
+ const handlers = new Map<string, Handler>();
49
+ const commands = new Map<string, Command>();
50
+ const notes: string[] = [];
51
+ const confirms: string[] = [];
52
+ const env: NodeJS.ProcessEnv = options.env ?? { TMPDIR: options.tmp ?? fx.base, GENTLE_PI_CONFIG_HOME: fx.configHome, ...(options.child ? { GENTLE_PI_AGENTS_CHILD: "1" } : {}) };
53
+ const pi = {
54
+ on: (name: string, handler: Handler) => { handlers.set(name, handler); },
55
+ registerCommand: (name: string, command: Command) => { commands.set(name, command); },
56
+ } as unknown as ExtensionAPI;
57
+ (options.factory ?? createSessionScratchExtension)({ env, isProcessAlive: options.isProcessAlive, processStartToken: options.processStartToken, now: options.now, platform: options.platform })(pi);
58
+ const ctx = {
59
+ cwd: fx.cwd,
60
+ hasUI: options.hasUI ?? true,
61
+ sessionManager: { getSessionId: () => options.sessionId ?? "s-1" },
62
+ ui: {
63
+ notify: (message: string) => { notes.push(message); },
64
+ confirm: async (title: string, message: string) => { confirms.push(`${title}\n${message}`); options.duringConfirm?.(); return options.confirm ?? true; },
65
+ },
66
+ } as unknown as ExtensionContext;
67
+ const emit = async (name: string, event: unknown) => await handlers.get(name)?.(event, ctx);
68
+ return {
69
+ env, handlers, commands, notes, confirms, ctx,
70
+ start: (reason = "startup") => emit("session_start", { type: "session_start", reason }),
71
+ shutdown: (reason = "quit") => emit("session_shutdown", { type: "session_shutdown", reason }),
72
+ // The call event, then the command's effect on disk, then its result, as Pi runs a tool.
73
+ bash: async (command: string, effect?: () => void, { isError = false, toolName = "bash" }: BashOptions = {}) => {
74
+ const toolCallId = `c${++toolCallCount}`;
75
+ await emit("tool_call", { type: "tool_call", toolName, toolCallId, input: { command } });
76
+ effect?.();
77
+ return await emit("tool_result", { type: "tool_result", toolName, toolCallId, input: { command }, content: [], isError });
78
+ },
79
+ run: async (args: string) => { await commands.get("gentle:scratch")!.handler(args, ctx); return notes.at(-1) ?? ""; },
80
+ };
81
+ }
82
+
83
+ const ledgerFile = (fx: Fixture, sessionId: string) => join(fx.configHome, "scratch", `${sessionId}.json`);
84
+ const readLedger = (fx: Fixture, sessionId: string) => JSON.parse(readFileSync(ledgerFile(fx, sessionId), "utf8")) as ScratchLedger;
85
+
86
+ function git(cwd: string, ...args: string[]): string {
87
+ return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
88
+ }
89
+
90
+ function origin(fx: Fixture): string {
91
+ const path = join(fx.work, "origin");
92
+ mkdirSync(path);
93
+ git(path, "init", "-q");
94
+ writeFileSync(join(path, "a.txt"), "a\n");
95
+ git(path, "add", "a.txt");
96
+ git(path, "commit", "-q", "-m", "init");
97
+ return path;
98
+ }
99
+
100
+ function writeForeignLedger(fx: Fixture, ledger: Partial<ScratchLedger> & { sessionId: string }): void {
101
+ mkdirSync(join(fx.configHome, "scratch"), { recursive: true });
102
+ const full: ScratchLedger = { version: LEDGER_VERSION, pid: 999_999, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: fx.base, inherited: { TMPDIR: fx.base }, scratchDir: null, artifacts: [], ...ledger };
103
+ writeFileSync(ledgerFile(fx, ledger.sessionId), JSON.stringify(full));
104
+ }
105
+
106
+ const later = () => Date.now() + 2 * 3_600_000;
107
+ // clean keeps the live scratch's entries changed in the last minute (advisories S5).
108
+ const minutesLater = () => Date.now() + 2 * 60_000;
109
+
110
+ // S2
111
+
112
+ // Pi's /reload re-evaluates every extension module (jiti `moduleCache: false`
113
+ // after `clearExtensionCache()`), so the module-level state is new; a fresh
114
+ // import with a cache-busting query string is a separate module instance.
115
+ const loadInstance = async (tag: string): Promise<ExtensionFactory> =>
116
+ ((await import(`../extensions/session-scratch.ts?instance=${tag}-${Date.now()}`)) as { createSessionScratchExtension: ExtensionFactory }).createSessionScratchExtension;
117
+
118
+ // Native review R3-scratch-socket-path-length: TMPDIR now points at the scratch, and
119
+ // Unix socket paths are capped near 104 bytes, so the name must stay short (S9).
120
+ const SCRATCH_NAME = /^gp[A-Za-z0-9]{6}$/;
121
+
122
+ test("S9: the scratch name is gpXXXXXX whatever the session id", async (t) => {
123
+ const fx = fixture(t);
124
+ const s = session(fx, { sessionId: "a".repeat(64) });
125
+ await s.start();
126
+ assert.match(basename(s.env.TMPDIR!), SCRATCH_NAME);
127
+ await s.shutdown();
128
+ });
129
+
130
+ test("S9: TMPDIR keeps the inherited spelling while the ledger records real paths", async (t) => {
131
+ const fx = fixture(t);
132
+ const link = join(fx.root, "tmp-link");
133
+ symlinkSync(fx.base, link, "dir");
134
+ const env: NodeJS.ProcessEnv = { TMPDIR: link, GENTLE_PI_CONFIG_HOME: fx.configHome };
135
+ const first = session(fx, { env, factory: await loadInstance("spelling-a") });
136
+ await first.start();
137
+ const scratch = env.TMPDIR!;
138
+ assert.equal(dirname(scratch), link, "the TMPDIR value joins the inherited value, never a real path");
139
+ const ledger = readLedger(fx, "s-1");
140
+ assert.equal(ledger.scratchDir, join(fx.base, basename(scratch)));
141
+ assert.equal(ledger.tempRoot, fx.base);
142
+ assert.deepEqual(ledger.inherited, { TMPDIR: link });
143
+ writeFileSync(join(scratch, "work.txt"), "x");
144
+ await first.shutdown("reload");
145
+ const second = session(fx, { env, factory: await loadInstance("spelling-b") });
146
+ await second.start("reload");
147
+ assert.equal(env.TMPDIR, scratch, "a reload recovers the scratch under the same spelling");
148
+ assert.deepEqual(readdirSync(fx.base), [basename(scratch)]);
149
+ await second.shutdown("quit");
150
+ assert.deepEqual(readdirSync(fx.base), []);
151
+ assert.equal(env.TMPDIR, link);
152
+ });
153
+
154
+ test("S2: a primary session points TMPDIR at a private session scratch under the inherited one", async (t) => {
155
+ const fx = fixture(t);
156
+ const s = session(fx, { sessionId: "abc/../1" });
157
+ await s.start();
158
+ const scratch = s.env.TMPDIR!;
159
+ assert.equal(dirname(scratch), fx.base);
160
+ assert.match(basename(scratch), SCRATCH_NAME);
161
+ assert.ok(statSync(scratch).isDirectory());
162
+ if (process.platform !== "win32") assert.equal(statSync(scratch).mode & 0o777, 0o700);
163
+ assert.equal(s.env.TMP, undefined, "TMP/TEMP are only set on win32");
164
+ });
165
+
166
+ test("S2: on win32 TMP and TEMP follow the session scratch", async (t) => {
167
+ const fx = fixture(t);
168
+ const s = session(fx, { platform: "win32" });
169
+ await s.start();
170
+ assert.equal(s.env.TMP, s.env.TMPDIR);
171
+ assert.equal(s.env.TEMP, s.env.TMPDIR);
172
+ await s.shutdown();
173
+ assert.equal(s.env.TMPDIR, fx.base);
174
+ assert.equal(s.env.TMP, undefined);
175
+ assert.equal(s.env.TEMP, undefined);
176
+ });
177
+
178
+ test("S2: a child session creates no scratch, ledger, capture, or command", async (t) => {
179
+ const fx = fixture(t);
180
+ const s = session(fx, { child: true });
181
+ await s.start();
182
+ assert.equal(s.env.TMPDIR, fx.base);
183
+ assert.equal(s.commands.has("gentle:scratch"), false);
184
+ assert.equal(s.handlers.size, 0);
185
+ assert.deepEqual(readdirSync(fx.base), []);
186
+ assert.equal(existsSync(join(fx.configHome, "scratch")), false);
187
+ // Parent control: the same fixture in a primary session does create one.
188
+ const parent = session(fx);
189
+ await parent.start();
190
+ assert.notEqual(parent.env.TMPDIR, fx.base);
191
+ await parent.shutdown();
192
+ });
193
+
194
+ test("S2: when the scratch cannot be created the session keeps the inherited TMPDIR", async (t) => {
195
+ const fx = fixture(t);
196
+ const missing = join(fx.root, "missing");
197
+ const s = session(fx, { tmp: missing });
198
+ await s.start();
199
+ assert.equal(s.env.TMPDIR, missing);
200
+ assert.equal(existsSync(missing), false);
201
+ });
202
+
203
+ test("S2/S5: reload keeps the same scratch; quit removes it and restores TMPDIR", async (t) => {
204
+ const fx = fixture(t);
205
+ const s = session(fx);
206
+ await s.start();
207
+ const scratch = s.env.TMPDIR!;
208
+ writeFileSync(join(scratch, "work.txt"), "x");
209
+ await s.shutdown("reload");
210
+ await s.start("reload");
211
+ assert.equal(s.env.TMPDIR, scratch);
212
+ assert.ok(existsSync(join(scratch, "work.txt")));
213
+ await s.shutdown("quit");
214
+ assert.equal(existsSync(scratch), false);
215
+ assert.equal(s.env.TMPDIR, fx.base);
216
+ assert.equal(existsSync(ledgerFile(fx, "s-1")), false, "a ledger without artifacts is deleted");
217
+ });
218
+
219
+ test("S2/S5: a reload into a fresh module instance recovers the live scratch from the ledger and quit removes it once", async (t) => {
220
+ const fx = fixture(t);
221
+ const env: NodeJS.ProcessEnv = { TMPDIR: fx.base, GENTLE_PI_CONFIG_HOME: fx.configHome };
222
+ const first = session(fx, { env, factory: await loadInstance("first") });
223
+ await first.start();
224
+ const scratch = env.TMPDIR!;
225
+ assert.notEqual(scratch, fx.base);
226
+ writeFileSync(join(scratch, "work.txt"), "x");
227
+ await first.shutdown("reload");
228
+ const second = session(fx, { env, factory: await loadInstance("second") });
229
+ assert.notEqual(second.handlers.get("session_start"), first.handlers.get("session_start"), "the second instance is a separate module evaluation");
230
+ await second.start("reload");
231
+ assert.equal(env.TMPDIR, scratch, "TMPDIR is unchanged");
232
+ assert.equal(readFileSync(join(scratch, "work.txt"), "utf8"), "x", "the live scratch survives the reload");
233
+ assert.deepEqual(readdirSync(fx.base), [basename(scratch)], "no second scratch is created");
234
+ assert.equal(readLedger(fx, "s-1").scratchDir, scratch);
235
+ await second.shutdown("quit");
236
+ assert.deepEqual(readdirSync(fx.base), [], "quit removes the one scratch");
237
+ assert.equal(env.TMPDIR, fx.base, "the inherited TMPDIR recorded in the ledger is restored");
238
+ assert.equal(existsSync(ledgerFile(fx, "s-1")), false);
239
+ // The first instance saw only the reload shutdown and removes nothing more.
240
+ await first.shutdown("quit");
241
+ assert.equal(env.TMPDIR, fx.base);
242
+ });
243
+
244
+ test("S5: startup never removes a scratch of this process that TMPDIR no longer points into", async (t) => {
245
+ const fx = fixture(t);
246
+ const env: NodeJS.ProcessEnv = { TMPDIR: fx.base, GENTLE_PI_CONFIG_HOME: fx.configHome };
247
+ const first = session(fx, { env, factory: await loadInstance("keep-a") });
248
+ await first.start();
249
+ const scratch = env.TMPDIR!;
250
+ writeFileSync(join(scratch, "work.txt"), "x");
251
+ env.TMPDIR = fx.base;
252
+ const second = session(fx, { env, factory: await loadInstance("keep-b") });
253
+ await second.start("reload");
254
+ assert.ok(existsSync(join(scratch, "work.txt")), "a scratch owned by this pid is never removed at startup");
255
+ await second.shutdown();
256
+ });
257
+
258
+ test("S5: a session replacement removes the old scratch and the new session gets its own", async (t) => {
259
+ const fx = fixture(t);
260
+ let id = "first";
261
+ const s = session(fx);
262
+ (s.ctx.sessionManager as { getSessionId: () => string }).getSessionId = () => id;
263
+ await s.start();
264
+ const first = s.env.TMPDIR!;
265
+ await s.shutdown("new");
266
+ id = "second";
267
+ await s.start("new");
268
+ assert.equal(existsSync(first), false);
269
+ assert.equal(dirname(s.env.TMPDIR!), fx.base, "the new scratch nests under the inherited TMPDIR, not the old scratch");
270
+ assert.match(basename(s.env.TMPDIR!), SCRATCH_NAME);
271
+ await s.shutdown();
272
+ });
273
+
274
+ // S3
275
+
276
+ test("S3: the ledger records the session, owning process, cwd and scratch, written atomically and privately", async (t) => {
277
+ const fx = fixture(t);
278
+ const s = session(fx);
279
+ await s.start();
280
+ const ledger = readLedger(fx, "s-1");
281
+ assert.equal(ledger.version, LEDGER_VERSION);
282
+ assert.equal(ledger.sessionId, "s-1");
283
+ assert.equal(ledger.pid, process.pid);
284
+ assert.ok(!Number.isNaN(Date.parse(ledger.processStartedAt)));
285
+ assert.equal(ledger.cwd, fx.cwd);
286
+ assert.equal(ledger.scratchDir, s.env.TMPDIR);
287
+ assert.deepEqual(ledger.artifacts, []);
288
+ assert.deepEqual(readdirSync(join(fx.configHome, "scratch")), ["s-1.json"], "no temp file is left after the rename");
289
+ if (process.platform !== "win32") assert.equal(statSync(ledgerFile(fx, "s-1")).mode & 0o777, 0o600);
290
+ await s.shutdown();
291
+ });
292
+
293
+ // S4
294
+
295
+ test("S4: clone and worktree destinations resolve against cd segments and git -C", () => {
296
+ const cwd = "/w";
297
+ const paths = (command: string) => gitArtifactTargets(command, cwd).map(({ kind, path }) => `${kind}:${path}`);
298
+ assert.deepEqual(paths("git clone https://example.com/org/repo.git"), ["clone:/w/repo"]);
299
+ assert.deepEqual(paths("git clone --depth 1 -b main git@github.com:org/tool.git dest"), ["clone:/w/dest"]);
300
+ assert.deepEqual(paths("git clone --origin=up ../a/"), ["clone:/w/a"]);
301
+ assert.deepEqual(paths("cd sub && git clone ../origin"), ["clone:/w/sub/origin"]);
302
+ assert.deepEqual(paths("cd sub && git clone ../o here"), ["clone:/w/sub/here"]);
303
+ assert.deepEqual(paths("git -C sub clone ../o d 2>&1 | tail -1"), ["clone:/w/sub/d"]);
304
+ assert.deepEqual(paths("GIT_TERMINAL_PROMPT=0 git clone x/b.bundle"), ["clone:/w/b"]);
305
+ assert.deepEqual(paths("git worktree add -b feat ../wt main"), ["worktree:/wt"]);
306
+ assert.deepEqual(paths("git worktree add --detach wt2 HEAD; git worktree add -B x \"with space\""), ["worktree:/w/wt2", "worktree:/w/with space"]);
307
+ for (const none of ["echo git clone x y", "git worktree list", "git clone", "git log --grep clone", "printf 'git worktree add z'"]) {
308
+ assert.deepEqual(paths(none), [], none);
309
+ }
310
+ });
311
+
312
+ test("S4: a successful foreground bash clone or worktree add is recorded with its command and time", async (t) => {
313
+ const fx = fixture(t);
314
+ const source = origin(fx);
315
+ const s = session(fx);
316
+ await s.start();
317
+ const clone = `cd ${fx.work} && git clone -q ${source} copy`;
318
+ await s.bash(clone, () => git(fx.work, "clone", "-q", source, "copy"));
319
+ await s.bash(`git -C ${source} worktree add -q -b feat ../wt`, () => git(source, "worktree", "add", "-q", "-b", "feat", join(fx.work, "wt")));
320
+ const ledger = readLedger(fx, "s-1");
321
+ assert.deepEqual(ledger.artifacts.map(({ kind, path, command }) => ({ kind, path, command })), [
322
+ { kind: "clone", path: join(fx.work, "copy"), command: clone },
323
+ { kind: "worktree", path: join(fx.work, "wt"), command: `git -C ${source} worktree add -q -b feat ../wt` },
324
+ ]);
325
+ assert.ok(ledger.artifacts.every((artifact) => !Number.isNaN(Date.parse(artifact.recordedAt))));
326
+ // The same path is recorded once.
327
+ await s.bash(clone, () => { rmSync(join(fx.work, "copy"), { recursive: true, force: true }); git(fx.work, "clone", "-q", source, "copy"); });
328
+ assert.equal(readLedger(fx, "s-1").artifacts.length, 2);
329
+ await s.shutdown();
330
+ assert.equal(readLedger(fx, "s-1").scratchDir, null, "the ledger survives shutdown while artifacts remain");
331
+ });
332
+
333
+ test("S4: failed, missing, non-bash, and child results are not recorded", async (t) => {
334
+ const fx = fixture(t);
335
+ const source = origin(fx);
336
+ git(fx.work, "clone", "-q", source, "copy");
337
+ const s = session(fx);
338
+ await s.start();
339
+ await s.bash(`cd ${fx.work} && git clone ${source} failed`, () => git(fx.work, "clone", "-q", source, "failed"), { isError: true });
340
+ await s.bash(`cd ${fx.work} && git clone ${source} never-created`);
341
+ await s.bash(`cd ${fx.work} && git clone ${source} background`, () => git(fx.work, "clone", "-q", source, "background"), { toolName: "bg_start" });
342
+ // A result without its call event has no before-state, so nothing is recorded.
343
+ git(fx.work, "clone", "-q", source, "no-call");
344
+ await s.handlers.get("tool_result")?.({ type: "tool_result", toolName: "bash", toolCallId: "unseen", input: { command: `cd ${fx.work} && git clone ${source} no-call` }, content: [], isError: false }, s.ctx);
345
+ assert.deepEqual(readLedger(fx, "s-1").artifacts, []);
346
+ const child = session(fx, { child: true, sessionId: "child" });
347
+ await child.start();
348
+ await child.bash(`cd ${fx.work} && git clone ${source} child-copy`, () => git(fx.work, "clone", "-q", source, "child-copy"));
349
+ assert.equal(existsSync(ledgerFile(fx, "child")), false);
350
+ await s.shutdown();
351
+ });
352
+
353
+ test("S4: a clone fallback over an existing user clone records nothing and clean keeps it", async (t) => {
354
+ const fx = fixture(t);
355
+ const source = origin(fx);
356
+ // The user's own clean, fully pushed clone, made before the session.
357
+ git(fx.work, "clone", "-q", source, "dir");
358
+ writeFileSync(join(fx.work, "dir", ".git", "info", "exclude"), "ignored.txt\n");
359
+ writeFileSync(join(fx.work, "dir", "ignored.txt"), "user data");
360
+ const s = session(fx);
361
+ await s.start();
362
+ await s.bash(`cd ${fx.work} && git clone ${source} dir || (cd dir && git pull)`, () => git(join(fx.work, "dir"), "pull", "-q"));
363
+ assert.deepEqual(readLedger(fx, "s-1").artifacts, [], "a destination that existed before the command is not the session's");
364
+ await s.run("clean");
365
+ assert.equal(s.confirms.length, 0);
366
+ assert.equal(readFileSync(join(fx.work, "dir", "ignored.txt"), "utf8"), "user data");
367
+ // Control: the same fallback creating the clone is recorded.
368
+ await s.bash(`cd ${fx.work} && git clone ${source} fresh || (cd fresh && git pull)`, () => git(fx.work, "clone", "-q", source, "fresh"));
369
+ assert.deepEqual(readLedger(fx, "s-1").artifacts.map((artifact) => artifact.path), [join(fx.work, "fresh")]);
370
+ await s.shutdown();
371
+ });
372
+
373
+ test("S4: a clone into the session directory or an ancestor of it is never recorded", async (t) => {
374
+ const fx = fixture(t);
375
+ const source = origin(fx);
376
+ const s = session(fx);
377
+ await s.start();
378
+ await s.bash(`git clone ${source} .`, () => git(fx.cwd, "clone", "-q", source, "."));
379
+ await s.bash(`git clone ${source} ..`);
380
+ assert.deepEqual(readLedger(fx, "s-1").artifacts, []);
381
+ await s.shutdown();
382
+ });
383
+
384
+ // S5
385
+
386
+ test("S5: startup sweeps the scratch of dead sessions past the grace period and keeps their artifacts", async (t) => {
387
+ const fx = fixture(t);
388
+ const source = origin(fx);
389
+ const deadA = session(fx, { sessionId: "dead-a" });
390
+ await deadA.start();
391
+ const scratchA = deadA.env.TMPDIR!;
392
+ writeFileSync(join(scratchA, "left.txt"), "x");
393
+ const deadB = session(fx, { sessionId: "dead-b" });
394
+ await deadB.start();
395
+ const scratchB = deadB.env.TMPDIR!;
396
+ await deadB.bash(`cd ${fx.work} && git clone ${source} kept`, () => git(fx.work, "clone", "-q", source, "kept"));
397
+ // Neither session shut down (crash). A later session finds both owners dead.
398
+ const next = session(fx, { sessionId: "next", isProcessAlive: () => false, now: later });
399
+ await next.start();
400
+ assert.equal(existsSync(scratchA), false);
401
+ assert.equal(existsSync(scratchB), false);
402
+ assert.equal(existsSync(ledgerFile(fx, "dead-a")), false);
403
+ const b = readLedger(fx, "dead-b");
404
+ assert.equal(b.scratchDir, null);
405
+ assert.deepEqual(b.artifacts.map((artifact) => artifact.path), [join(fx.work, "kept")]);
406
+ assert.ok(existsSync(join(fx.work, "kept", "a.txt")), "the sweep never removes artifacts");
407
+ assert.ok(existsSync(next.env.TMPDIR!), "the sweep keeps the new session's own scratch");
408
+ await next.shutdown();
409
+ });
410
+
411
+ test("S5: the sweep keeps scratch of live owners and of ledgers younger than the grace period", async (t) => {
412
+ const fx = fixture(t);
413
+ const other = session(fx, { sessionId: "other" });
414
+ await other.start();
415
+ const scratch = other.env.TMPDIR!;
416
+ const live = session(fx, { sessionId: "live-check", isProcessAlive: () => true, now: later });
417
+ await live.start();
418
+ assert.ok(existsSync(scratch), "a live owning process keeps its scratch");
419
+ const young = session(fx, { sessionId: "young-check", isProcessAlive: () => false });
420
+ await young.start();
421
+ assert.ok(existsSync(scratch), "a ledger younger than the grace period keeps its scratch");
422
+ for (const s of [live, young, other]) await s.shutdown();
423
+ });
424
+
425
+ test("S5: the sweep refuses a symlinked scratch, a scratch outside the recorded root, and a directory without the scratch name", async (t) => {
426
+ const fx = fixture(t);
427
+ const victim = join(fx.root, "victim");
428
+ mkdirSync(victim);
429
+ writeFileSync(join(victim, "keep.txt"), "precious");
430
+ const link = join(fx.base, "gpLinkAA");
431
+ symlinkSync(victim, link, "dir");
432
+ writeForeignLedger(fx, { sessionId: "evil-link", scratchDir: link });
433
+ writeForeignLedger(fx, { sessionId: "evil-outside", scratchDir: victim });
434
+ const otherNames = ["gentle-pi-session-evil-prefix-BBBBBB", "gp-1234abcd-BBBBBB", "gpBBBBBBB", "gpBBBBB", "xpBBBBBB"].map((name) => join(fx.base, name));
435
+ for (const [index, dir] of otherNames.entries()) {
436
+ mkdirSync(dir);
437
+ writeFileSync(join(dir, "keep.txt"), "x");
438
+ writeForeignLedger(fx, { sessionId: `evil-prefix-${index}`, scratchDir: dir });
439
+ }
440
+ const s = session(fx, { sessionId: "sweeper", isProcessAlive: () => false, now: later });
441
+ await s.start();
442
+ assert.equal(readFileSync(join(victim, "keep.txt"), "utf8"), "precious");
443
+ assert.ok(lstatSync(link).isSymbolicLink());
444
+ for (const dir of otherNames) assert.ok(existsSync(join(dir, "keep.txt")), dir);
445
+ for (const id of ["evil-link", "evil-outside", ...otherNames.map((_, index) => `evil-prefix-${index}`)]) assert.ok(existsSync(ledgerFile(fx, id)), `${id} ledger is kept for the command`);
446
+ await s.shutdown();
447
+ });
448
+
449
+ test("S5: a forged ledger naming a session-prefixed directory outside its recorded temp root is refused", async (t) => {
450
+ const fx = fixture(t);
451
+ const forged = join(fx.work, "gpEvilAb");
452
+ mkdirSync(forged);
453
+ writeFileSync(join(forged, "keep.txt"), "precious");
454
+ writeForeignLedger(fx, { sessionId: "evil", tempRoot: fx.base, scratchDir: forged });
455
+ const rootless = join(fx.base, "gpRootAb");
456
+ mkdirSync(rootless);
457
+ writeForeignLedger(fx, { sessionId: "rootless", tempRoot: null, scratchDir: rootless });
458
+ const s = session(fx, { sessionId: "sweeper", isProcessAlive: () => false, now: later });
459
+ await s.start();
460
+ await s.run("clean");
461
+ assert.equal(readFileSync(join(forged, "keep.txt"), "utf8"), "precious");
462
+ assert.ok(existsSync(rootless), "a ledger without a recorded temp root names no removable scratch");
463
+ assert.equal(readLedger(fx, "evil").scratchDir, forged);
464
+ await s.shutdown();
465
+ });
466
+
467
+ // S9: without the session id in the name, a forged ledger could name another
468
+ // session's live scratch; the ledger of a live session keeps it.
469
+ test("S9: a dead forged ledger never removes the scratch a live session's ledger records, in the sweep or in clean", async (t) => {
470
+ const fx = fixture(t);
471
+ const livePid = 4_444_444;
472
+ const liveScratch = join(fx.base, "gpLiveAb");
473
+ mkdirSync(liveScratch);
474
+ writeFileSync(join(liveScratch, "work.txt"), "live");
475
+ writeForeignLedger(fx, { sessionId: "live", pid: livePid, scratchDir: liveScratch });
476
+ writeForeignLedger(fx, { sessionId: "forged", scratchDir: liveScratch });
477
+ // A forged scratch that holds a live session's scratch is kept too.
478
+ const outer = join(fx.base, "gpOuterA");
479
+ const nested = join(outer, "gpInnerA");
480
+ mkdirSync(nested, { recursive: true });
481
+ writeForeignLedger(fx, { sessionId: "nested-live", pid: livePid, tempRoot: outer, scratchDir: nested });
482
+ writeForeignLedger(fx, { sessionId: "forged-outer", scratchDir: outer });
483
+ const s = session(fx, { sessionId: "sweeper", isProcessAlive: (pid) => pid === livePid, now: later });
484
+ await s.start();
485
+ assert.equal(readFileSync(join(liveScratch, "work.txt"), "utf8"), "live", "the sweep keeps the live scratch");
486
+ assert.ok(existsSync(nested), "the sweep keeps a scratch holding a live one");
487
+ assert.equal(readLedger(fx, "forged").scratchDir, liveScratch);
488
+ await s.run("clean");
489
+ assert.equal(readFileSync(join(liveScratch, "work.txt"), "utf8"), "live", "clean keeps the live scratch");
490
+ assert.ok(existsSync(nested), "clean keeps a scratch holding a live one");
491
+ // This session's own scratch is live whatever the process check says.
492
+ const own = readLedger(fx, "sweeper").scratchDir!;
493
+ writeFileSync(join(own, "mine.txt"), "x");
494
+ writeForeignLedger(fx, { sessionId: "forged-own", scratchDir: own });
495
+ await s.run("clean");
496
+ assert.ok(existsSync(own), "clean keeps this session's scratch named by a forged ledger");
497
+ // Control: a dead ledger's own scratch that no live ledger records is removed.
498
+ const deadScratch = join(fx.base, "gpDeadAb");
499
+ mkdirSync(deadScratch);
500
+ writeForeignLedger(fx, { sessionId: "dead", scratchDir: deadScratch });
501
+ await s.run("clean");
502
+ assert.equal(existsSync(deadScratch), false);
503
+ await s.shutdown();
504
+ assert.equal(existsSync(own), false, "shutdown still removes the session's own scratch");
505
+ });
506
+
507
+ test("S5: a recorded temp root inside the home is accepted only when it is the session's own temp root", async (t) => {
508
+ const fx = fixture(t);
509
+ const home = process.env.HOME;
510
+ process.env.HOME = fx.root;
511
+ t.after(() => { if (home === undefined) delete process.env.HOME; else process.env.HOME = home; });
512
+ // fx.base and fx.work are now inside the home; fx.base is this session's inherited TMPDIR.
513
+ const foreign = join(fx.work, "gpHomeAb");
514
+ mkdirSync(foreign);
515
+ writeForeignLedger(fx, { sessionId: "home", tempRoot: fx.work, scratchDir: foreign });
516
+ const dead = join(fx.base, "gpDeadAb");
517
+ mkdirSync(dead);
518
+ writeForeignLedger(fx, { sessionId: "dead", tempRoot: fx.base, scratchDir: dead });
519
+ const s = session(fx, { sessionId: "sweeper", isProcessAlive: () => false, now: later });
520
+ await s.start();
521
+ assert.ok(existsSync(foreign), "a forged root inside the home is refused");
522
+ assert.equal(existsSync(dead), false, "an ended session under the same temp root is swept");
523
+ const scratch = s.env.TMPDIR!;
524
+ await s.shutdown();
525
+ assert.equal(existsSync(scratch), false, "the session's own scratch under a home temp root is removed");
526
+ });
527
+
528
+ // Native review R3-untracked-status-guard: `git status --porcelain` honors the user's
529
+ // status.showUntrackedFiles, so with "no" a clone holding new work looked clean.
530
+ test("S6: clean keeps a clone with untracked work even when status hides untracked files", async (t) => {
531
+ const fx = fixture(t);
532
+ const source = origin(fx);
533
+ const s = session(fx);
534
+ await s.start();
535
+ await s.bash(`cd ${fx.work} && git clone ${source} hidden`, () => git(fx.work, "clone", "-q", source, "hidden"));
536
+ git(join(fx.work, "hidden"), "config", "status.showUntrackedFiles", "no");
537
+ writeFileSync(join(fx.work, "hidden", "work.txt"), "new work\n");
538
+ const out = await s.run("clean");
539
+ assert.equal(s.confirms.length, 0, "nothing is offered for removal");
540
+ assert.equal(readFileSync(join(fx.work, "hidden", "work.txt"), "utf8"), "new work\n");
541
+ assert.match(out, /hidden: uncommitted changes/);
542
+ });
543
+
544
+ test("S5: shutdown keeps a scratch that holds a recorded clone", async (t) => {
545
+ const fx = fixture(t);
546
+ const source = origin(fx);
547
+ const s = session(fx);
548
+ await s.start();
549
+ const scratch = s.env.TMPDIR!;
550
+ await s.bash(`cd ${scratch} && git clone ${source} inside`, () => git(scratch, "clone", "-q", source, "inside"));
551
+ await s.shutdown();
552
+ assert.ok(existsSync(join(scratch, "inside", "a.txt")));
553
+ assert.equal(readLedger(fx, "s-1").scratchDir, scratch);
554
+ });
555
+
556
+ // S6
557
+
558
+ test("S6: list shows the session scratch with its size and the artifacts of this and dead sessions only", async (t) => {
559
+ const fx = fixture(t);
560
+ const source = origin(fx);
561
+ git(fx.work, "clone", "-q", source, "dead");
562
+ git(fx.work, "clone", "-q", source, "live");
563
+ writeForeignLedger(fx, { sessionId: "dead-1", pid: 1_111_111, artifacts: [{ kind: "clone", path: join(fx.work, "dead"), command: "git clone", recordedAt: new Date().toISOString() }] });
564
+ writeForeignLedger(fx, { sessionId: "live-1", pid: 2_222_222, artifacts: [{ kind: "clone", path: join(fx.work, "live"), command: "git clone", recordedAt: new Date().toISOString() }] });
565
+ const s = session(fx, { isProcessAlive: (pid) => pid === 2_222_222 });
566
+ await s.start();
567
+ writeFileSync(join(s.env.TMPDIR!, "f.bin"), Buffer.alloc(2048));
568
+ await s.bash(`cd ${fx.work} && git clone ${source} mine`, () => git(fx.work, "clone", "-q", source, "mine"));
569
+ const out = await s.run("list");
570
+ assert.match(out, new RegExp(`Session scratch: ${s.env.TMPDIR!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\(2\\.0 KiB\\)`));
571
+ assert.match(out, /clone .*\/work\/mine \(this session\)/);
572
+ assert.match(out, /clone .*\/work\/dead \(session dead-1, ended\)/);
573
+ assert.doesNotMatch(out, /work\/live/);
574
+ assert.equal(await s.run(""), out, "no argument lists");
575
+ await s.shutdown();
576
+ });
577
+
578
+ test("S6: clean removes scratch without asking and, after one confirmation, only clean recorded worktrees and pushed clones", async (t) => {
579
+ const fx = fixture(t);
580
+ const source = origin(fx);
581
+ const s = session(fx, { now: minutesLater });
582
+ await s.start();
583
+ const scratch = s.env.TMPDIR!;
584
+ writeFileSync(join(scratch, "junk.txt"), "x");
585
+ mkdirSync(join(scratch, "gaLiveAa"));
586
+ const add = async (args: string[], command: string) => { await s.bash(`cd ${fx.work} && ${command}`, () => git(fx.work, ...args)); };
587
+ await add(["clone", "-q", source, "pushed"], `git clone ${source} pushed`);
588
+ await add(["clone", "-q", source, "unpushed"], `git clone ${source} unpushed`);
589
+ git(join(fx.work, "unpushed"), "commit", "-q", "--allow-empty", "-m", "local");
590
+ await add(["clone", "-q", source, "dirty-clone"], `git clone ${source} dirty-clone`);
591
+ writeFileSync(join(fx.work, "dirty-clone", "new.txt"), "x");
592
+ await s.bash(`cd ${source} && git worktree add -b clean-wt ../wt-clean`, () => git(source, "worktree", "add", "-q", "-b", "clean-wt", join(fx.work, "wt-clean")));
593
+ await s.bash(`cd ${source} && git worktree add -b dirty-wt ../wt-dirty`, () => git(source, "worktree", "add", "-q", "-b", "dirty-wt", join(fx.work, "wt-dirty")));
594
+ writeFileSync(join(fx.work, "wt-dirty", "a.txt"), "changed\n");
595
+ git(fx.work, "clone", "-q", source, "unrecorded");
596
+ const out = await s.run("clean");
597
+ assert.equal(s.confirms.length, 1);
598
+ assert.match(s.confirms[0], /pushed/);
599
+ assert.match(s.confirms[0], /wt-clean/);
600
+ assert.match(s.confirms[0], /ignored files inside these paths are deleted too/);
601
+ assert.doesNotMatch(s.confirms[0], /work\/(unpushed|dirty-clone|wt-dirty)/);
602
+ assert.equal(existsSync(join(fx.work, "pushed")), false);
603
+ assert.equal(existsSync(join(fx.work, "wt-clean")), false);
604
+ assert.doesNotMatch(git(source, "worktree", "list"), /wt-clean/, "the worktree is removed and pruned");
605
+ assert.ok(existsSync(join(fx.work, "unpushed", "a.txt")));
606
+ assert.ok(existsSync(join(fx.work, "dirty-clone", "new.txt")));
607
+ assert.equal(readFileSync(join(fx.work, "wt-dirty", "a.txt"), "utf8"), "changed\n");
608
+ assert.ok(existsSync(join(fx.work, "unrecorded", "a.txt")), "paths outside the recorded set are never touched");
609
+ assert.match(out, /unpushed: commits not on any remote/);
610
+ assert.match(out, /dirty-clone: uncommitted changes/);
611
+ assert.match(out, /wt-dirty: uncommitted changes/);
612
+ assert.equal(existsSync(join(scratch, "junk.txt")), false);
613
+ assert.ok(existsSync(join(scratch, "gaLiveAa")), "live subagent scratch is left to the runner");
614
+ assert.ok(existsSync(scratch), "the session keeps its TMPDIR");
615
+ assert.deepEqual(readLedger(fx, "s-1").artifacts.map((artifact) => basename(artifact.path)).sort(), ["dirty-clone", "unpushed", "wt-dirty"]);
616
+ await s.shutdown();
617
+ });
618
+
619
+ test("S6: a declined confirmation keeps every artifact; without UI clean removes scratch only and reports artifacts", async (t) => {
620
+ const fx = fixture(t);
621
+ const source = origin(fx);
622
+ const declined = session(fx, { confirm: false });
623
+ await declined.start();
624
+ await declined.bash(`cd ${fx.work} && git clone ${source} pushed`, () => git(fx.work, "clone", "-q", source, "pushed"));
625
+ await declined.run("clean");
626
+ assert.ok(existsSync(join(fx.work, "pushed", "a.txt")));
627
+ await declined.shutdown();
628
+ const headless = session(fx, { hasUI: false, sessionId: "headless", now: minutesLater });
629
+ await headless.start();
630
+ writeFileSync(join(headless.env.TMPDIR!, "junk.txt"), "x");
631
+ await headless.bash(`cd ${fx.work} && git clone ${source} headless-pushed`, () => git(fx.work, "clone", "-q", source, "headless-pushed"));
632
+ const out = await headless.run("clean");
633
+ assert.equal(headless.confirms.length, 0);
634
+ assert.ok(existsSync(join(fx.work, "headless-pushed", "a.txt")));
635
+ assert.equal(existsSync(join(headless.env.TMPDIR!, "junk.txt")), false);
636
+ assert.match(out, /headless-pushed/);
637
+ await headless.shutdown();
638
+ });
639
+
640
+ test("S6: clean refuses a recorded clone replaced by a symlink and leaves live sessions alone", async (t) => {
641
+ const fx = fixture(t);
642
+ const source = origin(fx);
643
+ git(fx.work, "clone", "-q", source, "real");
644
+ const s = session(fx, { isProcessAlive: (pid) => pid === 3_333_333 });
645
+ await s.start();
646
+ await s.bash(`cd ${fx.work} && git clone ${source} copy`, () => git(fx.work, "clone", "-q", source, "copy"));
647
+ renameSync(join(fx.work, "copy"), join(fx.work, "moved"));
648
+ symlinkSync(join(fx.work, "real"), join(fx.work, "copy"), "dir");
649
+ git(fx.work, "clone", "-q", source, "theirs");
650
+ const liveScratch = join(fx.base, "gpLiveCC");
651
+ mkdirSync(liveScratch);
652
+ writeForeignLedger(fx, { sessionId: "live-2", pid: 3_333_333, scratchDir: liveScratch, artifacts: [{ kind: "clone", path: join(fx.work, "theirs"), command: "git clone", recordedAt: new Date().toISOString() }] });
653
+ const out = await s.run("clean");
654
+ assert.equal(s.confirms.length, 0, "nothing removable, nothing to confirm");
655
+ assert.ok(lstatSync(join(fx.work, "copy")).isSymbolicLink());
656
+ assert.ok(existsSync(join(fx.work, "real", "a.txt")));
657
+ assert.ok(existsSync(join(fx.work, "theirs", "a.txt")));
658
+ assert.ok(existsSync(liveScratch));
659
+ assert.match(out, /copy: not the recorded directory/);
660
+ await s.shutdown();
661
+ });
662
+
663
+ // PR #2300 review: `--all` does not include a detached HEAD, so a commit made only
664
+ // there looked pushed and the clone holding its only copy was removable.
665
+ test("S6: clean keeps a clone whose detached HEAD has a commit on no remote", async (t) => {
666
+ const fx = fixture(t);
667
+ const source = origin(fx);
668
+ const s = session(fx);
669
+ await s.start();
670
+ await s.bash(`cd ${fx.work} && git clone ${source} detached`, () => git(fx.work, "clone", "-q", source, "detached"));
671
+ const clone = join(fx.work, "detached");
672
+ git(clone, "checkout", "-q", "--detach", "HEAD");
673
+ git(clone, "commit", "-q", "--allow-empty", "-m", "only here");
674
+ const out = await s.run("clean");
675
+ assert.equal(s.confirms.length, 0, "nothing is offered for removal");
676
+ assert.ok(existsSync(join(clone, "a.txt")));
677
+ assert.match(out, /detached: commits not on any remote/);
678
+ });
679
+
680
+ // PR #2300 review: the check ran before the confirmation, so work added while it was
681
+ // open was deleted with the clone.
682
+ test("S6: clean checks a clone again after the confirmation and keeps it if it changed", async (t) => {
683
+ const fx = fixture(t);
684
+ const source = origin(fx);
685
+ const clone = join(fx.work, "late");
686
+ const s = session(fx, { duringConfirm: () => writeFileSync(join(clone, "late.txt"), "written while asked\n") });
687
+ await s.start();
688
+ await s.bash(`cd ${fx.work} && git clone ${source} late`, () => git(fx.work, "clone", "-q", source, "late"));
689
+ const out = await s.run("clean");
690
+ assert.equal(s.confirms.length, 1, "the clean clone was offered");
691
+ assert.equal(readFileSync(join(clone, "late.txt"), "utf8"), "written while asked\n");
692
+ assert.match(out, /late: uncommitted changes/);
693
+ });
694
+
695
+ // PR #2300 review: a different directory put at the recorded path while the
696
+ // confirmation was open is not the one that was checked, so it stays.
697
+ test("S6: clean keeps a directory that replaced the checked clone at the same path", async (t) => {
698
+ const fx = fixture(t);
699
+ const source = origin(fx);
700
+ const clone = join(fx.work, "swap");
701
+ const s = session(fx, { duringConfirm: () => {
702
+ renameSync(clone, join(fx.work, "swap-old"));
703
+ git(fx.work, "clone", "-q", source, "swap");
704
+ writeFileSync(join(clone, ".git", "marker"), "replacement\n");
705
+ } });
706
+ await s.start();
707
+ await s.bash(`cd ${fx.work} && git clone ${source} swap`, () => git(fx.work, "clone", "-q", source, "swap"));
708
+ const out = await s.run("clean");
709
+ assert.equal(s.confirms.length, 1, "the checked clone was offered");
710
+ assert.equal(readFileSync(join(clone, ".git", "marker"), "utf8"), "replacement\n", "the replacement stays");
711
+ assert.match(out, /swap: replaced after the check/);
712
+ });
713
+
714
+ // PR #2300 review: a recorded path whose parent became a file (ENOTDIR) is gone, not foreign.
715
+ test("S6: a recorded clone whose parent is no longer a directory is reported gone and dropped", async (t) => {
716
+ const fx = fixture(t);
717
+ const source = origin(fx);
718
+ const s = session(fx);
719
+ await s.start();
720
+ mkdirSync(join(fx.work, "nest"));
721
+ await s.bash(`cd ${fx.work}/nest && git clone ${source} c`, () => git(join(fx.work, "nest"), "clone", "-q", source, "c"));
722
+ rmSync(join(fx.work, "nest"), { recursive: true, force: true });
723
+ writeFileSync(join(fx.work, "nest"), "now a file\n");
724
+ const first = await s.run("clean");
725
+ assert.match(first, /Already gone: clone .*nest\/c/);
726
+ assert.doesNotMatch(await s.run("list"), /nest\/c/, "the stale entry is dropped");
727
+ });
728
+
729
+ test("S6: clean keeps a detached worktree whose commits are on no branch", async (t) => {
730
+ const fx = fixture(t);
731
+ const source = origin(fx);
732
+ const s = session(fx);
733
+ await s.start();
734
+ await s.bash(`cd ${source} && git worktree add --detach ../detached`, () => git(source, "worktree", "add", "-q", "--detach", join(fx.work, "detached")));
735
+ git(join(fx.work, "detached"), "commit", "-q", "--allow-empty", "-m", "orphan");
736
+ const out = await s.run("clean");
737
+ assert.equal(s.confirms.length, 0);
738
+ assert.ok(existsSync(join(fx.work, "detached", "a.txt")));
739
+ assert.match(out, /detached: commits on no branch/);
740
+ await s.shutdown();
741
+ });
742
+
743
+ test("S6/S8: the command help names list and clean", async (t) => {
744
+ const fx = fixture(t);
745
+ const s = session(fx);
746
+ await s.start();
747
+ assert.match(s.commands.get("gentle:scratch")!.description ?? "", /list\|clean/);
748
+ assert.equal(await s.run("bogus"), SCRATCH_COMMAND_USAGE);
749
+ assert.match(SCRATCH_COMMAND_USAGE, /^Usage: \/gentle:scratch \[list\|clean\]/);
750
+ await s.shutdown();
751
+ });
752
+
753
+ test("S8: the reference documents the scratch dir, ledger, automatic cleanup, sweep rule, and the command help", () => {
754
+ const reference = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
755
+ const section = reference.split("### Session scratch and artifact ledger\n")[1]?.split("\n### ")[0] ?? "";
756
+ for (const term of [
757
+ "`gpXXXXXX`", "never its real path", "`TMPDIR`", "`<config home>/scratch/<session id>.json`", "atomically",
758
+ "`version`", "`sessionId`", "`pid`", "`processStartedAt`", "`processStartToken`", "`cwd`", "`tempRoot`", "`inherited`", "`scratchDir`", "`artifacts`", "`git clone ... [<dir>]`", "`git worktree add ... <path>`",
759
+ "did not exist before the command", "recovers the live scratch from the ledger", "never removes a scratch of its own process",
760
+ "At session shutdown", "older than one hour", "never follows symlinks", "directly under the temp root its ledger recorded", "the ledger of a live session",
761
+ "`/gentle:scratch list`", "`/gentle:scratch clean`", "no uncommitted changes and no unpushed commits", "ignored files inside them are deleted too", "`git worktree remove`", "Without a UI",
762
+ ]) assert.ok(section.includes(term), `scratch reference missing ${term}`);
763
+ assert.match(reference, /\| `\/gentle:scratch` +\| Lists \(`list`, the default\) or cleans \(`clean`\)/);
764
+ for (const line of SCRATCH_COMMAND_USAGE.split("\n").slice(1)) {
765
+ const [action] = line.trim().split(/\s+/);
766
+ assert.ok(section.includes(`\`/gentle:scratch ${action}\``), `reference missing the ${action} action from the help`);
767
+ }
768
+ });
769
+
770
+ // Native review advisories of gentle-shell#2300 (odd/tasks/scratch-review-advisories.md).
771
+
772
+ // R3-pid-reuse-liveness: a pid the system reused for another process made an
773
+ // ended session look live, so its scratch was never swept. The comparison uses
774
+ // the OS's own start token, never the wall clock: a clock step must not make a
775
+ // live session look ended (review-e6f96fb8f1a7b6c5 R1-start-time-mismatch).
776
+ test("advisories S3: a ledger counts as live only when its pid has the start token the ledger recorded; an unreadable or missing token keeps it", async (t) => {
777
+ const fx = fixture(t);
778
+ const current = new Map([[5_555_550, "linux:900"], [5_555_551, "linux:100"], [5_555_553, "linux:900"]]);
779
+ const cases = [
780
+ { name: "gpReuseA", token: "linux:100" },
781
+ { name: "gpMatchA", token: "linux:100", startedAt: "2020-01-01T00:00:00.000Z" },
782
+ { name: "gpNoTime", token: "linux:100" },
783
+ { name: "gpOldLed", token: undefined },
784
+ ];
785
+ const dirs = cases.map(({ name }) => join(fx.base, name));
786
+ for (const [index, { token, startedAt }] of cases.entries()) {
787
+ mkdirSync(dirs[index]);
788
+ writeForeignLedger(fx, { sessionId: `pid-${index}`, pid: 5_555_550 + index, scratchDir: dirs[index], ...(startedAt ? { processStartedAt: startedAt } : {}), ...(token ? { processStartToken: token } : {}) });
789
+ }
790
+ const s = session(fx, { sessionId: "sweeper", isProcessAlive: () => true, processStartToken: (pid) => current.get(pid), now: later });
791
+ await s.start();
792
+ assert.equal(existsSync(dirs[0]), false, "a reused pid has another start token: the session ended and its scratch is swept");
793
+ assert.ok(existsSync(dirs[1]), "the same start token is the same process, whatever the wall clock says");
794
+ assert.ok(existsSync(dirs[2]), "a start token that cannot be read keeps the ledger live");
795
+ assert.ok(existsSync(dirs[3]), "a ledger without a start token keeps pid liveness");
796
+ const listed = await s.run("list");
797
+ assert.doesNotMatch(listed, /pid-[123]/, "live ledgers are not listed as ended");
798
+ await s.shutdown();
799
+ });
800
+
801
+ test("advisories S3: the process start token is read from the OS, stable for a process, and skipped on Windows", () => {
802
+ const read = (scratchLib as { processStartToken?: (pid: number, platform?: NodeJS.Platform) => string | undefined }).processStartToken;
803
+ assert.equal(typeof read, "function", "lib/session-scratch.ts exports processStartToken");
804
+ assert.equal(read!(process.pid, "win32"), undefined, "Windows checks pid liveness only");
805
+ assert.equal(read!(2 ** 22 + 12_345), undefined, "a pid that does not exist has no start token");
806
+ if (process.platform === "linux" || process.platform === "darwin") {
807
+ const own = read!(process.pid);
808
+ assert.ok(typeof own === "string" && own.length > 0, `own start token ${own}`);
809
+ assert.equal(read!(process.pid), own, "the token of one process never changes");
810
+ assert.notEqual(read!(process.ppid), own, "another process has another token");
811
+ }
812
+ });
813
+
814
+ test("advisories S3: the ledger records the owning process's start token", async (t) => {
815
+ const fx = fixture(t);
816
+ const s = session(fx);
817
+ await s.start();
818
+ const ledger = readLedger(fx, "s-1") as ScratchLedger & { processStartToken?: string };
819
+ const own = (scratchLib as { processStartToken?: (pid: number) => string | undefined }).processStartToken?.(process.pid);
820
+ if (process.platform === "linux") assert.match(own ?? "", /^linux:\d+$/);
821
+ assert.equal(ledger.processStartToken, own);
822
+ await s.shutdown();
823
+ });
824
+
825
+ // R1-clone-removal-orphans-linked-worktrees: removing a clone left the worktrees
826
+ // linked to it without a repository.
827
+ test("advisories S4: clean keeps a recorded clone with linked worktrees; a prunable one does not count", async (t) => {
828
+ const fx = fixture(t);
829
+ const source = origin(fx);
830
+ const s = session(fx);
831
+ await s.start();
832
+ await s.bash(`cd ${fx.work} && git clone ${source} host`, () => git(fx.work, "clone", "-q", source, "host"));
833
+ const host = join(fx.work, "host");
834
+ git(host, "worktree", "add", "-q", "--detach", join(fx.work, "linked"));
835
+ const out = await s.run("clean");
836
+ assert.equal(s.confirms.length, 0, "nothing is offered for removal");
837
+ assert.ok(existsSync(join(host, "a.txt")));
838
+ assert.ok(existsSync(join(fx.work, "linked", "a.txt")));
839
+ assert.match(out, /host: has linked worktrees/);
840
+ // Control: once the linked worktree's directory is gone it is prunable, and the clone is removable.
841
+ rmSync(join(fx.work, "linked"), { recursive: true, force: true });
842
+ await s.run("clean");
843
+ assert.equal(s.confirms.length, 1);
844
+ assert.equal(existsSync(host), false);
845
+ await s.shutdown();
846
+ });
847
+
848
+ // R3-clean-empties-live-tmpdir: clean emptied the live TMPDIR while a running
849
+ // command could still be using what it had just created there.
850
+ test("advisories S5: clean keeps the live scratch's entries changed within the minute before it started", async (t) => {
851
+ const fx = fixture(t);
852
+ let offset = 0;
853
+ const s = session(fx, { now: () => Date.now() + offset });
854
+ await s.start();
855
+ const scratch = s.env.TMPDIR!;
856
+ writeFileSync(join(scratch, "fresh.txt"), "x");
857
+ mkdirSync(join(scratch, "tree", "deep"), { recursive: true });
858
+ writeFileSync(join(scratch, "tree", "deep", "f.txt"), "x");
859
+ mkdirSync(join(scratch, "gaChildA"));
860
+ offset = 30_000;
861
+ const out = await s.run("clean");
862
+ assert.ok(existsSync(join(scratch, "fresh.txt")), "an entry changed 30 s before clean stays");
863
+ assert.ok(existsSync(join(scratch, "tree", "deep", "f.txt")));
864
+ assert.match(out, /Emptied scratch .* except .*changed in the last minute/);
865
+ offset = 120_000;
866
+ await s.run("clean");
867
+ assert.deepEqual(readdirSync(scratch), ["gaChildA"], "entries older than a minute go; subagent scratch stays");
868
+ await s.shutdown();
869
+ });
870
+
871
+ // R3-reload-fallback-inherits-scratch: a reload that could not recover the scratch
872
+ // recorded the old scratch as the inherited TMPDIR and nested the new one in it.
873
+ test("advisories S6: a reload that cannot recover the scratch restores the ledger's inherited TMPDIR", async (t) => {
874
+ const fx = fixture(t);
875
+ const env: NodeJS.ProcessEnv = { TMPDIR: fx.base, GENTLE_PI_CONFIG_HOME: fx.configHome };
876
+ const first = session(fx, { env, factory: await loadInstance("fallback-a") });
877
+ await first.start();
878
+ const old = env.TMPDIR!;
879
+ await first.shutdown("reload");
880
+ rmSync(old, { recursive: true, force: true });
881
+ const second = session(fx, { env, factory: await loadInstance("fallback-b") });
882
+ await second.start("reload");
883
+ assert.equal(dirname(env.TMPDIR!), fx.base, "the new scratch nests under the inherited TMPDIR, not the old scratch");
884
+ assert.match(basename(env.TMPDIR!), SCRATCH_NAME);
885
+ assert.deepEqual(readLedger(fx, "s-1").inherited, { TMPDIR: fx.base });
886
+ await second.shutdown("quit");
887
+ assert.equal(env.TMPDIR, fx.base);
888
+ });
889
+
890
+ test("advisories S6: a reload fallback whose ledger recorded TMPDIR unset creates the new scratch in the system temp dir, not the old scratch", async (t) => {
891
+ if (process.platform === "win32") { t.skip("os.tmpdir() reads TMP/TEMP differently on Windows"); return; }
892
+ const fx = fixture(t);
893
+ // os.tmpdir() falls back to TMP when TMPDIR is unset, which keeps the fixture under its root.
894
+ const saved = { TMPDIR: process.env.TMPDIR, TMP: process.env.TMP, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: process.env.GENTLE_PI_AGENTS_CHILD };
895
+ t.after(() => { for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } });
896
+ delete process.env.TMPDIR;
897
+ delete process.env.GENTLE_PI_AGENTS_CHILD; // The test runner may itself run inside a subagent.
898
+ process.env.TMP = fx.base;
899
+ process.env.GENTLE_PI_CONFIG_HOME = fx.configHome;
900
+ const first = session(fx, { env: process.env, factory: await loadInstance("unset-a") });
901
+ await first.start();
902
+ const old = process.env.TMPDIR!;
903
+ assert.equal(dirname(old), fx.base);
904
+ assert.deepEqual(readLedger(fx, "s-1").inherited, { TMPDIR: null });
905
+ await first.shutdown("reload");
906
+ rmSync(old, { recursive: true, force: true });
907
+ const second = session(fx, { env: process.env, factory: await loadInstance("unset-b") });
908
+ await second.start("reload");
909
+ assert.equal(dirname(process.env.TMPDIR ?? ""), fx.base, "tmpdir() is asked with the old scratch no longer in TMPDIR");
910
+ assert.deepEqual(readLedger(fx, "s-1").inherited, { TMPDIR: null });
911
+ await second.shutdown("quit");
912
+ assert.equal(process.env.TMPDIR, undefined);
913
+ });
914
+
915
+ test("advisories S6: a reload without a readable ledger never inherits a gpXXXXXX TMPDIR", async (t) => {
916
+ const fx = fixture(t);
917
+ const env: NodeJS.ProcessEnv = { TMPDIR: fx.base, GENTLE_PI_CONFIG_HOME: fx.configHome };
918
+ const first = session(fx, { env, factory: await loadInstance("lost-a") });
919
+ await first.start();
920
+ const old = env.TMPDIR!;
921
+ await first.shutdown("reload");
922
+ writeFileSync(ledgerFile(fx, "s-1"), "{}");
923
+ const second = session(fx, { env, factory: await loadInstance("lost-b") });
924
+ await second.start("reload");
925
+ assert.equal(dirname(env.TMPDIR!), fx.base, "the new scratch is not nested in the old one");
926
+ assert.notEqual(env.TMPDIR, old);
927
+ assert.deepEqual(readLedger(fx, "s-1").inherited, { TMPDIR: fx.base });
928
+ await second.shutdown("quit");
929
+ assert.equal(env.TMPDIR, fx.base);
930
+ // Control: at startup a gpXXXXXX TMPDIR (a Pi started inside another session) is inherited as it is.
931
+ const nested = session(fx, { sessionId: "nested", tmp: old });
932
+ await nested.start();
933
+ assert.equal(dirname(nested.env.TMPDIR!), old);
934
+ await nested.shutdown();
935
+ assert.equal(nested.env.TMPDIR, old);
936
+ });
937
+
938
+ test("advisories S7: the reference documents the advisory rules", () => {
939
+ const reference = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
940
+ const section = reference.split("### Session scratch and artifact ledger\n")[1]?.split("\n### ")[0] ?? "";
941
+ for (const term of [
942
+ "live or quarantined subagent", "a later startup sweep", "has a start token other than the one its ledger recorded", "never through the wall clock", "cannot be read", "has linked worktrees",
943
+ "changed in the last minute", "recorded `inherited` values", "not a `gpXXXXXX` directory",
944
+ ]) assert.ok(section.includes(term), `scratch reference missing ${term}`);
945
+ });
946
+
947
+ // Perf (long-session audit): the ledger file I/O must not grow with the number
948
+ // of retained ledgers or artifacts. `listLedgers` and `writeLedger` use only
949
+ // the async fs seam, and `capture` keeps at most `LEDGER_ARTIFACTS_CAP`
950
+ // artifacts, evicting the oldest first. Old pre-cap ledgers still read.
951
+
952
+ test("perf: pushing beyond the cap keeps the ledger bounded and keeps the newest artifacts", async (t) => {
953
+ const fx = fixture(t);
954
+ const s = session(fx);
955
+ await s.start();
956
+ for (let i = 0; i < LEDGER_ARTIFACTS_CAP + 6; i++) {
957
+ await s.bash(`cd ${fx.work} && git clone x d${i}`, () => mkdirSync(join(fx.work, `d${i}`)));
958
+ }
959
+ const ledger = readLedger(fx, "s-1");
960
+ assert.equal(ledger.artifacts.length, LEDGER_ARTIFACTS_CAP, "the ledger file stays bounded at the cap");
961
+ assert.equal(basename(ledger.artifacts[0].path), `d6`, "eviction is oldest-first");
962
+ assert.equal(basename(ledger.artifacts.at(-1)!.path), `d${LEDGER_ARTIFACTS_CAP + 5}`, "the newest artifacts are kept");
963
+ assert.ok(!ledger.artifacts.some((artifact) => basename(artifact.path) === "d0"));
964
+ await s.shutdown();
965
+ // An old ledger written before the cap still reads, whole, by the pre-cap reader.
966
+ writeForeignLedger(fx, { sessionId: "pre-cap", artifacts: Array.from({ length: LEDGER_ARTIFACTS_CAP + 10 }, (_, i) => ({ kind: "clone" as const, path: join(fx.work, `old${i}`), command: "git clone", recordedAt: new Date(0).toISOString() })) });
967
+ const preCap = scratchLib.readLedger(ledgerFile(fx, "pre-cap"));
968
+ assert.equal(preCap?.artifacts.length, LEDGER_ARTIFACTS_CAP + 10, "a pre-cap ledger is read without pruning");
969
+ const stored = await scratchLib.listLedgers(fx.configHome);
970
+ assert.equal(stored.find(({ ledger: found }) => found.sessionId === "pre-cap")?.ledger.artifacts.length, LEDGER_ARTIFACTS_CAP + 10);
971
+ const out = await s.run("list");
972
+ assert.match(out, /old0 \(session pre-cap, ended\)/);
973
+ });
974
+
975
+ test("perf: listLedgers and writeLedger run on the async fs seam only", async (t) => {
976
+ const fx = fixture(t);
977
+ assert.equal(scratchLib.writeLedger.constructor.name, "AsyncFunction", "writeLedger is async");
978
+ assert.equal(scratchLib.listLedgers.constructor.name, "AsyncFunction", "listLedgers is async");
979
+ const saved = { ...scratchLib.ledgerFs };
980
+ const calls: Record<string, number> = {};
981
+ t.after(() => Object.assign(scratchLib.ledgerFs, saved));
982
+ for (const name of Object.keys(scratchLib.ledgerFs) as (keyof typeof scratchLib.ledgerFs)[]) {
983
+ (scratchLib.ledgerFs as Record<string, unknown>)[name] = async (...args: unknown[]) => {
984
+ calls[name] = (calls[name] ?? 0) + 1;
985
+ return await (saved[name] as (...rest: unknown[]) => unknown)(...args);
986
+ };
987
+ }
988
+ const ledger: ScratchLedger = { version: LEDGER_VERSION, sessionId: "async", pid: 1, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: null, inherited: {}, scratchDir: null, artifacts: [] };
989
+ await scratchLib.writeLedger(ledgerFile(fx, "async"), ledger);
990
+ for (const name of ["mkdir", "writeFile", "rename"]) assert.ok(calls[name], `writeLedger goes through the seam: ${name}`);
991
+ const stored = await scratchLib.listLedgers(fx.configHome);
992
+ assert.equal(stored.length, 1);
993
+ assert.equal(stored[0].ledger.sessionId, "async");
994
+ for (const name of ["readdir", "readFile", "stat"]) assert.ok(calls[name], `listLedgers goes through the seam: ${name}`);
995
+ });
996
+
997
+ test("perf: a failed ledger write leaves the previous ledger file intact", async (t) => {
998
+ if (process.getuid?.() === 0) { t.skip("a write never fails for root"); return; }
999
+ const fx = fixture(t);
1000
+ const path = ledgerFile(fx, "intact");
1001
+ const ledger: ScratchLedger = { version: LEDGER_VERSION, sessionId: "intact", pid: 1, processStartedAt: new Date(0).toISOString(), cwd: fx.cwd, tempRoot: null, inherited: {}, scratchDir: null, artifacts: [{ kind: "clone", path: join(fx.work, "kept"), command: "git clone", recordedAt: new Date(0).toISOString() }] };
1002
+ await scratchLib.writeLedger(path, ledger);
1003
+ chmodSync(dirname(path), 0o500);
1004
+ try {
1005
+ await assert.rejects(() => scratchLib.writeLedger(path, { ...ledger, sessionId: "lost" }));
1006
+ assert.equal(scratchLib.readLedger(path)?.sessionId, "intact", "the previous ledger file survives the failed write");
1007
+ assert.deepEqual(readdirSync(dirname(path)).filter((name) => name.endsWith(".tmp")), [], "no temp file is left behind");
1008
+ } finally {
1009
+ chmodSync(dirname(path), 0o700);
1010
+ }
1011
+ });