gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -1,27 +1,29 @@
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";
6
3
  import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
7
4
  import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
8
5
  import { publishForeignSessionChange } from "../lib/session-change-capture.ts";
9
6
  import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
10
7
  import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
11
- import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
8
+ import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts";
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
12
10
  import { randomUUID } from "node:crypto";
13
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
14
12
  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";
13
+ import { join, resolve, isAbsolute } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
17
16
  import { Text, type TUI } from "@earendil-works/pi-tui";
18
17
  import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
18
+ import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
19
+ import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
19
20
  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";
21
+ import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
22
+ import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
21
23
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
22
24
  import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
23
25
  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";
26
+ import { AgentRunner, piCommand, abortReasonText, type AskAnswer, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
25
27
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
26
28
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
27
29
  import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts";
@@ -30,6 +32,7 @@ import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts";
30
32
  import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
31
33
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
32
34
  import { AgentsView } from "../lib/agents-view.ts";
35
+ import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
33
36
  import { PresencePublisher } from "../lib/orchestrator-presence.ts";
34
37
  import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
35
38
  import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
@@ -38,8 +41,8 @@ import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../l
38
41
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
39
42
  import { openInExternalEditor } from "./gentle-shell.ts";
40
43
  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";
44
+ import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts";
45
+ import { allowedEditSurfaces, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
43
46
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
44
47
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
45
48
 
@@ -57,125 +60,14 @@ export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
57
60
  const RENDER_COALESCE_MS = 400;
58
61
  const CLOCK_TICK_MS = 1000;
59
62
  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
- } },
63
+ // Wakes an idle parent after child content was stored as a custom message.
64
+ // It names itself as automated so the model never attributes it to the human.
65
+ 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.";
66
+ // How long a dispatched wake may take to start a parent run before a later
67
+ // delivery may send another one.
68
+ export const PARENT_WAKE_GRACE_MS = 30_000;
98
69
 
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
- };
178
- }
70
+ const retiredSddAgent = (name: string): boolean => /^sdd(?:-|$)/.test(name);
179
71
 
180
72
  export interface SessionTransportRegistry {
181
73
  list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]>;
@@ -202,7 +94,6 @@ export interface SessionTransportFactory {
202
94
  }
203
95
 
204
96
  export interface AgentsDeps extends RunnerDeps {
205
- nativeSdd?: NativeReviewCli;
206
97
  home: string;
207
98
  agentHome?: string;
208
99
  childIpc?: IpcEndpoint;
@@ -212,6 +103,22 @@ export interface AgentsDeps extends RunnerDeps {
212
103
  runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
213
104
  metricsNow?: () => number;
214
105
  metricsSchedule?: RunnerDeps["schedule"];
106
+ // Extensions every child loads with --extension (gentle-shell#1587).
107
+ childExtensionPaths?: string[];
108
+ }
109
+
110
+ // gentle-shell#1587: children do not load the gentle-pi package in the
111
+ // isolated Gentle Shell home, so context filtering and destructive-command
112
+ // safety are passed to every child explicitly. Missing files are omitted;
113
+ // installations must include both entries to provide the delegated boundary.
114
+ export function childContextExtensionPaths(exists: (path: string) => boolean = existsSync): string[] {
115
+ try {
116
+ return ["./child-context.ts", "./child-safety.ts"]
117
+ .map((path) => fileURLToPath(new URL(path, import.meta.url)))
118
+ .filter(exists);
119
+ } catch {
120
+ return [];
121
+ }
215
122
  }
216
123
 
217
124
  export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
@@ -234,9 +141,11 @@ const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
234
141
  return () => clearTimeout(timer);
235
142
  },
236
143
  pi: piCommand(),
144
+ resolvePi: () => piCommand(),
237
145
  home: os.homedir(),
238
146
  resolveWorktree: resolveSessionWorktree,
239
147
  env,
148
+ childExtensionPaths: childContextExtensionPaths(),
240
149
  });
241
150
 
242
151
  export function agentsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -322,6 +231,22 @@ function text(value: string, details: Record<string, unknown> = {}, terminate =
322
231
  return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
323
232
  }
324
233
 
234
+ // gentle-pi#1175: the writer profile the runtime resolved for this task,
235
+ // carried on its review-mutation receipt so ASSESS never trusts a model
236
+ // re-declaration. `task.model` is the child's reported model once get_state
237
+ // answers, or the launch profile's model. An inherited model is recorded as
238
+ // the unresolved `formatModelRef(undefined)` placeholder; the parent's current
239
+ // model is not reliable evidence (the child resolves its own default), so it is
240
+ // omitted and ASSESS treats the writer as unknown, i.e. small.
241
+ function runtimeWriterProfile(task: TaskRecord): { writerModelId?: string; writerEffort?: string } {
242
+ const modelId = typeof task.model === "string" && task.model !== formatModelRef(undefined) && task.model.trim() ? task.model : undefined;
243
+ const effort = typeof task.thinking === "string" && task.thinking.trim() ? task.thinking : undefined;
244
+ return {
245
+ ...(modelId === undefined ? {} : { writerModelId: modelId }),
246
+ ...(effort === undefined ? {} : { writerEffort: effort }),
247
+ };
248
+ }
249
+
325
250
  function taskDetails(task: TaskRecord): Record<string, unknown> {
326
251
  return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } };
327
252
  }
@@ -364,12 +289,24 @@ export function resolveDefaultSubagentMode(input: {
364
289
  }
365
290
 
366
291
  // What the model reads when a background task ends: the outcome first, then
367
- // the answer itself. The card renderer shows the same text.
292
+ // the answer itself. The expanded card shows the same text.
368
293
  export function completionText(task: TaskRecord): string {
369
294
  const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " ");
370
295
  return `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome}.\n\n${finishedText(task)}`;
371
296
  }
372
297
 
298
+ const COMPLETION_HEADER = /^Subagent [^\n]+ \(task [^\n]*\) [^\n]+\.$/;
299
+
300
+ // The collapsed card leads with the answer or error: completionText's
301
+ // bookkeeping paragraph stays for the model and the expanded card. Entries
302
+ // without that header (older sessions) preview their full text.
303
+ export function agentResultPreview(text: string): string {
304
+ const split = text.indexOf("\n\n");
305
+ if (split < 0 || !COMPLETION_HEADER.test(text.slice(0, split))) return text;
306
+ const rest = text.slice(split + 2);
307
+ return rest.trim() === "" ? text : rest;
308
+ }
309
+
373
310
  // Host-side answer to a child's dialog: the same ctx.ui the human already
374
311
  // uses, so a subagent's question looks like any other pi dialog.
375
312
  export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, ask: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer> {
@@ -396,66 +333,21 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
396
333
  }
397
334
  }
398
335
 
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
336
  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
337
  const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
434
338
  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
- });
339
+ // A stale managed-SDD child must never inherit unrestricted ordinary tools.
340
+ 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) {
341
+ pi.on("tool_call", () => ({ block: true, reason: "Retired SDD child launch is not supported." }));
342
+ return;
453
343
  }
454
344
  if (childIpc) registerChildMessaging(pi, childIpc);
455
345
  return;
456
346
  }
457
347
  if (!agentsEnabled(env)) return;
458
348
  const deps: AgentsDeps = { ...defaultDeps(env), ...overrides };
349
+ // An explicitly injected pi command wins over the default per-spawn resolver.
350
+ if (overrides?.pi && !overrides.resolvePi) delete deps.resolvePi;
459
351
  const selectedHome = overrides.agentHome ?? (overrides.home === undefined ? resolveGentlePiAgentHome(deps.env) : join(deps.home, ".pi", "agent"));
460
352
  // Expand environment tildes like Pi, but leave explicit path APIs literal.
461
353
  const environmentHome = overrides.agentHome === undefined && overrides.home === undefined;
@@ -479,6 +371,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
479
371
  let ui: ExtensionContext["ui"] | undefined;
480
372
  let host: { requestRender(): void } | undefined;
481
373
  let sidebarTui: TUI | undefined;
374
+ const visualHome = gentlePiConfigHome(env);
375
+ let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
376
+ let stopVisualUpdates: (() => void) | undefined;
482
377
  let sessions: ExtensionContext["sessionManager"] | undefined;
483
378
  let presence: PresencePublisher | undefined;
484
379
  let rpcActivityPublisher: RpcActivityPublisher | undefined;
@@ -499,12 +394,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
499
394
  } catch { presence?.dispose(); presence = undefined; }
500
395
  };
501
396
  let worktrees: SessionWorktreeRegistry | undefined;
397
+ let worktreeManager: ExtensionContext["sessionManager"] | undefined;
398
+ let worktreeAuthority: (() => boolean) | undefined;
502
399
  const foreignGrants = new ForeignTargetGrants();
400
+ const messagingGrants = new SessionMessagingGrants();
503
401
  const foreignTasks = new Map<string, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
504
402
  const foreignRequests = new WeakMap<TaskRequest, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
