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
@@ -2,8 +2,11 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import fs, { existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { syncBuiltinESMExports } from "node:module";
5
- import { basename, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, join } from "node:path";
6
7
  import { PassThrough } from "node:stream";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { createSessionScratchExtension } from "../extensions/session-scratch.ts";
7
10
  import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinition } from "../lib/agents-config.ts";
8
11
  import { MISSING_TOOLS_NOTE_PREFIX, TASK_STATUS, TaskStore, THREAD_ITEM, type TaskRecord } from "../lib/agents-protocol.ts";
9
12
  import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type ChildLike, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
@@ -31,7 +34,7 @@ interface Harness {
31
34
  advance(ms: number): void;
32
35
  }
33
36
 
34
- function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
37
+ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; cleanupTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; exitOnStdinEnd?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
35
38
  const children: FakeChild[] = [];
36
39
  const timers: Harness["timers"] = [];
37
40
  const asks: Harness["asks"] = [];
@@ -45,7 +48,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
45
48
  spawn: (command, args, launchOptions) => {
46
49
  if (options.failStart) throw new Error("fixture spawn failed");
47
50
  spawnOptions.push({ command, args, env: launchOptions.env, stdio: launchOptions.stdio });
48
- const fake = fakeChild({ exitOnKill: options.exitOnKill, pid: options.pid });
51
+ const fake = fakeChild({ exitOnKill: options.exitOnKill, exitOnStdinEnd: options.exitOnStdinEnd, pid: options.pid });
49
52
  if (options.state !== undefined) {
50
53
  fake.child.stdin.removeAllListeners("data");
51
54
  fake.child.stdin.on("data", (chunk) => {
@@ -70,7 +73,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
70
73
  pi: { command: "pi", args: [] },
71
74
  };
72
75
  const store = new TaskStore();
73
- const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs }, deps, {
76
+ const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs, cleanupTimeoutMs: options.cleanupTimeoutMs }, deps, {
74
77
  askUser: async (taskId, ask) => {
75
78
  asks.push({ taskId, method: ask.method });
76
79
  return options.answer ?? { value: "yes" };
@@ -92,6 +95,87 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
92
95
 
93
96
  const tick = () => new Promise((resolve) => setImmediate(resolve));
94
97
 
98
+ for (const mode of [AGENT_MODE.TASK, AGENT_MODE.BACKGROUND]) {
99
+ for (const policy of [undefined, "off", "malformed"] as const) {
100
+ test(`background capability admission rejects ${mode} with ${policy ?? "default"} policy`, async (t) => {
101
+ const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
102
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
103
+ const config = join(root, "config");
104
+ const file = join(config, "background-subagents.json");
105
+ fs.mkdirSync(config);
106
+ const raw = policy === "malformed" ? "{broken" : JSON.stringify({ schema: "gentle-pi.background-subagents/v1", policy });
107
+ if (policy) fs.writeFileSync(file, raw);
108
+ const h = harness({ maxConcurrency: 1 });
109
+ let launches = 0;
110
+ const env = { GENTLE_PI_CONFIG_HOME: config, ...(policy === "malformed" ? { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } : {}) };
111
+ const task = h.runner.run(request({ cwd: root, env, mode, agent: { ...explorer, tools: ["read", "bash_background"] }, onLaunch: () => { launches++; } }));
112
+ await tick();
113
+ assert.equal(h.store.get(task.id)?.status, TASK_STATUS.FAILED);
114
+ const target = policy ? file : join(root, ".pi", "gentle-ai", "background-subagents.json");
115
+ assert.equal(h.store.get(task.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${target} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
116
+ assert.equal(h.children.length, 0);
117
+ assert.equal(h.spawnOptions.length, 0);
118
+ assert.equal(h.timers.length, 0);
119
+ assert.equal(launches, 0);
120
+ assert.deepEqual(h.finishes, [task.id]);
121
+ assert.equal((await h.runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
122
+ assert.equal(existsSync(file), policy !== undefined);
123
+ if (policy) assert.equal(readFileSync(file, "utf8"), raw);
124
+ const next = h.runner.run(request({ cwd: root, env }));
125
+ await tick();
126
+ assert.equal(h.store.get(next.id)?.status, TASK_STATUS.RUNNING);
127
+ h.runner.cancel(next.id);
128
+ await tick();
129
+ });
130
+ }
131
+ }
132
+
133
+ for (const { tools, policy } of [
134
+ ...[undefined, "off"].flatMap(policy => [["read", "unknown_tool"], []].map(tools => ({ tools, policy }))),
135
+ { tools: ["read", "bash_background", "grep"], policy: "on" },
136
+ ]) {
137
+ test(`background capability admission preserves launch for ${tools.join(",") || "default tools"} with ${policy ?? "default off"} policy`, async (t) => {
138
+ const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
139
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
140
+ const h = harness();
141
+ const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), ...(policy === undefined ? {} : { GENTLE_PI_BACKGROUND_SUBAGENTS: policy }), KEEP: "yes" };
142
+ const task = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools } }));
143
+ await tick();
144
+ assert.equal(h.store.get(task.id)?.status, TASK_STATUS.RUNNING);
145
+ assert.equal(h.store.get(task.id)?.error, null);
146
+ assert.equal(h.children.length, 1);
147
+ const expected = tools.length ? [...tools, "subagent_parent_message"].join(",") : undefined;
148
+ assert.equal(h.spawnOptions[0].env[REQUESTED_TOOLS_ENV], expected);
149
+ assert.equal(h.spawnOptions[0].env.KEEP, "yes");
150
+ assert.deepEqual(h.spawnOptions[0].args, childArguments(request({ cwd: root, env, agent: { ...explorer, tools } })));
151
+ assert.equal(h.timers.filter(timer => !timer.cancelled).length, 1);
152
+ h.runner.cancel(task.id);
153
+ await tick();
154
+ });
155
+ }
156
+
157
+ test("background capability admission refreshes project policy when queued work dispatches", async (t) => {
158
+ const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
159
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
160
+ const dir = join(root, ".pi", "gentle-ai");
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ const file = join(dir, "background-subagents.json");
163
+ const h = harness({ maxConcurrency: 1 });
164
+ const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), GENTLE_PI_BACKGROUND_SUBAGENTS: "on" };
165
+ const first = h.runner.run(request({ cwd: root, env }));
166
+ const queued = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools: ["bash_background"] } }));
167
+ await tick();
168
+ assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.QUEUED);
169
+ const raw = '{"schema":"gentle-pi.background-subagents/v1","policy":"off"}';
170
+ fs.writeFileSync(file, raw);
171
+ h.runner.cancel(first.id);
172
+ await tick();
173
+ assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.FAILED);
174
+ assert.equal(h.store.get(queued.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${file} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
175
+ assert.equal(h.children.length, 1);
176
+ assert.equal(readFileSync(file, "utf8"), raw);
177
+ });
178
+
95
179
  const FOUR_MIN_MS = 4 * 60_000;
