gentle-pi 3.6.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 (320) 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 +95 -30
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +117 -235
  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 +509 -275
  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 +105 -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/foreign-target-grants.ts +32 -0
  55. package/lib/gentle-ai-elapsed-store.ts +87 -0
  56. package/lib/gentle-ai-renderer.ts +196 -39
  57. package/lib/gentle-shell-launcher.ts +24 -13
  58. package/lib/gentle-shell-resume-hint.ts +176 -0
  59. package/lib/history-capture-policy.ts +95 -0
  60. package/lib/model-routing-authority.ts +5 -1
  61. package/lib/nan-provider.ts +227 -0
  62. package/lib/native-review-cli.ts +53 -108
  63. package/lib/odd-phase-inference.ts +231 -0
  64. package/lib/odd-phase.ts +141 -0
  65. package/lib/overlay-repaint.ts +26 -0
  66. package/lib/pi-tui-keys.ts +53 -0
  67. package/lib/review-candidate-view-owner.ts +67 -17
  68. package/lib/review-candidate-view.ts +112 -25
  69. package/lib/review-reminder-receipt.ts +48 -8
  70. package/lib/review-risk-assessment.ts +156 -11
  71. package/lib/review-sidebar-state.ts +223 -0
  72. package/lib/selection-engine.ts +515 -0
  73. package/lib/session-change-capture.ts +10 -1
  74. package/lib/session-messaging-grants.ts +135 -0
  75. package/lib/session-worktree-registry.ts +14 -2
  76. package/lib/shell-bar.ts +179 -24
  77. package/lib/shell-card.ts +287 -18
  78. package/lib/shell-changes-view.ts +2 -1
  79. package/lib/shell-prompt.ts +102 -6
  80. package/lib/shell-sidebar-layout.ts +78 -14
  81. package/lib/shell-sidebar.ts +15 -1
  82. package/lib/shell-todo.ts +23 -13
  83. package/lib/shell-usage-view.ts +9 -4
  84. package/lib/shell-usage.ts +66 -10
  85. package/lib/stats-collector.ts +381 -0
  86. package/lib/stats-view.ts +431 -0
  87. package/lib/theme-customization.ts +52 -0
  88. package/lib/vim-editor-adapter.ts +379 -0
  89. package/lib/vim-normal-engine.ts +154 -0
  90. package/lib/vim-operator-engine.ts +416 -0
  91. package/lib/vim-policy.ts +49 -0
  92. package/lib/vim-visual-engine.ts +107 -0
  93. package/lib/visual-customization-policy.ts +108 -0
  94. package/lib/visual-customize-view.ts +330 -0
  95. package/lib/visual-profiles.ts +228 -0
  96. package/lib/yolo-session-policy.ts +240 -0
  97. package/package.json +20 -8
  98. package/runtime/gentle-shell-launcher.mjs +23 -12
  99. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  100. package/runtime/native-review-cli.mjs +53 -108
  101. package/runtime/review-risk-assessment.mjs +154 -9
  102. package/scripts/build-runtime-modules.mjs +1 -0
  103. package/scripts/gentle-ai-installer.mjs +14 -13
  104. package/scripts/mirror-odd-routing.mjs +2 -2
  105. package/scripts/run-test-suite.mjs +76 -0
  106. package/scripts/test-packed-runner.mjs +31 -14
  107. package/scripts/verify-package-files.mjs +11 -22
  108. package/skills/branch-pr/SKILL.md +24 -52
  109. package/skills/chained-pr/SKILL.md +31 -15
  110. package/skills/chained-pr/references/chaining-details.md +31 -20
  111. package/skills/gentle-ai/SKILL.md +9 -15
  112. package/skills/issue-creation/SKILL.md +8 -2
  113. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  114. package/skills/work-unit-commits/SKILL.md +4 -3
  115. package/tests/agent-profiles.test.ts +18 -0
  116. package/tests/agents-fake-child.ts +2 -2
  117. package/tests/agents-message-delivery.test.ts +106 -0
  118. package/tests/agents-protocol.test.ts +40 -0
  119. package/tests/agents-runner.test.ts +400 -89
  120. package/tests/agents-view-thread-identity.test.ts +169 -0
  121. package/tests/agents-view.test.ts +8 -2
  122. package/tests/agents-widget.test.ts +154 -15
  123. package/tests/append-system-prompt-route.test.ts +160 -0
  124. package/tests/append-system-prompt.test.ts +46 -0
  125. package/tests/artifact-language.test.ts +19 -213
  126. package/tests/ask-user-question.test.ts +44 -1
  127. package/tests/asset-installation-runtime.test.ts +5 -16
  128. package/tests/autonomous-guard.test.ts +69 -1
  129. package/tests/bounded-writer-admission.test.ts +95 -0
  130. package/tests/branch-pr-skill.test.ts +43 -0
  131. package/tests/card-style-policy.test.ts +55 -0
  132. package/tests/chained-pr-skill.test.ts +124 -0
  133. package/tests/child-context-files.test.ts +255 -0
  134. package/tests/child-safety.test.ts +82 -0
  135. package/tests/codemode-rendering.test.ts +491 -0
  136. package/tests/command-palette.test.ts +39 -3
  137. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  138. package/tests/destructive-command-guard.test.ts +84 -0
  139. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  140. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  145. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  146. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  148. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  149. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  150. package/tests/foreign-target-grants.test.ts +58 -0
  151. package/tests/generic-agent-tools.test.ts +54 -0
  152. package/tests/gentle-agents.test.ts +1411 -256
  153. package/tests/gentle-ai-binary.test.ts +3 -3
  154. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  155. package/tests/gentle-ai-installer.test.ts +68 -54
  156. package/tests/gentle-ai-renderer.test.ts +487 -8
  157. package/tests/gentle-ai.test.ts +288 -65
  158. package/tests/gentle-card-text.ts +2 -1
  159. package/tests/gentle-shell-bin.test.ts +651 -114
  160. package/tests/gentle-shell-launcher.test.ts +99 -52
  161. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  162. package/tests/gentle-shell.test.ts +3839 -187
  163. package/tests/gentle-stats.test.ts +152 -0
  164. package/tests/gentle-theme.test.ts +4 -1
  165. package/tests/gentle-todo.test.ts +80 -8
  166. package/tests/history-atomic-write.test.ts +57 -0
  167. package/tests/history-capture-policy.test.ts +102 -0
  168. package/tests/history-command-registration.test.ts +164 -0
  169. package/tests/history-dedupe-entries.test.ts +123 -0
  170. package/tests/history-delete-backfill.test.ts +190 -0
  171. package/tests/history-delete-confirm.test.ts +460 -0
  172. package/tests/history-dispatch.test.ts +180 -0
  173. package/tests/history-drain-hidden.test.ts +110 -0
  174. package/tests/history-drain-order.test.ts +98 -0
  175. package/tests/history-expanded-globals.test.ts +62 -0
  176. package/tests/history-gc.test.ts +832 -0
  177. package/tests/history-header-layout.test.ts +265 -0
  178. package/tests/history-hide-prompts.test.ts +275 -0
  179. package/tests/history-lazy-windowing.test.ts +508 -0
  180. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  181. package/tests/history-load-shared-history.test.ts +53 -0
  182. package/tests/history-max-results-cap.test.ts +76 -0
  183. package/tests/history-multi-reader.test.ts +203 -0
  184. package/tests/history-off-path.test.ts +170 -0
  185. package/tests/history-openflow-integration.test.ts +173 -0
  186. package/tests/history-overlay-margin.test.ts +326 -0
  187. package/tests/history-preview-layout.test.ts +93 -0
  188. package/tests/history-registry.test.ts +143 -0
  189. package/tests/history-scope-delete.test.ts +411 -0
  190. package/tests/history-search-caret-keys.test.ts +142 -0
  191. package/tests/history-seed-bootstrap.test.ts +170 -0
  192. package/tests/history-seed-regen.test.ts +129 -0
  193. package/tests/history-selector-windowing.test.ts +94 -0
  194. package/tests/history-session-scan-directory.test.ts +87 -0
  195. package/tests/history-session-scan-extract.test.ts +583 -0
  196. package/tests/history-session-writer.test.ts +351 -0
  197. package/tests/history-store-paths.test.ts +79 -0
  198. package/tests/history-tombstone-exact.test.ts +139 -0
  199. package/tests/history-wheel-mouse.test.ts +242 -0
  200. package/tests/inprocess-reviewer.test.ts +29 -19
  201. package/tests/issue-creation-skill.test.ts +61 -0
  202. package/tests/model-routing-authority.test.ts +16 -0
  203. package/tests/nan-provider.test.ts +471 -0
  204. package/tests/native-review-capability-contract.test.ts +13 -1
  205. package/tests/native-review-cli.test.ts +6 -120
  206. package/tests/native-review-parity-runtime.test.ts +100 -3
  207. package/tests/odd-integration.test.ts +67 -0
  208. package/tests/odd-phase-inference.test.ts +213 -0
  209. package/tests/odd-phase-loader.test.ts +253 -0
  210. package/tests/odd-phase.test.ts +307 -0
  211. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  212. package/tests/odd-routing-contract.test.ts +86 -35
  213. package/tests/orchestrator-budget.test.ts +14 -39
  214. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  215. package/tests/overlay-repaint.test.ts +74 -0
  216. package/tests/package-manifest.test.ts +252 -115
  217. package/tests/packed-runner-owned-path.test.ts +46 -0
  218. package/tests/persona-single-channel.test.ts +6 -6
  219. package/tests/provider-defect-handoff.test.ts +3 -11
  220. package/tests/quiet-bash-runtime.test.ts +76 -0
  221. package/tests/quiet-tool-rendering.test.ts +409 -184
  222. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  223. package/tests/rdd-status-line.test.ts +9 -4
  224. package/tests/resume-hint-extension.test.ts +122 -0
  225. package/tests/review-agent-end-preflight.test.ts +176 -12
  226. package/tests/review-candidate-owner-retry.test.ts +22 -1
  227. package/tests/review-candidate-view.test.ts +298 -0
  228. package/tests/review-contract-prompt.test.ts +108 -43
  229. package/tests/review-controller-lock-status.test.ts +0 -1
  230. package/tests/review-controller-native-routing.test.ts +611 -5
  231. package/tests/review-controller-workspace-root.test.ts +163 -4
  232. package/tests/review-host-relay-routing.test.ts +338 -2
  233. package/tests/review-integration-v2-forward.test.ts +200 -0
  234. package/tests/review-ledger-contract.test.ts +10 -34
  235. package/tests/review-reminder-receipt.test.ts +47 -1
  236. package/tests/review-risk-assessment.test.ts +498 -6
  237. package/tests/review-sidebar-state.test.ts +402 -0
  238. package/tests/run-test-suite.test.ts +124 -0
  239. package/tests/runtime-harness.mjs +145 -786
  240. package/tests/runtime-metrics-children.test.ts +16 -23
  241. package/tests/selection-engine.test.ts +421 -0
  242. package/tests/session-change-capture.test.ts +12 -1
  243. package/tests/session-messaging-grants.test.ts +255 -0
  244. package/tests/session-worktree-registry.test.ts +77 -0
  245. package/tests/shell-bar.test.ts +382 -1
  246. package/tests/shell-card.test.ts +353 -1
  247. package/tests/shell-changes-view.test.ts +52 -0
  248. package/tests/shell-prompt.test.ts +94 -2
  249. package/tests/shell-sidebar-layout.test.ts +325 -21
  250. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  251. package/tests/shell-todo.test.ts +87 -1
  252. package/tests/shell-usage-view.test.ts +27 -0
  253. package/tests/shell-usage.test.ts +73 -0
  254. package/tests/skill-registry.test.ts +50 -1
  255. package/tests/startup-banner.test.ts +130 -2
  256. package/tests/stats-collector.test.ts +195 -0
  257. package/tests/stats-view.test.ts +202 -0
  258. package/tests/telemetry-trigger.test.ts +81 -20
  259. package/tests/theme-customization.test.ts +72 -0
  260. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  261. package/tests/vim-editor-adapter.test.ts +804 -0
  262. package/tests/vim-normal-engine.test.ts +101 -0
  263. package/tests/vim-operator-engine.test.ts +215 -0
  264. package/tests/vim-policy.test.ts +19 -0
  265. package/tests/vim-visual-engine.test.ts +52 -0
  266. package/tests/visual-customization-policy.test.ts +110 -0
  267. package/tests/visual-customize-view.test.ts +418 -0
  268. package/tests/visual-profiles.test.ts +87 -0
  269. package/tests/yolo-customize.test.ts +256 -0
  270. package/tests/yolo-mode-runtime.test.ts +161 -0
  271. package/tests/yolo-mode.test.ts +261 -0
  272. package/tests/yolo-session-policy.test.ts +59 -0
  273. package/themes/Gentle.json +2 -1
  274. package/themes/Gentleman-Cute.json +2 -1
  275. package/themes/Gentleman-Sexy.json +2 -1
  276. package/assets/agents/sdd-apply.md +0 -159
  277. package/assets/agents/sdd-archive.md +0 -228
  278. package/assets/agents/sdd-design.md +0 -49
  279. package/assets/agents/sdd-explore.md +0 -48
  280. package/assets/agents/sdd-init.md +0 -56
  281. package/assets/agents/sdd-onboard.md +0 -52
  282. package/assets/agents/sdd-proposal.md +0 -64
  283. package/assets/agents/sdd-remediate.md +0 -37
  284. package/assets/agents/sdd-research.md +0 -49
  285. package/assets/agents/sdd-spec.md +0 -192
  286. package/assets/agents/sdd-status.md +0 -54
  287. package/assets/agents/sdd-tasks.md +0 -108
  288. package/assets/agents/sdd-verify.md +0 -124
  289. package/assets/chains/sdd-full.chain.md +0 -83
  290. package/assets/chains/sdd-plan.chain.md +0 -56
  291. package/assets/chains/sdd-verify.chain.md +0 -43
  292. package/assets/sdd-orchestrator-workflow.md +0 -319
  293. package/assets/support/sdd-status-contract.md +0 -77
  294. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  295. package/extensions/sdd-init.ts +0 -816
  296. package/lib/openspec-deltas.ts +0 -156
  297. package/lib/sdd-preflight.ts +0 -1066
  298. package/lib/sdd-research-capabilities.ts +0 -94
  299. package/lib/sdd-status.ts +0 -26
  300. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  301. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  302. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  303. package/tests/openspec-deltas.test.ts +0 -209
  304. package/tests/sdd-agent-tools.test.ts +0 -156
  305. package/tests/sdd-archive-replay.test.ts +0 -82
  306. package/tests/sdd-classical-continuation.test.ts +0 -74
  307. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  308. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  309. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  310. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  311. package/tests/sdd-odd-integration.test.ts +0 -33
  312. package/tests/sdd-optional-research.test.ts +0 -124
  313. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  314. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  315. package/tests/sdd-preflight.test.ts +0 -541
  316. package/tests/sdd-research-capabilities.test.ts +0 -114
  317. package/tests/sdd-research-live.test.ts +0 -241
  318. package/tests/sdd-selection-transport.test.ts +0 -653
  319. package/tests/sdd-status.test.ts +0 -9
  320. package/tests/sdd-task-truth.test.ts +0 -43
