gentle-pi 3.7.0 → 4.1.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 (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -1,27 +1,38 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import { agentsViewKey, agentsCollapseKey, agentsStopKey } from "../lib/agents-keys.ts";
3
- import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
4
- import { NativeReviewCliV216, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli } from "../lib/native-review-cli.ts";
5
2
  import { spawn } from "node:child_process";
3
+ import { Type } from "typebox";
6
4
  import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
7
5
  import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
8
6
  import { publishForeignSessionChange } from "../lib/session-change-capture.ts";
9
- import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
7
+ import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
8
+ import { canonicalWriterRoot } from "../lib/writer-surfaces.ts";
10
9
  import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
11
- import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
10
+ import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts";
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { mkdir, readFile, writeFile } from "node:fs/promises";
14
14
  import os from "node:os";
15
- import { join, resolve, isAbsolute, sep } from "node:path";
16
- import { createBashToolDefinition, createLocalBashOperations, type BashOperations, keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { join, resolve, isAbsolute } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createLocalBashOperations, keyHint, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { createJobRegistry, isStandaloneSleep, type JobExecOperations, type JobRecord } from "../lib/background-jobs.ts";
19
+ import { JOB_GLYPH, JOB_NOTICE_TYPE, jobDetails, jobNoticeText, monitorEventsText, monitorStoppedText, registerBackgroundJobTools } from "../lib/background-jobs-tools.ts";
20
+ import { createMonitorController, mergeMonitorBatches, type MonitorNotice } from "../lib/background-monitor.ts";
21
+ import { JobsView } from "../lib/jobs-view.ts";
22
+ import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY } from "../lib/jobs-sidebar-state.ts";
17
23
  import { Text, type TUI } from "@earendil-works/pi-tui";
18
24
  import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
25
+ import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
26
+ import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
19
27
  import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
20
- import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
28
+ import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
29
+ import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
30
+ import { readSessionProfileBinding, sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts";
21
31
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
22
32
  import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
23
- import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
24
- import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest } from "../lib/agents-runner.ts";
33
+ import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
34
+ import { AgentRunner, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type AskAnswer, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
35
+ import { parseChildPackageInjection } from "../lib/child-package-injection.ts";
25
36
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
26
37
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
27
38
  import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts";
@@ -30,18 +41,31 @@ import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts";
30
41
  import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
31
42
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
32
43
  import { AgentsView } from "../lib/agents-view.ts";
33
- import { PresencePublisher } from "../lib/orchestrator-presence.ts";
44
+ import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
45
+ import { PresencePublisher, sanitizeDisplayLabel, listPresence, sessionHash } from "../lib/orchestrator-presence.ts";
46
+ import { incomingMessageCard, outgoingMessageCall, outgoingMessageResult, type OrchestratorMessageDetails } from "../lib/orchestrator-message-card.ts";
47
+ import { orchestratorToolRenderers } from "../lib/orchestrator-tool-card.ts";
48
+ import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts";
49
+ import { consultPublishedMetadata, unavailableMetadata, type MetadataReceipt } from "../lib/orchestrator-consultation.ts";
50
+ import { HelperCostPermission } from "../lib/orchestrator-helper-consent.ts";
51
+ import { OrchestratorStateCache } from "../lib/orchestrator-state.ts";
52
+ import { normalizeTaskSubject } from "../lib/orchestrator-alias.ts";
53
+ import { decodeWorkDescriptor } from "../lib/orchestrator-work.ts";
54
+ import { validateWorkFilter, searchPublishedWork } from "../lib/orchestrator-work-search.ts";
55
+ import { OrchestratorScopeCache, type RepositoryFact } from "../lib/orchestrator-scope.ts";
34
56
  import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
35
57
  import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
36
58
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
37
59
  import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
38
60
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
39
61
  import { openInExternalEditor } from "./gentle-shell.ts";
40
- import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
41
- import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
42
- import { canonicalArtifactPath, researchAgent, renderResearchCapabilities, RESEARCH_CHILD_TOOLS_ENV, RESEARCH_SELECTION_ENV } from "../lib/sdd-research-capabilities.ts";
62
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
63
+ import { resolveAgentHomeDirectory } from "../lib/agent-model-resolution.ts";
64
+ import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts";
65
+ import { allowedEditSurfaces, inheritAllowedEditSurfaces, isBoundedWriter, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
43
66
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
44
67
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
68
+ import { publishTaskStatusChanges } from "../lib/notification-events.ts";
45
69
 
46
70
  // Gentle Agents: subagents as isolated `pi --mode rpc` children, a task
47
71
  // store that notifies per task, and a Gentle Shell card above the editor.
@@ -50,132 +74,48 @@ import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/r
50
74
 
51
75
  export const AGENTS_WIDGET_KEY = "gentle-agents";
52
76
  export const AGENTS_COMMAND_NAME = "gentle:agents";
77
+ export const JOBS_COMMAND_NAME = "gentle:jobs";
78
+ // How long session shutdown waits for stopped jobs to close their logs.
79
+ const JOBS_SHUTDOWN_GRACE_MS = 2000;
53
80
  export const AGENTS_RESULT_TYPE = "gentle-agents.result";
54
81
  export const AGENTS_MESSAGE_TYPE = "gentle-agents.message";
55
82
  export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message";
83
+
84
+ // Display labels are best-effort local snapshots, never routing or authority.
85
+ function messagePeerLabel(profile: string, sessionId: string): string | undefined {
86
+ try {
87
+ const page = listPresence(profile);
88
+ if (page.unavailable || page.overflow || page.rejected) return undefined;
89
+ const matches = page.entries.filter(header => header.sessionHash === sessionHash(sessionId));
90
+ return matches.length === 1 && matches[0].recent ? sanitizeDisplayLabel(matches[0].label) || undefined : undefined;
91
+ } catch { return undefined; }
92
+ }
56
93
  export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
94
+ export const AGENTS_STALE_NOTICE_TYPE = "gentle-agents.stale-notice";
57
95
  const RENDER_COALESCE_MS = 400;
58
96
  const CLOCK_TICK_MS = 1000;
59
97
  const TOOL_PREFIX = "subagent_";
60
- const SHIPPED_SDD_AGENT_NAME_SET = new Set(SHIPPED_SDD_AGENT_NAMES);
61
-
62
- const SDD_PHASE_BY_AGENT = {
63
- "sdd-apply": "apply",
64
- "sdd-remediate": "remediate",
65
- "sdd-verify": "verify",
66
- "sdd-archive": "archive",
67
- } as const;
68
-
69
- function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined {
70
- return Object.hasOwn(SDD_PHASE_BY_AGENT, name) ? SDD_PHASE_BY_AGENT[name as keyof typeof SDD_PHASE_BY_AGENT] : undefined;
71
- }
72
-
73
- function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
74
- if (value === undefined) return undefined;
75
- const expectedPhase = sddPhaseForAgent(agentName);
76
- if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, or archive agents.");
77
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
78
- const selection = value as Record<string, unknown>;
79
- const keys = Object.keys(selection).sort();
80
- if (keys.join(",") !== (expectedPhase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : "changeName,phase,workspaceRoot") ||
81
- typeof selection.changeName !== "string" || selection.changeName.length === 0 ||
82
- typeof selection.workspaceRoot !== "string" || selection.workspaceRoot.length === 0 ||
83
- selection.phase !== expectedPhase) {
84
- throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
85
- }
86
- if (expectedPhase === "remediate" && (typeof selection.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(selection.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
87
- return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: expectedPhase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
88
- }
89
-
90
- const REMEDIATION_SCHEMA = {
91
- type: "object", additionalProperties: false, required: ["plan"],
92
- properties: {
93
- plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
94
- editPaths: { type: "array", maxItems: 32, items: { type: "string" }, description: "Exact canonical files requested for this launch; no entry means no edit/write authority." }, cwd: { type: "string" }, commands: { type: "array", minItems: 1, maxItems: 16, items: { type: "string" } },
95
- runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
96
- rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
97
- } },
98
-
99
- },
100
- };
101
-
102
- export async function confirmRemediationScope(plan: RemediationPlan, action: Record<string, unknown>, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<RemediationScope> {
103
- const cwd = plan.cwd, roots = action.allowedEditRoots;
104
- if (realpathSync(cwd) !== cwd || action.workspaceRoot !== cwd || !Array.isArray(roots) || !roots.every(root => typeof root === "string" && isAbsolute(root) && canonicalArtifactPath(root) === root)) throw new Error("Remediation native scope mismatch");
105
- const inside = (path: string, root: string) => path === root || path.startsWith(`${root}${sep}`);
106
- const editPaths = plan.editPaths ?? [];
107
- if (!Array.isArray(editPaths) || editPaths.length > 32 || new Set(editPaths).size !== editPaths.length) throw new Error("Ambiguous remediation paths");
108
- for (const path of editPaths) {
109
- if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path || /[*?\[\]{}]/.test(path) || canonicalArtifactPath(path) !== path || !inside(path, cwd) || !roots.some(root => inside(path, root))) throw new Error("Remediation path outside canonical native scope");
110
- if (existsSync(path) && !lstatSync(path).isFile()) throw new Error("Remediation requires exact file paths, not directories");
111
- }
112
- const scope: RemediationScope = { cwd, editPaths: [...editPaths], commands: plannedCommands(plan), allowedEditRoots: [...roots] };
113
- if (!context?.hasUI || !context.ui?.confirm || await context.ui.confirm("Authorize one remediation launch", `Canonical worktree: ${cwd}\nNative allowed roots: ${JSON.stringify(roots)}\nExact edit/write files: ${JSON.stringify(editPaths)}\nExact invocations (not a shell sandbox):\n${scope.commands.map((command, slot) => `${slot}: cwd=${JSON.stringify(cwd)} command=${JSON.stringify(command)}`).join("\n")}`) !== true) throw new Error("Remediation requires fresh human authorization; no actor started");
114
- if (realpathSync(cwd) !== cwd || editPaths.some(path => canonicalArtifactPath(path) !== path)) throw new Error("Remediation scope changed during authorization");
115
- return scope;
116
- }
117
- export function remediationToolAllowed(scope: RemediationScope | undefined, cwd: string, tool: string, input: Record<string, unknown>): boolean {
118
- try {
119
- if (!scope || scope.cwd !== cwd || canonicalArtifactPath(cwd) !== cwd) return false;
120
- if (tool === "bash") return typeof input.command === "string" && scope.commands.includes(input.command);
121
- if (["edit", "write"].includes(tool)) {
122
- if (typeof input.path !== "string") return false;
123
- const path = resolve(cwd, input.path);
124
- return scope.editPaths.includes(path) && canonicalArtifactPath(path) === path && scope.allowedEditRoots.some(root => path === root || path.startsWith(`${root}${sep}`));
125
- }
126
- if (["read", "grep", "find"].includes(tool)) {
127
- if (input.path === undefined) return true;
128
- if (typeof input.path !== "string" || !input.path || isAbsolute(input.path)) return false;
129
- const path = resolve(cwd, input.path);
130
- return canonicalArtifactPath(path) === path && (path === cwd || path.startsWith(`${cwd}${sep}`));
131
- }
132
- return tool === "subagent_parent_message";
133
- } catch { return false; }
134
- }
135
-
136
- export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<Partial<TaskRequest>> {
137
- const selected = request.sddChange;
138
- const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
139
- if (request.agent.name !== "sdd-remediate" || selected?.phase !== "remediate" || selected.workspaceRoot !== request.cwd || !native.sddStatus || getPackageAssetOwner("agents/sdd-remediate.md") !== "sdd" || !existsSync(request.agent.filePath) ) throw new Error("Unsupported managed remediation owner/asset or native capability; install matching package assets before retrying");
140
- const owned = parseAgentDefinition(readFileSync(asset, "utf8"), asset, "global");
141
- const installed = parseAgentDefinition(readFileSync(request.agent.filePath, "utf8"), request.agent.filePath, request.agent.scope);
142
- if (!("instructions" in owned) || !("instructions" in installed) || [request.agent, installed].some(agent => agent.instructions !== owned.instructions || JSON.stringify(agent.tools) !== JSON.stringify(owned.tools))) throw new Error("Unsupported remediation actor content; install matching managed assets");
143
- const value = input as { plan?: unknown };
144
- const plan = parseRemediationPlan(value?.plan, request.cwd);
145
- const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
146
- // Legacy remediation status carries the verification failure being repaired;
147
- // that is not permission to bypass a missing native edit grant.
148
- if (status.blockedReasons.some(reason => reason.includes("edit_authority_missing")) || status.nextRecommended !== "remediate" || !status.phaseInstructions?.remediate || status.remediationState?.failedEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Stale remediation selection; refresh native status");
149
- const scope = await confirmRemediationScope(plan, status.actionContext, context);
150
- return { sddRemediation: { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan } };
151
- }
152
-
153
- // Installed only for the admitted remediation child. Stock shell execution,
154
- // cancellation, truncation and rendering remain owned by the SDK definition.
155
- export function remediationBash(cwd: string, operations: BashOperations = createLocalBashOperations(), scope?: RemediationScope) {
156
- const captured = new Map<string, { toolCallId: string; command: string; cwd: string; exitCode: number | null }>();
157
- const remaining = [...(scope?.commands ?? [])], used = new Set<string>();
158
- const stock = createBashToolDefinition(cwd);
159
- return {
160
- definition: { ...stock, async execute(...input: Parameters<typeof stock.execute>) {
161
- const [id, args, signal, onUpdate, ctx] = input;
162
- const slot = remaining.indexOf(args.command);
163
- if (!remediationToolAllowed(scope, ctx?.cwd ?? cwd, "bash", args) || slot < 0 || used.has(id)) throw new Error("Bash invocation is outside this launch human authorization");
164
- remaining.splice(slot, 1); used.add(id);
165
- const definition = createBashToolDefinition(cwd, { operations: { exec: async (command, directory, options) => {
166
- const result = await operations.exec(command, directory, options);
167
- if (captured.size < 32) captured.set(id, { toolCallId: id, command, cwd: directory, exitCode: result.exitCode });
168
- return result;
169
- } } });
170
- return definition.execute(id, args, signal, onUpdate, ctx);
171
- } },
172
- result(event: { toolCallId: string; details?: unknown }) {
173
- const observation = captured.get(event.toolCallId);
174
- captured.delete(event.toolCallId);
175
- return observation ? { details: { ...(event.details as object ?? {}), remediationCommand: observation } } : undefined;
176
- },
177
- };
98
+ // Wakes an idle parent after child content was stored as a custom message.
99
+ // It names itself as automated so the model never attributes it to the human.
100
+ const PARENT_WAKE_TEXT = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue.";
101
+ const PARENT_WAKE_TYPE = "gentle-agents.wake";
102
+ const BRIDGE_WAKE_IDENTITY_TYPE = "gentle-agents.wake-identity";
103
+ interface BridgeWakeIdentity {
104
+ sessionId: string;
105
+ nonce: string;
106
+ text: string;
178
107
  }
108
+ const bridgeWakeText = (nonce: string): string => `${PARENT_WAKE_TEXT} [gentle-agents wake: ${nonce}]`;
109
+ const NATIVE_PARENT_WAKE_TEXT = "Review the delivered subagent output and continue.";
110
+ // How long a dispatched wake may take to start a parent run before a later
111
+ // delivery may send another one.
112
+ export const PARENT_WAKE_GRACE_MS = 30_000;
113
+ // A delivery or wake the host rejected while the parent was idle is retried
114
+ // after the grace at most this many times in a row, so a permanently broken
115
+ // runtime never retries forever.
116
+ const PARENT_DELIVERY_RETRY_LIMIT = 3;
117
+
118
+ const retiredSddAgent = (name: string): boolean => /^sdd(?:-|$)/.test(name);
179
119
 
180
120
  export interface SessionTransportRegistry {
181
121
  list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]>;
@@ -184,6 +124,7 @@ export interface SessionTransportRegistry {
184
124
  }
185
125
 
186
126
  export interface SessionTransportListener {
127
+ readonly record?: PresenceRecord;
187
128
  readonly registry: SessionTransportRegistry;
188
129
  readonly closesRegistry?: boolean;
189
130
  start(): Promise<void>;
@@ -202,7 +143,6 @@ export interface SessionTransportFactory {
202
143
  }
203
144
 
204
145
  export interface AgentsDeps extends RunnerDeps {
205
- nativeSdd?: NativeReviewCli;
206
146
  home: string;
207
147
  agentHome?: string;
208
148
  childIpc?: IpcEndpoint;
@@ -212,6 +152,39 @@ export interface AgentsDeps extends RunnerDeps {
212
152
  runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
213
153
  metricsNow?: () => number;
214
154
  metricsSchedule?: RunnerDeps["schedule"];
155
+ // Extensions every child loads with --extension (gentle-shell#1587).
156
+ childExtensionPaths?: string[];
157
+ /** Shell for background jobs; defaults to Pi's configured Bash. */
158
+ jobShell?: (cwd: string) => { operations: JobExecOperations; commandPrefix?: string };
159
+ /** Directory for background job output; defaults to a private temp dir. */
160
+ jobOutputDir?: () => string;
161
+ }
162
+
163
+ // gentle-shell#1587: fallback for a parent without the launcher's package
164
+ // injection signal (#1690), e.g. a manual `pi -e <package>` launch. Children
165
+ // then get context filtering, destructive-command safety, and the nan
166
+ // provider (gentle-shell#1731 T32: a model routed to nan/* is not found
167
+ // without it). When the signal is present, children load the whole package
168
+ // instead. This list is frozen: new child-facing behavior ships inside the
169
+ // package, never here. Missing files are omitted.
170
+ export function childContextExtensionPaths(exists: (path: string) => boolean = existsSync): string[] {
171
+ try {
172
+ return ["./child-context.ts", "./child-safety.ts", "./nan-provider.ts"]
173
+ .map((path) => fileURLToPath(new URL(path, import.meta.url)))
174
+ .filter(exists);
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ // #1690: when the gentle-shell launcher injected the package with -e, the
181
+ // host env carries that exact set and every child loads it too, which already
182
+ // includes the curated entries. Without a valid signal (declared package,
183
+ // regular gentle-pi, malformed value) children keep the curated entries.
184
+ function childExtensionRequest(env: NodeJS.ProcessEnv, curated: string[] | undefined): Pick<TaskRequest, "extensionPaths" | "noExtensions"> {
185
+ const injection = parseChildPackageInjection(env);
186
+ if (injection) return { extensionPaths: [...injection.extensionPaths], ...(injection.noExtensions ? { noExtensions: true } : {}) };
187
+ return curated && curated.length > 0 ? { extensionPaths: [...curated] } : {};
215
188
  }
216
189
 
217
190
  export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
@@ -219,6 +192,22 @@ export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "a
219
192
  return { sessions: join(root, "sessions"), transcripts: join(root, "transcripts") };
220
193
  }
221
194
 
195
+ const workDescriptorSchema = {
196
+ type: "object", additionalProperties: false,
197
+ description: "Explicit non-authoritative classification, not child context. Topic requires area; limits are UTF-8 bytes. No nested tasks.",
198
+ properties: {
199
+ area: { type: "string", maxLength: 64 }, topic: { type: "string", maxLength: 64 },
200
+ tags: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "string", maxLength: 64 } },
201
+ refs: { type: "array", maxItems: 8, uniqueItems: true, items: {
202
+ type: "object", additionalProperties: false, required: ["kind", "repository", "id"], properties: {
203
+ kind: { type: "string", enum: ["issue", "pr", "task"] },
204
+ repository: { type: "string", maxLength: 256, description: "Explicit public host/owner/repo; not a URL or inferred identity." },
205
+ id: { type: "string", maxLength: 256, description: "Canonical positive decimal issue/PR ID or opaque historical task ID; never a peer route." },
206
+ },
207
+ } },
208
+ },
209
+ };
210
+
222
211
  interface ToolText {
223
212
  content: Array<{ type: "text"; text: string }>;
224
213
  details: Record<string, unknown>;
@@ -234,9 +223,11 @@ const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
234
223
  return () => clearTimeout(timer);
235
224
  },
236
225
  pi: piCommand(),
226
+ resolvePi: () => piCommand(),
237
227
  home: os.homedir(),
238
228
  resolveWorktree: resolveSessionWorktree,
239
229
  env,
230
+ childExtensionPaths: childContextExtensionPaths(),
240
231
  });
241
232
 
242
233
  export function agentsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -281,6 +272,43 @@ function ownedChildIpc(env: NodeJS.ProcessEnv, candidate: IpcEndpoint | undefine
281
272
  return candidate;
282
273
  }
283
274
 
275
+ // Catalog declarations are routing hints, not proof of child availability.
276
+ // Filter non-name entries such as `"*": false`, but do not claim that a
277
+ // syntactically valid name is registered. Empty declarations omit --tools and
278
+ // use Pi defaults. The runner adds parent messaging separately.
279
+ const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/;
280
+
281
+ function declaredToolInventory(agent: AgentDefinition): string {
282
+ const usable = agent.tools.filter((name) => TOOL_NAME.test(name));
283
+ if (usable.length > 0) return `[declared tools (not verified): ${usable.join(", ")}]`;
284
+ return agent.tools.length === 0
285
+ ? "[declared tools: Pi defaults (no allowlist)]"
286
+ : "[declared tools: no tool names declared]";
287
+ }
288
+
289
+ // Pi drops unknown --tools names without a diagnostic (#1690), so the child
290
+ // reports them once through the one notify the parent keeps. The check runs
291
+ // at the first before_agent_start, not session_start: Pi runs session_start
292
+ // handlers in extension load order, so an extension loaded after this one may
293
+ // still register its tools there. MCP tools register later still, and entries
294
+ // that cannot be tool names (the `"*": false` frontmatter entry) are not
295
+ // checkable.
296
+ function registerMissingToolsCheck(pi: ExtensionAPI, env: NodeJS.ProcessEnv): void {
297
+ const requested = (env[REQUESTED_TOOLS_ENV] ?? "").split(",").map((name) => name.trim())
298
+ .filter((name) => /^[A-Za-z0-9_.:-]+$/.test(name) && !name.startsWith("mcp__"));
299
+ if (requested.length === 0) return;
300
+ let reported = false;
301
+ pi.on("before_agent_start", (_event, ctx) => {
302
+ if (reported) return;
303
+ reported = true;
304
+ try {
305
+ const available = new Set(pi.getAllTools().map((tool) => tool.name));
306
+ const missing = [...new Set(requested.filter((name) => !available.has(name)))];
307
+ if (missing.length > 0) ctx.ui.notify(`${MISSING_TOOLS_NOTE_PREFIX} ${missing.join(", ")}`, "warning");
308
+ } catch { /* A diagnostic must never break the child's first prompt. */ }
309
+ });
310
+ }
311
+
284
312
  function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
285
313
  const messenger = new ChildMessenger(ipc);
286
314
  pi.registerTool({
@@ -288,7 +316,7 @@ function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
288
316
  label: "Agent parent message",
289
317
  description: "Send a bounded notification or correlated query to this subagent's parent.",
290
318
  parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { kind: { type: "string", enum: ["notification", "query"] }, message: { type: "string" } } } as never,
291
- async execute(_id, params) {
319
+ async execute(_id, params): Promise<ToolText> {
292
320
  const input = params as { kind?: unknown; message?: unknown };
293
321
  if (typeof input.message !== "string") throw new Error("parent messages require text");
294
322
  if (input.kind === undefined || input.kind === "notification") {
@@ -322,6 +350,22 @@ function text(value: string, details: Record<string, unknown> = {}, terminate =
322
350
  return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
323
351
  }
324
352
 
353
+ // gentle-pi#1175: the writer profile the runtime resolved for this task,
354
+ // carried on its review-mutation receipt so ASSESS never trusts a model
355
+ // re-declaration. `task.model` is the child's reported model once get_state
356
+ // answers, or the launch profile's model. An inherited model is recorded as
357
+ // the unresolved `formatModelRef(undefined)` placeholder; the parent's current
358
+ // model is not reliable evidence (the child resolves its own default), so it is
359
+ // omitted and ASSESS treats the writer as unknown, i.e. small.
360
+ function runtimeWriterProfile(task: TaskRecord): { writerModelId?: string; writerEffort?: string } {
361
+ const modelId = typeof task.model === "string" && task.model !== formatModelRef(undefined) && task.model.trim() ? task.model : undefined;
362
+ const effort = typeof task.thinking === "string" && task.thinking.trim() ? task.thinking : undefined;
363
+ return {
364
+ ...(modelId === undefined ? {} : { writerModelId: modelId }),
365
+ ...(effort === undefined ? {} : { writerEffort: effort }),
366
+ };
367
+ }
368
+
325
369
  function taskDetails(task: TaskRecord): Record<string, unknown> {
326
370
  return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } };
327
371
  }
@@ -347,29 +391,50 @@ function expandHint(expanded: boolean): string {
347
391
  }
348
392
  }
349
393
 
394
+ // pi runs `-p` and `--mode json` through one one-shot runner that disposes
395
+ // the runtime as soon as the prompt returns, so a background result has no
396
+ // parent session left to reach (gentle-shell#1731 T18).
397
+ export function isSingleShotMode(mode: string | undefined): boolean {
398
+ return mode === "print" || mode === "json";
399
+ }
400
+
401
+ const SINGLE_SHOT_BACKGROUND_ERROR = "Background subagents are unavailable in single-shot modes: pi -p and pi --mode json exit before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
402
+
350
403
  // The default mode for a subagent_run request that named neither an explicit
351
404
  // mode nor an agent-defined one. Background is a runtime default only when
352
405
  // the background-subagents policy is on AND the parent can receive results:
353
- // print mode exits before a parent session exists to deliver them to (see
354
- // the `ctx.mode === "print"` guard in `launch` below), so it must keep the
355
- // configured default (normally task) even when the policy is on.
406
+ // single-shot modes exit before a parent session exists to deliver them to
407
+ // (see the guard in `launch` below), so they keep the configured default
408
+ // (normally task) even when the policy is on.
356
409
  export function resolveDefaultSubagentMode(input: {
357
410
  configuredDefault: AgentMode;
358
411
  policy: "on" | "off";
359
412
  parentMode: string | undefined;
360
413
  }): AgentMode {
361
- return input.policy === "on" && input.parentMode !== "print"
414
+ return input.policy === "on" && !isSingleShotMode(input.parentMode)
362
415
  ? AGENT_MODE.BACKGROUND
363
416
  : input.configuredDefault;
364
417
  }
365
418
 
366
419
  // What the model reads when a background task ends: the outcome first, then
367
- // the answer itself. The card renderer shows the same text.
420
+ // the answer itself. The expanded card shows the same text.
368
421
  export function completionText(task: TaskRecord): string {
369
422
  const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " ");
370
423
  return `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome}.\n\n${finishedText(task)}`;
371
424
  }
372
425
 
426
+ const COMPLETION_HEADER = /^Subagent [^\n]+ \(task [^\n]*\) [^\n]+\.$/;
427
+
428
+ // The collapsed card leads with the answer or error: completionText's
429
+ // bookkeeping paragraph stays for the model and the expanded card. Entries
430
+ // without that header (older sessions) preview their full text.
431
+ export function agentResultPreview(text: string): string {
432
+ const split = text.indexOf("\n\n");
433
+ if (split < 0 || !COMPLETION_HEADER.test(text.slice(0, split))) return text;
434
+ const rest = text.slice(split + 2);
435
+ return rest.trim() === "" ? text : rest;
436
+ }
437
+
373
438
  // Host-side answer to a child's dialog: the same ctx.ui the human already
374
439
  // uses, so a subagent's question looks like any other pi dialog.
375
440
  export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, ask: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer> {
@@ -396,73 +461,24 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
396
461
  }
397
462
  }
398
463
 
399
- const RESEARCH_SELECTION_SCHEMA = {
400
- type: "object",
401
- additionalProperties: false,
402
- description: "Untrusted narrowing intent for sdd-research; exact tools and existing sourceInfo.path per tool. Never grants permissions or installs extensions.",
403
- properties: Object.fromEntries(["documentation", "open-web"].map(kind => [kind, {
404
- type: "object", additionalProperties: false, required: ["tools", "extensions"],
405
- properties: {
406
- tools: { type: "array", items: { type: "string" } },
407
- extensions: { type: "object", additionalProperties: { type: "string" } },
408
- },
409
- }])),
410
- };
411
-
412
464
  export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<AgentsDeps> = {}): void {
413
- if (env.GENTLE_PI_AGENTS_CHILD === "1" && env[RESEARCH_CHILD_TOOLS_ENV] !== undefined) {
414
- let allowed: string[] = [];
415
- try {
416
- const parsed: unknown = JSON.parse(env[RESEARCH_CHILD_TOOLS_ENV]!);
417
- if (Array.isArray(parsed) && parsed.every(value => typeof value === "string")) allowed = parsed;
418
- } catch { /* Invalid launch restrictions deny every tool. */ }
419
- let selection: unknown;
420
- try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
421
- const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
422
- pi.on("before_agent_start", event => ({
423
- systemPrompt: `${event.systemPrompt}\n\n${renderResearchCapabilities(current().capabilities)}\n\nResearch is output-only. Use parent-supplied local context; do not read or mutate repository or Engram artifacts. Return useful partial findings and unavailable sources honestly. The parent owns authorized persistence and actual readback.`,
424
- }));
425
- pi.on("tool_call", event => {
426
- const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
427
- const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
428
- if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
429
- return { block: true, reason: "Tool is outside the output-only research child's active launch allowlist." };
430
- }
431
- });
432
- }
433
465
  const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
434
466
  if (env.GENTLE_PI_AGENTS_CHILD === "1") {
435
- if (env[REMEDIATION_PLAN_ENV] !== undefined) {
436
- let granted: RemediationScope | undefined;
437
- pi.on("tool_call", (event, current) => remediationToolAllowed(granted, current.cwd, event.toolName, event.input) ? undefined : { block: true, reason: "Outside exact remediation human authorization" });
438
- pi.on("session_start", (_event, ctx) => {
439
- granted = undefined;
440
- try {
441
- const retained = JSON.parse(env[REMEDIATION_PLAN_ENV]!);
442
- // SDK flags are owner-local; the runner transports the same selected context.
443
- const selection = Object.hasOwn(retained, "selection") ? retained.selection : JSON.parse(String(pi.getFlag("gentle-sdd-change")));
444
- parseSddChange(selection, "sdd-remediate");
445
- const plan = parseRemediationPlan(retained.plan, ctx.cwd);
446
- if (selection.workspaceRoot !== ctx.cwd || JSON.stringify(plannedCommands(plan)) !== JSON.stringify(retained.scope?.commands) || JSON.stringify(plan.editPaths ?? []) !== JSON.stringify(retained.scope?.editPaths)) throw new Error("Remediation grant/plan mismatch");
447
- const shell = remediationBash(ctx.cwd, undefined, retained.scope);
448
- pi.registerTool(shell.definition);
449
- pi.on("tool_result", event => event.toolName === "bash" ? shell.result(event) : undefined);
450
- granted = retained.scope;
451
- } catch { /* No valid host grant: deny every tool, even if Pi continues initialization. */ }
452
- });
467
+ // A stale managed-SDD child must never inherit unrestricted ordinary tools.
468
+ if (env.GENTLE_PI_RESEARCH_TOOLS !== undefined || env.GENTLE_PI_RESEARCH_SELECTION !== undefined || env.GENTLE_PI_RESEARCH_ARTIFACT !== undefined || env.GENTLE_PI_SDD_REMEDIATION_PLAN !== undefined) {
469
+ pi.on("tool_call", () => ({ block: true, reason: "Retired SDD child launch is not supported." }));
470
+ return;
453
471
  }
454
472
  if (childIpc) registerChildMessaging(pi, childIpc);
473
+ registerMissingToolsCheck(pi, env);
455
474
  return;
456
475
  }
457
476
  if (!agentsEnabled(env)) return;
458
477
  const deps: AgentsDeps = { ...defaultDeps(env), ...overrides };
459
- const selectedHome = overrides.agentHome ?? (overrides.home === undefined ? resolveGentlePiAgentHome(deps.env) : join(deps.home, ".pi", "agent"));
460
- // Expand environment tildes like Pi, but leave explicit path APIs literal.
461
- const environmentHome = overrides.agentHome === undefined && overrides.home === undefined;
462
- const expandedHome = environmentHome && selectedHome === "~" ? deps.home
463
- : environmentHome && (selectedHome.startsWith("~/") || (process.platform === "win32" && selectedHome.startsWith("~\\"))) ? join(deps.home, selectedHome.slice(2)) : selectedHome;
478
+ // An explicitly injected pi command wins over the default per-spawn resolver.
479
+ if (overrides?.pi && !overrides.resolvePi) delete deps.resolvePi;
464
480
  // Freeze the host's root before a child uses a different session cwd.
465
- const agentHome = resolve(expandedHome);
481
+ const agentHome = resolveAgentHomeDirectory({ env: deps.env, home: deps.home, agentHome: overrides.agentHome, homeOverridden: overrides.home !== undefined });
466
482
  const sessionTransport = deps.sessionTransport ?? createDefaultSessionTransport();
467
483
  if (legacySubagentsInstalledAt(agentHome)) {
468
484
  pi.on("session_start", (_event, ctx) => {
@@ -474,13 +490,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
474
490
  const viewKey = agentsViewKey(env);
475
491
  const stopKey = agentsStopKey(env);
476
492
  const store = new TaskStore();
493
+ // Attach before any live add/start; restore and unchanged updates are silent.
494
+ // TaskStore contains bus listener failures so notifications cannot fail tools.
495
+ const stopStatusNotifications = publishTaskStatusChanges(store, pi.events, () => performance.now());
496
+ pi.on("session_shutdown", () => { stopStatusNotifications(); });
477
497
  const restoredTaskIds = new Set<string>();
478
498
  const tasksDir = historyDir(deps.home, agentHome);
479
499
  let ui: ExtensionContext["ui"] | undefined;
480
500
  let host: { requestRender(): void } | undefined;
481
501
  let sidebarTui: TUI | undefined;
502
+ const visualHome = gentlePiConfigHome(env);
503
+ let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
504
+ let stopVisualUpdates: (() => void) | undefined;
482
505
  let sessions: ExtensionContext["sessionManager"] | undefined;
483
506
  let presence: PresencePublisher | undefined;
507
+ const scopeCache = new OrchestratorScopeCache(deps.resolveWorktree);
508
+ const stateCache = new OrchestratorStateCache();
484
509
  let rpcActivityPublisher: RpcActivityPublisher | undefined;
485
510
  // Messages already surfaced to the user this session through the RPC
486
511
  // activity publisher's `onError`, so a recurring push failure (the
@@ -488,23 +513,55 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
488
513
  // session instead of flooding the UI. Reset on every `session_start`.
489
514
  let notifiedRpcActivityErrors: Set<string> | undefined;
490
515
  const overlays = new Set<AgentsView>();
516
+ const startPresence = (manager: ExtensionContext["sessionManager"]) => {
517
+ scopeCache.clear();
518
+ const sessionId = manager.getSessionId() ?? "";
519
+ const labelSource = () => {
520
+ if (sessions !== manager || (manager.getSessionId() ?? "") !== sessionId) throw new Error("stale-session");
521
+ return manager.getSessionName?.() || manager.getCwd().split(/[\\/]/).pop() || "Orchestrator";
522
+ };
523
+ return PresencePublisher.start({ profile: agentHome, sessionId, label: labelSource(), labelSource, activity: [] });
524
+ };
491
525
  const publishActivity = () => {
492
526
  if (!sessions) return;
493
527
  try {
494
528
  if (!presence || presence.error) {
495
- presence = PresencePublisher.start({ profile: agentHome, sessionId: activeSessionId() ?? "",
496
- label: sessions.getSessionName?.() || sessions.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
529
+ presence?.dispose();
530
+ presence = startPresence(sessions);
531
+ }
532
+ const tasks = store.list(activeSessionId()).filter((task) => ownedTaskIds.has(task.id) && !isFinished(task.status) && !restoredTaskIds.has(task.id));
533
+ presence?.update(tasks.map((task) => ({ task, thread: store.thread(task.id) })));
534
+ const transport = activeSessionTransport;
535
+ if (transport?.sessionManager === sessions && transport.sessionId === activeSessionId() && transport.listener.record) {
536
+ // Read the existing durable registry, without roots()'s repeated Git validation.
537
+ // These are recorded contexts, never authority for admission or messaging.
538
+ const registered = sessions.getEntries().flatMap(entry => {
539
+ if (entry.type !== "custom" || entry.customType !== SESSION_WORKTREE_ENTRY) return [];
540
+ const data = entry.data as { sessionId?: string; root?: string; evidence?: string } | undefined;
541
+ return data?.sessionId === activeSessionId() && typeof data.root === "string" && typeof data.evidence === "string" ? [data.root] : [];
542
+ });
543
+ presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks, registered,
544
+ scope: scopeCache.project(sessions.getCwd(), tasks, registered), state: stateCache.get(sessions) });
497
545
  }
498
- presence?.update(store.list(activeSessionId()).filter((task) => !isFinished(task.status) && !restoredTaskIds.has(task.id)).map((task) => ({ task, thread: store.thread(task.id) })));
499
546
  } catch { presence?.dispose(); presence = undefined; }
500
547
  };
548
+ const unsubscribeScope = pi.events.on(SESSION_WORKTREE_CHANGED, (event) => {
549
+ if ((event as { sessionId?: string } | undefined)?.sessionId !== sessions?.getSessionId()) return;
550
+ scopeCache.clear();
551
+ publishActivity();
552
+ });
501
553
  let worktrees: SessionWorktreeRegistry | undefined;
554
+ let worktreeManager: ExtensionContext["sessionManager"] | undefined;
555
+ let worktreeAuthority: (() => boolean) | undefined;
502
556
  const foreignGrants = new ForeignTargetGrants();
557
+ const messagingGrants = new SessionMessagingGrants();
503
558
  const foreignTasks = new Map<string, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
504
559
  const foreignRequests = new WeakMap<TaskRequest, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
505
560
  const registryFor = (ctx: ExtensionContext) => {
506
- if (!worktrees || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
561
+ if (!worktrees || worktreeManager !== ctx.sessionManager || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
507
562
  worktrees?.close();
563
+ worktreeManager = ctx.sessionManager;
564
+ worktreeAuthority = sessionRepositoryAuthority(ctx.sessionManager.getCwd(), deps.resolveWorktree);
508
565
  worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree);
509
566
  }
510
567
  return worktrees;
@@ -537,6 +594,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
537
594
  pi.on("session_shutdown", () => {
538
595
  clearTaskMetrics();
539
596
  unsubscribeMetrics();
597
+ unsubscribeScope();
598
+ scopeCache.clear();
540
599
  });
541
600
  let stopAllConfirmation: Promise<void> | undefined;
542
601
 
@@ -598,7 +657,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
598
657
  listener = sessionTransport.createListener(registry, sessionId, async (notification) => {
599
658
  const active = activeSessionTransport;
600
659
  if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport");
601
- pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true });
660
+ pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming", message: notification.message, senderLabel: messagePeerLabel(agentHome, notification.senderSessionId), recipientLabel: sanitizeDisplayLabel(sessionManager.getSessionName?.() ?? "") } } }, { deliverAs: "followUp", triggerTurn: true });
602
661
  });
603
662
  client = sessionTransport.createClient(registry, sessionId);
604
663
  if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) {
@@ -615,6 +674,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
615
674
  await closeStartupTransport();
616
675
  return;
617
676
  }
677
+ publishActivity();
618
678
  } catch {
619
679
  if (activeSessionTransport?.generation === generation) activeSessionTransport = undefined;
620
680
  await closeStartupTransport();
@@ -688,46 +748,417 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
688
748
  // flushed at the next turn boundary, and a stale one never re-enters the
689
749
  // conversation.
690
750
  const completions = createCompletionQueue<TaskRecord>();
751
+ const messages = createAgentMessageQueue();
752
+ // A job's exit, or a monitor's line events and harness stop, waiting for
753
+ // the parent delivery router.
754
+ type JobNotice = { job: JobRecord; kind: "exit" } | MonitorNotice;
755
+ const jobNotices: JobNotice[] = [];
756
+ let jobOutputDir: string | undefined;
757
+ const jobs = createJobRegistry({
758
+ outputDir: deps.jobOutputDir ?? (() => (jobOutputDir ??= mkdtempSync(join(os.tmpdir(), "gentle-jobs-")))),
759
+ now: deps.now,
760
+ shell: deps.jobShell ?? ((cwd) => {
761
+ const settings = SettingsManager.create(cwd, agentHome);
762
+ return { operations: createLocalBashOperations({ shellPath: settings.getShellPath() }), commandPrefix: settings.getShellCommandPrefix() };
763
+ }),
764
+ onSettled: (job) => settleJob(job),
765
+ });
766
+ const monitors = createMonitorController({
767
+ registry: jobs,
768
+ schedule: (fn, ms) => deps.schedule(fn, ms),
769
+ now: deps.now,
770
+ deliver: (notice) => queueJobNotice(notice),
771
+ });
772
+ // A parent that sleeps while background work runs only burns the turn: each
773
+ // completion already arrives as a session message (#1903). bash_background
774
+ // and monitor stay allowed because they are the sanctioned way to wait.
775
+ pi.on("tool_call", (event) => {
776
+ if (event.toolName !== "bash") return undefined;
777
+ const command = (event.input as { command?: unknown }).command;
778
+ if (typeof command !== "string" || !isStandaloneSleep(command)) return undefined;
779
+ const sessionId = activeSessionId();
780
+ // Same active-task predicate as publishActivity.
781
+ const tasks = store.list(sessionId).filter((task) => ownedTaskIds.has(task.id) && !isFinished(task.status) && !restoredTaskIds.has(task.id)).length;
782
+ const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running").length;
783
+ if (tasks + running === 0) return undefined;
784
+ return {
785
+ block: true,
786
+ reason: `Standalone sleep is blocked while background work is active (${tasks} task(s), ${running} job(s)). End the turn now; each completion arrives automatically as a session message and starts a new turn.`,
787
+ };
788
+ });
691
789
  let activeAgentRuns = 0;
790
+ // ExtensionAPI has no idle probe; ctx.isIdle() is the only one. It is live
791
+ // and also reports compaction, which activeAgentRuns cannot see. The
792
+ // session_start context is kept for it and dropped at shutdown; a stale
793
+ // context throws instead of answering, so delivery fails closed.
794
+ let parentCtx: ExtensionContext | undefined;
795
+ const helperPermission = new HelperCostPermission(() => parentCtx && activeTransportFor(parentCtx) ? parentCtx : undefined);
796
+ pi.on("session_before_switch", () => helperPermission.clear());
797
+ pi.on("session_before_fork", () => helperPermission.clear());
798
+ pi.on("session_before_tree", () => helperPermission.clear());
799
+ pi.on("model_select", () => helperPermission.clear());
800
+ pi.on("resources_discover", () => helperPermission.clear());
801
+ let bridgeWakeIdentity: BridgeWakeIdentity | undefined;
802
+ let wakeVisibilityWarning = false;
803
+ const restoreBridgeWakeIdentity = (ctx: ExtensionContext | undefined) => {
804
+ bridgeWakeIdentity = undefined;
805
+ if (!ctx) return;
806
+ // Rendering is session-wide, not model/branch state: an identity on an
807
+ // abandoned branch still owns its generated bubbles in the session tree.
808
+ for (const entry of ctx.sessionManager.getEntries()) {
809
+ if (entry.type !== "custom" || entry.customType !== BRIDGE_WAKE_IDENTITY_TYPE) continue;
810
+ const data = entry.data as Partial<BridgeWakeIdentity> | undefined;
811
+ if (data?.sessionId === ctx.sessionManager.getSessionId() && typeof data.nonce === "string"
812
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(data.nonce)
813
+ && data.text === bridgeWakeText(data.nonce)) {
814
+ bridgeWakeIdentity = data as BridgeWakeIdentity;
815
+ break;
816
+ }
817
+ }
818
+ };
819
+ const hasWakeTransformer = typeof pi.registerMarkdownTransformer === "function";
820
+ if (hasWakeTransformer) {
821
+ // Register once in this runtime. Reload replaces the runtime; session
822
+ // replacement changes the identity, never the transformer registration.
823
+ pi.registerMarkdownTransformer((markdown, context) =>
824
+ context.messageType === "user" && bridgeWakeIdentity?.sessionId === parentCtx?.sessionManager.getSessionId()
825
+ && markdown === bridgeWakeIdentity?.text ? "" : markdown);
826
+ }
827
+ const bridgeWake = (): string => {
828
+ if (!parentCtx) return PARENT_WAKE_TEXT;
829
+ try {
830
+ if (!hasWakeTransformer) throw new Error("Markdown transformer API unavailable");
831
+ if (!bridgeWakeIdentity || bridgeWakeIdentity.sessionId !== parentCtx.sessionManager.getSessionId()) {
832
+ const nonce = randomUUID();
833
+ const identity = { sessionId: parentCtx.sessionManager.getSessionId(), nonce, text: bridgeWakeText(nonce) };
834
+ // Persist before sending so a restart can reconstruct exact ownership.
835
+ pi.appendEntry(BRIDGE_WAKE_IDENTITY_TYPE, identity);
836
+ bridgeWakeIdentity = identity;
837
+ }
838
+ return bridgeWakeIdentity.text;
839
+ } catch {
840
+ // Compatibility fallback preserves continuation, not invisibility.
841
+ if (!wakeVisibilityWarning) {
842
+ wakeVisibilityWarning = true;
843
+ try { parentCtx.ui.notify("Gentle Agents cannot hide Claude Bridge continuation on this runtime; the generated user wake remains visible.", "warning"); } catch { /* UI failure must not drop continuation. */ }
844
+ }
845
+ return PARENT_WAKE_TEXT;
846
+ }
847
+ };
848
+ // Mirrors the host's agent run, which spans agent_start through
849
+ // agent_settled, including post-run retries and in-run compaction. Unlike
850
+ // activeAgentRuns it stays set between agent_end and agent_settled, where
851
+ // Pi still streams and still drains steering.
852
+ let parentRunActive = false;
853
+ // Idle wake-up state. Child content stored while the parent is idle owes a
854
+ // wake, and one wake covers everything stored before the run it starts.
855
+ // While a prompt is starting (a dispatched wake, or any prompt seen at
856
+ // before_agent_start) no second wake is sent: two prompts racing through
857
+ // Pi's asynchronous pre-run phase can both reach the agent, and the loser
858
+ // resets the winner's run state. A prompt that never starts a run (handled
859
+ // by an input handler, or rejected) emits no extension event, so the
860
+ // starting state expires after PARENT_WAKE_GRACE_MS and an owed wake is
861
+ // sent then; it never suppresses later wakes for longer than that.
862
+ let wakeOwed = false;
863
+ let wakeQueued = false;
864
+ let promptStarting = false;
865
+ let cancelPromptGrace: (() => void) | undefined;
866
+ let cancelBoundaryFlush: (() => void) | undefined;
867
+ // An idle parent has no turn boundary coming, so a failed idle delivery or
868
+ // wake is retried by one bounded timer that runs a full flush.
869
+ let cancelDeliveryRetry: (() => void) | undefined;
870
+ let deliveryRetries = 0;
871
+ const endDeliveryRetry = () => {
872
+ cancelDeliveryRetry?.();
873
+ cancelDeliveryRetry = undefined;
874
+ };
875
+ const armDeliveryRetry = () => {
876
+ if (cancelDeliveryRetry || deliveryRetries >= PARENT_DELIVERY_RETRY_LIMIT) return;
877
+ deliveryRetries += 1;
878
+ cancelDeliveryRetry = deps.schedule(() => {
879
+ cancelDeliveryRetry = undefined;
880
+ flushAll();
881
+ }, PARENT_WAKE_GRACE_MS);
882
+ };
883
+
884
+ const isTaskLive = (id: string): boolean => {
885
+ const task = store.get(id);
886
+ return Boolean(task && ownedTaskIds.has(task.id) && !isFinished(task.status));
887
+ };
888
+
889
+ // Hands model-visible child content to the parent session.
890
+ //
891
+ // A busy parent gets "steer" + triggerTurn, which keeps delivery bounded to
892
+ // the current turn: the host polls steering each turn and injects the
893
+ // message before the next LLM call. "followUp" is NOT acceptable here
894
+ // because the host drains the follow-up queue only in the run loop's stop
895
+ // branch, so a parent that keeps calling tools would see the content only
896
+ // when the whole run ends — the original #867 delay.
897
+ //
898
+ // Idle child content is stored durably without a turn, then a separate
899
+ // coalesced wake requests continuation without repeating that content.
900
+ // Claude Bridge needs a user wake through the prompt lifecycle for capture;
901
+ // native providers can use a hidden custom-message turn instead.
902
+ //
903
+ // A parent that is busy without a run (compaction, or a prompt's pre-run
904
+ // compaction) is not streaming, so steer + triggerTurn would also start a
905
+ // direct turn. Its content stays queued ("hold") until a later boundary.
906
+ type ParentRoute = "idle" | "run" | "hold";
907
+ // Throws for a missing or stale parent context, so delivery fails closed.
908
+ const parentRoute = (): ParentRoute => {
909
+ if (!parentCtx) throw new Error("Gentle Agents has no live parent session context");
910
+ if (parentCtx.isIdle()) {
911
+ // The host is authoritative: an idle parent has no run, even if a
912
+ // lifecycle event was missed.
913
+ parentRunActive = false;
914
+ return "idle";
915
+ }
916
+ return parentRunActive ? "run" : "hold";
917
+ };
692
918
 
693
- const deliver = (task: TaskRecord) => {
919
+ const endPromptStart = () => {
920
+ promptStarting = false;
921
+ cancelPromptGrace?.();
922
+ cancelPromptGrace = undefined;
923
+ };
924
+ const beginPromptStart = () => {
925
+ endPromptStart();
926
+ promptStarting = true;
927
+ cancelPromptGrace = deps.schedule(() => {
928
+ cancelPromptGrace = undefined;
929
+ promptStarting = false;
930
+ requestWake();
931
+ }, PARENT_WAKE_GRACE_MS);
932
+ };
933
+
934
+ // Every wake requested during one synchronous delivery pass is coalesced
935
+ // into a single dispatch after it, so the wake follows all stored content.
936
+ const requestWake = () => {
937
+ if (!wakeOwed || wakeQueued || promptStarting) return;
938
+ wakeQueued = true;
939
+ queueMicrotask(dispatchWake);
940
+ };
941
+ const dispatchWake = () => {
942
+ wakeQueued = false;
943
+ if (!wakeOwed || promptStarting) return;
944
+ let route: ParentRoute;
945
+ try { route = parentRoute(); } catch { return; }
946
+ // A run in progress already carries the stored content; a parent busy
947
+ // without a run keeps the wake owed until a later boundary flush.
948
+ if (route !== "idle") return;
949
+ wakeOwed = false;
950
+ endDeliveryRetry();
951
+ beginPromptStart();
952
+ try {
953
+ // "steer" matters only when a run started in between: the wake is then
954
+ // queued into it instead of being rejected as a concurrent prompt.
955
+ // Read the live selection at dispatch, not when child content arrived.
956
+ // Only Claude Bridge is currently evidenced to require prompt capture;
957
+ // registering a custom provider alone does not make it a bridge.
958
+ if (parentCtx?.model?.provider === "claude-bridge") {
959
+ pi.sendUserMessage(bridgeWake(), { deliverAs: "steer" });
960
+ } else {
961
+ pi.sendMessage({
962
+ customType: PARENT_WAKE_TYPE,
963
+ content: NATIVE_PARENT_WAKE_TEXT,
964
+ display: false,
965
+ }, { deliverAs: "steer", triggerTurn: true });
966
+ }
967
+ deliveryRetries = 0;
968
+ } catch {
969
+ // A stale runtime fails closed instead of throwing from a microtask.
970
+ // The stored content still needs a turn, so the wake stays owed and is
971
+ // retried once per grace, a bounded number of times in a row.
972
+ endPromptStart();
973
+ wakeOwed = true;
974
+ armDeliveryRetry();
975
+ }
976
+ };
977
+
978
+ const sendToParent = (message: Parameters<ExtensionAPI["sendMessage"]>[0], route: Exclude<ParentRoute, "hold">) => {
979
+ if (route === "run") {
980
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
981
+ return;
982
+ }
983
+ pi.sendMessage(message, { triggerTurn: false });
984
+ wakeOwed = true;
985
+ requestWake();
986
+ };
987
+
988
+ const deliver = (task: TaskRecord, route: Exclude<ParentRoute, "hold">) => {
694
989
  // Ownership is consulted at delivery time, matching onNotification and
695
990
  // onQuery: a completion owned by another session is dropped, not delivered.
696
991
  if (activeSessionId() !== task.parentSessionId) return;
697
- // "steer" + triggerTurn keeps delivery bounded to the current turn. While
698
- // the parent streams, the host polls steering each turn and injects the
699
- // message before the next LLM call; "followUp" is NOT acceptable here
700
- // because the host drains the follow-up queue only in the run loop's stop
701
- // branch, so a parent that keeps calling tools would see the completion
702
- // only when the whole run ends — the original #867 delay. When the parent
703
- // is idle, triggerTurn runs the prompt immediately, preserving wake-up.
704
- pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true });
992
+ sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route);
705
993
  };
706
994
 
707
- // A stale completion must not re-enter the LLM conversation, so it is
708
- // delivered as durable TUI-only content and the human still sees it.
709
- const deliverStale = (task: TaskRecord, settledAt: number) => {
995
+ // A stale report must not re-enter the LLM conversation, but the parent is
996
+ // told never to poll: it gets a compact model-facing notice naming the task,
997
+ // and the human gets durable TUI-only content.
998
+ const deliverStale = (task: TaskRecord, settledAt: number, route: Exclude<ParentRoute, "hold">) => {
710
999
  if (activeSessionId() !== task.parentSessionId) return;
711
1000
  const ageSeconds = Math.max(0, Math.round((deps.now() - settledAt) / 1000));
712
- pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds });
1001
+ const status = task.status.replace("_", " ");
1002
+ sendToParent({
1003
+ customType: AGENTS_STALE_NOTICE_TYPE,
1004
+ content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${status} about ${ageSeconds}s ago while you were busy. Its report was not replayed here; call subagent_result with task_id ${task.id} to read it.`,
1005
+ display: false,
1006
+ details: taskDetails(task),
1007
+ }, route);
1008
+ // The notice already reached the parent; a failed card must not resend it.
1009
+ try { pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds }); } catch { /* TUI-only content. */ }
1010
+ };
1011
+
1012
+ const deliverMessage = (msg: PendingAgentMessage, route: Exclude<ParentRoute, "hold">) => {
1013
+ if (activeSessionId() !== msg.parentSessionId) return;
1014
+ sendToParent({ customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, route);
1015
+ };
1016
+
1017
+ // Child content leaves its queue only when the parent can receive it. A
1018
+ // held or undeliverable flush leaves everything queued for a later boundary;
1019
+ // `rethrow` lets a query report a stale parent back to its child.
1020
+ const deliveryRoute = (rethrow: boolean): Exclude<ParentRoute, "hold"> | undefined => {
1021
+ try {
1022
+ const route = parentRoute();
1023
+ return route === "hold" ? undefined : route;
1024
+ } catch (error) {
1025
+ if (rethrow) throw error;
1026
+ return undefined;
1027
+ }
1028
+ };
1029
+
1030
+ const flushMessages = (rethrow = false) => {
1031
+ const route = deliveryRoute(rethrow);
1032
+ if (!route) return;
1033
+ for (const msg of messages.takeDeliverable(deps.now(), activeSessionId() ?? "", isTaskLive)) {
1034
+ try {
1035
+ deliverMessage(msg, route);
1036
+ } catch (error) {
1037
+ if (rethrow) throw error;
1038
+ /* Best-effort delivery: at most once, even if forwarding fails. */
1039
+ }
1040
+ }
713
1041
  };
714
1042
 
715
1043
  const flushCompletions = () => {
716
- for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) {
1044
+ const route = deliveryRoute(false);
1045
+ if (!route) return;
1046
+ for (const entry of completions.takeDeliverable(deps.now())) {
1047
+ try {
1048
+ if (entry.stale) deliverStale(entry.task, entry.settledAt, route);
1049
+ else deliver(entry.task, route);
1050
+ } catch {
1051
+ // The parent is told never to poll, so a failed forward must not
1052
+ // lose the completion: it waits for the next boundary instead, and
1053
+ // an idle parent, which has none coming, gets a bounded retry.
1054
+ completions.requeue(entry);
1055
+ if (route === "idle") armDeliveryRetry();
1056
+ }
1057
+ }
1058
+ };
1059
+
1060
+ // A background job's exit notice rides the same router as a completion. It
1061
+ // is compact (no full output), so it has no stale form; a failed forward
1062
+ // requeues it like a completion does.
1063
+ const flushJobNotices = () => {
1064
+ const route = deliveryRoute(false);
1065
+ if (!route) return;
1066
+ const failed: JobNotice[] = [];
1067
+ for (const notice of jobNotices.splice(0)) {
1068
+ const { job } = notice;
1069
+ if (activeSessionId() !== job.ownerSessionId) continue;
1070
+ const content = notice.kind === "events" ? monitorEventsText(job, notice.batch)
1071
+ : notice.kind === "stopped" ? monitorStoppedText(job, notice.reason) : jobNoticeText(job);
1072
+ const event = notice.kind === "events" ? "events" : notice.kind === "stopped" ? notice.reason : undefined;
717
1073
  try {
718
- if (stale) deliverStale(task, settledAt);
719
- else deliver(task);
720
- } catch { /* Best-effort delivery: at most once, even if forwarding fails. */ }
1074
+ sendToParent({ customType: JOB_NOTICE_TYPE, content, display: true, details: jobDetails(job, event) }, route);
1075
+ } catch {
1076
+ failed.push(notice);
1077
+ }
1078
+ }
1079
+ if (failed.length === 0) return;
1080
+ jobNotices.unshift(...failed);
1081
+ if (route === "idle") armDeliveryRetry();
1082
+ };
1083
+
1084
+ const flushAll = () => {
1085
+ flushMessages();
1086
+ flushCompletions();
1087
+ flushJobNotices();
1088
+ // A wake left owed by an earlier held or expired attempt is retried here.
1089
+ requestWake();
1090
+ };
1091
+
1092
+ // Pi still reports compaction while these handlers run (a manual
1093
+ // session_compact, an automatic compaction's session_compact_failed), so
1094
+ // held content is flushed once on the next scheduler tick instead. A parent
1095
+ // still busy then keeps it held for the next boundary; nothing polls.
1096
+ const scheduleBoundaryFlush = () => {
1097
+ if (cancelBoundaryFlush) return;
1098
+ cancelBoundaryFlush = deps.schedule(() => {
1099
+ cancelBoundaryFlush = undefined;
1100
+ flushAll();
1101
+ }, 0);
1102
+ };
1103
+
1104
+ // Session changes discard every pending wake and boundary flush.
1105
+ const resetParentDelivery = (ctx: ExtensionContext | undefined) => {
1106
+ helperPermission.clear(); // Revoke before dropping old callback authority.
1107
+ parentCtx = ctx;
1108
+ restoreBridgeWakeIdentity(ctx);
1109
+ wakeVisibilityWarning = false;
1110
+ parentRunActive = false;
1111
+ wakeOwed = false;
1112
+ endPromptStart();
1113
+ cancelBoundaryFlush?.();
1114
+ cancelBoundaryFlush = undefined;
1115
+ endDeliveryRetry();
1116
+ deliveryRetries = 0;
1117
+ };
1118
+
1119
+ // Structured descriptions feed Status; native hosts keep the compact count.
1120
+ const refreshJobStatus = () => {
1121
+ const sessionId = activeSessionId();
1122
+ const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running")
1123
+ .sort((a, b) => b.startedAt - a.startedAt);
1124
+ try { parentCtx?.ui.setStatus(JOBS_STATUS_KEY, running.length === 0 ? undefined : `${JOB_GLYPH} ${running.length} job${running.length === 1 ? "" : "s"}`); } catch { /* Status is cosmetic. */ }
1125
+ if (sessionId) {
1126
+ try { pi.events.emit(JOBS_SIDEBAR_EVENT, { sessionId, snapshot: { jobs: running.map(({ id, label, startedAt }) => ({ id, label, startedAt })) } }); } catch { /* Display only. */ }
721
1127
  }
722
1128
  };
723
1129
 
1130
+ // A job exit settles like a completion below: flushed at once for an idle
1131
+ // parent, at the next turn boundary for a busy one.
1132
+ // Line events still waiting for a busy parent coalesce per monitor.
1133
+ const queueJobNotice = (notice: JobNotice) => {
1134
+ // Merge into this monitor's latest pending notice when that is still
1135
+ // an events notice, so events never move past its own end.
1136
+ let index = -1;
1137
+ for (let i = jobNotices.length - 1; i >= 0; i--) if (jobNotices[i]!.job === notice.job) { index = i; break; }
1138
+ const pending = index >= 0 ? jobNotices[index]! : undefined;
1139
+ if (notice.kind === "events" && pending?.kind === "events") {
1140
+ jobNotices[index] = { ...pending, batch: mergeMonitorBatches(pending.batch, notice.batch) };
1141
+ } else {
1142
+ jobNotices.push(notice);
1143
+ }
1144
+ refreshJobStatus();
1145
+ requestRender();
1146
+ if (activeAgentRuns === 0) flushAll();
1147
+ };
1148
+ const settleJob = (job: JobRecord) => {
1149
+ // A monitor's pending lines are delivered before its end.
1150
+ if (job.kind === "monitor") monitors.finish(job);
1151
+ queueJobNotice({ job, kind: "exit" });
1152
+ };
1153
+
724
1154
  // A completion settles into our queue. An idle parent flushes right away so
725
1155
  // the wake-up behavior is unchanged; a busy parent flushes at the next turn
726
1156
  // boundary, and the steer mode injects it before that turn's next LLM call
727
1157
  // instead of parking it behind the whole run.
728
1158
  const settleCompletion = (task: TaskRecord) => {
1159
+ messages.invalidateTask(task.id);
729
1160
  completions.enqueue(task, deps.now());
730
- if (activeAgentRuns === 0) flushCompletions();
1161
+ if (activeAgentRuns === 0) flushAll();
731
1162
  };
732
1163
 
733
1164
  // `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at
@@ -737,35 +1168,62 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
737
1168
  // a final `turn_end` (an aborted run, or the host's early post-run return
738
1169
  // when a run produced no assistant message; the host compensates via
739
1170
  // hasQueuedMessages() + continue(), so steering there is still bounded).
740
- // `agent_settled` is the final idle boundary after retries — normally a
741
- // no-op safety net, since anything enqueued while idle flushes right away.
742
- pi.on("agent_start", () => { activeAgentRuns += 1; });
1171
+ // `agent_settled` is the final idle boundary after retries: it ends the
1172
+ // host run and flushes anything held, including content held while a
1173
+ // compaction ran inside or right before the run. `before_agent_start`
1174
+ // marks a prompt that is about to start a run, and the compaction events
1175
+ // release content held while the parent compacted without a run.
1176
+ pi.on("before_agent_start", () => {
1177
+ if (!parentRunActive) beginPromptStart();
1178
+ });
1179
+ pi.on("agent_start", () => {
1180
+ activeAgentRuns += 1;
1181
+ parentRunActive = true;
1182
+ // The run carries everything stored while the parent was idle.
1183
+ wakeOwed = false;
1184
+ endDeliveryRetry();
1185
+ deliveryRetries = 0;
1186
+ endPromptStart();
1187
+ });
743
1188
  pi.on("agent_end", () => {
744
1189
  activeAgentRuns = Math.max(0, activeAgentRuns - 1);
745
- flushCompletions();
1190
+ flushAll();
1191
+ });
1192
+ pi.on("agent_settled", () => {
1193
+ parentRunActive = false;
1194
+ endPromptStart();
1195
+ flushAll();
746
1196
  });
747
- pi.on("agent_settled", () => flushCompletions());
748
- pi.on("turn_end", () => flushCompletions());
1197
+ pi.on("turn_end", () => flushAll());
1198
+ pi.on("session_compact", scheduleBoundaryFlush);
1199
+ pi.on("session_compact_failed", scheduleBoundaryFlush);
749
1200
 
750
1201
  const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, {
751
1202
  askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw),
752
1203
  onNotification: (task, message) => {
753
- if (activeSessionId() !== task.parentSessionId) return false;
754
- pi.sendMessage({ customType: AGENTS_MESSAGE_TYPE, content: message, display: false, details: { gentleAgents: { taskId: task.id, agent: task.agent, parentSessionId: task.parentSessionId, kind: "notification" } } }, { deliverAs: "followUp", triggerTurn: true });
1204
+ if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
1205
+ messages.enqueueNotification(task, message, deps.now());
1206
+ if (activeAgentRuns === 0) flushMessages();
755
1207
  return true;
756
1208
  },
757
1209
  onQuery: (task, requestId, message) => {
758
- if (activeSessionId() !== task.parentSessionId) return false;
1210
+ if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
759
1211
  const hadYield = yieldedTaskIds.has(task.id);
760
1212
  if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id);
761
1213
  try {
762
- pi.sendMessage({ customType: AGENTS_MESSAGE_TYPE, content: `Subagent ${task.agent} asks:\nTask ID: ${task.id}\nRequest ID: ${requestId}\nQuestion: ${message}`, display: true, details: { gentleAgents: { taskId: task.id, agent: task.agent, parentSessionId: task.parentSessionId, requestId, kind: "query" } } }, { deliverAs: "followUp", triggerTurn: true });
1214
+ messages.enqueueQuery(task, requestId, message, deps.now());
1215
+ if (activeAgentRuns === 0) flushMessages(true);
763
1216
  return true;
764
1217
  } catch (error) {
1218
+ messages.expireQuery(task.id, requestId);
765
1219
  if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id);
766
1220
  throw error;
767
1221
  }
768
1222
  },
1223
+ onQuerySettled: (taskId, requestId, outcome) => {
1224
+ if (outcome === "replied") messages.consumeQuery(taskId, requestId);
1225
+ else messages.expireQuery(taskId, requestId);
1226
+ },
769
1227
  onSuccessfulMutation: (task, tool) => {
770
1228
  // Same-clone registry attribution remains unchanged. A foreign task
771
1229
  // uses a separately bound, live-grant path only for successful tool evidence.
@@ -813,7 +1271,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
813
1271
  }
814
1272
  else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch");
815
1273
  }
816
- if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
1274
+ if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId, ...runtimeWriterProfile(task) });
817
1275
  },