96
180
 
97
181
  function argumentUpdate(type: string, fields: Record<string, unknown> = {}): Record<string, unknown> {
@@ -618,6 +702,21 @@ test("runner captures resolved model and effort, retaining omitted launch values
618
702
  h.runner.cancel(task.id);
619
703
  });
620
704
 
705
+ test("record creation copies the launch-layer model source next to the model (#2180)", () => {
706
+ const sourced = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
707
+ const sourcedTask = sourced.runner.run(request({ modelSource: "binding" }));
708
+ assert.equal(sourced.store.get(sourcedTask.id)?.modelSource, "binding", "the record keeps the host-computed layer next to the resolved model");
709
+ sourced.runner.cancel(sourcedTask.id);
710
+
711
+ // The absence contract: a request without a model source (older callers,
712
+ // peers) records no field at all, so the card renders exactly as before.
713
+ const plain = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
714
+ const plainTask = plain.runner.run(request());
715
+ assert.equal(plain.store.get(plainTask.id)?.modelSource, undefined);
716
+ assert.equal("modelSource" in (plain.store.get(plainTask.id) as TaskRecord), false, "no modelSource key is recorded when the launch did not name a layer");
717
+ plain.runner.cancel(plainTask.id);
718
+ });
719
+
621
720
  test("runner delivers each response combination once at finish, never attributing launch selection", async () => {
622
721
  const snapshots: NonNullable<Parameters<NonNullable<RunnerHooks["onFinish"]>>[1]>[] = [];
623
722
  const h = harness({ exitOnKill: false, state: { model: { provider: "anthropic", id: "launch" }, thinkingLevel: "max" },
@@ -905,7 +1004,8 @@ test("AgentRunner runs a task end to end: prompt, deltas into the store, complet
905
1004
  assert.equal(finished?.toolCalls, 1);
906
1005
  assert.equal(finished?.sessionPath, "/sessions/child.jsonl");
907
1006
  assert.equal(finished?.label, "Map the repo");
908
- assert.ok(children[0].killed.length > 0, "the child is stopped once the answer is in");
1007
+ assert.ok(children[0].child.stdin.writableEnded, "the child is stopped via orderly shutdown once the answer is in");
1008
+ assert.equal(children[0].killed.length, 0, "no kill signals sent during orderly settlement");
909
1009
  assert.equal(store.thread(task.id).items.length, 2);
910
1010
  assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
911
1011
  });
@@ -955,6 +1055,143 @@ test("AgentRunner queues beyond max concurrency and starts the next task when on
955
1055
  assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING);
956
1056
  });
957
1057
 
1058
+ test("AgentRunner requests orderly shutdown via stdin on settlement and does not send SIGTERM immediately (#1740)", async () => {
1059
+ const { store, runner, children } = harness({ exitOnKill: false });
1060
+ const task = runner.run(request());
1061
+ await tick();
1062
+ assert.equal(children.length, 1);
1063
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1064
+ await tick();
1065
+ assert.equal(children[0].killed.length, 0);
1066
+ children[0].emit({ type: "agent_settled" });
1067
+ await tick();
1068
+
1069
+ // Orderly shutdown must have requested stdin.end()
1070
+ assert.equal(children[0].child.stdin.writableEnded, true, "stdin must be ended on settlement to trigger Pi orderly shutdown");
1071
+ // Must NOT send SIGTERM immediately
1072
+ assert.deepEqual(children[0].killed, [], "SIGTERM must not be sent immediately upon settlement");
1073
+ // Task completion and cleanup in flight should be distinguishable
1074
+ assert.equal(store.get(task.id)?.lastStep, "cleaning up", "lastStep distinguishes in-flight cleanup");
1075
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "task waits for cleanup exit");
1076
+
1077
+ // Child exits cleanly upon session_shutdown completion
1078
+ children[0].exit(0);
1079
+ await tick();
1080
+ await tick();
1081
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
1082
+ assert.equal(store.get(task.id)?.lastStep, "done");
1083
+ assert.deepEqual(children[0].killed, [], "clean shutdown completes with zero kill signals sent");
1084
+ });
1085
+
1086
+ test("AgentRunner allows delayed cleanup and cancels escalation after clean exit (#1740)", async () => {
1087
+ // EOF must be independent of kill behavior: this child honors signals but
1088
+ // needs time to finish asynchronous shutdown after its input closes.
1089
+ const { store, runner, children, timers, advance, finishes } = harness({ exitOnStdinEnd: false });
1090
+ const task = runner.run(request());
1091
+ await tick();
1092
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1093
+ children[0].emit({ type: "agent_settled" });
1094
+ await tick();
1095
+ assert.equal(children[0].child.stdin.writableEnded, true);
1096
+ advance(2_000);
1097
+ await tick();
1098
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING);
1099
+ assert.equal(store.get(task.id)?.lastStep, "cleaning up");
1100
+ assert.deepEqual(children[0].killed, []);
1101
+ assert.deepEqual(finishes, []);
1102
+ children[0].exit(0);
1103
+ await tick();
1104
+ assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
1105
+ assert.equal(store.get(task.id)?.lastStep, "done");
1106
+ assert.deepEqual(finishes, [task.id]);
1107
+ assert.ok(timers.filter((timer) => timer.ms === 5_000).every((timer) => timer.cancelled));
1108
+ advance(5_000);
1109
+ await tick();
1110
+ assert.deepEqual(children[0].killed, []);
1111
+ assert.deepEqual(finishes, [task.id]);
1112
+ });
1113
+
1114
+ test("AgentRunner escalates to SIGTERM and SIGKILL if orderly cleanup exceeds cleanupTimeoutMs (#1740)", async () => {
1115
+ const { store, runner, children, timers } = harness({ exitOnKill: false, cleanupTimeoutMs: 3000 });
1116
+ const task = runner.run(request());
1117
+ await tick();
1118
+ assert.equal(children.length, 1);
1119
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1120
+ await tick();
1121
+ children[0].emit({ type: "agent_settled" });
1122
+ await tick();
1123
+
1124
+ assert.equal(children[0].child.stdin.writableEnded, true);
1125
+ assert.deepEqual(children[0].killed, []);
1126
+
1127
+ // Find the cleanup timeout timer (3000 ms) and trigger it
1128
+ const cleanupTimer = timers.find((timer) => !timer.cancelled && timer.ms === 3000);
1129
+ assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
1130
+ cleanupTimer.fn();
1131
+ await tick();
1132
+
1133
+ // Escalation has fired SIGTERM
1134
+ assert.deepEqual(children[0].killed, ["SIGTERM"]);
1135
+
1136
+ // And termination grace timer (250 ms) is scheduled for SIGKILL
1137
+ const killTimer = timers.find((timer) => !timer.cancelled && timer.ms === 250);
1138
+ assert.ok(killTimer, "SIGKILL escalation timer must be scheduled");
1139
+ killTimer.fn();
1140
+ await tick();
1141
+
1142
+ assert.deepEqual(children[0].killed, ["SIGTERM", "SIGKILL"]);
1143
+ });
1144
+
1145
+ test("orderly cleanup escalation retains the full exit confirmation deadline after SIGKILL (#1740)", async () => {
1146
+ let groupAlive = true;
1147
+ const { store, runner, children, timers, advance } = harness({
1148
+ exitOnKill: false,
1149
+ pid: 42,
1150
+ cleanupTimeoutMs: 5_000,
1151
+ process: {
1152
+ platform: "linux",
1153
+ kill: () => {
1154
+ if (!groupAlive) throw Object.assign(new Error(), { code: "ESRCH" });
1155
+ return true;
1156
+ },
1157
+ },
1158
+ });
1159
+ const task = runner.run(request());
1160
+ await tick();
1161
+ assert.equal(children.length, 1);
1162
+ children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
1163
+ await tick();
1164
+ children[0].emit({ type: "agent_settled" });
1165
+ await tick();
1166
+
1167
+ // Advance time by the cleanup window (5000 ms)
1168
+ const cleanupTimer = timers.find((t) => !t.cancelled && t.ms === 5_000);
1169
+ assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
1170
+ advance(5_000);
1171
+ await tick();
1172
+
1173
+ // Advance time by termination grace (250 ms)
1174
+ const killTimer = timers.find((t) => !t.cancelled && t.ms === 250);
1175
+ assert.ok(killTimer, "SIGKILL timer must be scheduled");
1176
+ advance(250);
1177
+ await tick();
1178
+
1179
+ // After SIGKILL, confirmGroupExit checks the process group. Because cleanupDeadlineAt was set
1180
+ // at SIGKILL time, it must NOT immediately fail with capacity quarantined!
1181
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "must not prematurely quarantine after cleanup window");
1182
+ const probeTimer = timers.find((t) => !t.cancelled && t.ms === 25);
1183
+ assert.ok(probeTimer, "exit confirmation polling timer must be scheduled");
1184
+
1185
+ // If the group exits during the confirmation window:
1186
+ groupAlive = false;
1187
+ children[0].exit(0);
1188
+ advance(25);
1189
+ await tick();
1190
+
1191
+ assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
1192
+ assert.equal(store.get(task.id)?.error, null);
1193
+ });
1194
+
958
1195
  test("AgentRunner classifies terminal assistant outcomes only after settlement", async () => {
959
1196
  const scenarios = [
960
1197
  { name: "error", messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "WebSocket error: secret=never-copy" }], status: TASK_STATUS.FAILED, error: /assistant reported an error/ },
@@ -1029,10 +1266,13 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const)
1029
1266
  await tick();