@@ -1,32 +1,38 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import { agentsViewKey, agentsCollapseKey, agentsStopKey } from "../lib/agents-keys.ts";
3
- import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
4
- import { NativeReviewCliV216, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli } from "../lib/native-review-cli.ts";
5
2
  import { spawn } from "node:child_process";
6
3
  import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
7
4
  import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
5
+ import { publishForeignSessionChange } from "../lib/session-change-capture.ts";
8
6
  import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
9
- import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
7
+ import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
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";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
12
12
  import os from "node:os";
13
- import { join, resolve, isAbsolute, sep } from "node:path";
14
- 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";
15
16
  import { Text, type TUI } from "@earendil-works/pi-tui";
16
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";
17
20
  import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
18
- 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";
19
23
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
20
24
  import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
21
25
  import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
22
- 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";
23
27
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
24
28
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
25
29
  import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts";
26
30
  import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
31
+ import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts";
27
32
  import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
28
33
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
29
34
  import { AgentsView } from "../lib/agents-view.ts";
35
+ import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
30
36
  import { PresencePublisher } from "../lib/orchestrator-presence.ts";
31
37
  import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
32
38
  import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
@@ -35,8 +41,8 @@ import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../l
35
41
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
36
42
  import { openInExternalEditor } from "./gentle-shell.ts";