505
403
  const registryFor = (ctx: ExtensionContext) => {
506
- if (!worktrees || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
404
+ if (!worktrees || worktreeManager !== ctx.sessionManager || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
507
405
  worktrees?.close();
406
+ worktreeManager = ctx.sessionManager;
407
+ worktreeAuthority = sessionRepositoryAuthority(ctx.sessionManager.getCwd(), deps.resolveWorktree);
508
408
  worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree);
509
409
  }
510
410
  return worktrees;
@@ -688,20 +588,128 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
688
588
  // flushed at the next turn boundary, and a stale one never re-enters the
689
589
  // conversation.
690
590
  const completions = createCompletionQueue<TaskRecord>();
591
+ const messages = createAgentMessageQueue();
691
592
  let activeAgentRuns = 0;
593
+ // ExtensionAPI has no idle probe; ctx.isIdle() is the only one. It is live
594
+ // and also reports compaction, which activeAgentRuns cannot see. The
595
+ // session_start context is kept for it and dropped at shutdown; a stale
596
+ // context throws instead of answering, so delivery fails closed.
597
+ let parentCtx: ExtensionContext | undefined;
598
+ // Mirrors the host's agent run, which spans agent_start through
599
+ // agent_settled, including post-run retries and in-run compaction. Unlike
600
+ // activeAgentRuns it stays set between agent_end and agent_settled, where
601
+ // Pi still streams and still drains steering.
602
+ let parentRunActive = false;
603
+ // Idle wake-up state. Child content stored while the parent is idle owes a
604
+ // wake, and one wake covers everything stored before the run it starts.
605
+ // While a prompt is starting (a dispatched wake, or any prompt seen at
606
+ // before_agent_start) no second wake is sent: two prompts racing through
607
+ // Pi's asynchronous pre-run phase can both reach the agent, and the loser
608
+ // resets the winner's run state. A prompt that never starts a run (handled
609
+ // by an input handler, or rejected) emits no extension event, so the
610
+ // starting state expires after PARENT_WAKE_GRACE_MS and an owed wake is
611
+ // sent then; it never suppresses later wakes for longer than that.
612
+ let wakeOwed = false;
613
+ let wakeQueued = false;
614
+ let promptStarting = false;
615
+ let cancelPromptGrace: (() => void) | undefined;
616
+ let cancelBoundaryFlush: (() => void) | undefined;
617
+
618
+ const isTaskLive = (id: string): boolean => {
619
+ const task = store.get(id);
620
+ return Boolean(task && ownedTaskIds.has(task.id) && !isFinished(task.status));
621
+ };
622
+
623
+ // Hands model-visible child content to the parent session.
624
+ //
625
+ // A busy parent gets "steer" + triggerTurn, which keeps delivery bounded to
626
+ // the current turn: the host polls steering each turn and injects the
627
+ // message before the next LLM call. "followUp" is NOT acceptable here
628
+ // because the host drains the follow-up queue only in the run loop's stop
629
+ // branch, so a parent that keeps calling tools would see the content only
630
+ // when the whole run ends — the original #867 delay.
631
+ //
632
+ // An idle parent must not get triggerTurn: the host would run the custom
633
+ // message as a direct turn that skips the prompt lifecycle
634
+ // (before_agent_start and the prompt refresh), and prompt-capture
635
+ // integrations such as the Claude bridge reject that turn. The structured
636
+ // message is stored durably without a turn instead, and a short
637
+ // system-generated user message wakes the parent through the normal prompt
638
+ // path. The wake never repeats child content, so the model sees it once.
639
+ //
640
+ // A parent that is busy without a run (compaction, or a prompt's pre-run
641
+ // compaction) is not streaming, so steer + triggerTurn would also start a
642
+ // direct turn. Its content stays queued ("hold") until a later boundary.
643
+ type ParentRoute = "idle" | "run" | "hold";
644
+ // Throws for a missing or stale parent context, so delivery fails closed.
645
+ const parentRoute = (): ParentRoute => {
646
+ if (!parentCtx) throw new Error("Gentle Agents has no live parent session context");
647
+ if (parentCtx.isIdle()) {
648
+ // The host is authoritative: an idle parent has no run, even if a
649
+ // lifecycle event was missed.
650
+ parentRunActive = false;
651
+ return "idle";
652
+ }
653
+ return parentRunActive ? "run" : "hold";
654
+ };
655
+
656
+ const endPromptStart = () => {
657
+ promptStarting = false;
658
+ cancelPromptGrace?.();
659
+ cancelPromptGrace = undefined;
660
+ };
661
+ const beginPromptStart = () => {
662
+ endPromptStart();
663
+ promptStarting = true;
664
+ cancelPromptGrace = deps.schedule(() => {
665
+ cancelPromptGrace = undefined;
666
+ promptStarting = false;
667
+ requestWake();
668
+ }, PARENT_WAKE_GRACE_MS);
669
+ };
670
+
671
+ // Every wake requested during one synchronous delivery pass is coalesced
672
+ // into a single dispatch after it, so the wake follows all stored content.
673
+ const requestWake = () => {
674
+ if (!wakeOwed || wakeQueued || promptStarting) return;
675
+ wakeQueued = true;
676
+ queueMicrotask(dispatchWake);
677
+ };
678
+ const dispatchWake = () => {
679
+ wakeQueued = false;
680
+ if (!wakeOwed || promptStarting) return;
681
+ let route: ParentRoute;
682
+ try { route = parentRoute(); } catch { return; }
683
+ // A run in progress already carries the stored content; a parent busy
684
+ // without a run keeps the wake owed until a later boundary flush.
685
+ if (route !== "idle") return;
686
+ wakeOwed = false;
687
+ beginPromptStart();
688
+ try {
689
+ // "steer" matters only when a run started in between: the wake is then
690
+ // queued into it instead of being rejected as a concurrent prompt.
691
+ pi.sendUserMessage(PARENT_WAKE_TEXT, { deliverAs: "steer" });
692
+ } catch {
693
+ // A stale runtime fails closed instead of throwing from a microtask.
694
+ endPromptStart();
695
+ }
696
+ };
692
697
 
693
- const deliver = (task: TaskRecord) => {
698
+ const sendToParent = (message: Parameters<ExtensionAPI["sendMessage"]>[0], route: Exclude<ParentRoute, "hold">) => {
699
+ if (route === "run") {
700
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
701
+ return;
702
+ }
703
+ pi.sendMessage(message, { triggerTurn: false });
704
+ wakeOwed = true;
705
+ requestWake();
706
+ };
707
+
708
+ const deliver = (task: TaskRecord, route: Exclude<ParentRoute, "hold">) => {
694
709
  // Ownership is consulted at delivery time, matching onNotification and
695
710
  // onQuery: a completion owned by another session is dropped, not delivered.
696
711
  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 });
712
+ sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route);
705
713
  };