818
1276
  onFinish: (task, observations) => {
819
1277
  // Completion is the only forwarding opportunity. No pending event, policy
@@ -831,6 +1289,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
831
1289
  } catch { /* Metrics must never interrupt task finalization. */ }
832
1290
  try {
833
1291
  ownedTaskIds.delete(task.id);
1292
+ messages.invalidateTask(task.id);
834
1293
  requestRender();
835
1294
  persist(task);
836
1295
  const yielded = yieldedTaskIds.delete(task.id);
@@ -839,6 +1298,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
839
1298
  },
840
1299
  });
841
1300
 
1301
+ registerBackgroundJobTools(pi, { registry: jobs, monitors, sessionId: (ctx) => ctx.sessionManager.getSessionId() ?? "", now: deps.now, onChange: () => { refreshJobStatus(); requestRender(); } });
1302
+
842
1303
  pi.registerMessageRenderer(AGENTS_MESSAGE_TYPE, (message, options, theme) => {
843
1304
  const details = (message.details as { gentleAgents?: { taskId?: unknown; agent?: unknown } } | undefined)?.gentleAgents;
844
1305
  const taskId = typeof details?.taskId === "string" ? details.taskId : "unknown";
@@ -849,22 +1310,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
849
1310
  });
850
1311
 
851
1312
  pi.registerMessageRenderer(AGENTS_ORCHESTRATOR_MESSAGE_TYPE, (message, options, theme) => {
852
- const details = (message.details as { gentleAgents?: { senderSessionId?: unknown } } | undefined)?.gentleAgents;
853
- const sender = typeof details?.senderSessionId === "string" ? details.senderSessionId : "unknown";
854
- const heading = `⇄ Orchestrator message · Received · From ${sanitizeTerminalText(sender)}`;
855
- const body = sanitizeTerminalText(messageText(message.content));
856
- return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0);
1313
+ const details = (message.details as { gentleAgents?: OrchestratorMessageDetails } | undefined)?.gentleAgents ?? {};
1314
+ return incomingMessageCard(details, messageText(message.content), options.expanded, theme, expandHint(options.expanded));
857
1315
  });