37
43
  import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
38
- import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
39
- 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";
40
46
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
41
47
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
42
48
 
@@ -54,125 +60,14 @@ export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
54
60
  const RENDER_COALESCE_MS = 400;
55
61
  const CLOCK_TICK_MS = 1000;
56
62
  const TOOL_PREFIX = "subagent_";
57
- const SHIPPED_SDD_AGENT_NAME_SET = new Set(SHIPPED_SDD_AGENT_NAMES);
58
-
59
- const SDD_PHASE_BY_AGENT = {
60
- "sdd-apply": "apply",
61
- "sdd-remediate": "remediate",
62
- "sdd-verify": "verify",
63
- "sdd-archive": "archive",
64
- } as const;
65
-
66
- function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined {
67
- return Object.hasOwn(SDD_PHASE_BY_AGENT, name) ? SDD_PHASE_BY_AGENT[name as keyof typeof SDD_PHASE_BY_AGENT] : undefined;
68
- }
69
-
70
- function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
71
- if (value === undefined) return undefined;
72
- const expectedPhase = sddPhaseForAgent(agentName);
73
- if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, or archive agents.");
74
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
75
- const selection = value as Record<string, unknown>;
76
- const keys = Object.keys(selection).sort();
77
- if (keys.join(",") !== (expectedPhase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : "changeName,phase,workspaceRoot") ||
78
- typeof selection.changeName !== "string" || selection.changeName.length === 0 ||
79
- typeof selection.workspaceRoot !== "string" || selection.workspaceRoot.length === 0 ||
80
- selection.phase !== expectedPhase) {
81
- throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
82
- }
83
- if (expectedPhase === "remediate" && (typeof selection.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(selection.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
84
- return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: expectedPhase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
85
- }
86
-
87
- const REMEDIATION_SCHEMA = {
88
- type: "object", additionalProperties: false, required: ["plan"],
89
- properties: {
90
- plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
91
- 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" } },
92
- runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
93
- rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
94
- } },
95
-
96
- },
97
- };
98
-
99
- export async function confirmRemediationScope(plan: RemediationPlan, action: Record<string, unknown>, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<RemediationScope> {
100
- const cwd = plan.cwd, roots = action.allowedEditRoots;
101
- 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");
102
- const inside = (path: string, root: string) => path === root || path.startsWith(`${root}${sep}`);
103
- const editPaths = plan.editPaths ?? [];
104
- if (!Array.isArray(editPaths) || editPaths.length > 32 || new Set(editPaths).size !== editPaths.length) throw new Error("Ambiguous remediation paths");
105
- for (const path of editPaths) {
106
- 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");
107
- if (existsSync(path) && !lstatSync(path).isFile()) throw new Error("Remediation requires exact file paths, not directories");
108
- }
109
- const scope: RemediationScope = { cwd, editPaths: [...editPaths], commands: plannedCommands(plan), allowedEditRoots: [...roots] };
110
- 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");
111
- if (realpathSync(cwd) !== cwd || editPaths.some(path => canonicalArtifactPath(path) !== path)) throw new Error("Remediation scope changed during authorization");
112
- return scope;
113
- }
114
- export function remediationToolAllowed(scope: RemediationScope | undefined, cwd: string, tool: string, input: Record<string, unknown>): boolean {
115
- try {
116
- if (!scope || scope.cwd !== cwd || canonicalArtifactPath(cwd) !== cwd) return false;
117
- if (tool === "bash") return typeof input.command === "string" && scope.commands.includes(input.command);
118
- if (["edit", "write"].includes(tool)) {
119
- if (typeof input.path !== "string") return false;
120
- const path = resolve(cwd, input.path);
121
- return scope.editPaths.includes(path) && canonicalArtifactPath(path) === path && scope.allowedEditRoots.some(root => path === root || path.startsWith(`${root}${sep}`));
122
- }
123
- if (["read", "grep", "find"].includes(tool)) {
124
- if (input.path === undefined) return true;
125
- if (typeof input.path !== "string" || !input.path || isAbsolute(input.path)) return false;
126
- const path = resolve(cwd, input.path);
127
- return canonicalArtifactPath(path) === path && (path === cwd || path.startsWith(`${cwd}${sep}`));
128
- }
129
- return tool === "subagent_parent_message";
130
- } catch { return false; }
131
- }
132
-
133
- export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<Partial<TaskRequest>> {
134
- const selected = request.sddChange;
135
- const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
136
- 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");
137
- const owned = parseAgentDefinition(readFileSync(asset, "utf8"), asset, "global");
138
- const installed = parseAgentDefinition(readFileSync(request.agent.filePath, "utf8"), request.agent.filePath, request.agent.scope);
139
- 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");
140
- const value = input as { plan?: unknown };
141
- const plan = parseRemediationPlan(value?.plan, request.cwd);
142
- const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
143
- // Legacy remediation status carries the verification failure being repaired;
144
- // that is not permission to bypass a missing native edit grant.
145
- 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");
146
- const scope = await confirmRemediationScope(plan, status.actionContext, context);
147
- return { sddRemediation: { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan } };
148
- }
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;
149
69
 
150
- // Installed only for the admitted remediation child. Stock shell execution,
151
- // cancellation, truncation and rendering remain owned by the SDK definition.
152
- export function remediationBash(cwd: string, operations: BashOperations = createLocalBashOperations(), scope?: RemediationScope) {
153
- const captured = new Map<string, { toolCallId: string; command: string; cwd: string; exitCode: number | null }>();
154
- const remaining = [...(scope?.commands ?? [])], used = new Set<string>();
155
- const stock = createBashToolDefinition(cwd);
156
- return {
157
- definition: { ...stock, async execute(...input: Parameters<typeof stock.execute>) {
158
- const [id, args, signal, onUpdate, ctx] = input;
159
- const slot = remaining.indexOf(args.command);
160
- if (!remediationToolAllowed(scope, ctx?.cwd ?? cwd, "bash", args) || slot < 0 || used.has(id)) throw new Error("Bash invocation is outside this launch human authorization");
161
- remaining.splice(slot, 1); used.add(id);
162
- const definition = createBashToolDefinition(cwd, { operations: { exec: async (command, directory, options) => {
163
- const result = await operations.exec(command, directory, options);
164
- if (captured.size < 32) captured.set(id, { toolCallId: id, command, cwd: directory, exitCode: result.exitCode });
165
- return result;
166
- } } });
167
- return definition.execute(id, args, signal, onUpdate, ctx);
168
- } },
169
- result(event: { toolCallId: string; details?: unknown }) {
170
- const observation = captured.get(event.toolCallId);
171
- captured.delete(event.toolCallId);
172
- return observation ? { details: { ...(event.details as object ?? {}), remediationCommand: observation } } : undefined;
173
- },
174
- };
175
- }
70
+ const retiredSddAgent = (name: string): boolean => /^sdd(?:-|$)/.test(name);
176
71
 
177
72
  export interface SessionTransportRegistry {
178
73
  list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]>;
@@ -199,7 +94,6 @@ export interface SessionTransportFactory {
199
94
  }
200
95
 
201
96
  export interface AgentsDeps extends RunnerDeps {
202
- nativeSdd?: NativeReviewCli;
203
97
  home: string;
204
98
  agentHome?: string;
205
99
  childIpc?: IpcEndpoint;
@@ -209,6 +103,22 @@ export interface AgentsDeps extends RunnerDeps {
209
103
  runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
210
104
  metricsNow?: () => number;
211
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
+ }
212
122
  }
213
123
 
214
124
  export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
@@ -231,9 +141,11 @@ const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
231
141
  return () => clearTimeout(timer);
232
142
  },