706
714
 
707
715
  // A stale completion must not re-enter the LLM conversation, so it is
@@ -712,22 +720,85 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
712
720
  pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds });
713
721
  };
714
722
 
723
+ const deliverMessage = (msg: PendingAgentMessage, route: Exclude<ParentRoute, "hold">) => {
724
+ if (activeSessionId() !== msg.parentSessionId) return;
725
+ sendToParent({ customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, route);
726
+ };
727
+
728
+ // Child content leaves its queue only when the parent can receive it. A
729
+ // held or undeliverable flush leaves everything queued for a later boundary;
730
+ // `rethrow` lets a query report a stale parent back to its child.
731
+ const deliveryRoute = (rethrow: boolean): Exclude<ParentRoute, "hold"> | undefined => {
732
+ try {
733
+ const route = parentRoute();
734
+ return route === "hold" ? undefined : route;
735
+ } catch (error) {
736
+ if (rethrow) throw error;
737
+ return undefined;
738
+ }
739
+ };
740
+
741
+ const flushMessages = (rethrow = false) => {
742
+ const route = deliveryRoute(rethrow);
743
+ if (!route) return;
744
+ for (const msg of messages.takeDeliverable(deps.now(), activeSessionId() ?? "", isTaskLive)) {
745
+ try {
746
+ deliverMessage(msg, route);
747
+ } catch (error) {
748
+ if (rethrow) throw error;
749
+ /* Best-effort delivery: at most once, even if forwarding fails. */
750
+ }
751
+ }
752
+ };
753
+
715
754
  const flushCompletions = () => {
755
+ const route = deliveryRoute(false);
756
+ if (!route) return;
716
757
  for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) {
717
758
  try {
718
759
  if (stale) deliverStale(task, settledAt);
719
- else deliver(task);
760
+ else deliver(task, route);
720
761
  } catch { /* Best-effort delivery: at most once, even if forwarding fails. */ }
721
762
  }
722
763
  };
723
764
 
765
+ const flushAll = () => {
766
+ flushMessages();
767
+ flushCompletions();
768
+ // A wake left owed by an earlier held or expired attempt is retried here.
769
+ requestWake();
770
+ };
771
+
772
+ // Pi still reports compaction while these handlers run (a manual
773
+ // session_compact, an automatic compaction's session_compact_failed), so
774
+ // held content is flushed once on the next scheduler tick instead. A parent
775
+ // still busy then keeps it held for the next boundary; nothing polls.
776
+ const scheduleBoundaryFlush = () => {
777
+ if (cancelBoundaryFlush) return;
778
+ cancelBoundaryFlush = deps.schedule(() => {
779
+ cancelBoundaryFlush = undefined;
780
+ flushAll();
781
+ }, 0);
782
+ };
783
+
784
+ // Session changes discard every pending wake and boundary flush.
785
+ const resetParentDelivery = (ctx: ExtensionContext | undefined) => {
786
+ parentCtx = ctx;
787
+ parentRunActive = false;
788
+ wakeOwed = false;
789
+ endPromptStart();
790
+ cancelBoundaryFlush?.();
791
+ cancelBoundaryFlush = undefined;
792
+ };
793
+
724
794
  // A completion settles into our queue. An idle parent flushes right away so
725
795
  // the wake-up behavior is unchanged; a busy parent flushes at the next turn
726
796
  // boundary, and the steer mode injects it before that turn's next LLM call
727
797
  // instead of parking it behind the whole run.
728
798
  const settleCompletion = (task: TaskRecord) => {
799
+ messages.invalidateTask(task.id);
729
800
  completions.enqueue(task, deps.now());
730
- if (activeAgentRuns === 0) flushCompletions();
801
+ if (activeAgentRuns === 0) flushAll();
731
802
  };
732
803
 
733
804
  // `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at
@@ -737,35 +808,60 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
737
808
  // a final `turn_end` (an aborted run, or the host's early post-run return
738
809
  // when a run produced no assistant message; the host compensates via
739
810
  // 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; });
811
+ // `agent_settled` is the final idle boundary after retries: it ends the
812
+ // host run and flushes anything held, including content held while a
813
+ // compaction ran inside or right before the run. `before_agent_start`
814
+ // marks a prompt that is about to start a run, and the compaction events
815
+ // release content held while the parent compacted without a run.
816
+ pi.on("before_agent_start", () => {
817
+ if (!parentRunActive) beginPromptStart();
818
+ });
819
+ pi.on("agent_start", () => {
820
+ activeAgentRuns += 1;
821
+ parentRunActive = true;
822
+ // The run carries everything stored while the parent was idle.
823
+ wakeOwed = false;
824
+ endPromptStart();
825
+ });
743
826
  pi.on("agent_end", () => {
744
827
  activeAgentRuns = Math.max(0, activeAgentRuns - 1);
745
- flushCompletions();
828
+ flushAll();
746
829
  });