858
1316
 
859
1317
  pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
860
1318
  const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
861
1319
  const content = message.content as string | Array<{ type: string; text?: string }>;
862
- const body = (typeof content === "string" ? content : content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n")).split("\n");
1320
+ const text = typeof content === "string" ? content : content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n");
1321
+ const body = options.expanded ? text.split("\n") : agentResultPreview(text).split("\n").filter((line) => line.trim() !== "");
863
1322
  const tone = details?.status === "completed" ? CARD_TONE.SUCCESS : CARD_TONE.ERROR;
864
1323
  const hint = expandHint(options.expanded);
865
1324
  return {
866
1325
  render(width: number) {
867
- return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, hint });
1326
+ return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint });
868
1327
  },
869
1328
  invalidate() {},
870
1329
  };
@@ -886,7 +1345,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
886
1345
  ];
887
1346
  return {
888
1347
  render(width: number) {
889
- return renderCard({ title: "Stale agent result", subtitle: `${agent} · task ${taskId}`, body, tone: CARD_TONE.WARNING, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, hint: expandHint(options.expanded) });
1348
+ return renderCard({ title: "Stale agent result", subtitle: `${agent} · task ${taskId}`, body, tone: CARD_TONE.WARNING, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint: expandHint(options.expanded) });
890
1349
  },
891
1350
  invalidate() {},
892
1351
  };
