gentle-pi 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,13 +1,12 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, VERSION } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
- import * as os from "node:os";
5
4
  import { execFile } from "node:child_process";
6
5
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
6
  import { join } from "node:path";
8
7
  import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
9
8
  import { PI_SUBCOMMANDS } from "../lib/gentle-shell-launcher.ts";
10
- import { bundledGentlePiConfigHome } from "../lib/gentle-shell-bundled.ts";
9
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
11
10
 
12
11
 
13
12
  export type BannerColor = "pink" | "cyan" | "yellow" | "green";
@@ -68,7 +67,7 @@ function rgb(r: number, g: number, b: number, text: string): string {
68
67
  }
69
68
 
70
69
  function gentleAiConfigHome(): string {
71
- return bundledGentlePiConfigHome(process.env) ?? process.env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai");
70
+ return gentlePiConfigHome(process.env);
72
71
  }
73
72
 
74
73
  function bannerConfigPath(configHome = gentleAiConfigHome()): string {
package/lib/agent-home.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { bundledGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts";
3
+ import { resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts";
4
+
5
+ export { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts";
4
6
 
5
7
  // Pi Subagents resolves its global directory as `PI_CODING_AGENT_DIR || ~/.pi/agent`,
6
8
  // so an empty value must fall through here too or the two homes diverge again.
@@ -12,9 +14,12 @@ export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env):
12
14
  // commands own lives here. It is defined once because the launch-time profile pin
13
15
  // resolver reads the same profiles store the `/gentle:profiles` panel writes, and
14
16
  // two spellings of the override would silently read two different stores.
15
- // A bundled Gentle Shell keeps it inside its prefix (S9, bundledGentlePiConfigHome).
17
+ // A bundled Gentle Shell keeps it inside its prefix (S9, bundledGentlePiConfigHome),
18
+ // the standalone launcher's isolated home next to its agent dir (#2201). The rule
19
+ // itself lives in resolveGentlePiConfigHome, a generated runtime module, so the
20
+ // launcher and the Gentle AI process environment share it too.
16
21
  export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env): string {
17
- return bundledGentlePiConfigHome(env) ?? (env.GENTLE_PI_CONFIG_HOME || join(homedir(), ".pi", "gentle-ai"));
22
+ return resolveGentlePiConfigHome(env, homedir());
18
23
  }
19
24
 
20
25
  // The agent home Pi runs with, only in a bundled Gentle Shell (S9): there the
@@ -8,6 +8,9 @@
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
+ import {
12
+ unmatchedProfileKeys,
13
+ } from "./agents-config.ts";
11
14
  import {
12
15
  normalizeModelConfig,
13
16
  type AgentModelConfig,
@@ -364,6 +367,29 @@ export function profileRoutingRows(config: AgentModelConfig): ProfileRoutingRow[
364
367
  .sort((left, right) => left.agent.localeCompare(right.agent));
365
368
  }
366
369
 
370
+ /**
371
+ * Issue #2196 (PR #2233 maintainer decision): a routing row whose key names no
372
+ * discovered agent was rendered as a valid-looking row while its setting was
373
+ * silently ignored at launch. Each unmatched row gets one note: a did-you-mean
374
+ * suggestion when the packaged short name maps to a discovered agent, and a
375
+ * plain "no matching agent" otherwise. Matched rows get no note.
376
+ */
377
+ export function unmatchedRoutingNotes(
378
+ rows: ProfileRoutingRow[],
379
+ agentNames: string[],
380
+ ): Map<string, string> {
381
+ const unmatched = unmatchedProfileKeys(
382
+ Object.fromEntries(rows.map((row) => [row.agent, { model: undefined, thinking: undefined }])),
383
+ agentNames.map((name) => ({ name })),
384
+ );
385
+ return new Map(
386
+ unmatched.map(({ key, suggestion }) => [
387
+ key,
388
+ suggestion === undefined ? "no matching agent" : `did you mean ${suggestion}?`,
389
+ ]),
390
+ );
391
+ }
392
+
367
393
  /**
368
394
  * Column widths shared by every routing table in one panel, so the profile's
369
395
  * routing and the effective routing align column for column instead of each
@@ -316,6 +316,41 @@ function pick<T>(candidates: Array<[T | undefined, ProfileSource]>): [T | undefi
316
316
  return candidates.find(([value]) => value !== undefined) ?? [undefined, PROFILE_SOURCE.UNRESOLVED];
317
317
  }
318
318
 
319
+ export interface UnmatchedProfileKey {
320
+ key: string;
321
+ suggestion: string | undefined;
322
+ }
323
+
324
+ // Issue #2196: a role key that names no discovered agent was silently ignored,
325
+ // so the child inherited the orchestrator model with no warning anywhere. The
326
+ // alias approach (PR #2233) was rejected because applying a bundled agent's
327
+ // profile to a user-owned same-short-name agent is the exact silence the
328
+ // prefix exists to prevent; reporting the unmatched key keeps resolution
329
+ // exact-match and makes the misconfiguration visible instead.
330
+ export function unmatchedProfileKeys(
331
+ modelProfiles: Record<string, ModelProfile>,
332
+ agents: ReadonlyArray<{ name: string }>,
333
+ ): UnmatchedProfileKey[] {
334
+ const names = new Set(agents.map((agent) => agent.name));
335
+ const lowerNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
336
+ const suggestionFor = (key: string): string | undefined => {
337
+ const lowered = key.toLowerCase();
338
+ if (lowerNames.has(lowered)) return agents.find((agent) => agent.name.toLowerCase() === lowered)?.name;
339
+ const stripped = lowered.replace(/^gentle-ai-/, "");
340
+ for (const candidate of [lowered, stripped]) {
341
+ for (const name of lowerNames) {
342
+ if (name === candidate || name === `gentle-ai-${candidate}`) {
343
+ return agents.find((agent) => agent.name.toLowerCase() === name)?.name;
344
+ }
345
+ }
346
+ }
347
+ return undefined;
348
+ };
349
+ return Object.keys(modelProfiles)
350
+ .filter((key) => !names.has(key))
351
+ .map((key) => ({ key, suggestion: suggestionFor(key) }));
352
+ }
353
+
319
354
  export function resolveAgentProfile(agent: AgentDefinition, config: AgentsConfig): ResolvedProfile {
320
355
  const profile = config.modelProfiles[agent.name];
321
356
  const [model, modelSource] = pick<ModelRef>([
@@ -127,6 +127,9 @@ export interface TaskRecord {
127
127
  startedAt: number | null;
128
128
  endedAt: number | null;
129
129
  model: string;
130
+ /** Launch-layer token recorded next to `model` (#2180): which profile layer won.
131
+ * Host-computed at the launch seam; absent for requests and records that never named one. */
132
+ modelSource?: string;
130
133
  thinking: string | undefined;
131
134
  sessionPath: string | null;
132
135
  error: string | null;
@@ -161,6 +164,12 @@ export interface TaskStatusChange {
161
164
  export type StatusChangeListener = (change: TaskStatusChange) => void | Promise<void>;
162
165
 
163
166
  const DEFAULT_LIMITS: ThreadLimits = { maxItems: 400, maxOutputChars: 16_000 };
167
+ /** Finished tasks keep their thread weight only for this many most-recent
168
+ * records per parent session, matching the agents view's visible history cap
169
+ * (MAX_FINISHED_PER_SESSION in agents-view.ts, pinned by tests); older
170
+ * terminal tasks (record, thread, run id, listeners) leave the store
171
+ * entirely. Running tasks are never evicted. */
172
+ export const TERMINAL_RETENTION = 200;
164
173
  /** Child UI requests that block on an answer; everything else (notify, setStatus, setWidget) is noise here. */
165
174
  export const DIALOG_METHODS: ReadonlySet<string> = new Set(["select", "confirm", "input", "editor"]);
166
175
  /** The one child notify that reaches the task thread: requested --tools the child does not have (#1690). */
@@ -478,6 +487,9 @@ export class TaskStore {
478
487
  private sequence = 0;
479
488
  private readonly runIds = new Map<string, string>();
480
489
  private readonly statusListeners = new Set<StatusChangeListener>();
490
+ // Insertion-ordered recency of finished tasks, oldest first (Set keeps
491
+ // insertion order); prune evicts from the front.
492
+ private readonly terminalOrder = new Set<string>();
481
493
 
482
494
  constructor(limits: Partial<ThreadLimits> = {}) {
483
495
  this.limits = limits;
@@ -489,6 +501,11 @@ export class TaskStore {
489
501
  this.tasks.set(task.id, task);
490
502
  this.threads.set(task.id, emptyThread(this.limits));
491
503
  this.notifyStatus(task, previous);
504
+ if (isFinished(task.status)) {
505
+ this.markTerminal(task.id);
506
+ this.listeners.delete(task.id);
507
+ this.pruneTerminal();
508
+ }
492
509
  this.notifySummary();
493
510
  }
494
511
 
@@ -499,6 +516,11 @@ export class TaskStore {
499
516
  this.tasks.set(task.id, task);
500
517
  this.runIds.set(task.id, randomUUID());
501
518
  this.threads.set(task.id, { ...thread, limits: { ...DEFAULT_LIMITS, ...this.limits } });
519
+ if (isFinished(task.status)) {
520
+ this.markTerminal(task.id);
521
+ this.listeners.delete(task.id);
522
+ this.pruneTerminal();
523
+ }
502
524
  this.notifySummary();
503
525
  return true;
504
526
  }
@@ -530,7 +552,16 @@ export class TaskStore {
530
552
  this.tasks.set(id, next);
531
553
  this.notifyStatus(next, current.status);
532
554
  this.notifyTask(next);
533
- if (next.status !== current.status) this.notifySummary();
555
+ if (next.status !== current.status) {
556
+ // The terminal transition itself is the final notification; after it
557
+ // the task never changes, so its listeners stop costing anything.
558
+ if (isFinished(next.status)) {
559
+ this.listeners.delete(next.id);
560
+ this.markTerminal(next.id);
561
+ }
562
+ this.pruneTerminal();
563
+ this.notifySummary();
564
+ }
534
565
  return next;
535
566
  }
536
567
 
@@ -562,6 +593,34 @@ export class TaskStore {
562
593
  return () => { this.statusListeners.delete(listener); };
563
594
  }
564
595
 
596
+ private markTerminal(id: string): void {
597
+ if (!this.terminalOrder.has(id)) this.terminalOrder.add(id);
598
+ }
599
+
600
+ /** Evict the oldest finished tasks beyond TERMINAL_RETENTION per parent
601
+ * session, with their threads, run ids and listeners. Running tasks are
602
+ * never evicted; a task that left the terminal state is skipped as stale. */
603
+ private pruneTerminal(): void {
604
+ const finished = new Map<string, number>();
605
+ for (const task of this.tasks.values()) {
606
+ if (!isFinished(task.status)) continue;
607
+ finished.set(task.parentSessionId, (finished.get(task.parentSessionId) ?? 0) + 1);
608
+ }
609
+ if (finished.size === 0) return;
610
+ for (const id of this.terminalOrder) {
611
+ const task = this.tasks.get(id);
612
+ if (!task || !isFinished(task.status)) { this.terminalOrder.delete(id); continue; }
613
+ const count = finished.get(task.parentSessionId) ?? 0;
614
+ if (count <= TERMINAL_RETENTION) continue;
615
+ this.tasks.delete(id);
616
+ this.threads.delete(id);
617
+ this.listeners.delete(id);
618
+ this.runIds.delete(id);
619
+ this.terminalOrder.delete(id);
620
+ finished.set(task.parentSessionId, count - 1);
621
+ }
622
+ }
623
+
565
624
  private notifyStatus(task: TaskRecord, previousStatus?: TaskStatus): void {
566
625
  if (task.status === previousStatus) return;
567
626
  const change: TaskStatusChange = Object.freeze({ producerId: this.producerId,
@@ -2,16 +2,19 @@ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-c
2
2
  import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
4
4
  import { tmpdir, userInfo } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { Duplex, type Readable, type Writable } from "node:stream";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { withoutInteractiveHost } from "./rpc-host.ts";
9
+ import { gentlePiConfigHome } from "./agent-home.ts";
10
+ import { resolveBackgroundSubagentsPolicy } from "./background-subagents-policy.ts";
9
11
  import { childPackageExtensionArgs } from "./child-package-injection.ts";
10
12
  import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
11
13
  import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
12
14
  import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
13
15
  import { isFinished, normalizeRpcEvent, ToolArgumentProgress, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
14
16
  import { WriterSurfaceRegistry, writerSurfaceConflictMessage } from "./writer-surfaces.ts";
17
+ import { holdSubagentScratch, releaseSubagentScratch, SUBAGENT_SCRATCH_PREFIX } from "./session-scratch.ts";
15
18
 
16
19
  // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
17
20
  // the host never runs subagent work on the TUI thread. It writes JSON
@@ -69,6 +72,8 @@ export interface RunnerLimits {
69
72
  // Longer ceiling used while an announced tool call is in flight. Optional so
70
73
  // callers that only bound silence keep the idle budget as the tool ceiling.
71
74
  toolStallTimeoutMs?: number;
75
+ // Bounded asynchronous cleanup window allowed after settlement before escalating to SIGTERM/SIGKILL.
76
+ cleanupTimeoutMs?: number;
72
77
  }
73
78
 
74
79
  export interface AskAnswer {
@@ -133,6 +138,11 @@ export interface TaskRequest {
133
138
  // Host-provided only: the launcher's package injection signal (#1690) or
134
139
  // the curated fallback. Never derived from tool input or agent definitions.
135
140
  extensionPaths?: string[];
141
+ // Host-computed launch-layer token for the card (#2180): which profile layer
142
+ // supplied `model` (`binding`, `frozen:*`, `pin:*`, `declaration`, `profile`,
143
+ // `definition`, `default`). Same host-only provenance as `extensionPaths`:
144
+ // never derived from tool input or agent definitions.
145
+ modelSource?: string;
136
146
  // Same host-only provenance as extensionPaths (#1690).
137
147
  noExtensions?: boolean;
138
148
  // Parsed `## Allowed edit surfaces` of a bounded writer. While the task is
@@ -202,6 +212,8 @@ interface LiveTask {
202
212
  childExit: number | null | undefined;
203
213
  childExitSignal?: string | null;
204
214
  instructionsTransportDir?: string;
215
+ /** The child's own TMPDIR, removed when the child ends unless its exit is unconfirmed. */
216
+ scratchDir?: string;
205
217
  cleanupDeadlineAt: number | undefined;
206
218
  quarantined: boolean;
207
219
  nextId: number;
@@ -236,6 +248,8 @@ const DEFAULT_TOOLS: readonly string[] = [];
236
248
  const TERMINATION_GRACE_MS = 250;
237
249
  const GROUP_CONFIRM_MS = 25;
238
250
  const GROUP_CONFIRM_DEADLINE_MS = 1_000;
251
+ const QUARANTINE_PROBE_MS = 1_000;
252
+ export const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000;
239
253
  const QUERY_REJECTION_ERRORS = new Set([
240
254
  "invalid child IPC frame",
241
255
  "invalid child IPC correlation",
@@ -407,6 +421,8 @@ export class AgentRunner {
407
421
  startedAt: null,
408
422
  endedAt: null,
409
423
  model: formatModelRef(request.model),
424
+ // Copied, never recomputed: the layer token was resolved once at the seam (#2180).
425
+ ...(request.modelSource === undefined ? {} : { modelSource: request.modelSource }),
410
426
  thinking: request.thinking,
411
427
  sessionPath: request.resumeSessionPath ?? null,
412
428
  error: null,
@@ -541,6 +557,15 @@ export class AgentRunner {
541
557
  delete env.GENTLE_PI_SDD_REMEDIATION_PLAN;
542
558
  delete env.GENTLE_PI_RESEARCH_SELECTION;
543
559
  const tools = requestedTools(request);
560
+ if (request.agent.tools.includes("bash_background")) {
561
+ const policy = resolveBackgroundSubagentsPolicy(request.cwd, { env: request.env, gentlePiConfigHome: gentlePiConfigHome(request.env) });
562
+ if (policy.policy === "off") {
563
+ const file = policy.source === "global_file" ? policy.globalFile : policy.projectFile;
564
+ this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
565
+ this.finish(id, TASK_STATUS.FAILED, `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.`);
566
+ return;
567
+ }
568
+ }
544
569
  if (tools !== undefined) env[REQUESTED_TOOLS_ENV] = tools;
545
570
  else delete env[REQUESTED_TOOLS_ENV];
546
571
  let instructionsTransportDir: string | undefined;
@@ -562,6 +587,22 @@ export class AgentRunner {
562
587
  return;
563
588
  }
564
589
  }
590
+ // Harness roadmap T3: scratch the child makes with mktemp lands in its own
591
+ // TMPDIR, under the inherited one, so the runtime removes it at the end
592
+ // instead of asking the parent. Best effort: without it the child keeps
593
+ // the inherited TMPDIR.
594
+ let scratchDir: string | undefined;
595
+ try {
596
+ const inherited = request.env.TMPDIR?.trim();
597
+ // A relative TMPDIR would resolve against this process, not the child cwd.
598
+ // A short name and no real path: socket paths under TMPDIR are capped near 104 bytes.
599
+ scratchDir = mkdtempSync(join(inherited && isAbsolute(inherited) ? inherited : tmpdir(), SUBAGENT_SCRATCH_PREFIX));
600
+ // Session shutdown keeps it while it is held (extensions/session-scratch.ts).
601
+ holdSubagentScratch(scratchDir);
602
+ try { chmodSync(scratchDir, DIR_MODE); } catch { /* best effort */ }
603
+ env.TMPDIR = scratchDir;
604
+ if (this.processControl.platform === "win32") { env.TMP = scratchDir; env.TEMP = scratchDir; }
605
+ } catch { scratchDir = undefined; }
565
606
  let child: ChildLike;
566
607
  try {
567
608
  const pi = this.deps.resolvePi?.() ?? this.deps.pi;
@@ -573,15 +614,16 @@ export class AgentRunner {
573
614
  stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
574
615
  });
575
616
  } catch (error) {
576
- if (instructionsTransportDir) {
577
- try { rmSync(instructionsTransportDir, { recursive: true, force: true }); } catch { /* best effort */ }
617
+ for (const dir of [instructionsTransportDir, scratchDir]) {
618
+ if (dir) try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
578
619
  }
620
+ if (scratchDir) releaseSubagentScratch(scratchDir);
579
621
  this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
580
622
  this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error instanceof Error ? error.message : String(error)}`);
581
623
  return;
582
624
  }
583
625
  const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
584
- const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), argumentProgress: new ToolArgumentProgress(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, childExitSignal: undefined, instructionsTransportDir, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
626
+ const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), argumentProgress: new ToolArgumentProgress(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, childExitSignal: undefined, instructionsTransportDir, scratchDir, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
585
627
  if (request.prepareResponseObservations) {
586
628
  let ready = false;
587
629
  live.observationPreparation = () => ready;
@@ -878,9 +920,9 @@ export class AgentRunner {
878
920
  if (event.type === TASK_EVENT.AGENT_SETTLED) {
879
921
  if (live.observations) live.observations.agentSettled = true;
880
922
  const terminal = this.store.get(id);
881
- if (terminal?.error) this.requestStop(id, TASK_STATUS.FAILED, terminal.error);
882
- else if (terminal?.result) this.requestStop(id, TASK_STATUS.COMPLETED, null);
883
- else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
923
+ if (terminal?.error) this.settleOrderly(id, TASK_STATUS.FAILED, terminal.error);
924
+ else if (terminal?.result) this.settleOrderly(id, TASK_STATUS.COMPLETED, null);
925
+ else this.settleOrderly(id, TASK_STATUS.FAILED, "assistant settled without a final report");
884
926
  }
885
927
  }
886
928
  if (!live.terminal && progress) this.armStall(id, live);
@@ -921,6 +963,41 @@ export class AgentRunner {
921
963
  }
922
964
  }
923
965
 
966
+ /**
967
+ * Requests orderly child shutdown via stdin upon settlement and schedules
968
+ * bounded signal escalation if the child process does not exit within cleanupTimeoutMs.
969
+ */
970
+ private settleOrderly(id: string, status: TaskRecord["status"], error: string | null): void {
971
+ const live = this.live.get(id);
972
+ if (!live || live.terminal) return;
973
+ live.terminal = { status, error };
974
+ live.mutationStarts.clear();
975
+ live.inFlightTools.clear();
976
+ live.permissionBroker?.close();
977
+ this.closeIpc(live);
978
+ live.cancelStall();
979
+ const current = this.store.get(id);
980
+ if (current && !isFinished(current.status)) {
981
+ this.store.update(id, { lastStep: "cleaning up" });
982
+ }
983
+ try {
984
+ live.child.stdin.end();
985
+ } catch {
986
+ this.signal(live, "SIGTERM");
987
+ }
988
+ const cleanupTimeoutMs = this.limits.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS;
989
+ live.cancelGrace = this.deps.schedule(() => {
990
+ if (this.live.get(id) !== live) return;
991
+ this.signal(live, "SIGTERM");
992
+ live.cancelGrace = this.deps.schedule(() => {
993
+ if (this.live.get(id) !== live) return;
994
+ live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
995
+ this.signal(live, "SIGKILL");
996
+ this.confirmGroupExit(id, live);
997
+ }, TERMINATION_GRACE_MS);
998
+ }, cleanupTimeoutMs);
999
+ }
1000
+
924
1001
  private requestStop(id: string, status: TaskRecord["status"], error: string | null, abort = false): void {
925
1002
  const live = this.live.get(id);
926
1003
  if (!live || live.terminal) return;
@@ -997,7 +1074,8 @@ export class AgentRunner {
997
1074
  live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
998
1075
  }
999
1076
 
1000
- private cleanupLive(live: LiveTask): void {
1077
+ /** `exitProven` is true only in completeExit, after the child's exit was observed or its group is gone. */
1078
+ private cleanupLive(live: LiveTask, exitProven = false): void {
1001
1079
  live.permissionBroker?.close();
1002
1080
  this.closeIpc(live);
1003
1081
  live.cancelStall();
@@ -1006,6 +1084,13 @@ export class AgentRunner {
1006
1084
  try { rmSync(live.instructionsTransportDir, { recursive: true, force: true }); } catch { /* best effort */ }
1007
1085
  live.instructionsTransportDir = undefined;
1008
1086
  }
1087
+ // A quarantined child's exit is unconfirmed: a live descendant may still
1088
+ // hold files there, so its scratch stays until the exit is proven.
1089
+ if (live.scratchDir && (!live.quarantined || exitProven)) {
1090
+ try { rmSync(live.scratchDir, { recursive: true, force: true }); } catch { /* best effort */ }
1091
+ releaseSubagentScratch(live.scratchDir);
1092
+ live.scratchDir = undefined;
1093
+ }
1009
1094
  }
1010
1095
 
1011
1096
  private childError(id: string, error: Error): void {
@@ -1030,13 +1115,26 @@ export class AgentRunner {
1030
1115
  const exitDesc = formatChildExit(code, signal);
1031
1116
  if (this.groupExists(live)) {
1032
1117
  if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with ${exitDesc} before agent_settled${this.stderrSuffix(live)}`);
1118
+ else if (live.quarantined) live.cancelGrace = this.deps.schedule(() => this.awaitQuarantinedGroup(id, live), QUARANTINE_PROBE_MS);
1119
+ return;
1120
+ }
1121
+ this.completeExit(id, live);
1122
+ }
1123
+
1124
+ // Quarantine stopped probing, and a descendant outlives the child's observed
1125
+ // exit: no later event reports the group's end, so keep probing until it is
1126
+ // gone, then release the slot and scratch.
1127
+ private awaitQuarantinedGroup(id: string, live: LiveTask): void {
1128
+ if (this.live.get(id) !== live) return;
1129
+ if (this.groupExists(live)) {
1130
+ live.cancelGrace = this.deps.schedule(() => this.awaitQuarantinedGroup(id, live), QUARANTINE_PROBE_MS);
1033
1131
  return;
1034
1132
  }
1035
1133
  this.completeExit(id, live);
1036
1134
  }
1037
1135
 
1038
1136
  private completeExit(id: string, live: LiveTask): void {
1039
- this.cleanupLive(live);
1137
+ this.cleanupLive(live, true);
1040
1138
  this.live.delete(id);
1041
1139
  // Quarantine already notified completion, but its retained slot is now free.
1042
1140
  if (live.quarantined) {
@@ -1,11 +1,17 @@
1
- import { chmod, constants, link, lstat, mkdir, open, opendir, realpath, unlink, writeFile } from "node:fs/promises";
1
+ import { chmod, constants, link, lstat, mkdir, open, opendir, readdir, realpath, unlink, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { createConnection, createServer, type Server, type Socket } from "node:net";
5
5
 
6
6
  const DIR_MODE = 0o700, FILE_MODE = 0o600, MAX_RECORD_BYTES = 8192, MAX_ENTRIES = 64, MAX_SOCKET_PATH_BYTES = 100;
7
+ // Over-cap debris collection bounds: a presence directory may hold at most
8
+ // this many schema-matching records before sweeping is refused, and each
9
+ // liveness probe gets a short bounded connect attempt. A genuine flood of
10
+ // live listeners stays over the cap and keeps failing busy, unchanged.
11
+ const SWEEP_MAX_FILES = 4096, SWEEP_PROBE_TIMEOUT_MS = 1_000, SWEEP_PROBE_CONCURRENCY = 8;
7
12
  const RUNTIME_TMP = "/tmp", RUNTIME_PREFIX = "gentle-pi-", PROFILE_HASH_HEX_LENGTH = 32, SOCKET_NAME_BYTES = 27;
8
13
  const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, TOKEN = /^[A-Za-z0-9_-]{22}$/;
14
+ const PRESENCE_FILE = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/;
9
15
  const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
10
16
  type Code = "invalid_session" | "not_found" | "invalid_presence" | "unsafe_path" | "io_error" | "busy";
11
17
 
@@ -131,7 +137,25 @@ export class SessionPresenceRegistry {
131
137
  return (await this.listActivations(excludeSessionId)).map(({ sessionId }) => Object.freeze({ sessionId, reachability: "unknown" as const }));
132
138
  }
133
139
 
134
- async listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
140
+ // An over-cap failure is stale debris until proven otherwise: sessions that
141
+ // exit uncleanly never remove their record, so a long-lived profile accrues
142
+ // them until every scan fails busy (gentle-shell#2501: 133 records, oldest
143
+ // four weeks old, 126 provably dead). Sweep once — bounded, liveness-based —
144
+ // and retry; a flood of live listeners stays over the cap and still fails.
145
+ private async withStaleSweep<T>(scan: () => Promise<T>): Promise<T> {
146
+ try { return await scan(); }
147
+ catch (error) {
148
+ if (!(error instanceof SessionPresenceError) || error.code !== "busy") throw error;
149
+ if (!await this.sweepStaleRecords().catch(() => false)) throw error;
150
+ return scan();
151
+ }
152
+ }
153
+
154
+ listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
155
+ return this.withStaleSweep(() => this.scanActivations(excludeSessionId));
156
+ }
157
+
158
+ private async scanActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
135
159
  const excluded = excludeSessionId === undefined ? undefined : sessionId(excludeSessionId);
136
160
  await privateDirectory(this.paths.root);
137
161
  await privateDirectory(this.paths.presence);
@@ -142,9 +166,12 @@ export class SessionPresenceRegistry {
142
166
  try {
143
167
  dir = await opendir(this.paths.presence);
144
168
  for await (const entry of dir) {
145
- if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
146
- const match = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/.exec(entry.name);
169
+ const match = PRESENCE_FILE.exec(entry.name);
170
+ // Foreign-schema records (orchestrator headers, activity, and discovery
171
+ // sidecars) share this directory without ever being activations, so
172
+ // only schema-matching entries count toward the spam guard.
147
173
  if (!match) continue;
174
+ if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
148
175
  try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
149
176
  try {
150
177
  const loaded = await this.read(join(this.paths.presence, entry.name), match[1], match[2]);
@@ -160,7 +187,11 @@ export class SessionPresenceRegistry {
160
187
  return [...newest.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId));
161
188
  }
162
189
 
163
- async resolve(id: string): Promise<PresenceRecord> {
190
+ resolve(id: string): Promise<PresenceRecord> {
191
+ return this.withStaleSweep(() => this.scanResolve(id));
192
+ }
193
+
194
+ private async scanResolve(id: string): Promise<PresenceRecord> {
164
195
  id = sessionId(id);
165
196
  await privateDirectory(this.paths.root);
166
197
  await privateDirectory(this.paths.presence);
@@ -170,9 +201,11 @@ export class SessionPresenceRegistry {
170
201
  try {
171
202
  dir = await opendir(this.paths.presence);
172
203
  for await (const entry of dir) {
173
- if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
174
204
  const match = new RegExp(`^${id}\\.([A-Za-z0-9_-]{22})\\.json$`).exec(entry.name);
205
+ // Same shared-directory rule as listActivations: foreign-schema
206
+ // records are skipped before counting toward the cap.
175
207
  if (!match) continue;
208
+ if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
176
209
  try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
177
210
  try {
178
211
  const loaded = await this.read(join(this.paths.presence, entry.name), id, match[1]);
@@ -215,6 +248,68 @@ export class SessionPresenceRegistry {
215
248
  }
216
249
  }
217
250
 
251
+ // One bounded connect attempt against a candidate's own socket endpoint.
252
+ // Endpoints reaching this probe were validated into this registry's sockets
253
+ // directory and lstat-verified as same-user sockets by advertises(), so the
254
+ // probe cannot reach foreign services. Only an active refusal proves the
255
+ // record dead: an ambiguous outcome (timeout, unexpected error) keeps it.
256
+ private liveness(endpoint: string): Promise<"live" | "dead" | "unclear"> {
257
+ return new Promise((resolveProbe) => {
258
+ const socket = createConnection(endpoint);
259
+ const settle = (verdict: "live" | "dead" | "unclear") => {
260
+ clearTimeout(timer);
261
+ socket.destroy();
262
+ resolveProbe(verdict);
263
+ };
264
+ const timer = setTimeout(() => settle("unclear"), SWEEP_PROBE_TIMEOUT_MS);
265
+ socket.once("connect", () => settle("live"));
266
+ socket.once("error", (error: NodeJS.ErrnoException) => settle(error.code === "ETIMEDOUT" ? "unclear" : "dead"));
267
+ });
268
+ }
269
+
270
+ // Garbage-collects provably dead records once the directory is over cap:
271
+ // no live endpoint (advertises false, i.e. the socket is gone) or a refused
272
+ // connect. Removes the record files only — socket files belong to their
273
+ // owning session. Returns whether anything was removed, so the caller
274
+ // retries the scan exactly once; nothing here runs on a healthy directory.
275
+ private async sweepStaleRecords(): Promise<boolean> {
276
+ const names = (await readdir(this.paths.presence)).filter((name) => PRESENCE_FILE.test(name));
277
+ if (names.length === 0 || names.length > SWEEP_MAX_FILES) return false;
278
+ let removed = 0;
279
+ const remove = async (file: string) => {
280
+ try { await unlink(file); removed += 1; }
281
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") removed += 1; }
282
+ };
283
+ const live: PresenceRecord[] = [];
284
+ for (const name of names) {
285
+ const match = PRESENCE_FILE.exec(name);
286
+ if (!match) continue;
287
+ try {
288
+ const loaded = await this.read(join(this.paths.presence, name), match[1], match[2]);
289
+ const record = loaded?.record;
290
+ // A record that no longer validates (malformed, pruned endpoint) is
291
+ // debris; an unsafe_path one is left for its owner, never touched.
292
+ if (!record) { await remove(join(this.paths.presence, name)); continue; }
293
+ if (await this.advertises(record)) live.push(record);
294
+ else await remove(join(this.paths.presence, name));
295
+ } catch (error) {
296
+ // Malformed content is debris from an interrupted write and is
297
+ // collected; unsafe-path records are left for their owner, never
298
+ // touched by the sweep.
299
+ if (error instanceof SessionPresenceError && error.code === "invalid_presence") await remove(join(this.paths.presence, name));
300
+ }
301
+ }
302
+ let next = 0;
303
+ const worker = async () => {
304
+ while (next < live.length) {
305
+ const record = live[next++];
306
+ if (await this.liveness(record.endpoint) === "dead") await remove(this.presencePath(record));
307
+ }
308
+ };
309
+ await Promise.all(Array.from({ length: Math.min(SWEEP_PROBE_CONCURRENCY, live.length) }, worker));
310
+ return removed > 0;
311
+ }
312
+
218
313
  private token(endpoint: unknown) {
219
314
  if (typeof endpoint !== "string" || dirname(endpoint) !== this.paths.sockets || resolve(endpoint) !== endpoint || Buffer.byteLength(endpoint) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
220
315
  const name = basename(endpoint), token = name.endsWith(".sock") ? name.slice(0, -5) : "";
@@ -143,7 +143,9 @@ function sameItemValue(a: unknown, b: unknown): boolean {
143
143
  }
144
144
 
145
145
  export function taskHeader(task: TaskRecord, now: number): string {
146
- const parts = [task.agent, task.status, task.model, task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? formatCost(task.cost) : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
146
+ // #2180: the winning profile layer is shown right after the model it supplied;
147
+ // records without one render exactly as before.
148
+ const parts = [task.agent, task.status, task.model, ...(task.modelSource === undefined ? [] : [task.modelSource]), task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? formatCost(task.cost) : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
147
149
  return parts.filter((part) => part.length > 0).join(" · ");
148
150
  }
149
151