233
143
  pi: piCommand(),
144
+ resolvePi: () => piCommand(),
234
145
  home: os.homedir(),
235
146
  resolveWorktree: resolveSessionWorktree,
236
147
  env,
148
+ childExtensionPaths: childContextExtensionPaths(),
237
149
  });
238
150
 
239
151
  export function agentsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -319,6 +231,22 @@ function text(value: string, details: Record<string, unknown> = {}, terminate =
319
231
  return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
320
232
  }
321
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
+
322
250
  function taskDetails(task: TaskRecord): Record<string, unknown> {
323
251
  return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } };
324
252
  }
@@ -361,12 +289,24 @@ export function resolveDefaultSubagentMode(input: {
361
289
  }
362
290
 
363
291
  // What the model reads when a background task ends: the outcome first, then
364
- // the answer itself. The card renderer shows the same text.
292
+ // the answer itself. The expanded card shows the same text.
365
293
  export function completionText(task: TaskRecord): string {
366
294
  const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " ");
367
295
  return `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome}.\n\n${finishedText(task)}`;
368
296
  }
369
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
+
370
310
  // Host-side answer to a child's dialog: the same ctx.ui the human already
371
311
  // uses, so a subagent's question looks like any other pi dialog.
372
312
  export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, ask: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer> {
@@ -393,66 +333,21 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
393
333
  }
394
334
  }
395
335
 