@@ -953,6 +1412,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
953
1412
  return stored?.task;
954
1413
  };
955
1414
 
1415
+ // A guessed id ("1") leads back to real ids instead of a dead end, so the
1416
+ // parent retries the same call rather than re-summarizing it (gentle-shell#1713).
1417
+ const unknownTask = (id: unknown, ctx: ExtensionContext | undefined) => {
1418
+ const recent = [...store.list(ctx?.sessionManager?.getSessionId() ?? "")].sort((a, b) => b.createdAt - a.createdAt).slice(0, 5);
1419
+ const hint = recent.length ? ` Recent task ids: ${recent.map((task) => `${task.id} (${task.agent})`).join(", ")}.` : "";
1420
+ return text(`Error: no task ${String(id)}.${hint}`, { error: "unknown task" });
1421
+ };
1422
+
956
1423
  // Restores this exact session's own finished subagents as visible history
957
1424
  // on an explicit resume, or on startup into a session that already has
958
1425
  // entries -- never on new, fork, or reload (see the session_start handler's
@@ -967,12 +1434,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
967
1434
  } catch {
968
1435
  return;
969
1436
  }
970
- // The session may have moved on while disk was read; a stale restore
971
- // must never land in the wrong session's store.
972
- if (ctx.sessionManager.getSessionId() !== sessionId) return;
973
- // Fire-and-forget from session_start: a throwing summary subscriber must
974
- // never surface as an unhandled rejection. History is best-effort.
1437
+ // Fire-and-forget from session_start: a stale SDK context or throwing
1438
+ // summary subscriber must never surface as an unhandled rejection.
975
1439
  try {
1440
+ // The session may have moved on while disk was read; a stale restore
1441
+ // must never land in the wrong session's store.
1442
+ if (ctx.sessionManager.getSessionId() !== sessionId) return;
976
1443
  for (const { task, thread } of history) {
977
1444
  if (task.parentSessionId !== sessionId) continue;
978
1445
  if (store.restore(task, thread)) restoredTaskIds.add(task.id);
@@ -990,6 +1457,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
990
1457
  let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined;
991
1458
  const chosen = await ctx.ui.custom<TaskRecord | null>(
992
1459
  (tui, theme, _keybindings, done) => {
1460
+ const close = withOverlayRepaint(tui, done);
993
1461
  overlayHost = tui;
994
1462
  view = new AgentsView({
995
1463
  theme,
@@ -1004,8 +1472,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1004
1472
  onCancel: (task) => void stopSelected(task, ctx),
1005
1473
  canCancel: isOwnedActive,
1006
1474
  isLocalTask: (task) => !restoredTaskIds.has(task.id),
1007
- onOpen: (task) => done(task),
1008
- onClose: () => done(null),
1475
+ onOpen: (task) => close(task),
1476
+ onClose: () => close(null),
1009
1477
  requestRender: () => tui.requestRender(),
1010
1478
  });
1011
1479
  overlays.add(view);
@@ -1058,6 +1526,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1058
1526
  const showWidget = (ctx: ExtensionContext) => {
1059
1527
  ui = ctx.hasUI ? ctx.ui : undefined;
1060
1528
  sessions = ctx.sessionManager;
1529
+ agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
1530
+ stopVisualUpdates?.();
1531
+ stopVisualUpdates = pi.events.on(VISUAL_SETTINGS_CHANGED, (event) => {
1532
+ if ((event as { configHome?: unknown } | undefined)?.configHome !== visualHome) return;
1533
+ agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
1534
+ if (sidebarTui) invalidateSidebar(sidebarTui);
1535
+ host?.requestRender();
1536
+ });
1061
1537
  tickClock();
1062
1538
  // Agents is not a rail part: the fullscreen sidebar only suppresses
1063
1539
  // bottom components registered through sidebarPart, and this widget is
@@ -1067,6 +1543,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1067
1543
  sidebarTui = tui;
1068
1544
  return {
1069
1545
  render(width: number) {
1546
+ if (!agentsVisible) return [];
1070
1547
  const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
1071
1548
  return lines.length === 0 ? [] : [...lines, ""];
1072
1549
  },
@@ -1077,15 +1554,50 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1077
1554
 
1078
1555
  const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
1079
1556
 
1080
- const buildRequest = async (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, sddChange?: SddChangeSelection, researchSelection?: unknown, remediationIntent?: unknown, signal?: AbortSignal, repositoryRoot?: string): Promise<TaskRequest> => {
1557
+ const buildRequest = async (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, signal?: AbortSignal, repositoryRoot?: string): Promise<TaskRequest> => {
1558
+ if (retiredSddAgent(agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
1559
+ if (![AGENT_MODE.TASK, AGENT_MODE.BACKGROUND].includes(mode)) throw new Error("Subagent mode must be task or background.");
1560
+ if (isSingleShotMode(ctx.mode) && mode === AGENT_MODE.BACKGROUND) throw new Error(SINGLE_SHOT_BACKGROUND_ERROR);
1561
+ const scopeDenied = rejectUnscopedBoundedWriterDispatch({ agent: agent.name, task: prompt, context });
1562
+ if (scopeDenied) throw new Error(scopeDenied.reason);
1081
1563
  if (signal?.aborted) throw new Error("Subagent launch aborted before authorization.");
1082
1564
  const registry = registryFor(ctx);
1565
+ const authorityCurrent = worktreeAuthority!;
1566
+ const originalManager = ctx.sessionManager;
1567
+ const originalId = originalManager.getSessionId();
1568
+ const originalCwd = originalManager.getCwd();
1569
+ const current = () => sessions === originalManager && originalManager.getSessionId() === originalId && originalManager.getCwd() === originalCwd && !signal?.aborted && authorityCurrent();
1570
+ if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission.");
1571
+ let admittedModel: string | undefined;
1572
+ const surfaces = allowedEditSurfaces(prompt, context);
1573
+ // gentle-shell#1064 slice 2: the binding is read once per task request,
1574
+ // before admission, so the admitted model and the launch routing resolve
1575
+ // the same session layer and can never disagree about it (#1558: a bound
1576
+ // session used to kill its own non-git writer mid-preparation because
1577
+ // admission still read only the pin/global layers).
1578
+ const sessionBinding = readSessionProfileBinding(ctx.sessionManager.getSessionId());
1579
+ if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) {
1580
+ const root = safeBootstrapDirectory(originalCwd);
1581
+ if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root.");
1582
+ const config = withPinnedModelProfiles(
1583
+ loadAgentsConfig(roots(ctx)),
1584
+ sessionOrPinModelProfiles(
1585
+ sessionBinding?.modelProfiles,
1586
+ resolveUnversionedProjectProfile(root, gentlePiConfigHome(deps.env))?.modelProfiles,
1587
+ ),
1588
+ );
1589
+ const model = resolveAgentProfile(agent, config).model ?? ctx.model;
1590
+ const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
1591
+ if (!catalogModel) throw new Error("Writer bootstrap requires a valid effective model in this session's catalog.");
1592
+ admittedModel = `${catalogModel.provider}/${catalogModel.id}`;
1593
+ if (!current() || !await prepareBoundSessionRepository(originalManager, originalCwd, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root || resolveSessionWorktree(originalCwd, originalCwd)?.root !== root) throw new Error("Writer repository preparation was unavailable or its session/target changed.");
1594
+ }
1083
1595
  const parentCwd = ctx.sessionManager.getCwd();
1084
1596
  // An explicit target is validated before any queue or session-dir writes.
1085
1597
  const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
1086
1598
  if (repositoryRoot !== undefined && workspaceRoot !== undefined) throw new Error("repository_root and workspace_root are mutually exclusive.");
1087
- if (repositoryRoot !== undefined && (SHIPPED_SDD_AGENT_NAME_SET.has(agent.name) || sddChange || remediationIntent || deps.env.GENTLE_PI_AGENTS_CHILD === "1" || ctx.mode !== "tui" || !ctx.hasUI)) throw new Error("Foreign repository launch requires an interactive parent session without SDD or remediation.");
1088
- const selectedRoot = repositoryRoot ?? workspaceRoot ?? sddChange?.workspaceRoot;
1599
+ if (repositoryRoot !== undefined && (deps.env.GENTLE_PI_AGENTS_CHILD === "1" || ctx.mode !== "tui" || !ctx.hasUI)) throw new Error("Foreign repository launch requires an interactive parent session.");
1600
+ const selectedRoot = repositoryRoot ?? workspaceRoot;
1089
1601
  // Preserve ordinary non-Git continuation, without admitting any new root.
1090
1602
  const sameNonGitContinuation = resume !== undefined && repositoryRoot === undefined && selectedRoot === parentCwd && !parentIdentity;
1091
1603
  let foreign = false;
@@ -1099,7 +1611,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1099
1611
  // same-clone registry. Never use a caller-injected resolver for this identity.
1100
1612
  const identity = resolveSessionWorktree(selectedRoot, parentCwd);
1101
1613
  const canonicalParent = resolveSessionWorktree(parentCwd, parentCwd);
1102
- if (!identity || (canonicalParent && identity.commonDir === canonicalParent.commonDir) || selectedRoot !== identity.root || !isAbsolute(selectedRoot) || realpathSync(selectedRoot) !== selectedRoot || sddChange || remediationIntent) throw new Error("repository_root must select a canonical independent Git repository.");
1614
+ if (!identity || (canonicalParent && identity.commonDir === canonicalParent.commonDir) || selectedRoot !== identity.root || !isAbsolute(selectedRoot) || realpathSync(selectedRoot) !== selectedRoot) throw new Error("repository_root must select a canonical independent Git repository.");
1103
1615
  await foreignGrants.authorize(ctx, identity, { continuation: resume !== undefined, signal });
1104
1616
  foreignIdentity = identity;
1105
1617
  foreignParent = canonicalParent;
@@ -1107,12 +1619,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1107
1619
  foreign = true;
1108
1620
  }
1109
1621
  } else target = parentIdentity?.root;
1110
- if (sddChange && target !== sddChange.workspaceRoot && target !== resolve(sddChange.workspaceRoot)) {
1111
- throw new Error("sdd_change workspaceRoot must resolve to the selected child worktree.");
1112
- }
1113
- const launchSddChange = sddChange === undefined || target === undefined
1114
- ? undefined
1115
- : { ...sddChange, workspaceRoot: target };
1116
1622
  // A per-repository profile pin replaces subagent routing for this launch without
1117
1623
  // materialising anything: the global stores stay untouched, so two repositories
1118
1624
  // worked in parallel stop fighting over one global routing. The child's own
@@ -1124,16 +1630,28 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1124
1630
  const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
1125
1631
  ? () => parentIdentity
1126
1632
  : deps.resolveWorktree;
1633
+ // gentle-shell#1064 slice 1: a parent-session profile binding outranks the
1634
+ // pin layers for launches from that session (`session → p → P → global`),
1635
+ // with the same wholesale-replacement contract as the pin. The binding is
1636
+ // resolved here, at task-request creation, so queued and running children
1637
+ // keep the routing frozen into their requests even if the session rebinds.
1127
1638
  const config = withPinnedModelProfiles(
1128
1639
  loadAgentsConfig(roots(ctx)),
1129
- resolveProfilePin({
1130
- cwd: target ?? parentCwd,
1131
- configHome: gentlePiConfigHome(deps.env),
1132
- resolveWorktree: pinIdentity,
1133
- })?.modelProfiles,
1640
+ sessionOrPinModelProfiles(
1641
+ sessionBinding?.modelProfiles,
1642
+ resolveProfilePin({
1643
+ cwd: target ?? parentCwd,
1644
+ configHome: gentlePiConfigHome(deps.env),
1645
+ resolveWorktree: pinIdentity,
1646
+ })?.modelProfiles,
1647
+ ),
1134
1648
  );
1135
1649
  const profile = resolveAgentProfile(agent, config);
1136
- const research = agent.name === "sdd-research" ? researchAgent(agent, pi, researchSelection) : undefined;
1650
+ if (admittedModel !== undefined) {
1651
+ const model = profile.model ?? ctx.model;
1652
+ const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
1653
+ if (!catalogModel || `${catalogModel.provider}/${catalogModel.id}` !== admittedModel || !current()) throw new Error("Writer effective profile or session changed during preparation.");
1654
+ }
1137
1655
  const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
1138
1656
  if (foreign && target) {
1139
1657
  const identity = resolveSessionWorktree(target, parentCwd);
@@ -1146,21 +1664,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1146
1664
  const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
1147
1665
  const parentWorktreeRoot = ctx.sessionManager.getCwd();
1148
1666
  const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot);
1149
- const sddPreflightContext = SHIPPED_SDD_AGENT_NAME_SET.has(agent.name)
1150
- ? extractParentConfirmedSddPreflightContext(context)
1151
- : undefined;
1152
1667
  const childEnv = { ...deps.env };
1153
1668
  if (foreign) for (const key of inheritedUnsafeGitEnvironmentKeys(childEnv)) delete childEnv[key];
1154
1669
  const request: TaskRequest = {
1155
- agent: research?.agent ?? agent,
1156
- remediationIntent,
1670
+ agent,
1157
1671
  prompt,
1158
1672
  label,
1159
1673
  context,
1160
- ...(sddPreflightContext === undefined ? {} : { sddPreflightContext }),
1161
1674
  mode,
1162
1675
  cwd: target ?? parentWorktreeRoot,
1163
1676
  parentSessionId,
1677
+ ...(admittedModel === undefined ? {} : { beforeSpawn: () => {
1678
+ if (!current()) throw new Error("Writer session changed before spawn.");
1679
+ registry.validate(originalCwd);
1680
+ } }),
1164
1681
  ...(foreign && target ? { beforeSpawn: () => {
1165
1682
  if (signal?.aborted || sessions !== ctx.sessionManager || ctx.sessionManager.getSessionId() !== registry.sessionId || ctx.sessionManager.getCwd() !== parentCwd) throw new Error("Foreign clone session or tool call changed before spawn.");
1166
1683
  const identity = resolveSessionWorktree(target, parentCwd);
@@ -1173,9 +1690,11 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1173
1690
  thinking: profile.thinking,
1174
1691
  sessionDir,
1175
1692
  resumeSessionPath: resume,
1176
- env: research ? { ...childEnv, [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify([...research.agent.tools, "subagent_parent_message"]) } : childEnv,
1177
- ...(research ? { researchSelection, extensionPaths: research.extensionPaths } : {}),
1178
- ...(launchSddChange === undefined ? {} : { sddChange: launchSddChange }),
1693
+ ...childExtensionRequest(deps.env, deps.childExtensionPaths),
1694
+ // A writer claims its surfaces in its canonical worktree root for its queued
1695
+ // and running lifetime; a continuation is a new task and claims them again.
1696
+ ...(isBoundedWriter(agent.name) && surfaces ? { writerSurfaces: surfaces, writerRoot: canonicalWriterRoot(target ?? parentWorktreeRoot, foreign ? resolveSessionWorktree : deps.resolveWorktree) } : {}),
1697
+ env: childEnv,
1179
1698
  ...(foreign || parentRepositoryIdentity === undefined ? {} : {
1180
1699
  authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
1181
1700
  try {
@@ -1198,21 +1717,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1198
1717
  return request;
1199
1718
  };
1200
1719
 
1201
- const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal): Promise<ToolText> => {
1202
- if (ctx.mode === "print" && request.mode === AGENT_MODE.BACKGROUND) {
1203
- throw new Error("Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.");
1204
- }
1205
- // This is the process-spawn boundary. A child receives its task context only
1206
- // after its RPC process starts, so validate the single parent transport here
1207
- // rather than letting a child invent/persist preferences during startup.
1208
- if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
1209
- throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
1210
- }
1211
- if (request.agent.name === "sdd-remediate") {
1212
- const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1213
- Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, ctx));
1214
- if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; request fresh authorization before dispatch");
1215
- }
1720
+ const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal, publishWork?: (id: string) => void): Promise<ToolText> => {
1721
+ if (isSingleShotMode(ctx.mode) && request.mode === AGENT_MODE.BACKGROUND) throw new Error(SINGLE_SHOT_BACKGROUND_ERROR);
1722
+ if (retiredSddAgent(request.agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
1216
1723
 
1217
1724
  // Bounded live observation only. Native send owns the fresh policy decision;
1218
1725
  // child execution never starts a telemetry policy process or renewal timer.
@@ -1245,6 +1752,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1245
1752
  const foreignRequest = foreignRequests.get(request);
1246
1753
  if (launched && foreignRequest) foreignTasks.set(task.id, foreignRequest);
1247
1754
  ownedTaskIds.add(task.id);
1755
+ publishWork?.(task.id);
1756
+ publishActivity(); // Admission's summary notification precedes runtime ownership.
1248
1757
  store.subscribe(task.id, () => { publishActivity(); requestRender(); });
1249
1758
  if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task));
1250
1759
  // A tool call aborted by the host (a human interrupting the turn, a timeout)
@@ -1265,11 +1774,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1265
1774
  const query = await runner.waitForQuery(task.id);
1266
1775
  if (query) {
1267
1776
  const live = store.get(task.id) ?? task;
1268
- return text(`Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.`, { gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId } }, true);
1777
+ messages.consumeQuery(task.id, query.requestId);
1778
+ const questionSuffix = query.message ? `\n\nQuestion:\n${query.message}` : "";
1779
+ return text(
1780
+ `Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.${questionSuffix}`,
1781
+ { gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId, ...(query.message ? { question: query.message } : {}) } },
1782
+ true,
1783
+ );
1269
1784
  }
1270
1785
  const finished = await runner.waitFor(task.id);
1271
1786
  completions.consume(finished.id);
1272
- return text(finishedText(finished), taskDetails(finished));
1787
+ messages.invalidateTask(finished.id);
1788
+ // The id rides in the text too: details never reach the model, which
1789
+ // otherwise guesses ordinal ids for subagent_continue (#1731 T19).
1790
+ return text(completionText(finished), taskDetails(finished));
1273
1791
  } finally {
1274
1792
  signal?.removeEventListener("abort", onAbort);
1275
1793
  }
@@ -1283,12 +1801,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1283
1801
  description,
1284
1802
  parameters: { type: "object", additionalProperties: false, ...parameters } as never,
1285
1803
  renderCall(args, theme) {
1804
+ // The result title belongs with the result body so a running poll
1805
+ // can hide both without changing the tool call or its model output.
1806
+ if (name === "result") return { render: () => [], invalidate() {} };
1286
1807
  const params = args as { agent?: string; task_id?: string };
1287
1808
  return new Text(theme.fg("toolTitle", `${AGENTS_GLYPH} agent ${name.replace(/_/g, " ")}${params.agent ? ` · ${params.agent}` : params.task_id ? ` · ${params.task_id}` : ""}`), 0, 0);
1288
1809
  },
1289
1810
  renderResult(result, options, theme) {
1811
+ const task = (result.details as { gentleAgents?: { status?: string; taskId?: string } } | undefined)?.gentleAgents;
1812
+ if (name === "result" && task?.status === TASK_STATUS.RUNNING) {
1813
+ return { render: () => [], invalidate() {} };
1814
+ }
1290
1815
  const body = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
1291
- return new Text(options.expanded ? body : theme.fg("muted", body.split("\n")[0] ?? ""), 0, 0);
1816
+ const visibleBody = options.expanded ? body : theme.fg("muted", agentResultPreview(body).split("\n")[0] ?? "");
1817
+ const title = `${AGENTS_GLYPH} agent result${task?.taskId ? ` · ${sanitizeTerminalText(task.taskId)}` : ""}`;
1818
+ return new Text(name === "result" ? `${theme.fg("toolTitle", title)}\n${visibleBody}` : visibleBody, 0, 0);
1292
1819
  },
1293
1820
  async execute(_id, params, signal, _onUpdate, ctx) {
1294
1821
  return execute(params as Record<string, unknown>, ctx, signal);
@@ -1299,25 +1826,150 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1299
1826
  pi.registerTool({
1300
1827
  name: "orchestrator_session_id",
1301
1828
  label: "Orchestrator session ID",
1302
- description: "Return this host session's active ID.",
1303
- parameters: { type: "object", additionalProperties: false, properties: {} } as never,
1304
- async execute(_id, _params, _signal, _onUpdate, ctx) {
1829
+ description: "Return this host session's stable routing ID, Initial alias (FIRST TASK), Current alias (CURRENT TASK), and separate human Session name. Explicit subjects update Current even when named; Initial stays the first declared branch topic, unknown for legacy history. Before delegation or cross-session coordination, declare a short recognizable subject here. Do not require a subject declaration for small direct tasks; do not query all peers. Names never authenticate. Existing Pi names and human renames are preserved. Use a concise non-sensitive label, not a prompt. For work beyond small direct tasks, publish subject and current/next status here; refresh at task changes/completion. Owner-curated state (2048 UTF-8 bytes total): null withdraws, omission leaves unchanged. Never include credentials, internal instructions, or raw prompts. Historical notes are not consent or an owner reply.",
1830
+ ...orchestratorToolRenderers("session", expandHint),
1831
+ parameters: { type: "object", additionalProperties: false, properties: {
1832
+ subject: { type: "string", maxLength: 120, description: "Optional short task subject; updates task aliases independently of human names. Also names an unnamed Pi session." },
1833
+ state: { anyOf: [{ type: "null" }, { type: "object", additionalProperties: false, properties: {
1834
+ objective: { type: "string" }, progress: { type: "string" }, decisions: { type: "string" }, blockers: { type: "string" },
1835
+ work: { type: "object", additionalProperties: false,
1836
+ description: "Optional historical, non-authoritative classification; topic requires area. Text plus work JSON fits 2048 UTF-8 bytes. Exact duplicate tags/refs are rejected; no ref resolution or routing.",
1837
+ properties: {
1838
+ ...workDescriptorSchema.properties,
1839
+ tasks: { type: "object", maxProperties: 8, propertyNames: { type: "string", maxLength: 256 },
1840
+ additionalProperties: workDescriptorSchema,
1841
+ description: "Exact actual owner-declared task IDs, at most 256 UTF-8 bytes; controls, surrogates and __proto__/prototype/constructor rejected. Root-only; nonempty tasks alone are valid. Replacement omitting tasks clears annotations." },
1842
+ },
1843
+ },
1844
+ } }] },
1845
+ } } as never,
1846
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1305
1847
  const transport = activeTransportFor(ctx);
1306
- return transport ? text(`Active session ID: ${transport.sessionId}`, { gentleAgents: { senderSessionId: transport.sessionId } }) : text("Error: session messaging is not ready.", { error: "not ready" });
1848
+ if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
1849
+ const { subject, state } = params as { subject?: unknown; state?: unknown };
1850
+ const declared = normalizeTaskSubject(subject);
1851
+ if (state !== undefined || declared) stateCache.publish(ctx.sessionManager, state, (type, data) => pi.appendEntry(type, data), Date.now(), declared);
1852
+ if (activeTransportFor(ctx) !== transport) throw new Error("stale-published-state");
1853
+ if (declared && !ctx.sessionManager.getSessionName?.()) pi.setSessionName(declared);
1854
+ const alias = sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? "");
1855
+ const aliases = stateCache.get(ctx.sessionManager)?.aliases;
1856
+ if (state !== undefined || declared) publishActivity();
1857
+ presence?.refreshLabel();
1858
+ return text(`Active session ID: ${transport.sessionId}\nCurrent alias (CURRENT TASK): ${aliases?.currentAlias ?? "unknown"}\nInitial alias (FIRST TASK): ${aliases?.initialAlias ?? "unknown"}\nSession name: ${alias || "unnamed"}`,
1859
+ { gentleAgents: { senderSessionId: transport.sessionId, alias, sessionName: alias, initialAlias: aliases?.initialAlias ?? null, currentAlias: aliases?.currentAlias ?? null } });
1860
+ },
1861
+ });
1862
+ pi.registerTool({
1863
+ name: "orchestrator_consult",
1864
+ label: "Consult published context",
1865
+ ...orchestratorToolRenderers("consult", expandHint),
1866
+ description: "Read published metadata (default), request bounded helper reasoning with explicit UI model-cost permission and a question, or revoke-reasoning for an exact target. Never an owner reply, consent or private context access.",
1867
+ parameters: { type: "object", additionalProperties: false, required: ["recipient_session_id"], properties: {
1868
+ kind: { type: "string", enum: ["metadata", "reasoning", "revoke-reasoning"] },
1869
+ question: { type: "string", maxLength: 1024 },
1870
+ recipient_session_id: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" },
1871
+ cursor: { type: "string", maxLength: 1024 },
1872
+ } } as never,
1873
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1874
+ const input = params as { kind?: unknown; recipient_session_id?: unknown; cursor?: unknown; question?: unknown };
1875
+ const kind = input?.kind === undefined ? "metadata" : input.kind;
1876
+ if (!input || Object.keys(input).some(k => !["kind", "recipient_session_id", "cursor", "question"].includes(k))
1877
+ || !["metadata", "reasoning", "revoke-reasoning"].includes(kind as string) || !validTransportSessionId(input.recipient_session_id)
1878
+ || (kind === "revoke-reasoning" && input.cursor !== undefined)
1879
+ || (kind !== "reasoning" && input.question !== undefined)
1880
+ || (kind === "reasoning" && (typeof input.question !== "string" || !input.question.trim()
1881
+ || Buffer.byteLength(input.question) > 1024 || /[\p{Cc}\p{Cf}\p{Cs}]/u.test(input.question)))
1882
+ || (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length > 1024))) throw new Error("Invalid metadata consultation parameters.");
1883
+ const selection = { recipientSessionId: input.recipient_session_id, cursor: input.cursor as string | undefined };
1884
+ const transport = activeTransportFor(ctx);
1885
+ const result = (receipt: MetadataReceipt) => {
1886
+ const output = kind === "metadata" ? receipt : { status: "unavailable", code: receipt.unknowns[0], source: "helper_advice", ownerReply: false, authority: "none" };
1887
+ return text(JSON.stringify(output), { gentleAgents: { senderSessionId: transport?.sessionId, receipt: output } });
1888
+ };
1889
+ if (!transport) return result(unavailableMetadata(selection.recipientSessionId, "not-ready"));
1890
+ const reply = (receipt: unknown) => text(JSON.stringify(receipt), { gentleAgents: { senderSessionId: transport.sessionId, receipt } });
1891
+ if (kind === "revoke-reasoning") {
1892
+ helperPermission.revoke(selection.recipientSessionId);
1893
+ return reply({ status: "revoked", source: "helper_advice", ownerReply: false, authority: "none" });
1894
+ }
1895
+ try {
1896
+ const activations = await transport.listener.registry.listActivations(transport.sessionId);
1897
+ if (activeTransportFor(ctx) !== transport) return result(unavailableMetadata(selection.recipientSessionId, "source-session-changed"));
1898
+ const receipt = consultPublishedMetadata(agentHome, activations, selection);
1899
+ if (kind === "metadata") return result(receipt);
1900
+ const current = () => activeTransportFor(ctx) === transport && parentCtx?.sessionManager === ctx.sessionManager;
1901
+ const readSource = async () => {
1902
+ const peers = await transport.listener.registry.listActivations(transport.sessionId);
1903
+ return current() ? consultPublishedMetadata(agentHome, peers, selection)
1904
+ : unavailableMetadata(selection.recipientSessionId, "source-session-changed");
1905
+ };
1906
+ const advice = await helperPermission.run({ receipt, question: input.question as string, signal: _signal,
1907
+ readSource, isSourceCurrent: () => current() && consultPublishedMetadata(agentHome, activations, selection).digest === receipt.digest });
1908
+ return reply(advice);
1909
+ } catch { return result(unavailableMetadata(selection.recipientSessionId, "discovery-unavailable")); }
1307
1910
  },
1308
1911
  });
1309
1912
  pi.registerTool({
1310
1913
  name: "orchestrator_list",
1311
1914
  label: "List orchestrators",
1312
- description: "List other sessions advertised by the trusted local profile. Advertised reachability is unknown and does not prove a session is live.",
1313
- parameters: { type: "object", additionalProperties: false, properties: {} } as never,
1915
+ ...orchestratorToolRenderers("list", expandHint),
1916
+ description: "List other sessions advertised by the trusted local profile. Optional filter searches only published classified work with bounded, non-exhaustive coverage, no authority and unknown reachability. Omit filter for the existing session list.",
1917
+ parameters: Type.Object({
1918
+ recipient_session_id: Type.Optional(Type.String({ description: "Exact routing ID of the peer to inspect." })),
1919
+ cursor: Type.Optional(Type.String({ description: "Opaque catalog continuation from that peer; requires recipient_session_id." })),
1920
+ filter: Type.Optional(Type.Object({
1921
+ area: Type.Optional(Type.String()),
1922
+ topic: Type.Optional(Type.String({ description: "Requires area." })),
1923
+ tag: Type.Optional(Type.String()),
1924
+ text: Type.Optional(Type.String({ description: "Literal label/descriptor search, never state prose or history." })),
1925
+ ref: Type.Optional(Type.Object({
1926
+ kind: Type.Union([Type.Literal("issue"), Type.Literal("pr"), Type.Literal("task")]),
1927
+ repository: Type.String({ description: "Exact public host/owner/repo scope." }),
1928
+ id: Type.String(),
1929
+ }, { additionalProperties: false })),
1930
+ repository_root: Type.Optional(Type.String({ description: "Recorded absolute Git root; no new Git probe." })),
1931
+ related_to: Type.Optional(Type.Object({
1932
+ session_id: Type.String({ description: "Exact stable source owner session ID." }),
1933
+ task_id: Type.Optional(Type.String({ description: "Actual task ID on the current catalog page, not child session ID." })),
1934
+ }, { additionalProperties: false })),
1935
+ }, { additionalProperties: false, description: "Explicit {} indexes classified work; criteria combine with AND." })),
1936
+ }, { additionalProperties: false }),
1314
1937
  async execute(_id, _params, _signal, _onUpdate, ctx) {
1938
+ const params = _params as { recipient_session_id?: string; cursor?: string; filter?: unknown };
1939
+ let filter;
1940
+ try {
1941
+ if (!params || typeof params !== "object" || Array.isArray(params)
1942
+ || Object.keys(params).some(key => !["recipient_session_id", "cursor", "filter"].includes(key))
1943
+ || (params.recipient_session_id !== undefined && !validTransportSessionId(params.recipient_session_id))
1944
+ || (params.cursor !== undefined && (typeof params.cursor !== "string" || params.cursor.length > 1024))) throw new Error();
1945
+ if (Object.hasOwn(params, "filter")) filter = validateWorkFilter(params.filter);
1946
+ } catch { throw new Error("Invalid orchestrator list parameters."); }
1947
+ if (params.cursor !== undefined && !params.recipient_session_id) return text("Error: cursor requires recipient_session_id.", { error: "invalid-cursor" });
1315
1948
  const transport = activeTransportFor(ctx);
1316
1949
  if (!transport) return text("Error: session discovery is not ready.", { error: "not ready" });
1317
1950
  try {
1318
- const peers = await transport.listener.registry.list(transport.sessionId);
1951
+ const activations = await transport.listener.registry.listActivations(transport.sessionId);
1319
1952
  if (activeTransportFor(ctx) !== transport) return text("Error: session discovery became unavailable before results were confirmed.", { error: "stale" });
1320
- return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${peers.map((peer) => `- ${peer.sessionId}`).join("\n")}`, { gentleAgents: { candidates: peers } });
1953
+ if (filter !== undefined) {
1954
+ const workSearch = searchPublishedWork(agentHome, activations, filter, params.recipient_session_id
1955
+ ? { recipientSessionId: params.recipient_session_id, cursor: params.cursor } : undefined);
1956
+ return text(JSON.stringify(workSearch), { gentleAgents: { workSearch } });
1957
+ }
1958
+ const peers = discoverOrchestrators(agentHome, activations, Date.now(), params.recipient_session_id
1959
+ ? { recipientSessionId: params.recipient_session_id, cursor: params.cursor } : undefined);
1960
+ const repository = (fact?: RepositoryFact) => fact?.root
1961
+ ? `repository: ${fact.root} · clone: ${fact.cloneHash} · Git resolved at: ${fact.resolvedAt} (${fact.source})`
1962
+ : "repository: unknown";
1963
+ const rows = peers.map(peer => {
1964
+ const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : " · context: unknown";
1965
+ const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"} · ${repository(peer.scope?.tasks.find(t => t.id === task.id)?.repository)}`).join("") ?? "";
1966
+ const registered = peer.scope?.registered.map(fact => `\n registered: ${repository(fact)}`).join("") ?? "";
1967
+ const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : "";
1968
+ const catalog = peer.catalog ? `\n recorded catalog (not Git identity): ${JSON.stringify(peer.catalog)}` : "\n recorded catalog: unknown";
1969
+ const note = params.recipient_session_id ? `\n owner-curated recorded state (not consent or owner reply; authority none): ${peer.state ? JSON.stringify(peer.state) : "unknown"}` : "";
1970
+ return `- ${peer.sessionId}${context} · ${repository(peer.scope?.host)} · metadata: ${peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`;
1971
+ });
1972
+ return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${rows.join("\n")}`, { gentleAgents: { candidates: peers } });
1321
1973
  } catch {
1322
1974
  return text("Error: session discovery is unavailable.", { error: "unavailable" });
1323
1975
  }
@@ -1326,13 +1978,33 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1326
1978
  pi.registerTool({
1327
1979
  name: "orchestrator_send_message",
1328
1980
  label: "Send orchestrator message",
1981
+ renderShell: "self",
1982
+ renderCall(args, theme, context) {
1983
+ return outgoingMessageCall(args as { message?: unknown }, theme, context, expandHint(context.expanded));
1984
+ },
1985
+ renderResult(result, options, theme, context) {
1986
+ return outgoingMessageResult(result, options.expanded, options.isPartial, theme, context);
1987
+ },
1329
1988
  description: "Send a notification to another active session in this trusted local profile. If recipient_session_id is omitted, the sole peer is selected or the user selects one. Acceptance means enqueued, not read or completed.",
1330
- parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { recipient_session_id: { type: "string" }, message: { type: "string" } } } as never,
1989
+ parameters: {
1990
+ type: "object",
1991
+ additionalProperties: false,
1992
+ required: ["message", "reason"],
1993
+ properties: {
1994
+ recipient_session_id: { type: "string" },
1995
+ message: { type: "string" },
1996
+ reason: { type: "string", minLength: MESSAGING_REASON_MIN_CHARACTERS, maxLength: MESSAGING_REASON_MAX_UTF8_BYTES, description: "Required concrete caller-supplied reason: at least 8 characters after trimming and at most 512 UTF-8 bytes." },
1997
+ },
1998
+ } as never,
1331
1999
  async execute(_id, params, signal, _onUpdate, ctx) {
1332
2000
  const transport = activeTransportFor(ctx);
1333
- const input = params as { recipient_session_id?: unknown; message?: unknown };
2001
+ const input = params as { recipient_session_id?: unknown; message?: unknown; reason?: unknown };
1334
2002
  if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
1335
- if (typeof input.message !== "string" || Buffer.byteLength(input.message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
2003
+ const message = input.message;
2004
+ if (typeof message !== "string" || Buffer.byteLength(message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
2005
+ let reason: string;
2006
+ try { reason = normalizeMessagingReason(input.reason); }
2007
+ catch { return text("Error: recipient session ID or message is invalid; a concrete reason of at least 8 trimmed characters and at most 512 UTF-8 bytes is required.", { error: "invalid input" }); }
1336
2008
  let recipient: string | undefined;
1337
2009
  let activation: PresenceRecord | undefined;
1338
2010
  if (input.recipient_session_id !== undefined) {
@@ -1364,17 +2036,23 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1364
2036
  if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1365
2037
  if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" });
1366
2038
  try {
1367
- const accepted = await transport.client.sendNotification(recipient, input.message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
1368
- return activeTransportFor(ctx) === transport ? text(`Message ${accepted.id} from ${transport.sessionId} to ${recipient} accepted for delivery; it is not a delivery or read receipt.`, { gentleAgents: { messageId: accepted.id, senderSessionId: transport.sessionId, recipientSessionId: recipient, state: "accepted" } }) : text("Error: session messaging is not ready.", { error: "stale" });
2039
+ await messagingGrants.authorize(ctx, recipient, { message, reason, signal });
2040
+ } catch (error) {
2041
+ if (signal?.aborted) return text("Cross-orchestrator communication was cancelled.", { error: "cancelled" });
2042
+ return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "denied" });
2043
+ }
2044
+ try {
2045
+ const accepted = await transport.client.sendNotification(recipient, message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
2046
+ return activeTransportFor(ctx) === transport ? text(`Message ${accepted.id} from ${transport.sessionId} to ${recipient} accepted for delivery; it is not a delivery or read receipt.`, { gentleAgents: { messageId: accepted.id, senderSessionId: transport.sessionId, recipientSessionId: recipient, state: "accepted", message, reason, senderLabel: sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? ""), recipientLabel: messagePeerLabel(agentHome, recipient) } }) : text("Error: session messaging is not ready.", { error: "stale" });
1369
2047
  } catch {
1370
2048
  return text("Error: session message was not accepted.", { error: "not accepted" });
1371
2049
  }
1372
2050
  },
1373
2051
  });
1374
2052
 
1375
- tool("list_agents", "List the subagents defined for this project and user, with their descriptions.", { properties: {} }, async (_params, ctx) => {
2053
+ tool("list_agents", "List the subagents defined for this project and user, with their descriptions and declared tool inventory.", { properties: {} }, async (_params, ctx) => {
1376
2054
  const { agents, errors } = discoverAgents(roots(ctx));
1377
- const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"}`);
2055
+ const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"} ${declaredToolInventory(agent)}`);
1378
2056
  const problems = errors.map((error) => `! ${error}`);
1379
2057
  return text(lines.length === 0 ? "No subagents defined." : [...lines, ...problems].join("\n"));
1380
2058
  });
@@ -1389,16 +2067,24 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1389
2067
  task: { type: "string", description: "What the subagent must do, self-contained." },
1390
2068
  label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
1391
2069
  context: { type: "string", description: "Optional extra context appended to the task." },
2070
+ work: workDescriptorSchema,
1392
2071
  workspace_root: { type: "string", description: "Optional canonical main or linked Git worktree within the parent's same clone only; mutually exclusive with repository_root." },
1393
2072
  repository_root: { type: "string", description: "Optional canonical independent Git repository; requires direct interactive session-scoped consent before queueing; mutually exclusive with workspace_root." },
1394
- research_selection: RESEARCH_SELECTION_SCHEMA,
1395
- remediation: REMEDIATION_SCHEMA, sdd_change: { type: "object", additionalProperties: false, required: ["changeName", "workspaceRoot", "phase"], properties: { changeName: { type: "string" }, workspaceRoot: { type: "string" }, failedEvidenceRevision: { type: "string" }, phase: { type: "string", enum: ["apply", "verify", "archive", "remediate"] } }, description: "Launch-local selected SDD identity, accepted only by matching SDD phase agents." },
1396
2073
  mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
1397
2074
  },
1398
2075
  },
1399
2076
  async (params, ctx, signal) => {
1400
- if (Object.hasOwn(params, "repository_root") && Object.hasOwn(params, "workspace_root")) throw new Error("repository_root and workspace_root are mutually exclusive.");
2077
+ const work = Object.hasOwn(params, "work") ? decodeWorkDescriptor(params.work) : undefined;
2078
+ const manager = ctx.sessionManager, sessionId = manager.getSessionId();
2079
+ if (typeof params.agent !== "string" || !params.agent.trim() || typeof params.task !== "string" || !params.task.trim() || (params.context !== undefined && typeof params.context !== "string") || (params.label !== undefined && typeof params.label !== "string")) throw new Error("Subagent dispatch requires a named agent, non-empty task and string context/label.");
2080
+ if (params.mode !== undefined && params.mode !== AGENT_MODE.TASK && params.mode !== AGENT_MODE.BACKGROUND) throw new Error("Subagent mode must be task or background.");
2081
+ // An empty selector names no destination: only real roots are mutually
2082
+ // exclusive, so a blank string must not masquerade as a second target.
2083
+ const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== "";
2084
+ const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== "";
2085
+ if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive.");
1401
2086
  if ((Object.hasOwn(params, "repository_root") && typeof params.repository_root !== "string") || (Object.hasOwn(params, "workspace_root") && typeof params.workspace_root !== "string")) throw new Error("Root selectors must be strings.");
2087
+ if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection") || retiredSddAgent(String(params.agent))) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" });
1402
2088
  const { agents } = discoverAgents(roots(ctx));
1403
2089
  const agent = agents.find((candidate) => candidate.name === params.agent);
1404
2090
  if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" });
@@ -1407,24 +2093,41 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1407
2093
  policy: resolveBackgroundSubagentsPolicy(ctx.cwd).policy,
1408
2094
  parentMode: ctx.mode,
1409
2095
  });