1030
1267
  const ownedIpc = launches[0]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
1031
1268
  assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the runner creates an opaque owned-IPC marker");
1269
+ const scratch = launches[0]?.options.env.TMPDIR;
1270
+ assert.match(basename(scratch ?? ""), /^ga[A-Za-z0-9]{6}$/, "the child gets its own TMPDIR");
1271
+ const scratchEnv = platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
1032
1272
  assert.deepEqual(launches, [{
1033
1273
  command: "pi-fixture",
1034
1274
  args: ["--from-host", "--mode", "rpc", "--session-dir", "/sessions", "--model", "openai-codex/gpt-5.6-terra:high", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."],
1035
- options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
1275
+ options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
1036
1276
  }]);
1037
1277
  child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "platform checked" }], stopReason: "stop" }] });
1038
1278
  child.emit({ type: "agent_settled" });
@@ -1115,7 +1355,10 @@ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4
1115
1355
  const launch = spawnOptions[0];
1116
1356
  const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
1117
1357
  assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
1118
- assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
1358
+ const scratch = launch?.env.TMPDIR;
1359
+ const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
1360
+ assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
1361
+ assert.match(basename(launch?.env.TMPDIR ?? ""), /^ga[A-Za-z0-9]{6}$/);
1119
1362
  assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"]);