396
- const RESEARCH_SELECTION_SCHEMA = {
397
- type: "object",
398
- additionalProperties: false,
399
- description: "Untrusted narrowing intent for sdd-research; exact tools and existing sourceInfo.path per tool. Never grants permissions or installs extensions.",
400
- properties: Object.fromEntries(["documentation", "open-web"].map(kind => [kind, {
401
- type: "object", additionalProperties: false, required: ["tools", "extensions"],
402
- properties: {
403
- tools: { type: "array", items: { type: "string" } },
404
- extensions: { type: "object", additionalProperties: { type: "string" } },
405
- },
406
- }])),
407
- };
408
-
409
336
  export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<AgentsDeps> = {}): void {
410
- if (env.GENTLE_PI_AGENTS_CHILD === "1" && env[RESEARCH_CHILD_TOOLS_ENV] !== undefined) {
411
- let allowed: string[] = [];
412
- try {
413
- const parsed: unknown = JSON.parse(env[RESEARCH_CHILD_TOOLS_ENV]!);
414
- if (Array.isArray(parsed) && parsed.every(value => typeof value === "string")) allowed = parsed;
415
- } catch { /* Invalid launch restrictions deny every tool. */ }
416
- let selection: unknown;
417
- try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
418
- const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
419
- pi.on("before_agent_start", event => ({
420
- 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.`,
421
- }));
422
- pi.on("tool_call", event => {
423
- const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
424
- const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
425
- if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
426
- return { block: true, reason: "Tool is outside the output-only research child's active launch allowlist." };
427
- }
428
- });
429
- }
430
337
  const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
431
338
  if (env.GENTLE_PI_AGENTS_CHILD === "1") {
432
- if (env[REMEDIATION_PLAN_ENV] !== undefined) {
433
- let granted: RemediationScope | undefined;
434
- pi.on("tool_call", (event, current) => remediationToolAllowed(granted, current.cwd, event.toolName, event.input) ? undefined : { block: true, reason: "Outside exact remediation human authorization" });
435
- pi.on("session_start", (_event, ctx) => {
436
- granted = undefined;
437
- try {
438
- const retained = JSON.parse(env[REMEDIATION_PLAN_ENV]!);
439
- // SDK flags are owner-local; the runner transports the same selected context.
440
- const selection = Object.hasOwn(retained, "selection") ? retained.selection : JSON.parse(String(pi.getFlag("gentle-sdd-change")));
441
- parseSddChange(selection, "sdd-remediate");
442
- const plan = parseRemediationPlan(retained.plan, ctx.cwd);
443
- 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");
444
- const shell = remediationBash(ctx.cwd, undefined, retained.scope);
445
- pi.registerTool(shell.definition);
446
- pi.on("tool_result", event => event.toolName === "bash" ? shell.result(event) : undefined);
447
- granted = retained.scope;
448
- } catch { /* No valid host grant: deny every tool, even if Pi continues initialization. */ }
449
- });
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;
450
343
  }
451
344
  if (childIpc) registerChildMessaging(pi, childIpc);
452
345
  return;
453
346
  }
454
347
  if (!agentsEnabled(env)) return;
455
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;
456
351
  const selectedHome = overrides.agentHome ?? (overrides.home === undefined ? resolveGentlePiAgentHome(deps.env) : join(deps.home, ".pi", "agent"));
457
352
  // Expand environment tildes like Pi, but leave explicit path APIs literal.
458
353
  const environmentHome = overrides.agentHome === undefined && overrides.home === undefined;
@@ -476,6 +371,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
476
371
  let ui: ExtensionContext["ui"] | undefined;
477
372
  let host: { requestRender(): void } | undefined;
478
373
  let sidebarTui: TUI | undefined;
374
+ const visualHome = gentlePiConfigHome(env);
375
+ let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
376
+ let stopVisualUpdates: (() => void) | undefined;
479
377
  let sessions: ExtensionContext["sessionManager"] | undefined;
480
378
  let presence: PresencePublisher | undefined;
481
379
  let rpcActivityPublisher: RpcActivityPublisher | undefined;
@@ -496,9 +394,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
496
394
  } catch { presence?.dispose(); presence = undefined; }
497
395
  };
498
396
  let worktrees: SessionWorktreeRegistry | undefined;
397
+ let worktreeManager: ExtensionContext["sessionManager"] | undefined;
398
+ let worktreeAuthority: (() => boolean) | undefined;
399
+ const foreignGrants = new ForeignTargetGrants();
400
+ const messagingGrants = new SessionMessagingGrants();
401
+ const foreignTasks = new Map<string, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
402
+ const foreignRequests = new WeakMap<TaskRequest, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
499
403
  const registryFor = (ctx: ExtensionContext) => {
500
- if (!worktrees || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
404
+ if (!worktrees || worktreeManager !== ctx.sessionManager || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
501
405
  worktrees?.close();
406
+ worktreeManager = ctx.sessionManager;
407
+ worktreeAuthority = sessionRepositoryAuthority(ctx.sessionManager.getCwd(), deps.resolveWorktree);
502
408
  worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree);
503
409
  }
504
410
  return worktrees;
@@ -682,20 +588,128 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
682
588
  // flushed at the next turn boundary, and a stale one never re-enters the
683
589
  // conversation.
684
590
  const completions = createCompletionQueue<TaskRecord>();
591
+ const messages = createAgentMessageQueue();
685
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
+ };
697
+
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
+ };
686
707
 
687
- const deliver = (task: TaskRecord) => {
708
+ const deliver = (task: TaskRecord, route: Exclude<ParentRoute, "hold">) => {
688
709
  // Ownership is consulted at delivery time, matching onNotification and
689
710
  // onQuery: a completion owned by another session is dropped, not delivered.
690
711
  if (activeSessionId() !== task.parentSessionId) return;
691
- // "steer" + triggerTurn keeps delivery bounded to the current turn. While
692
- // the parent streams, the host polls steering each turn and injects the
693
- // message before the next LLM call; "followUp" is NOT acceptable here
694
- // because the host drains the follow-up queue only in the run loop's stop
695
- // branch, so a parent that keeps calling tools would see the completion
696
- // only when the whole run ends — the original #867 delay. When the parent
697
- // is idle, triggerTurn runs the prompt immediately, preserving wake-up.
698
- 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);
699
713
  };
700
714
 
701
715
  // A stale completion must not re-enter the LLM conversation, so it is
@@ -706,22 +720,85 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
706
720
  pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds });
707
721
  };
708
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
+
709
754
  const flushCompletions = () => {
755
+ const route = deliveryRoute(false);
756
+ if (!route) return;
710
757
  for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) {
711
758
  try {
712
759
  if (stale) deliverStale(task, settledAt);
713
- else deliver(task);
760
+ else deliver(task, route);
714
761
  } catch { /* Best-effort delivery: at most once, even if forwarding fails. */ }
715
762
  }
716
763
  };
717
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
+
718
794
  // A completion settles into our queue. An idle parent flushes right away so
719
795
  // the wake-up behavior is unchanged; a busy parent flushes at the next turn
720
796
  // boundary, and the steer mode injects it before that turn's next LLM call
721
797
  // instead of parking it behind the whole run.
722
798
  const settleCompletion = (task: TaskRecord) => {
799
+ messages.invalidateTask(task.id);
723
800
  completions.enqueue(task, deps.now());
724
- if (activeAgentRuns === 0) flushCompletions();
801
+ if (activeAgentRuns === 0) flushAll();
725
802
  };
726
803
 
727
804
  // `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at
@@ -731,41 +808,63 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
731
808
  // a final `turn_end` (an aborted run, or the host's early post-run return
732
809
  // when a run produced no assistant message; the host compensates via
733
810
  // hasQueuedMessages() + continue(), so steering there is still bounded).
734
- // `agent_settled` is the final idle boundary after retries — normally a
735
- // no-op safety net, since anything enqueued while idle flushes right away.
736
- 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
+ });
737
826
  pi.on("agent_end", () => {
738
827
  activeAgentRuns = Math.max(0, activeAgentRuns - 1);
739
- flushCompletions();
828
+ flushAll();
829
+ });
830
+ pi.on("agent_settled", () => {
831
+ parentRunActive = false;
832
+ endPromptStart();
833
+ flushAll();
740
834
  });
741
- pi.on("agent_settled", () => flushCompletions());
742
- pi.on("turn_end", () => flushCompletions());
835
+ pi.on("turn_end", () => flushAll());
836
+ pi.on("session_compact", scheduleBoundaryFlush);
837
+ pi.on("session_compact_failed", scheduleBoundaryFlush);
743
838
 
744
839
  const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, {
745
840
  askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw),
746
841
  onNotification: (task, message) => {
747
- if (activeSessionId() !== task.parentSessionId) return false;
748
- 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();
749
845
  return true;
750
846
  },
751
847
  onQuery: (task, requestId, message) => {
752
- if (activeSessionId() !== task.parentSessionId) return false;
848
+ if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
753
849
  const hadYield = yieldedTaskIds.has(task.id);
754
850
  if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id);
755
851
  try {
756
- 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);
757
854
  return true;
758
855
  } catch (error) {
856
+ messages.expireQuery(task.id, requestId);
759
857
  if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id);
760
858
  throw error;
761
859
  }
762
860
  },