1410
- let sddChange: SddChangeSelection | undefined;
1411
- try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1412
- catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1413
- return launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, typeof params.workspace_root === "string" ? params.workspace_root : undefined, sddChange, params.research_selection, params.remediation, signal, typeof params.repository_root === "string" ? params.repository_root : undefined), signal);
2096
+ const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined;
2097
+ const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined;
2098
+ const publication: { status: "recorded" | "unavailable" } = { status: "unavailable" };
2099
+ const current = () => ctx.sessionManager === manager && manager.getSessionId() === sessionId && !!activeTransportFor(ctx);
2100
+ const result = await launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, workspaceRoot, signal, repositoryRoot), signal, work ? id => {
2101
+ try {
2102
+ if (!current()) return;
2103
+ const state = stateCache.get(manager)?.state ?? {};
2104
+ stateCache.publish(manager, { ...state, work: { ...state.work, tasks: { ...state.work?.tasks, [id]: work } } }, (type, data) => pi.appendEntry(type, data));
2105
+ if (current()) publication.status = "recorded";
2106
+ } catch { /* Optional metadata cannot invalidate an allocated task. */ }
2107
+ } : undefined);
2108
+ if (!work) return result;
2109
+ if (!current()) publication.status = "unavailable";
2110
+ const note = publication.status === "recorded"
2111
+ ? "Work recorded in local curated state; peer advertisement is best-effort."
2112
+ : "Work publication unavailable/unknown. Do not relaunch this allocated task. Use orchestrator_session_id with a bounded replacement state and this actual task ID when the owner session is active.";
2113
+ return { ...result, content: [...result.content, { type: "text", text: note }], details: { ...result.details, workPublication: publication } };
1414
2114
  },