747
- pi.on("agent_settled", () => flushCompletions());
748
- pi.on("turn_end", () => flushCompletions());
830
+ pi.on("agent_settled", () => {
831
+ parentRunActive = false;
832
+ endPromptStart();
833
+ flushAll();
834
+ });
835
+ pi.on("turn_end", () => flushAll());
836
+ pi.on("session_compact", scheduleBoundaryFlush);
837
+ pi.on("session_compact_failed", scheduleBoundaryFlush);
749
838
 
750
839
  const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, {
751
840
  askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw),
752
841
  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 });
842
+ if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
843
+ messages.enqueueNotification(task, message, deps.now());
844
+ if (activeAgentRuns === 0) flushMessages();
755
845
  return true;
756
846
  },
757
847
  onQuery: (task, requestId, message) => {
758
- if (activeSessionId() !== task.parentSessionId) return false;
848
+ if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
759
849
  const hadYield = yieldedTaskIds.has(task.id);
760
850
  if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id);
761
851
  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 });
852
+ messages.enqueueQuery(task, requestId, message, deps.now());
853
+ if (activeAgentRuns === 0) flushMessages(true);
763
854
  return true;
764
855
  } catch (error) {
856
+ messages.expireQuery(task.id, requestId);
765
857
  if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id);
766
858
  throw error;
767
859
  }
768
860
  },
861
+ onQuerySettled: (taskId, requestId, outcome) => {
862
+ if (outcome === "replied") messages.consumeQuery(taskId, requestId);
863
+ else messages.expireQuery(taskId, requestId);
864
+ },
769
865
  onSuccessfulMutation: (task, tool) => {
770
866
  // Same-clone registry attribution remains unchanged. A foreign task
771
867
  // uses a separately bound, live-grant path only for successful tool evidence.
@@ -813,7 +909,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
813
909
  }
814
910
  else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch");
815
911
  }
816
- if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
912
+ if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId, ...runtimeWriterProfile(task) });
817
913
  },
818
914
  onFinish: (task, observations) => {
819
915
  // Completion is the only forwarding opportunity. No pending event, policy
@@ -831,6 +927,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
831
927
  } catch { /* Metrics must never interrupt task finalization. */ }
832
928
  try {
833
929
  ownedTaskIds.delete(task.id);
930
+ messages.invalidateTask(task.id);
834
931
  requestRender();
835
932
  persist(task);
836
933
  const yielded = yieldedTaskIds.delete(task.id);
@@ -859,12 +956,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
859
956
  pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
860
957
  const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
861
958
  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");
959
+ const text = typeof content === "string" ? content : content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n");
960
+ const body = options.expanded ? text.split("\n") : agentResultPreview(text).split("\n").filter((line) => line.trim() !== "");
863
961
  const tone = details?.status === "completed" ? CARD_TONE.SUCCESS : CARD_TONE.ERROR;
864
962
  const hint = expandHint(options.expanded);
865
963
  return {
866
964
  render(width: number) {
867
- return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, hint });
965
+ return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint });
868
966
  },
869
967
  invalidate() {},
870
968
  };
@@ -886,7 +984,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
886
984
  ];
887
985
  return {
888
986
  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) });
987
+ 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
988
  },
891
989
  invalidate() {},
892
990
  };
@@ -990,6 +1088,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
990
1088
  let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined;