861
+ onQuerySettled: (taskId, requestId, outcome) => {
862
+ if (outcome === "replied") messages.consumeQuery(taskId, requestId);
863
+ else messages.expireQuery(taskId, requestId);
864
+ },
763
865
  onSuccessfulMutation: (task, tool) => {
764
- // Guard chain and posture are unchanged: only owned tasks of the
765
- // active parent, inside registered roots, ever relay. The notes only
766
- // explain a drop -- they never widen or narrow what gets attributed.
767
- // The cheap session/ownership guards decide before any worktree
768
- // resolution, so a foreign or stale mutation never reaches git.
866
+ // Same-clone registry attribution remains unchanged. A foreign task
867
+ // uses a separately bound, live-grant path only for successful tool evidence.
769
868
  let root: string | undefined;
770
869
  let childRoot: string | undefined;
771
870
  const noteDrop = (guard: string) => {
@@ -782,13 +881,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
782
881
  childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
783
882
  if (!root) return noteDrop("root-unresolved");
784
883
  if (root !== childRoot) return noteDrop("root-mismatch");
785
- if (!worktrees.roots().includes(root)) return noteDrop("root-not-registered");
884
+ const foreignTask = foreignTasks.get(task.id);
885
+ if (foreignTask) {
886
+ if (sessions !== foreignTask.manager || root !== foreignTask.root || tool.evidence?.root !== root) return noteDrop("foreign-identity-mismatch");
887
+ const identity = resolveSessionWorktree(root, root);
888
+ if (!identity || identity.commonDir !== foreignTask.commonDir) return noteDrop("foreign-identity-drift");
889
+ try { foreignGrants.assertCurrent({ sessionManager: sessions }, identity); }
890
+ catch { return noteDrop("foreign-grant-lost"); }
891
+ } else if (!worktrees.roots().includes(root)) return noteDrop("root-not-registered");
786
892
  if (!tool.evidence) {
787
893
  noteDrop("evidence-missing");
788
894
  } else if (tool.evidence.root !== root) {
789
895
  noteDrop("evidence-root-mismatch");
790
896
  } else {
791
- let path = tool.path.replace(/^@/, "");
897
+ let path = tool.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
792
898
  if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
793
899
  let resolvedPath: string | undefined;
794
900
  try {
@@ -796,10 +902,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
796
902
  } catch {
797
903
  noteDrop("evidence-path-unreadable");
798
904
  }
799
- if (resolvedPath === resolve(root, tool.evidence.path)) pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence: { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` } });
905
+ if (resolvedPath === resolve(root, tool.evidence.path)) {
906
+ const evidence = { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` };
907
+ if (foreignTask) publishForeignSessionChange(pi, task.parentSessionId, evidence);
908
+ else pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence });
909
+ }
800
910
  else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch");
801
911
  }
802
- 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) });
803
913
  },
804
914
  onFinish: (task, observations) => {
805
915
  // Completion is the only forwarding opportunity. No pending event, policy
@@ -817,6 +927,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
817
927
  } catch { /* Metrics must never interrupt task finalization. */ }
818
928
  try {
819
929
  ownedTaskIds.delete(task.id);
930
+ messages.invalidateTask(task.id);
820
931
  requestRender();
821
932
  persist(task);
822
933
  const yielded = yieldedTaskIds.delete(task.id);
@@ -845,12 +956,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
845
956
  pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
846
957
  const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
847
958
  const content = message.content as string | Array<{ type: string; text?: string }>;
848
- 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() !== "");
849
961
  const tone = details?.status === "completed" ? CARD_TONE.SUCCESS : CARD_TONE.ERROR;
850
962
  const hint = expandHint(options.expanded);
851
963
  return {
852
964
  render(width: number) {
853
- 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 });
854
966
  },
855
967
  invalidate() {},
856
968
  };
@@ -872,7 +984,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
872
984
  ];
873
985
  return {
874
986
  render(width: number) {
875
- 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) });
876
988
  },
877
989
  invalidate() {},
878
990
  };
@@ -976,6 +1088,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
976
1088
  let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined;
977
1089
  const chosen = await ctx.ui.custom<TaskRecord | null>(
978
1090
  (tui, theme, _keybindings, done) => {
1091
+ const close = withOverlayRepaint(tui, done);
979
1092
  overlayHost = tui;
980
1093
  view = new AgentsView({
981
1094
  theme,
@@ -990,8 +1103,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
990
1103
  onCancel: (task) => void stopSelected(task, ctx),
991
1104
  canCancel: isOwnedActive,
992
1105
  isLocalTask: (task) => !restoredTaskIds.has(task.id),
993
- onOpen: (task) => done(task),
994
- onClose: () => done(null),
1106
+ onOpen: (task) => close(task),
1107
+ onClose: () => close(null),
995
1108
  requestRender: () => tui.requestRender(),
996
1109
  });
997
1110
  overlays.add(view);
@@ -1044,6 +1157,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1044
1157
  const showWidget = (ctx: ExtensionContext) => {
1045
1158
  ui = ctx.hasUI ? ctx.ui : undefined;
1046
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
+ });
1047
1168
  tickClock();
1048
1169
  // Agents is not a rail part: the fullscreen sidebar only suppresses
1049
1170
  // bottom components registered through sidebarPart, and this widget is
@@ -1053,6 +1174,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1053
1174
  sidebarTui = tui;
1054
1175
  return {
1055
1176
  render(width: number) {
1177
+ if (!agentsVisible) return [];
1056
1178
  const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
1057
1179
  return lines.length === 0 ? [] : [...lines, ""];
1058
1180
  },
@@ -1063,21 +1185,59 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1063
1185
 
1064
1186
  const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
1065
1187
 
1066
- const buildRequest = (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, sddChange?: SddChangeSelection, researchSelection?: unknown, remediationIntent?: unknown): 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);
1194
+ if (signal?.aborted) throw new Error("Subagent launch aborted before authorization.");
1067
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
+ }
1068
1214
  const parentCwd = ctx.sessionManager.getCwd();
1069
1215
  // An explicit target is validated before any queue or session-dir writes.
1070
1216
  const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
1071
- const selectedRoot = workspaceRoot ?? sddChange?.workspaceRoot;
1217
+ if (repositoryRoot !== undefined && workspaceRoot !== undefined) throw new Error("repository_root and workspace_root are mutually exclusive.");
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;
1072
1220
  // Preserve ordinary non-Git continuation, without admitting any new root.
1073
- const sameNonGitContinuation = resume !== undefined && selectedRoot === parentCwd && !parentIdentity;
1074
- const target = selectedRoot !== undefined && !sameNonGitContinuation ? registry.validate(selectedRoot) : parentIdentity?.root;
1075
- if (sddChange && target !== sddChange.workspaceRoot && target !== resolve(sddChange.workspaceRoot)) {
1076
- throw new Error("sdd_change workspaceRoot must resolve to the selected child worktree.");
1077
- }
1078
- const launchSddChange = sddChange === undefined || target === undefined
1079
- ? undefined
1080
- : { ...sddChange, workspaceRoot: target };
1221
+ const sameNonGitContinuation = resume !== undefined && repositoryRoot === undefined && selectedRoot === parentCwd && !parentIdentity;
1222
+ let foreign = false;
1223
+ let foreignIdentity: { root: string; commonDir: string } | undefined;
1224
+ let foreignParent: { root: string; commonDir: string } | undefined;
1225
+ let target: string | undefined;
1226
+ if (selectedRoot !== undefined && !sameNonGitContinuation) {
1227
+ if (repositoryRoot === undefined) target = registry.validate(selectedRoot);
1228
+ else {
1229
+ // Only an explicitly selected canonical foreign Git root can escape the
1230
+ // same-clone registry. Never use a caller-injected resolver for this identity.
1231
+ const identity = resolveSessionWorktree(selectedRoot, parentCwd);
1232
+ const canonicalParent = resolveSessionWorktree(parentCwd, parentCwd);
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.");
1234
+ await foreignGrants.authorize(ctx, identity, { continuation: resume !== undefined, signal });
1235
+ foreignIdentity = identity;
1236
+ foreignParent = canonicalParent;
1237
+ target = identity.root;
1238
+ foreign = true;
1239
+ }
1240
+ } else target = parentIdentity?.root;
1081
1241
  // A per-repository profile pin replaces subagent routing for this launch without