1415
2115
  );
1416
2116
 
1417
- tool("status", "Report the status of one subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
2117
+ tool("status", "Report the status of one subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
1418
2118
  const task = await resolveTask(String(params.task_id));
1419
- return task ? text(describeTask(task), taskDetails(task)) : text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
2119
+ return task ? text(describeTask(task), taskDetails(task)) : unknownTask(params.task_id, ctx);
1420
2120
  });
1421
2121
 
1422
- tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
2122
+ tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
1423
2123
  const task = await resolveTask(String(params.task_id));
1424
- if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
2124
+ if (!task) return unknownTask(params.task_id, ctx);
1425
2125
  // The parent just pulled a finished result; its pending completion must
1426
2126
  // never be replayed on top of it.
1427
- if (isFinished(task.status)) completions.consume(task.id);
2127
+ if (isFinished(task.status)) {
2128
+ completions.consume(task.id);
2129
+ messages.invalidateTask(task.id);
2130
+ }
1428
2131
  return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task));
1429
2132
  });
1430
2133
 
@@ -1434,12 +2137,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1434
2137
  });
1435
2138
 
1436
2139
  tool("reply", "Reply once to a live query from a child of the current parent session.", { required: ["task_id", "request_id", "message"], properties: { task_id: { type: "string" }, request_id: { type: "string" }, message: { type: "string" } } }, async (params, ctx) => {
1437
- const accepted = await runner.reply(String(params.task_id), String(params.request_id), typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? "");
2140
+ const taskId = String(params.task_id);
2141
+ const requestId = String(params.request_id);
2142
+ const accepted = await runner.reply(taskId, requestId, typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? "");
1438
2143
  return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" });
1439
2144
  });