991
1089
  const chosen = await ctx.ui.custom<TaskRecord | null>(
992
1090
  (tui, theme, _keybindings, done) => {
1091
+ const close = withOverlayRepaint(tui, done);
993
1092
  overlayHost = tui;
994
1093
  view = new AgentsView({
995
1094
  theme,
@@ -1004,8 +1103,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1004
1103
  onCancel: (task) => void stopSelected(task, ctx),
1005
1104
  canCancel: isOwnedActive,
1006
1105
  isLocalTask: (task) => !restoredTaskIds.has(task.id),
1007
- onOpen: (task) => done(task),
1008
- onClose: () => done(null),
1106
+ onOpen: (task) => close(task),
1107
+ onClose: () => close(null),
1009
1108
  requestRender: () => tui.requestRender(),
1010
1109
  });
1011
1110
  overlays.add(view);
@@ -1058,6 +1157,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1058
1157
  const showWidget = (ctx: ExtensionContext) => {
1059
1158
  ui = ctx.hasUI ? ctx.ui : undefined;
1060
1159
  sessions = ctx.sessionManager;
1160
+ agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
1161
+ stopVisualUpdates?.();
1162
+ stopVisualUpdates = pi.events.on(VISUAL_SETTINGS_CHANGED, (event) => {
1163
+ if ((event as { configHome?: unknown } | undefined)?.configHome !== visualHome) return;
1164
+ agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
1165
+ if (sidebarTui) invalidateSidebar(sidebarTui);
1166
+ host?.requestRender();
1167
+ });
1061
1168
  tickClock();
1062
1169
  // Agents is not a rail part: the fullscreen sidebar only suppresses
1063
1170
  // bottom components registered through sidebarPart, and this widget is
@@ -1067,6 +1174,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1067
1174
  sidebarTui = tui;
1068
1175
  return {
1069
1176
  render(width: number) {
1177
+ if (!agentsVisible) return [];
1070
1178
  const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
1071
1179
  return lines.length === 0 ? [] : [...lines, ""];
1072
1180
  },
@@ -1077,15 +1185,38 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1077
1185
 
1078
1186
  const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
1079
1187
 
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> => {
1188
+ 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> => {
1189
+ if (retiredSddAgent(agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
1190
+ if (![AGENT_MODE.TASK, AGENT_MODE.BACKGROUND].includes(mode)) throw new Error("Subagent mode must be task or background.");
1191
+ if (ctx.mode === "print" && mode === AGENT_MODE.BACKGROUND) 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.");
1192
+ const scopeDenied = rejectUnscopedBoundedWriterDispatch({ agent: agent.name, task: prompt, context });
1193
+ if (scopeDenied) throw new Error(scopeDenied.reason);
1081
1194
  if (signal?.aborted) throw new Error("Subagent launch aborted before authorization.");
1082
1195
  const registry = registryFor(ctx);
1196
+ const authorityCurrent = worktreeAuthority!;
1197
+ const originalManager = ctx.sessionManager;
1198
+ const originalId = originalManager.getSessionId();
1199
+ const originalCwd = originalManager.getCwd();
1200
+ const current = () => sessions === originalManager && originalManager.getSessionId() === originalId && originalManager.getCwd() === originalCwd && !signal?.aborted && authorityCurrent();
1201
+ if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission.");
1202
+ let admittedModel: string | undefined;
1203
+ const surfaces = allowedEditSurfaces(prompt, context);
1204
+ if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) {
1205
+ const root = safeBootstrapDirectory(originalCwd);
1206
+ if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root.");
1207
+ const config = withPinnedModelProfiles(loadAgentsConfig(roots(ctx)), resolveUnversionedProjectProfile(root, gentlePiConfigHome(deps.env))?.modelProfiles);
1208
+ const model = resolveAgentProfile(agent, config).model ?? ctx.model;
1209
+ const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
1210
+ if (!catalogModel) throw new Error("Writer bootstrap requires a valid effective model in this session's catalog.");
1211
+ admittedModel = `${catalogModel.provider}/${catalogModel.id}`;
1212
+ 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.");
1213
+ }
1083
1214
  const parentCwd = ctx.sessionManager.getCwd();
1084
1215
  // An explicit target is validated before any queue or session-dir writes.
1085
1216
  const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
1086
1217
  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;
1218
+ 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.");
1219
+ const selectedRoot = repositoryRoot ?? workspaceRoot;
1089
1220
  // Preserve ordinary non-Git continuation, without admitting any new root.
1090
1221
  const sameNonGitContinuation = resume !== undefined && repositoryRoot === undefined && selectedRoot === parentCwd && !parentIdentity;
1091
1222
  let foreign = false;
@@ -1099,7 +1230,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1099
1230
  // same-clone registry. Never use a caller-injected resolver for this identity.
1100
1231
  const identity = resolveSessionWorktree(selectedRoot, parentCwd);
1101
1232
  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.");
1233
+ 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
1234
  await foreignGrants.authorize(ctx, identity, { continuation: resume !== undefined, signal });
1104
1235
  foreignIdentity = identity;
1105
1236
  foreignParent = canonicalParent;
@@ -1107,12 +1238,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1107
1238
  foreign = true;
1108
1239
  }
1109
1240
  } 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
1241
  // A per-repository profile pin replaces subagent routing for this launch without
1117
1242
  // materialising anything: the global stores stay untouched, so two repositories
1118
1243
  // worked in parallel stop fighting over one global routing. The child's own
@@ -1133,7 +1258,11 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1133
1258
  })?.modelProfiles,
1134
1259
  );
1135
1260
  const profile = resolveAgentProfile(agent, config);
1136
- const research = agent.name === "sdd-research" ? researchAgent(agent, pi, researchSelection) : undefined;
1261
+ if (admittedModel !== undefined) {
1262
+ const model = profile.model ?? ctx.model;
1263
+ const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
1264
+ if (!catalogModel || `${catalogModel.provider}/${catalogModel.id}` !== admittedModel || !current()) throw new Error("Writer effective profile or session changed during preparation.");
1265
+ }
1137
1266
  const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
1138
1267
  if (foreign && target) {
1139
1268
  const identity = resolveSessionWorktree(target, parentCwd);
@@ -1146,21 +1275,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1146
1275
  const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
1147
1276
  const parentWorktreeRoot = ctx.sessionManager.getCwd();
1148
1277
  const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot);
1149
- const sddPreflightContext = SHIPPED_SDD_AGENT_NAME_SET.has(agent.name)
1150
- ? extractParentConfirmedSddPreflightContext(context)
1151
- : undefined;
1152
1278
  const childEnv = { ...deps.env };
1153
1279
  if (foreign) for (const key of inheritedUnsafeGitEnvironmentKeys(childEnv)) delete childEnv[key];
1154
1280
  const request: TaskRequest = {
1155
- agent: research?.agent ?? agent,
1156
- remediationIntent,
1281
+ agent,
1157
1282
  prompt,
1158
1283
  label,
1159
1284
  context,
1160
- ...(sddPreflightContext === undefined ? {} : { sddPreflightContext }),
1161
1285
  mode,
1162
1286
  cwd: target ?? parentWorktreeRoot,
1163
1287
  parentSessionId,
1288
+ ...(admittedModel === undefined ? {} : { beforeSpawn: () => {
1289
+ if (!current()) throw new Error("Writer session changed before spawn.");
1290
+ registry.validate(originalCwd);
1291
+ } }),
1164
1292
  ...(foreign && target ? { beforeSpawn: () => {
1165
1293
  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
1294
  const identity = resolveSessionWorktree(target, parentCwd);
@@ -1173,9 +1301,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1173
1301
  thinking: profile.thinking,
1174
1302
  sessionDir,
1175
1303
  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 }),
1304
+ ...(deps.childExtensionPaths && deps.childExtensionPaths.length > 0 ? { extensionPaths: [...deps.childExtensionPaths] } : {}),
1305
+ env: childEnv,
1179
1306
  ...(foreign || parentRepositoryIdentity === undefined ? {} : {
1180
1307
  authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
1181
1308
  try {
@@ -1202,17 +1329,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1202
1329
  if (ctx.mode === "print" && request.mode === AGENT_MODE.BACKGROUND) {
1203
1330
  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
1331
  }
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
- }
1332
+ if (retiredSddAgent(request.agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
1216
1333
 
1217
1334
  // Bounded live observation only. Native send owns the fresh policy decision;
1218
1335
  // child execution never starts a telemetry policy process or renewal timer.
@@ -1265,10 +1382,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1265
1382
  const query = await runner.waitForQuery(task.id);
1266
1383
  if (query) {
1267
1384
  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);
1385
+ messages.consumeQuery(task.id, query.requestId);
1386
+ const questionSuffix = query.message ? `\n\nQuestion:\n${query.message}` : "";
1387
+ return text(
1388
+ `Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.${questionSuffix}`,
1389
+ { gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId, ...(query.message ? { question: query.message } : {}) } },
1390
+ true,
1391
+ );
1269
1392
  }
1270
1393
  const finished = await runner.waitFor(task.id);
1271
1394
  completions.consume(finished.id);
1395
+ messages.invalidateTask(finished.id);
1272
1396
  return text(finishedText(finished), taskDetails(finished));
1273
1397
  } finally {
1274
1398
  signal?.removeEventListener("abort", onAbort);
@@ -1283,12 +1407,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1283
1407
  description,
1284
1408
  parameters: { type: "object", additionalProperties: false, ...parameters } as never,
1285
1409
  renderCall(args, theme) {
1410
+ // The result title belongs with the result body so a running poll
1411
+ // can hide both without changing the tool call or its model output.
1412
+ if (name === "result") return { render: () => [], invalidate() {} };
1286
1413
  const params = args as { agent?: string; task_id?: string };
1287
1414
  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
1415
  },
1289
1416
  renderResult(result, options, theme) {
1417
+ const task = (result.details as { gentleAgents?: { status?: string; taskId?: string } } | undefined)?.gentleAgents;
1418
+ if (name === "result" && task?.status === TASK_STATUS.RUNNING) {
1419
+ return { render: () => [], invalidate() {} };
1420
+ }
1290
1421
  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);
1422
+ const visibleBody = options.expanded ? body : theme.fg("muted", body.split("\n")[0] ?? "");
1423
+ const title = `${AGENTS_GLYPH} agent result${task?.taskId ? ` · ${sanitizeTerminalText(task.taskId)}` : ""}`;
1424
+ return new Text(name === "result" ? `${theme.fg("toolTitle", title)}\n${visibleBody}` : visibleBody, 0, 0);
1292
1425
  },
1293
1426
  async execute(_id, params, signal, _onUpdate, ctx) {
1294
1427
  return execute(params as Record<string, unknown>, ctx, signal);
@@ -1327,12 +1460,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1327
1460
  name: "orchestrator_send_message",
1328
1461
  label: "Send orchestrator message",
1329
1462
  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,
1463
+ parameters: {
1464
+ type: "object",
1465
+ additionalProperties: false,
1466
+ required: ["message", "reason"],
1467
+ properties: {
1468
+ recipient_session_id: { type: "string" },
1469
+ message: { type: "string" },
1470
+ 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." },
1471
+ },
1472
+ } as never,
1331
1473
  async execute(_id, params, signal, _onUpdate, ctx) {
1332
1474
  const transport = activeTransportFor(ctx);
1333
- const input = params as { recipient_session_id?: unknown; message?: unknown };
1475
+ const input = params as { recipient_session_id?: unknown; message?: unknown; reason?: unknown };
1334
1476
  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" });
1477
+ const message = input.message;
1478
+ if (typeof message !== "string" || Buffer.byteLength(message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1479
+ let reason: string;
1480
+ try { reason = normalizeMessagingReason(input.reason); }
1481
+ 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
1482
  let recipient: string | undefined;
1337
1483
  let activation: PresenceRecord | undefined;
1338
1484
  if (input.recipient_session_id !== undefined) {
@@ -1364,7 +1510,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1364
1510
  if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1365
1511
  if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" });
1366
1512
  try {
1367
- const accepted = await transport.client.sendNotification(recipient, input.message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
1513
+ await messagingGrants.authorize(ctx, recipient, { message, reason, signal });
1514
+ } catch (error) {
1515
+ if (signal?.aborted) return text("Cross-orchestrator communication was cancelled.", { error: "cancelled" });
1516
+ return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "denied" });
1517
+ }
1518
+ try {
1519
+ const accepted = await transport.client.sendNotification(recipient, message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
1368
1520
  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" });
1369
1521
  } catch {
1370
1522
  return text("Error: session message was not accepted.", { error: "not accepted" });
@@ -1391,14 +1543,19 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1391
1543
  context: { type: "string", description: "Optional extra context appended to the task." },
1392
1544
  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
1545
  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
1546
  mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
1397
1547
  },
1398
1548
  },
1399
1549
  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.");
1550
+ 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.");
1551
+ if (params.mode !== undefined && params.mode !== AGENT_MODE.TASK && params.mode !== AGENT_MODE.BACKGROUND) throw new Error("Subagent mode must be task or background.");
1552
+ // An empty selector names no destination: only real roots are mutually
1553
+ // exclusive, so a blank string must not masquerade as a second target.
1554
+ const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== "";
1555
+ const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== "";
1556
+ if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive.");
1401
1557
  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.");
1558
+ 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
1559
  const { agents } = discoverAgents(roots(ctx));
1403
1560
  const agent = agents.find((candidate) => candidate.name === params.agent);
1404
1561
  if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" });
@@ -1407,10 +1564,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1407
1564
  policy: resolveBackgroundSubagentsPolicy(ctx.cwd).policy,
1408
1565
  parentMode: ctx.mode,
1409
1566
  });
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);
1567
+ const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined;
1568
+ const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined;
1569
+ 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, workspaceRoot, signal, repositoryRoot), signal);
1414
1570
  },
1415
1571
  );
1416
1572
 
@@ -1424,7 +1580,10 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1424
1580
  if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1425
1581
  // The parent just pulled a finished result; its pending completion must
1426
1582
  // never be replayed on top of it.
1427
- if (isFinished(task.status)) completions.consume(task.id);
1583
+ if (isFinished(task.status)) {
1584
+ completions.consume(task.id);
1585
+ messages.invalidateTask(task.id);
1586
+ }
1428
1587
  return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task));