1082
1242
  // materialising anything: the global stores stay untouched, so two repositories
1083
1243
  // worked in parallel stop fighting over one global routing. The child's own
@@ -1086,7 +1246,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1086
1246
  // launch stays in the session's own worktree the identity already resolved above
1087
1247
  // is reused instead of asking Git a second time. The orchestrator is out of
1088
1248
  // scope: only `modelProfiles` is replaced.
1089
- const pinIdentity: WorktreeResolver = target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
1249
+ const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
1090
1250
  ? () => parentIdentity
1091
1251
  : deps.resolveWorktree;
1092
1252
  const config = withPinnedModelProfiles(
@@ -1098,35 +1258,52 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1098
1258
  })?.modelProfiles,
1099
1259
  );
1100
1260
  const profile = resolveAgentProfile(agent, config);
1101
- 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
+ }
1102
1266
  const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
1267
+ if (foreign && target) {
1268
+ const identity = resolveSessionWorktree(target, parentCwd);
1269
+ if (!identity || identity.root !== target || identity.commonDir !== foreignIdentity?.commonDir) throw new Error("Foreign clone identity changed before launch.");
1270
+ foreignGrants.assertCurrent(ctx, identity);
1271
+ }
1272
+ if (signal?.aborted) throw new Error("Subagent launch aborted before queueing.");
1103
1273
  mkdirSync(sessionDir, { recursive: true });
1104
1274
  const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager;
1105
1275
  const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
1106
1276
  const parentWorktreeRoot = ctx.sessionManager.getCwd();
1107
1277
  const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot);
1108
- const sddPreflightContext = SHIPPED_SDD_AGENT_NAME_SET.has(agent.name)
1109
- ? extractParentConfirmedSddPreflightContext(context)
1110
- : undefined;
1111
- return {
1112
- agent: research?.agent ?? agent,
1113
- remediationIntent,
1278
+ const childEnv = { ...deps.env };
1279
+ if (foreign) for (const key of inheritedUnsafeGitEnvironmentKeys(childEnv)) delete childEnv[key];
1280
+ const request: TaskRequest = {
1281
+ agent,
1114
1282
  prompt,
1115
1283
  label,
1116
1284
  context,
1117
- ...(sddPreflightContext === undefined ? {} : { sddPreflightContext }),
1118
1285
  mode,
1119
1286
  cwd: target ?? parentWorktreeRoot,
1120
1287
  parentSessionId,
1121
- ...(target === undefined ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }),
1288
+ ...(admittedModel === undefined ? {} : { beforeSpawn: () => {
1289
+ if (!current()) throw new Error("Writer session changed before spawn.");
1290
+ registry.validate(originalCwd);
1291
+ } }),
1292
+ ...(foreign && target ? { beforeSpawn: () => {
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.");
1294
+ const identity = resolveSessionWorktree(target, parentCwd);
1295
+ const parent = resolveSessionWorktree(parentCwd, parentCwd);
1296
+ if (!identity || identity.root !== target || identity.commonDir !== foreignIdentity?.commonDir || parent?.root !== foreignParent?.root || parent?.commonDir !== foreignParent?.commonDir) throw new Error("Foreign clone identity changed before spawn.");
1297
+ foreignGrants.assertCurrent(ctx, identity);
1298
+ } } : {}),
1299
+ ...(target === undefined || foreign ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }),
1122
1300
  model: profile.model,
1123
1301
  thinking: profile.thinking,
1124
1302
  sessionDir,
1125
1303
  resumeSessionPath: resume,
1126
- env: research ? { ...deps.env, [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify([...research.agent.tools, "subagent_parent_message"]) } : deps.env,
1127
- ...(research ? { researchSelection, extensionPaths: research.extensionPaths } : {}),
1128
- ...(launchSddChange === undefined ? {} : { sddChange: launchSddChange }),
1129
- ...(parentRepositoryIdentity === undefined ? {} : {
1304
+ ...(deps.childExtensionPaths && deps.childExtensionPaths.length > 0 ? { extensionPaths: [...deps.childExtensionPaths] } : {}),
1305
+ env: childEnv,
1306
+ ...(foreign || parentRepositoryIdentity === undefined ? {} : {
1130
1307
  authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
1131
1308
  try {
1132
1309
  return repositoryIdentity === parentRepositoryIdentity &&
@@ -1144,23 +1321,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1144
1321
  },
1145
1322
  }),
1146
1323
  };
1324
+ if (foreign && target && foreignIdentity) foreignRequests.set(request, { root: target, commonDir: foreignIdentity.commonDir, manager: ctx.sessionManager });
1325
+ return request;
1147
1326
  };
1148
1327
 
1149
1328
  const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal): Promise<ToolText> => {
1150
1329
  if (ctx.mode === "print" && request.mode === AGENT_MODE.BACKGROUND) {
1151
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.");
1152
1331
  }
1153
- // This is the process-spawn boundary. A child receives its task context only
1154
- // after its RPC process starts, so validate the single parent transport here
1155
- // rather than letting a child invent/persist preferences during startup.
1156
- if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
1157
- throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
1158
- }
1159
- if (request.agent.name === "sdd-remediate") {
1160
- const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1161
- Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, ctx));
1162
- if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; request fresh authorization before dispatch");
1163
- }
1332
+ if (retiredSddAgent(request.agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
1164
1333
 
1165
1334
  // Bounded live observation only. Native send owns the fresh policy decision;
1166
1335
  // child execution never starts a telemetry policy process or renewal timer.
@@ -1170,8 +1339,16 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1170
1339
  current: () => owner === metricsOwner && request.parentSessionId === activeSessionId() && runtimeMetricsEnvAllows(deps.env),
1171
1340
  valid: () => !metrics.finished && metrics.current() };
1172
1341
  const observe = runtimeMetricsEnvAllows(deps.env) && metricTasks.size < 256;
1342
+ let launched = false;
1343
+ let launchedTaskId: string | undefined;
1173
1344
  const task = runner.run({ ...request, collectResponseObservations: false,
1174
- onLaunch: () => { metrics.launched = true; request.onLaunch?.(); },
1345
+ onLaunch: () => {
1346
+ metrics.launched = true;
1347
+ request.onLaunch?.();
1348
+ launched = true;
1349
+ const foreign = foreignRequests.get(request);
1350
+ if (foreign && launchedTaskId) foreignTasks.set(launchedTaskId, foreign);
1351
+ },
1175
1352
  ...(observe ? { canCollectResponseObservations: metrics.valid, prepareResponseObservations: async () => {
1176
1353
  if (metrics.finished || owner !== metricsOwner || request.parentSessionId !== activeSessionId() || !runtimeMetricsEnvAllows(deps.env)) return false;
1177
1354
  if (!metrics.valid()) return false;
@@ -1181,6 +1358,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1181
1358
  } } : {}),
1182
1359
  });
1183
1360
  if (observe) metricTasks.set(task.id, metrics);
1361
+ launchedTaskId = task.id;
1362
+ const foreignRequest = foreignRequests.get(request);
1363
+ if (launched && foreignRequest) foreignTasks.set(task.id, foreignRequest);
1184
1364
  ownedTaskIds.add(task.id);
1185
1365
  store.subscribe(task.id, () => { publishActivity(); requestRender(); });
1186
1366
  if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task));