1440
2145
 
1441
2146
  tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
1442
2147
  const id = String(params.task_id);
2148
+ messages.invalidateTask(id);
1443
2149
  return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
1444
2150
  });
1445
2151
 
@@ -1451,24 +2157,23 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1451
2157
  tool(
1452
2158
  "continue",
1453
2159
  "Resume a finished subagent task in its own session with a follow-up prompt.",
1454
- { required: ["task_id", "prompt"], properties: { research_selection: RESEARCH_SELECTION_SCHEMA, task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, remediation: REMEDIATION_SCHEMA, sdd_change: { type: "object", additionalProperties: false, required: ["changeName", "workspaceRoot", "phase"], properties: { changeName: { type: "string" }, workspaceRoot: { type: "string" }, failedEvidenceRevision: { type: "string" }, phase: { type: "string", enum: ["apply", "verify", "archive", "remediate"] } }, description: "Fresh launch-local selected SDD identity, required when continuing an SDD phase agent." }, mode: { type: "string", enum: ["task", "background"] } } },
2160
+ { required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } },
1455
2161
  async (params, ctx, signal) => {
2162
+ if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection")) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" });
1456
2163
  const previous = await resolveTask(String(params.task_id));
1457
- if (!previous) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
2164
+ if (!previous) return unknownTask(params.task_id, ctx);
2165
+ if (retiredSddAgent(previous.agent)) return text("Error: retired SDD agents cannot be continued.", { error: "retired SDD delegation" });
1458
2166
  if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" });