1429
1588
  });
1430
1589
 
@@ -1434,12 +1593,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1434
1593
  });
1435
1594
 
1436
1595
  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() ?? "");
1596
+ const taskId = String(params.task_id);
1597
+ const requestId = String(params.request_id);
1598
+ const accepted = await runner.reply(taskId, requestId, typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? "");
1438
1599
  return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" });
1439
1600
  });
1440
1601
 
1441
1602
  tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
1442
1603
  const id = String(params.task_id);
1604
+ messages.invalidateTask(id);
1443
1605
  return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
1444
1606
  });
1445
1607
 
@@ -1451,24 +1613,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1451
1613
  tool(
1452
1614
  "continue",
1453
1615
  "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"] } } },
1616
+ { 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
1617
  async (params, ctx, signal) => {
1618
+ 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
1619
  const previous = await resolveTask(String(params.task_id));
1457
1620
  if (!previous) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1621
+ if (retiredSddAgent(previous.agent)) return text("Error: retired SDD agents cannot be continued.", { error: "retired SDD delegation" });
1458
1622
  if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" });
1459
1623
  // Continuing acts on the previous result, so any pending completion for
1460
1624
  // it is already consumed by the parent.
1461
1625
  completions.consume(previous.id);
1626
+ messages.invalidateTask(previous.id);
1462
1627
  const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent);
1463
1628
  if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" });
1464
1629
  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
1630
  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);