@@ -1202,10 +1382,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1202
1382
  const query = await runner.waitForQuery(task.id);
1203
1383
  if (query) {
1204
1384
  const live = store.get(task.id) ?? task;
1205
- 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
+ );
1206
1392
  }
1207
1393
  const finished = await runner.waitFor(task.id);
1208
1394
  completions.consume(finished.id);
1395
+ messages.invalidateTask(finished.id);
1209
1396
  return text(finishedText(finished), taskDetails(finished));
1210
1397
  } finally {
1211
1398
  signal?.removeEventListener("abort", onAbort);
@@ -1220,12 +1407,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1220
1407
  description,
1221
1408
  parameters: { type: "object", additionalProperties: false, ...parameters } as never,
1222
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() {} };
1223
1413
  const params = args as { agent?: string; task_id?: string };
1224
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);
1225
1415
  },
1226
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
+ }
1227
1421
  const body = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
1228
- 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);
1229
1425
  },
1230
1426
  async execute(_id, params, signal, _onUpdate, ctx) {
1231
1427
  return execute(params as Record<string, unknown>, ctx, signal);
@@ -1264,12 +1460,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1264
1460
  name: "orchestrator_send_message",
1265
1461
  label: "Send orchestrator message",
1266
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.",
1267
- 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,
1268
1473
  async execute(_id, params, signal, _onUpdate, ctx) {
1269
1474
  const transport = activeTransportFor(ctx);
1270
- const input = params as { recipient_session_id?: unknown; message?: unknown };
1475
+ const input = params as { recipient_session_id?: unknown; message?: unknown; reason?: unknown };
1271
1476
  if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
1272
- 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" }); }
1273
1482
  let recipient: string | undefined;
1274
1483
  let activation: PresenceRecord | undefined;
1275
1484
  if (input.recipient_session_id !== undefined) {
@@ -1301,7 +1510,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1301
1510
  if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1302
1511
  if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" });
1303
1512
  try {
1304
- 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 });
1305
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" });
1306
1521
  } catch {
1307
1522
  return text("Error: session message was not accepted.", { error: "not accepted" });
@@ -1326,13 +1541,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1326
1541
  task: { type: "string", description: "What the subagent must do, self-contained." },
1327
1542
  label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
1328
1543
  context: { type: "string", description: "Optional extra context appended to the task." },
1329
- workspace_root: { type: "string", description: "Optional worktree in the same Git clone. Validated before queueing; the child runs at its canonical root and registers it on actual launch." },
1330
- research_selection: RESEARCH_SELECTION_SCHEMA,
1331
- 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." },
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." },
1545
+ repository_root: { type: "string", description: "Optional canonical independent Git repository; requires direct interactive session-scoped consent before queueing; mutually exclusive with workspace_root." },
1332
1546
  mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
1333
1547
  },
1334
1548
  },
1335
1549
  async (params, ctx, signal) => {
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.");
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" });
1336
1559
  const { agents } = discoverAgents(roots(ctx));
1337
1560
  const agent = agents.find((candidate) => candidate.name === params.agent);
1338
1561
  if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" });
@@ -1341,10 +1564,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1341
1564
  policy: resolveBackgroundSubagentsPolicy(ctx.cwd).policy,
1342
1565
  parentMode: ctx.mode,
1343
1566
  });
1344
- let sddChange: SddChangeSelection | undefined;
1345
- try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1346
- catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1347
- return launch(ctx, 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);
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);
1348
1570
  },
1349
1571
  );
1350
1572
 
@@ -1358,7 +1580,10 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1358
1580
  if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1359
1581
  // The parent just pulled a finished result; its pending completion must
1360
1582
  // never be replayed on top of it.
1361
- if (isFinished(task.status)) completions.consume(task.id);
1583
+ if (isFinished(task.status)) {
1584
+ completions.consume(task.id);
1585
+ messages.invalidateTask(task.id);
1586
+ }
1362
1587
  return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task));
1363
1588
  });
1364
1589
 
@@ -1368,12 +1593,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1368
1593
  });
1369
1594
 
1370
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) => {
1371
- 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() ?? "");
1372
1599
  return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" });
1373
1600
  });
1374
1601
 
1375
1602
  tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
1376
1603
  const id = String(params.task_id);
1604
+ messages.invalidateTask(id);
1377
1605
  return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
1378
1606
  });
1379
1607
 
@@ -1385,23 +1613,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1385
1613
  tool(
1386
1614
  "continue",
1387
1615
  "Resume a finished subagent task in its own session with a follow-up prompt.",
1388
- { 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"] } } },
1389
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" });
1390
1619
  const previous = await resolveTask(String(params.task_id));
1391
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" });
1392
1622
  if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" });
1393
1623
  // Continuing acts on the previous result, so any pending completion for
1394
1624
  // it is already consumed by the parent.
1395
1625
  completions.consume(previous.id);
1626
+ messages.invalidateTask(previous.id);
1396
1627
  const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent);
1397
1628
  if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" });
1398
1629
  const mode = (params.mode as AgentMode | undefined) ?? (previous.mode as AgentMode);
1399
- let sddChange: SddChangeSelection | undefined;
1400
- try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1401
- catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1402
- if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
1403
-
1404
- return launch(ctx, buildRequest(ctx, agent, String(params.prompt ?? ""), typeof params.label === "string" ? params.label : undefined, previous.sddPreflightContext, mode, previous.sessionPath, sddChange?.workspaceRoot ?? previous.cwd, sddChange, params.research_selection, params.remediation), signal);
1630
+ const foreignContinuation = foreignTasks.has(previous.id);
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);
1405
1632
  },
1406
1633
  );
1407
1634
 
@@ -1437,6 +1664,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1437
1664
  // A resumed, reloaded, or replaced session starts with an empty completion
1438
1665
  // queue so nothing pending from another session can replay here.
1439
1666
  completions.dropAll();
1667
+ messages.dropAll();
1668
+ resetParentDelivery(ctx);
1440
1669
  presence?.dispose();
1441
1670
  registryFor(ctx);
1442
1671
  showWidget(ctx);
@@ -1484,7 +1713,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1484
1713
  });
1485
1714
  pi.on("session_shutdown", async () => {
1486
1715
  completions.dropAll();
1716
+ messages.dropAll();
1487
1717
  activeAgentRuns = 0;
1718
+ resetParentDelivery(undefined);
1488
1719
  presence?.dispose();
1489
1720
  presence = undefined;
1490
1721
  rpcActivityPublisher?.stop();
@@ -1493,9 +1724,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1493
1724
  for (const view of overlays) { view.handleInput("q"); view.dispose(); }
1494
1725
  overlays.clear();
1495
1726
  sessions = undefined;
1727
+ stopVisualUpdates?.();
1728
+ stopVisualUpdates = undefined;
1496
1729
  sidebarTui = undefined;
1497
1730
  worktrees?.close();
1498
1731
  worktrees = undefined;
1732
+ worktreeManager = undefined;
1499
1733
  const stopped = shutdownSessionTransport();
1500
1734
  runner.cancelAll("cancelled: parent session shut down");
1501
1735
  await stopped;