1120
1363
  assert.equal(launch?.stdio?.length, 5);
1121
1364
  children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "channel checked" }], stopReason: "stop" }] });
@@ -1778,3 +2021,238 @@ test("temporary instructions transport file is cleaned up if child emits an earl
1778
2021
  assert.ok(!existsSync(capturedPromptPath), "temporary transport file must be cleaned up on early child error");
1779
2022
  assert.ok(!existsSync(dirname(capturedPromptPath)), "temporary transport directory must be cleaned up on early child error");
1780
2023
  });
2024
+
2025
+ // Harness roadmap T3 (R1): asking the parent to remove a child's scratch failed
2026
+ // in 3 of 6 bench cells, so each child gets its own TMPDIR that the runtime
2027
+ // removes when the child ends.
2028
+ function scratchRunner(spawnImpl: (env: NodeJS.ProcessEnv) => ChildLike) {
2029
+ let clock = 1000;
2030
+ const deps: RunnerDeps = {
2031
+ spawn: (_command, _args, options) => spawnImpl((options as { env: NodeJS.ProcessEnv }).env),
2032
+ now: () => (clock += 1),
2033
+ schedule: (_fn, _ms) => () => {},
2034
+ pi: { command: "pi", args: [] },
2035
+ };
2036
+ return new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, deps, { askUser: async () => ({ value: "yes" }) });
2037
+ }
2038
+
2039
+ test("each child gets its own TMPDIR under the parent's temp dir, removed with its contents on exit", async () => {
2040
+ const fake = fakeChild();
2041
+ let childTmp = "";
2042
+ const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
2043
+ const task = runner.run(request({ env: { ...process.env, TMPDIR: tmpdir() } }));
2044
+ await tick();
2045
+ assert.ok(childTmp.startsWith(tmpdir()) && childTmp !== tmpdir(), `child TMPDIR ${childTmp} is a fresh dir under ${tmpdir()}`);
2046
+ assert.ok(statSync(childTmp).isDirectory());
2047
+ const scratch = fs.mkdtempSync(`${childTmp}/check-`);
2048
+ fs.writeFileSync(`${scratch}/state.json`, "{}");
2049
+ fake.exit(0);
2050
+ await runner.waitFor(task.id);
2051
+ assert.equal(existsSync(childTmp), false, "the child's TMPDIR and everything mktemp created in it are removed");
2052
+ });
2053
+
2054
+ test("a child TMPDIR is removed when spawn throws", async () => {
2055
+ let childTmp = "";
2056
+ const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; throw new Error("spawn failed intentionally"); });
2057
+ const task = runner.run(request());
2058
+ await tick();
2059
+ assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
2060
+ assert.ok(childTmp, "the failed launch still received a child TMPDIR");
2061
+ assert.equal(existsSync(childTmp), false);
2062
+ });
2063
+
2064
+ test("a quarantined child keeps its TMPDIR, since a descendant may still use it", async () => {
2065
+ const timers: Array<{ fn: () => void; ms: number }> = [];
2066
+ let now = 1_000;
2067
+ let childTmp = "";
2068
+ let fake: FakeChild | undefined;
2069
+ const store = new TaskStore();
2070
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
2071
+ spawn: (_command, _args, options) => { childTmp = (options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? ""; return (fake = fakeChild({ exitOnKill: false, pid: 91 })).child; },
2072
+ now: () => now,
2073
+ schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
2074
+ pi: { command: "pi", args: [] },
2075
+ process: { platform: "win32", kill: () => {} },
2076
+ }, { askUser: async () => ({ value: "yes" }) });
2077
+ const task = runner.run(request());
2078
+ await tick();
2079
+ runner.cancel(task.id);
2080
+ timers.find((timer) => timer.ms === 250)!.fn();
2081
+ now = 5_000;
2082
+ timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
2083
+ await tick();
2084
+ assert.match(store.get(task.id)?.error ?? "", /capacity quarantined/);
2085
+ assert.equal(existsSync(childTmp), true, "an unconfirmed exit must not delete files a live descendant may hold");
2086
+ fake!.exit(0);
2087
+ await tick();
2088
+ assert.equal(existsSync(childTmp), false, "the proven exit removes the quarantined child's TMPDIR");
2089
+ });
2090
+
2091
+ // R1 review advisory: a quarantined POSIX child whose own exit arrived while a
2092
+ // descendant still held its group was never probed again, so its TMPDIR and
2093
+ // slot stayed for the rest of the session after the group ended.
2094
+ test("a quarantined child whose exit is seen before its group ends frees its TMPDIR and slot once the group is gone", async () => {
2095
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
2096
+ let now = 1_000;
2097
+ let groupAlive = true;
2098
+ let launches = 0;
2099
+ const childTmps: string[] = [];
2100
+ const store = new TaskStore();
2101
+ let fake: FakeChild | undefined;
2102
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
2103
+ spawn: (_command, _args, options) => {
2104
+ childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
2105
+ return (fake = fakeChild({ exitOnKill: false, pid: 90 + (launches += 1) })).child;
2106
+ },
2107
+ now: () => now,
2108
+ schedule: (fn, ms) => {
2109
+ const timer = { fn, ms, cancelled: false };
2110
+ timers.push(timer);
2111
+ return () => { timer.cancelled = true; };
2112
+ },
2113
+ pi: { command: "pi", args: [] },
2114
+ process: { platform: "linux", kill: (_pid, signal) => {
2115
+ if (signal === 0 && !groupAlive) throw Object.assign(new Error("group probe"), { code: "ESRCH" });
2116
+ } },
2117
+ }, { askUser: async () => ({ value: "yes" }) });
2118
+ const first = runner.run(request());
2119
+ const second = runner.run(request({ prompt: "queued" }));
2120
+ await tick();
2121
+ const quarantined = fake!;
2122
+ runner.cancel(first.id);
2123
+ timers.find((timer) => timer.ms === 250)!.fn();
2124
+ now = 5_000;
2125
+ timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
2126
+ await tick();
2127
+ assert.match(store.get(first.id)?.error ?? "", /capacity quarantined/);
2128
+ quarantined.exit(0);
2129
+ await tick();
2130
+ assert.equal(existsSync(childTmps[0]), true, "a descendant still holds the group, so the TMPDIR stays");
2131
+ assert.equal(store.get(second.id)?.status, TASK_STATUS.QUEUED, "the slot stays quarantined while the group lives");
2132
+ groupAlive = false;
2133
+ const probe = timers.filter((timer) => !timer.cancelled && timer.ms === 1_000).at(-1);
2134
+ assert.ok(probe, "the observed exit keeps probing the remaining group");
2135
+ probe.fn();
2136
+ await tick();
2137
+ assert.equal(existsSync(childTmps[0]), false, "the gone group removes the quarantined child's TMPDIR");
2138
+ assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING, "the freed slot starts queued work");
2139
+ assert.equal(store.get(first.id)?.error, `process cleanup unconfirmed after 1000ms; capacity quarantined`, "the recorded outcome is unchanged");
2140
+ runner.cancel(second.id);
2141
+ fake!.exit(0);
2142
+ await tick();
2143
+ });
2144
+
2145
+ test("a relative inherited TMPDIR falls back to the system temp dir, so the child's path is absolute", async () => {
2146
+ const fake = fakeChild();
2147
+ let childTmp = "";
2148
+ const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
2149
+ const task = runner.run(request({ env: { TMPDIR: "reltmp" } }));
2150
+ await tick();
2151
+ assert.ok(childTmp.startsWith(join(tmpdir(), "ga")), childTmp);
2152
+ fake.exit(0);
2153
+ await runner.waitFor(task.id);
2154
+ assert.equal(existsSync(childTmp), false);
2155
+ });
2156
+
2157
+ // odd/tasks/session-scratch-ledger.md S9: TMPDIR nests the session scratch and the
2158
+ // subagent's own TMPDIR, and a Unix socket path (104 bytes with the NUL on macOS)
2159
+ // under it must still fit, as Python's `pymp-XXXXXXXX/listener-XXXXXXXX` (32 bytes).
2160
+ test("S9: under a 48-byte inherited TMPDIR, a subagent TMPDIR leaves room for a 32-byte socket name", async (t) => {
2161
+ const INHERITED_BYTES = 48;
2162
+ const SOCKET_NAME = "/pymp-XXXXXXXX/listener-XXXXXXXX";
2163
+ // An unresolved parent, like macOS `/var/folders/xx/<30 chars>/T`, padded to 48 bytes.
2164
+ const parent = [tmpdir(), "/tmp"].map((root) => { try { return fs.mkdtempSync(join(root, "b")); } catch { return undefined; } })
2165
+ .find((dir): dir is string => dir !== undefined && Buffer.byteLength(dir) + 2 <= INHERITED_BYTES);
2166
+ if (!parent) { t.skip("no temp directory short enough to build a 48-byte path"); return; }
2167
+ t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
2168
+ const inherited = join(parent, "x".repeat(INHERITED_BYTES - Buffer.byteLength(parent) - 1));
2169
+ fs.mkdirSync(inherited);
2170
+ assert.equal(Buffer.byteLength(inherited), INHERITED_BYTES);
2171
+ const env: NodeJS.ProcessEnv = { TMPDIR: inherited, GENTLE_PI_CONFIG_HOME: join(parent, "config") };
2172
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
2173
+ createSessionScratchExtension({ env })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
2174
+ const ctx = { cwd: parent, hasUI: false, sessionManager: { getSessionId: () => "a-session-id-much-longer-than-the-scratch-name" } };
2175
+ handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx);
2176
+ t.after(() => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx));
2177
+ assert.equal(dirname(env.TMPDIR!), inherited, "the session scratch keeps the inherited spelling");
2178
+ const fake = fakeChild();
2179
+ let childTmp = "";
2180
+ const runner = scratchRunner((childEnv) => { childTmp = childEnv.TMPDIR ?? ""; return fake.child; });
2181
+ const task = runner.run(request({ env: { TMPDIR: env.TMPDIR } }));
2182
+ await tick();
2183
+ assert.equal(dirname(childTmp), env.TMPDIR, "the subagent TMPDIR nests under the session scratch");
2184
+ const socketPath = Buffer.byteLength(`${childTmp}${SOCKET_NAME}`);
2185
+ assert.ok(socketPath <= 103, `${childTmp}${SOCKET_NAME} is ${socketPath} bytes`);
2186
+ fake.exit(0);
2187
+ await runner.waitFor(task.id);
2188
+ });
2189
+
2190
+ // odd/tasks/scratch-review-advisories.md S2 (native review advisories of #2300):
2191
+ // session shutdown removed the whole scratch, including the TMPDIR of a child
2192
+ // still running or quarantined, whose descendants may still hold files there.
2193
+ test("advisories S2: session shutdown keeps a running or quarantined subagent's TMPDIR, removes the rest, and leaves the scratch to the sweep", async (t) => {
2194
+ const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "gentle-pi-advisories-s2-")));
2195
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
2196
+ const base = join(root, "tmp");
2197
+ fs.mkdirSync(base);
2198
+ const configHome = join(root, "config");
2199
+ const env: NodeJS.ProcessEnv = { TMPDIR: base, GENTLE_PI_CONFIG_HOME: configHome };
2200
+ const scratchSession = (sessionId: string, options: { isProcessAlive?: (pid: number) => boolean; now?: () => number } = {}) => {
2201
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
2202
+ createSessionScratchExtension({ env, ...options })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
2203
+ const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => sessionId } };
2204
+ return {
2205
+ start: () => handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx),
2206
+ shutdown: () => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx),
2207
+ };
2208
+ };
2209
+ const parent = scratchSession("s-1");
2210
+ parent.start();
2211
+ const scratch = env.TMPDIR!;
2212
+ assert.equal(dirname(scratch), base);
2213
+ const timers: Array<{ fn: () => void; ms: number }> = [];
2214
+ let now = 1_000;
2215
+ const fakes: FakeChild[] = [];
2216
+ const childTmps: string[] = [];
2217
+ const store = new TaskStore();
2218
+ const runner = new AgentRunner(store, { maxConcurrency: 2, stallTimeoutMs: 10_000 }, {
2219
+ spawn: (_command, _args, options) => {
2220
+ childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
2221
+ const fake = fakeChild({ exitOnKill: false, pid: 80 + fakes.length });
2222
+ fakes.push(fake);
2223
+ return fake.child;
2224
+ },
2225
+ now: () => now,
2226
+ schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
2227
+ pi: { command: "pi", args: [] },
2228
+ process: { platform: "win32", kill: () => {} },
2229
+ }, { askUser: async () => ({ value: "yes" }) });
2230
+ const quarantined = runner.run(request({ env: { TMPDIR: scratch } }));
2231
+ const running = runner.run(request({ prompt: "running", env: { TMPDIR: scratch } }));
2232
+ await tick();
2233
+ runner.cancel(quarantined.id);
2234
+ timers.find((timer) => timer.ms === 250)!.fn();
2235
+ now = 5_000;
2236
+ timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
2237
+ await tick();
2238
+ assert.match(store.get(quarantined.id)?.error ?? "", /capacity quarantined/);
2239
+ assert.deepEqual(childTmps.map((dir) => dirname(dir)), [scratch, scratch]);
2240
+ fs.writeFileSync(join(childTmps[0], "held.txt"), "x");
2241
+ fs.writeFileSync(join(scratch, "junk.txt"), "x");
2242
+ parent.shutdown();
2243
+ assert.ok(existsSync(join(childTmps[0], "held.txt")), "a quarantined child's TMPDIR stays");
2244
+ assert.ok(existsSync(childTmps[1]), "a running child's TMPDIR stays");
2245
+ assert.equal(existsSync(join(scratch, "junk.txt")), false, "the rest of the scratch is removed");
2246
+ assert.equal(env.TMPDIR, base, "the inherited TMPDIR is restored");
2247
+ const ledger = JSON.parse(readFileSync(join(configHome, "scratch", "s-1.json"), "utf8")) as { scratchDir: string | null };
2248
+ assert.equal(ledger.scratchDir, scratch, "the ledger keeps the scratch for a later sweep");
2249
+ fakes[0].exit(0);
2250
+ runner.cancel(running.id);
2251
+ fakes[1].exit(0);
2252
+ await tick();
2253
+ assert.ok(childTmps.every((dir) => !existsSync(dir)), "each proven exit removes its TMPDIR");
2254
+ const next = scratchSession("next", { isProcessAlive: () => false, now: () => Date.now() + 2 * 3_600_000 });
2255
+ next.start();
2256
+ assert.equal(existsSync(scratch), false, "the startup sweep removes the kept scratch once its owner ended");
2257
+ next.shutdown();
2258
+ });
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { once } from "node:events";
5
5
  import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