1631
+ return launch(ctx, await buildRequest(ctx, agent, String(params.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
1632
  },
1473
1633
  );
1474
1634
 
@@ -1504,6 +1664,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1504
1664
  // A resumed, reloaded, or replaced session starts with an empty completion
1505
1665
  // queue so nothing pending from another session can replay here.
1506
1666
  completions.dropAll();
1667
+ messages.dropAll();
1668
+ resetParentDelivery(ctx);
1507
1669
  presence?.dispose();
1508
1670
  registryFor(ctx);
1509
1671
  showWidget(ctx);
@@ -1551,7 +1713,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1551
1713
  });
1552
1714
  pi.on("session_shutdown", async () => {
1553
1715
  completions.dropAll();
1716
+ messages.dropAll();
1554
1717
  activeAgentRuns = 0;
1718
+ resetParentDelivery(undefined);
1555
1719
  presence?.dispose();
1556
1720
  presence = undefined;
1557
1721
  rpcActivityPublisher?.stop();
@@ -1560,9 +1724,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1560
1724
  for (const view of overlays) { view.handleInput("q"); view.dispose(); }
1561
1725
  overlays.clear();
1562
1726
  sessions = undefined;
1727
+ stopVisualUpdates?.();
1728
+ stopVisualUpdates = undefined;
1563
1729
  sidebarTui = undefined;
1564
1730
  worktrees?.close();
1565
1731
  worktrees = undefined;
1732
+ worktreeManager = undefined;
1566
1733
  const stopped = shutdownSessionTransport();
1567
1734
  runner.cancelAll("cancelled: parent session shut down");
1568
1735
  await stopped;