1459
2167
  // Continuing acts on the previous result, so any pending completion for
1460
2168
  // it is already consumed by the parent.
1461
2169
  completions.consume(previous.id);
2170
+ messages.invalidateTask(previous.id);
1462
2171
  const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent);
1463
2172
  if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" });
1464
2173
  const mode = (params.mode as AgentMode | undefined) ?? (previous.mode as AgentMode);
1465
- let sddChange: SddChangeSelection | undefined;
1466
- try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1467
- catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1468
- if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
1469
-
1470
2174
  const foreignContinuation = foreignTasks.has(previous.id);
1471
- return launch(ctx, await buildRequest(ctx, agent, String(params.prompt ?? ""), typeof params.label === "string" ? params.label : undefined, previous.sddPreflightContext, mode, previous.sessionPath, foreignContinuation ? undefined : sddChange?.workspaceRoot ?? previous.cwd, sddChange, params.research_selection, params.remediation, signal, foreignContinuation ? previous.cwd : undefined), signal);
2175
+ const prompt = inheritAllowedEditSurfaces(previous.agent, String(params.prompt ?? ""), params.context, previous.prompt);
2176
+ return launch(ctx, await buildRequest(ctx, agent, prompt, typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, previous.sessionPath, foreignContinuation ? undefined : previous.cwd, signal, foreignContinuation ? previous.cwd : undefined), signal);
1472
2177
  },
1473
2178
  );
1474
2179
 
@@ -1483,6 +2188,37 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1483
2188
  });
1484
2189
  }
1485
2190
 
2191
+ pi.registerCommand(JOBS_COMMAND_NAME, {
2192
+ description: "Show background jobs: running first, newest first within each group; status, command, output tail; s stops the selected job.",
2193
+ handler: async (_args, ctx) => {
2194
+ if (!ctx.hasUI) return;
2195
+ if (ctx.mode !== "tui") {
2196
+ ctx.ui.notify("The jobs overlay requires TUI mode.", "warning");
2197
+ return;
2198
+ }
2199
+ let view: JobsView | undefined;
2200
+ await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
2201
+ const close = withOverlayRepaint(tui, done);
2202
+ view = new JobsView({
2203
+ theme,
2204
+ rows: () => Math.max(0, tui.terminal.rows),
2205
+ jobs: () => jobs.list(ctx.sessionManager.getSessionId() ?? ""),
2206
+ now: () => deps.now(),
2207
+ // The agent was promised a notice, so a human stop is reported;
2208
+ // a job_stop needs none because the agent itself stopped it.
2209
+ onStop: (job) => {
2210
+ // Lines already seen are delivered before the stop notice.
2211
+ monitors.finish(job);
2212
+ const stopped = jobs.stop(job.id);
2213
+ if (stopped) queueJobNotice({ job: stopped, kind: "exit" });
2214
+ },
2215
+ onClose: () => close(null),
2216
+ requestRender: () => tui.requestRender(),
2217
+ });
2218
+ return view;
2219
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } }).finally(() => view?.dispose());
2220
+ },
2221
+ });
1486
2222
  pi.registerCommand(AGENTS_COMMAND_NAME, {
1487
2223
  description: "Show this session's active subagents; a lists open orchestrators in this profile. Peer threads are read-only; o opens a local task's transcript in $EDITOR.",
1488
2224
  handler: async (_args, ctx) => openOverlay(ctx),
@@ -1500,10 +2236,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1500
2236
  });
1501
2237
  }
1502
2238
 
2239
+ pi.on("session_tree", (_event, ctx) => {
2240
+ helperPermission.clear();
2241
+ if (sessions !== ctx.sessionManager) return;
2242
+ stateCache.load(ctx.sessionManager);
2243
+ publishActivity();
2244
+ });
1503
2245
  pi.on("session_start", async (event, ctx) => {
2246
+ stateCache.load(ctx.sessionManager);
1504
2247
  // A resumed, reloaded, or replaced session starts with an empty completion
1505
2248
  // queue so nothing pending from another session can replay here.
1506
2249
  completions.dropAll();
2250
+ messages.dropAll();
2251
+ jobNotices.length = 0;
2252
+ resetParentDelivery(ctx);
1507
2253
  presence?.dispose();
1508
2254
  registryFor(ctx);
1509
2255
  showWidget(ctx);
@@ -1520,8 +2266,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1520
2266
  const preexisting = event.reason === "resume" || (event.reason === "startup" && ctx.sessionManager.getEntries().length > 0);
1521
2267
  if (preexisting && sessionId) void restoreSessionHistory(ctx, sessionId);
1522
2268
  try {
1523
- presence = PresencePublisher.start({ profile: agentHome, sessionId: activeSessionId() ?? "",
1524
- label: ctx.sessionManager.getSessionName?.() || ctx.sessionManager.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
2269
+ presence = startPresence(ctx.sessionManager);
1525
2270
  publishActivity();
1526
2271
  } catch { presence = undefined; }
1527
2272
  void startSessionTransport(ctx);
@@ -1551,20 +2296,42 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1551
2296
  });
1552
2297
  pi.on("session_shutdown", async () => {
1553
2298
  completions.dropAll();
2299
+ messages.dropAll();
2300
+ // Jobs never outlive their session: a stopped job sends no notice.
2301
+ monitors.cancelAll();
2302
+ const jobsStopped = jobs.stopAll();
2303
+ jobNotices.length = 0;
2304
+ refreshJobStatus();
1554
2305
  activeAgentRuns = 0;
2306
+ resetParentDelivery(undefined);
1555
2307
  presence?.dispose();
1556
2308
  presence = undefined;
2309
+ stateCache.clear();
1557
2310
  rpcActivityPublisher?.stop();
1558
2311
  rpcActivityPublisher = undefined;
1559
2312
  cancelClock?.();
1560
2313
  for (const view of overlays) { view.handleInput("q"); view.dispose(); }
1561
2314
  overlays.clear();
1562
2315
  sessions = undefined;
2316
+ stopVisualUpdates?.();
2317
+ stopVisualUpdates = undefined;
1563
2318
  sidebarTui = undefined;
1564
2319
  worktrees?.close();
1565
2320
  worktrees = undefined;
2321
+ worktreeManager = undefined;
1566
2322
  const stopped = shutdownSessionTransport();
1567
2323
  runner.cancelAll("cancelled: parent session shut down");
1568
2324
  await stopped;
2325
+ // The default log directory goes with the session once every log is
2326
+ // closed, but a process that ignores its stop must not hang shutdown.
2327
+ await new Promise<void>((done) => {
2328
+ const cancel = deps.schedule(done, JOBS_SHUTDOWN_GRACE_MS);
2329
+ void jobsStopped.then(() => { cancel(); done(); });
2330
+ });
2331
+ if (jobOutputDir) {
2332
+ const dir = jobOutputDir;
2333
+ jobOutputDir = undefined;
2334
+ try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { /* A leftover temp directory is harmless. */ }
2335
+ }
1569
2336
  });
1570
2337
  }