6
- import { createConnection, createServer, Socket } from "node:net";
6
+ import { createConnection, createServer, Socket, type Server } from "node:net";
7
7
  import { join } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
9
  import nodeTest, { type TestContext } from "node:test";
@@ -124,7 +124,12 @@ test("lists only advertised peer IDs without mutating the private registry", asy
124
124
  assert.equal(await snapshot(), before, "listing leaves presence files, directory modes, and socket entries unchanged");
125
125
  assert.equal(alphaNew.createdAt >= alphaOld.createdAt, true);
126
126
  for (let index = 0; index < 65; index++) await transport.publish(await transport.record(`overflow-${index}`, index));
127
- await rejected(transport.list(), "busy");
127
+ // The over-cap scan sweeps provably dead records once instead of failing
128
+ // busy: the overflow endpoints were never bound, and the missing/malformed/
129
+ // symlinked fixtures are debris too. Live peers stay listed.
130
+ assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "alpha", reachability: "unknown" }]);
131
+ const remaining = (await readdir(transport.paths.presence)).sort();
132
+ assert.equal(remaining.length, 3, "only the live records (self, alpha twice) survive the sweep");
128
133
  });
129
134
 
130
135
  test("keeps same-session activations distinct and old cleanup preserves the newest", async (t) => {
@@ -161,7 +166,52 @@ test("isolates two sessions and bounds registry discovery at 65 entries", async
161
166
  assert.deepEqual(await transport.resolve("left"), left);
162
167
  assert.deepEqual(await transport.resolve("right"), right);
163
168
  for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index));
164
- await rejected(transport.resolve("capped"), "busy");
169
+ // The dead-endpoint overflow is swept on the first over-cap scan, so the
170
+ // retry resolves the truth (nothing left) instead of failing busy.
171
+ await rejected(transport.resolve("capped"), "not_found");
172
+ assert.equal((await readdir(transport.paths.presence)).length, 0, "dead overflow records are swept, never retained");
173
+ });
174
+
175
+ test("foreign-schema presence files never count toward the activation cap", async (t) => {
176
+ const transport = await registry(t);
177
+ // The orchestrator publisher shares this directory with a different naming
178
+ // schema (<sessionHash>.<incarnation>.<kind>.json, three files per peer),
179
+ // and long-lived profiles accumulate hundreds of those records. They are
180
+ // never transport activations, so they must not trip the spam guard.
181
+ const incarnation = (index: number) => `00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`;
182
+ for (let index = 0; index < 80; index++) {
183
+ for (const kind of ["header", "activity", "discovery"]) {
184
+ await writeFile(join(transport.paths.presence, `${"b".repeat(64)}.${incarnation(index)}.${kind}.json`), "{}");
185
+ }
186
+ }
187
+ const self = await raw(t, transport, "self", null);
188
+ const peer = await raw(t, transport, "peer", null);
189
+ assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "listing survives a foreign-schema flood and still sees real activations");
190
+ assert.deepEqual(await transport.resolve("peer"), peer);
191
+ // The cap still bounds real transport activations exactly as before: the
192
+ // 65 advertised dead-endpoint records overflow the guard, the sweep removes
193
+ // them, and the live peers remain discoverable afterwards.
194
+ for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index + 10));
195
+ assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "dead overflow is swept once and the live peer stays discoverable");
196
+ await rejected(transport.resolve("capped"), "not_found");
197
+ });
198
+
199
+ // The sweep must never trade the spam guard for liveness probing: a genuine
200
+ // flood of records backed by real listening sockets stays over the cap, keeps
201
+ // failing busy, and its records are never removed.
202
+ test("a flood of live listeners stays over the cap and keeps failing busy", async (t) => {
203
+ const transport = await registry(t);
204
+ const servers: Server[] = [];
205
+ t.after(() => { for (const server of servers) server.close(); });
206
+ for (let index = 0; index < 66; index++) {
207
+ const record = await transport.record(`flood-${index}`, index);
208
+ const server = createServer(() => {});
209
+ await new Promise<void>((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(record.endpoint, resolveListen); });
210
+ servers.push(server);
211
+ await transport.publish(record);
212
+ }
213
+ await rejected(transport.list(), "busy");
214
+ assert.equal((await readdir(transport.paths.presence)).length, 66, "live records are never swept");
165
215
  });
166
216
 
167
217
  test("rejects unsafe IDs, v1 schema violations, prototypes, and content-leaking errors", async (t) => {