gentle-pi 3.7.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -1,19 +1,20 @@
1
1
  import assert from "node:assert/strict";
2
- import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { createRequire, syncBuiltinESMExports } from "node:module";
6
6
  import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
7
- import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
7
+ import { fileURLToPath } from "node:url";
8
+ import { pendingReviewMutation, pendingReviewMutationProfiles, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
8
9
  import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
9
10
  import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
10
11
  import { SessionChanges, type SessionChangeEvidence } from "../lib/session-changes.ts";
11
- import test, { after, afterEach, mock } from "node:test";
12
+ import test, { after, afterEach, before, mock } from "node:test";
12
13
  import type { TestContext } from "node:test";
13
14
  import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
14
15
  import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
15
16
  import { sidebarState } from "../lib/shell-sidebar.ts";
16
- import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
17
+ import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, agentResultPreview, answerThroughUi, childContextExtensionPaths, completionText, createDefaultSessionTransport, legacySubagentsInstalled, PARENT_WAKE_GRACE_MS, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
17
18
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
18
19
  import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
19
20
  import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
@@ -24,9 +25,14 @@ import { PresenceCursor, PresencePublisher, listPresence, readActivity } from ".
24
25
  import { stripAnsi } from "../lib/terminal-theme.ts";
25
26
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
26
27
  import { AgentRunner } from "../lib/agents-runner.ts";
27
- import type { NativeReviewCli } from "../lib/native-review-cli.ts";
28
+ import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
28
29
  import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
29
- import { renderSddPreflightPrompt } from "../lib/sdd-preflight.ts";
30
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
31
+ // The card style defaults to float; these assertions pin the outlined (neon)
32
+ // panels unless a test switches the style itself.
33
+ const initialCardStyle = cardStyle();
34
+ before(() => setCardStyle(CARD_STYLE.NEON));
35
+ after(() => setCardStyle(initialCardStyle));
30
36
 
31
37
  // Gentle Agents extension: the subagent_* tools drive isolated pi children,
32
38
  // the card above the editor follows the store, and dialogs reach the host UI.
@@ -38,19 +44,11 @@ interface Registered {
38
44
  name: string;
39
45
  execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ text: string }>; details: Record<string, unknown> }>;
40
46
  renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
47
+ renderResult(result: { content: Array<{ type: string; text: string }>; details: Record<string, unknown> }, options: { expanded: boolean }, theme: unknown): { render(width: number): string[] };
41
48
  }
42
49
 
43
50
  const plainTheme = { fg: (_color: string, text: string) => text };
44
51
  const fakeTui = { requestRender() {} };
45
- const PARENT_CONFIRMED_SDD_CONTEXT = renderSddPreflightPrompt({
46
- executionMode: "auto",
47
- artifactStore: "openspec",
48
- chainedPrStrategy: "ask-on-risk",
49
- reviewBudgetLines: 400,
50
- engramAvailable: false,
51
- prompted: true,
52
- });
53
-
54
52
  const inertSessionTransport: SessionTransportFactory = {
55
53
  createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
56
54
  createListener: (registry) => ({ registry, start: async () => {}, close: async () => {} }),
@@ -118,6 +116,15 @@ function fakePi() {
118
116
  const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
119
117
  const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
120
118
  const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
119
+ // Idle wake-ups go through pi.sendUserMessage; `delivery` records the
120
+ // interleaving of custom messages and wake-ups so ordering stays provable.
121
+ const userMessages: Array<{ content: unknown; options: Record<string, unknown> | undefined }> = [];
122
+ const delivery: string[] = [];
123
+ // A live idle flag shared by every context fired through this host, like
124
+ // Pi's ctx.isIdle(): busy from agent_start until agent_settled. Tests can
125
+ // force it to simulate compaction or other non-run busy states.
126
+ let parentIdle = true;
127
+ const setIdle = (idle: boolean) => { parentIdle = idle; };
121
128
  const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
122
129
  const entryRenderers = new Map<string, (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
123
130
  const entries: Array<{ type: string; customType: string; data: unknown }> = [];
@@ -132,7 +139,8 @@ function fakePi() {
132
139
  return () => { set.delete(listener); };
133
140
  },
134
141
  },
135
- sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => sent.push({ message, options }),
142
+ sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => { sent.push({ message, options }); delivery.push(`custom:${String(message.customType)}`); },
143
+ sendUserMessage: (content: unknown, options?: Record<string, unknown>) => { userMessages.push({ content, options }); delivery.push("user"); },
136
144
  registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer),
137
145
  registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer),
138
146
  on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
@@ -148,6 +156,9 @@ function fakePi() {
148
156
  };
149
157
  const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
150
158
  const results: unknown[] = [];
159
+ if (!("isIdle" in ctx)) Object.assign(ctx, { isIdle: () => parentIdle });
160
+ if (event === "agent_start") parentIdle = false;
161
+ else if (event === "agent_settled") parentIdle = true;
151
162
  try {
152
163
  for (const handler of handlers.get(event) ?? []) results.push(await handler(payload, ctx));
153
164
  return results;
@@ -161,10 +172,10 @@ function fakePi() {
161
172
  }
162
173
  }
163
174
  };
164
- return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
175
+ return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, renderers, entryRenderers, entries, events, listeners };
165
176
  }
166
177
 
167
- function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise<boolean> = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise<string | undefined> = async () => undefined, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }) {
178
+ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise<boolean> = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise<string | undefined> = async () => undefined, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }, selectResult: (title: string, options: string[]) => Promise<string | undefined> = async (_title, options) => options[0]) {
168
179
  const widgets = new Map<string, (tui: unknown, theme: unknown) => { render(width: number): string[] }>();
169
180
  const dialogs: string[] = [];
170
181
  const overlays: Overlay[] = [];
@@ -193,7 +204,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
193
204
  },
194
205
  select: async (title: string, options: string[]) => {
195
206
  dialogs.push(`select:${title}:${options.join("|")}`);
196
- return options[0];
207
+ return selectResult(title, options);
197
208
  },
198
209
  confirm: async (title: string, message: string) => {
199
210
  dialogs.push(`confirm:${title}:${message}`);
@@ -213,6 +224,29 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
213
224
  return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
214
225
  }
215
226
 
227
+ // Records deps.schedule calls so a test fires exactly the timers it means to;
228
+ // unrelated runner timers stay pending.
229
+ function recordTimers(target: Partial<AgentsDeps>) {
230
+ const pending: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
231
+ target.schedule = (fn, ms) => {
232
+ const timer = { fn, ms, cancelled: false };
233
+ pending.push(timer);
234
+ return () => { timer.cancelled = true; };
235
+ };
236
+ const due = (ms: number) => pending.filter((timer) => timer.ms === ms && !timer.cancelled);
237
+ return {
238
+ pending: (ms: number) => due(ms).length,
239
+ run: (ms: number) => {
240
+ const timers = due(ms);
241
+ for (const timer of timers) {
242
+ pending.splice(pending.indexOf(timer), 1);
243
+ timer.fn();
244
+ }
245
+ return timers.length;
246
+ },
247
+ };
248
+ }
249
+
216
250
  function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: string[][] } {
217
251
  const children: FakeChild[] = [];
218
252
  const spawned: string[][] = [];
@@ -296,7 +330,8 @@ test("cache warming follows actual Gentle Agents ownership and completion lifecy
296
330
  assert.equal(h.sent.length, 1);
297
331
  assert.equal(h.sent[0].message.customType, "gentle-agents.result");
298
332
  assert.match(JSON.stringify(h.sent[0].message), new RegExp(taskId));
299
- assert.equal(h.sent[0].options.triggerTurn, true);
333
+ assert.equal(h.sent[0].options.triggerTurn, false, "an idle parent stores the completion without a direct turn");
334
+ assert.equal(h.userMessages.length, 1, "the idle parent is woken once through the prompt lifecycle");
300
335
  assert.equal(run.mock.callCount(), 1, "warming never launches equivalent work");
301
336
  // Seed the actual history-restore path with a stale live-looking record:
302
337
  // sharing the parent ID and running status must not confer ownership.
@@ -369,7 +404,8 @@ for (const mode of ["print", "tui", "rpc"] as const) {
369
404
  assert.equal(h.sent[0].message.customType, "gentle-agents.result");
370
405
  assert.equal(h.sent[0].message.content, `Subagent explore (task ${taskId}, "Map") finished.\n\nmapped`);
371
406
  assert.equal(h.sent[0].message.display, true);
372
- assert.deepEqual(h.sent[0].options, { deliverAs: "steer", triggerTurn: true });
407
+ assert.deepEqual(h.sent[0].options, { triggerTurn: false });
408
+ assert.deepEqual(h.delivery, ["custom:gentle-agents.result", "user"], "the idle wake follows the stored completion");
373
409
  await h.fire("turn_end", ctx);
374
410
  await h.fire("turn_end", ctx);
375
411
  await tick();
@@ -1052,7 +1088,8 @@ test("child parent-message tooling admits notifications and the active parent pr
1052
1088
  await tick();
1053
1089
  assert.equal(parent.sent[0]?.message.content, "raw\u001B[2J text");
1054
1090
  assert.equal(parent.sent[0]?.message.display, false, "ordinary child notifications remain model-visible but do not render in the transcript");
1055
- assert.deepEqual(parent.sent[0]?.options, { deliverAs: "followUp", triggerTurn: true });
1091
+ assert.deepEqual(parent.sent[0]?.options, { triggerTurn: false });
1092
+ assert.deepEqual(parent.delivery, ["custom:gentle-agents.message", "user"], "an idle parent is woken after the stored notification");
1056
1093
  const rendered = parent.renderers.get("gentle-agents.message")!(parent.sent[0]?.message, { expanded: true }, plainTheme).render(80).join("\n");
1057
1094
  assert.match(rendered, /raw\\x1B\[2J text/);
1058
1095
  (ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
@@ -1104,9 +1141,9 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
1104
1141
  writeFileSync(join(profile, "subagents.json"), JSON.stringify({ model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
1105
1142
  gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile, runtimeMetricsPolicy: policy, metricsNow: () => clock, metricsSchedule });
1106
1143
  const listenerCounts = () => [...h.listeners].map(([name, set]) => [name, set.size]);
1107
- const initialListeners = listenerCounts();
1108
1144
  await h.fire("session_start", context.ctx);
1109
- const result = h.tools.get("subagent_run")!.execute("call", { agent: "gentle-ai-worker", task: "private task", mode: "task" }, undefined, undefined, context.ctx);
1145
+ const initialListeners = listenerCounts();
1146
+ const result = h.tools.get("subagent_run")!.execute("call", { agent: "gentle-ai-worker", task: "private task\n## Allowed edit surfaces\nsrc/app.ts\n## Return\nReport", mode: "task" }, undefined, undefined, context.ctx);
1110
1147
  await tick();
1111
1148
  assert.equal(runtime.children.length, 1);
1112
1149
  const child = runtime.children[0];
@@ -1140,7 +1177,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
1140
1177
  Object.assign(fresh.pi, { events: h.pi.events });
1141
1178
  gentleAgents(fresh.pi, env, { ...runtime.deps, env, runtimeMetricsPolicy: policy, metricsSchedule });
1142
1179
  await fresh.fire("session_start", context.ctx);
1143
- assert.deepEqual(listenerCounts(), initialListeners, "fresh instance installs one subscription set");
1180
+ assert.deepEqual(listenerCounts(), initialListeners, "fresh instance installs one subscription set, including visual preference updates");
1144
1181
  await fresh.fire("session_shutdown", context.ctx);
1145
1182
  assert.ok([...h.listeners.values()].every(set => set.size === 0));
1146
1183
  assert.equal(renewalTimers.size, 0);
@@ -1445,96 +1482,16 @@ test("live-only directory traverses presence overflow, excludes expired and othe
1445
1482
  await panel.opened;
1446
1483
  });
1447
1484
 
1448
- test("research launch transports selected grants and only matching existing extensions", async () => {
1449
- const fixtureHome = join(root, "research-home");
1450
- mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
1451
- writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-research.md"), "---\nname: sdd-research\ntools: [read, write, fetch_content, web_search, source_check, mcp, bash]\n---\nCollect research.");
1452
- const fake = fakePi(), runtime = deps(), { ctx } = fakeContext();
1453
- fake.pi.getActiveTools = () => ["fetch_content", "web_search", "mcp", "bash"];
1454
- fake.pi.getAllTools = () => fake.pi.getActiveTools().map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) as never;
1455
- const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
1456
- let childEnv: NodeJS.ProcessEnv = {};
1457
- gentleAgents(fake.pi, {}, { ...runtime.deps, home: fixtureHome, spawn: (command, args, options) => {
1458
- childEnv = options.env!;
1459
- return runtime.deps.spawn!(command, args, options);
1460
- } });
1461
- const result = await fake.tools.get("subagent_run")!.execute("research", { agent: "sdd-research", task: "Research docs", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", research_selection: selection }, undefined, undefined, ctx);
1462
- await tick();
1463
- const argv = runtime.spawned[0];
1464
- assert.equal(argv[argv.indexOf("--tools") + 1], "fetch_content,subagent_parent_message");
1465
- assert.equal(argv[argv.indexOf("--extension") + 1], "/installed/web.ts");
1466
- assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), selection);
1467
- assert.equal(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact, undefined);
1468
- assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_selection, "fresh selection must be expressible on continuation");
1469
- assert.ok(JSON.parse(childEnv.GENTLE_PI_RESEARCH_TOOLS!).includes("subagent_parent_message"));
1470
- assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /documentation: available/);
1471
- assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /open-web: blocked/, "two reachable tools cannot admit open-web");
1472
- runtime.children[0].emit({ type: "agent_settled" });
1473
- await tick();
1474
- const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
1475
- await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background" }, undefined, undefined, ctx);
1476
- await tick();
1477
- assert.equal(runtime.spawned.length, 2);
1478
- assert.ok(!runtime.spawned[1].includes("--extension"), "no inherited research selection");
1479
- assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "subagent_parent_message");
1480
- assert.equal(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), null);
1481
- fake.pi.getActiveTools = () => ["web_search"];
1482
- runtime.children[1].emit({ type: "agent_settled" });
1483
- await tick();
1484
- const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
1485
- await tick();
1486
- assert.ok(!runtime.spawned[2].includes("--extension"));
1487
- runtime.children[2].emit({ type: "agent_settled" });
1488
- await tick();
1489
- fake.pi.getActiveTools = () => ["fetch_content", "web_search"];
1490
- await fake.tools.get("subagent_continue")!.execute("corrected", { task_id: (denied.details.gentleAgents as { taskId: string }).taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
1491
- await tick();
1492
- assert.equal(runtime.spawned[3][runtime.spawned[3].indexOf("--extension") + 1], "/installed/web.ts");
1493
- await fake.fire("session_shutdown", ctx);
1494
- });
1495
-
1496
- test("research registered continuation needs no prior artifact identity", async () => {
1497
- const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
1498
- const home = join(root, "optional-research-home");
1499
- mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
1500
- writeFileSync(join(home, ".pi/agent/agents/sdd-research.md"), "---\nname: sdd-research\ntools: [read]\n---\nExplore a question.");
1501
- gentleAgents(h.pi, {}, { ...runtime.deps, home });
1502
- await h.fire("session_start", ctx);
1503
- const first = await h.tools.get("subagent_run")!.execute("first", { agent: "sdd-research", task: "Inspect a question", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
1504
- await tick();
1505
- runtime.children[0].emit({ type: "agent_settled" });
1506
- await tick();
1507
- const taskId = (first.details.gentleAgents as { taskId: string }).taskId;
1508
- const next = await h.tools.get("subagent_continue")!.execute("next", { task_id: taskId, prompt: "Investigate the remaining question", mode: "background" }, undefined, undefined, ctx);
1509
- await tick();
1510
- assert.equal(runtime.spawned.length, 2, JSON.stringify(next));
1511
- await h.fire("session_shutdown", ctx);
1512
- });
1513
-
1514
- test("research child inventory exposes remaining authorized tools", () => {
1515
- const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
1516
- for (const missing of [undefined, ...required]) {
1517
- const hooks = new Map<string, (event: any) => any>();
1518
- const active = required.filter(name => name !== missing);
1519
- const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => active, getAllTools: () => required.map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) } as never;
1520
- gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(required), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify({ documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } }, "open-web": { tools: required, extensions: Object.fromEntries(required.map(name => [name, "/installed/web.ts"])) } }) });
1521
- const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt;
1522
- assert.match(prompt, /open-web: available/);
1523
- assert.match(prompt, new RegExp(`documentation: ${missing === "fetch_content" ? "blocked" : "available"}`));
1524
- assert.match(prompt, /Availability is not evidence/);
1485
+ test("retired managed SDD child envelopes deny all tools without registering a delegation host", () => {
1486
+ for (const env of [{ GENTLE_PI_RESEARCH_TOOLS: '["read"]' }, { GENTLE_PI_RESEARCH_SELECTION: '{}' }, { GENTLE_PI_RESEARCH_ARTIFACT: '{}' }, { GENTLE_PI_SDD_REMEDIATION_PLAN: '{}' }]) {
1487
+ const hooks = new Map<string, (event: { toolName: string }) => { block: boolean }>();
1488
+ const pi = { on: (name: string, handler: (event: { toolName: string }) => { block: boolean }) => hooks.set(name, handler) } as never;
1489
+ gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", ...env });
1490
+ assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true);
1491
+ assert.equal(hooks.get("tool_call")!({ toolName: "bash" }).block, true);
1525
1492
  }
1526
1493
  });
1527
1494
 
1528
- test("research child rechecks local inventory and blocks gateway calls", async () => {
1529
- const hooks = new Map<string, (event: any) => any>();
1530
- const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => ["read", "mcp"], getAllTools: () => [{ name: "read" }, { name: "mcp" }] } as never;
1531
- gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: '["read","fetch_content"]' });
1532
- assert.match(hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt, /documentation: blocked/);
1533
- assert.equal(hooks.get("tool_call")!({ toolName: "mcp" }).block, true);
1534
- assert.equal(hooks.get("tool_call")!({ toolName: "fetch_content" }).block, true);
1535
- assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing path context cannot authorize a read");
1536
- });
1537
-
1538
1495
  async function shutdownAndRestoreNativeSpawn(
1539
1496
  childProcess: typeof import("node:child_process"),
1540
1497
  originalSpawn: typeof import("node:child_process").spawn,
@@ -1776,6 +1733,56 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
1776
1733
  });
1777
1734
  }
1778
1735
 
1736
+ // gentle-pi#1175 (T2): the subagent mutation receipt carries the model and
1737
+ // effort the runtime resolved for the task, so ASSESS never re-trusts a model
1738
+ // declaration. An inherited (unresolved) model is omitted, never "default".
1739
+ for (const scenario of ["resolved", "inherited"] as const) {
1740
+ test(`subagent mutation receipt records the runtime-resolved writer profile: ${scenario}`, async () => {
1741
+ const h = fakePi();
1742
+ const d = deps();
1743
+ const { ctx } = fakeContext();
1744
+ if (scenario === "inherited") {
1745
+ const fixtureHome = realpathSync(mkdtempSync(join(root, "inherited-writer-")));
1746
+ mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
1747
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: inherits the parent model\ntools: [read]\n---\nYou map things.");
1748
+ d.deps.home = fixtureHome;
1749
+ }
1750
+ ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
1751
+ ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
1752
+ d.deps.resolveWorktree = (path, base) => containsResolvedPath(cwd, resolve(base, path)) ? { root: cwd, commonDir: "/fixture/common" } : undefined;
1753
+ const spawn = d.deps.spawn!;
1754
+ d.deps.spawn = (...args) => {
1755
+ const child = spawn(...args);
1756
+ const on = child.on.bind(child);
1757
+ child.on = ((event: string, listener: () => void) => {
1758
+ if (event === "spawn") queueMicrotask(listener);
1759
+ return on(event as "spawn", listener);
1760
+ }) as typeof child.on;
1761
+ return child;
1762
+ };
1763
+ gentleAgents(h.pi, {}, d.deps);
1764
+ await h.fire("session_start", ctx);
1765
+ await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
1766
+ await tick();
1767
+ d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "file.ts" } });
1768
+ d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
1769
+ await tick();
1770
+ const receipts = h.entries.filter((entry) => entry.customType === REVIEW_REMINDER_RECEIPT);
1771
+ assert.equal(receipts.length, 1);
1772
+ const data = receipts[0].data as Record<string, unknown>;
1773
+ if (scenario === "resolved") {
1774
+ assert.equal(data.writerModelId, "openai-codex/gpt-5.6-terra");
1775
+ assert.equal(data.writerEffort, "low", "the profile effort override is the runtime-resolved effort");
1776
+ } else {
1777
+ assert.equal(Object.hasOwn(data, "writerModelId"), false, "an inherited model is unknown here and must never be recorded as \"default\"");
1778
+ assert.equal(Object.hasOwn(data, "writerEffort"), false);
1779
+ }
1780
+ assert.deepEqual(pendingReviewMutationProfiles(ctx.sessionManager, cwd), [scenario === "resolved" ? { writerModelId: "openai-codex/gpt-5.6-terra", writerEffort: "low" } : {}]);
1781
+ await h.fire("session_shutdown", ctx);
1782
+ await tick();
1783
+ });
1784
+ }
1785
+
1779
1786
  // C1 diagnostics (odd/tasks/usage-click-and-changes-attribution.md): the
1780
1787
  // guard chain's posture is unchanged (spawn-gated registration stays, a
1781
1788
  // parent decision) -- these only verify each drop explains itself once, in
@@ -2080,7 +2087,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2080
2087
  children[2]!.emit({ type: "agent_settled" });
2081
2088
  await permission.result;
2082
2089
 
2083
- const args = ["--host-flag", "--mode", "rpc", "--session-dir", join(home, ".pi", "agent", "gentle-agents", "sessions"), "--model", "openai-codex/gpt-5.6-terra:low", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."];
2090
+ const args = ["--host-flag", "--mode", "rpc", "--session-dir", join(home, ".pi", "agent", "gentle-agents", "sessions"), ...childContextExtensionPaths().flatMap((path) => ["--extension", path]), "--model", "openai-codex/gpt-5.6-terra:low", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."];
2084
2091
  assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
2085
2092
  const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
2086
2093
  for (const [index, fixture] of ["task", "background", "permission"].entries()) {
@@ -2204,6 +2211,11 @@ test("foreign clone tool requires consent before queueing and never enters paren
2204
2211
  await assert.rejects(run.execute("wrong-selector", { agent: "explore", task: "Map", workspace_root: foreign, mode: "background" }, undefined, undefined, ctx), /same Git clone/);
2205
2212
  await assert.rejects(run.execute("both", { agent: "explore", task: "Map", workspace_root: parent, repository_root: foreign, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
2206
2213
  await assert.rejects(run.execute("both-malformed", { agent: "explore", task: "Map", workspace_root: parent, repository_root: 123, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
2214
+ // Blank selectors name no destination, so they must not read as a second target.
2215
+ const blank = await run.execute("blank-roots", { agent: "__absent__", task: "Map", workspace_root: "", repository_root: "", mode: "background" }, undefined, undefined, ctx);
2216
+ assert.match(JSON.stringify(blank), /no subagent named/, "blank selectors pass the exclusivity guard and reach agent lookup");
2217
+ assert.deepEqual(spawned, [], "a blank selector must not spawn a child");
2218
+ assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), [], "a blank selector must not register a worktree");
2207
2219
  const pending = run.execute("foreign", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
2208
2220
  await tick();
2209
2221
  assert.equal(prompts, 1);
@@ -2441,7 +2453,7 @@ test("an interactive non-Git umbrella can target an independent repository witho
2441
2453
  } finally { rmSync(fixture, { recursive: true, force: true }); }
2442
2454
  });
2443
2455
 
2444
- test("foreign selector rejects RPC even with UI and rejects SDD/remediation before consent", async () => {
2456
+ test("foreign selector rejects RPC and retired SDD selections before consent", async () => {
2445
2457
  const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-callers-")));
2446
2458
  const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
2447
2459
  mkdirSync(template);
@@ -2453,7 +2465,6 @@ test("foreign selector rejects RPC even with UI and rejects SDD/remediation befo
2453
2465
  mkdirSync(join(agentHome, ".pi", "agent", "agents"), { recursive: true });
2454
2466
  writeFileSync(join(agentHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: explore\ntools: [read]\n---\nExplore");
2455
2467
  writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nApply");
2456
- writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-research.md"), "---\ndescription: research\ntools: [read]\n---\nResearch");
2457
2468
  runtime.deps.home = agentHome;
2458
2469
  gentleAgents(h.pi, {}, runtime.deps);
2459
2470
  const { ctx, dialogs } = fakeContext();
@@ -2466,10 +2477,9 @@ test("foreign selector rejects RPC even with UI and rejects SDD/remediation befo
2466
2477
  ctx.mode = "print";
2467
2478
  await assert.rejects(run.execute("print", { ...base, mode: "task" }, undefined, undefined, ctx), /interactive parent session/);
2468
2479
  ctx.mode = "tui";
2469
- await assert.rejects(run.execute("remediate", { ...base, remediation: {} }, undefined, undefined, ctx), /interactive parent session/);
2470
- await assert.rejects(run.execute("foreign-sdd", { ...base, agent: "sdd-apply", context: PARENT_CONFIRMED_SDD_CONTEXT, sdd_change: { changeName: "alpha", workspaceRoot: foreign, phase: "apply" } }, undefined, undefined, ctx), /interactive parent session/);
2471
- await assert.rejects(run.execute("foreign-sdd-no-change", { ...base, agent: "sdd-research", context: PARENT_CONFIRMED_SDD_CONTEXT }, undefined, undefined, ctx), /interactive parent session/);
2472
- assert.equal(existsSync(join(agentHome, ".pi", "agent", "sessions")), false, "foreign SDD rejection must precede child session directory creation");
2480
+ assert.match((await run.execute("remediate", { ...base, remediation: {} }, undefined, undefined, ctx)).content[0].text, /retired SDD/);
2481
+ assert.match((await run.execute("foreign-sdd", { ...base, agent: "sdd-apply", sdd_change: { changeName: "alpha", workspaceRoot: foreign, phase: "apply" } }, undefined, undefined, ctx)).content[0].text, /retired SDD/);
2482
+ assert.equal(existsSync(join(agentHome, ".pi", "agent", "sessions")), false, "foreign explicit selection rejection must precede child session directory creation");
2473
2483
  const childHost = fakePi();
2474
2484
  gentleAgents(childHost.pi, { GENTLE_PI_AGENTS_CHILD: "1" }, runtime.deps);
2475
2485
  assert.equal(childHost.tools.has("subagent_run"), false, "child-originated foreign launches have no delegation tool");
@@ -2564,39 +2574,173 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
2564
2574
  await tick();
2565
2575
  });
2566
2576
 
2567
- test("SDD phase continuation requires a fresh selection and launches only that selection", async () => {
2577
+ test("retired SDD agent names and selection are rejected before dispatch", async () => {
2568
2578
  const h = fakePi();
2569
2579
  const runtime = deps();
2570
2580
  const fixtureHome = join(root, "sdd-selection-home");
2571
2581
  mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2572
2582
  writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nSDD apply executor");
2583
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: explore\ntools: [read]\n---\nExplore");
2573
2584
  gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome });
2574
2585
  const { ctx } = fakeContext();
2575
2586
  await h.fire("session_start", ctx);
2576
- const run = await h.tools.get("subagent_run")!.execute("run", {
2577
- agent: "sdd-apply", task: "Apply alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
2578
- sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "apply" },
2579
- }, undefined, undefined, ctx);
2580
- await tick();
2581
- const taskId = (run.details.gentleAgents as { taskId: string }).taskId;
2582
- const first = runtime.spawned[0]!;
2583
- assert.deepEqual(JSON.parse(first[first.indexOf("--gentle-sdd-change") + 1]!), { changeName: "alpha", workspaceRoot: cwd, phase: "apply" });
2584
- runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
2585
- runtime.children[0].emit({ type: "agent_settled" });
2587
+ const tool = h.tools.get("subagent_run")!;
2588
+ for (const agent of ["sdd-apply", "sdd-verify", "sdd-archive", "sdd-research", "sdd-remediate", "sdd-custom"]) {
2589
+ const rejected = await tool.execute(agent, { agent, task: "Retired phase", mode: "background" }, undefined, undefined, ctx);
2590
+ assert.match(rejected.content[0].text, /retired SDD/i, agent);
2591
+ }
2592
+ for (const selector of ["sdd_change", "remediation", "research_selection"] as const) {
2593
+ const rejected = await tool.execute(selector, { agent: "explore", task: "Map", [selector]: {} }, undefined, undefined, ctx);
2594
+ assert.match(rejected.content[0].text, /retired SDD/i, selector);
2595
+ }
2596
+ assert.equal(runtime.spawned.length, 0);
2597
+ const ordinary = await tool.execute("ordinary", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
2586
2598
  await tick();
2587
- const missing = await h.tools.get("subagent_continue")!.execute("missing", { task_id: taskId, prompt: "Continue", mode: "background" }, undefined, undefined, ctx);
2588
- assert.match(missing.content[0].text, /requires a fresh sdd_change/i);
2599
+ assert.equal(runtime.spawned.length, 1, "ordinary named agents remain dispatchable");
2600
+ const id = (ordinary.details.gentleAgents as { taskId: string }).taskId;
2601
+ for (const selector of ["sdd_change", "remediation", "research_selection"] as const) {
2602
+ const rejected = await h.tools.get("subagent_continue")!.execute(selector, { task_id: id, prompt: "Follow up", [selector]: {} }, undefined, undefined, ctx);
2603
+ assert.match(rejected.content[0].text, /retired SDD/i, selector);
2604
+ }
2589
2605
  assert.equal(runtime.spawned.length, 1);
2590
- await h.tools.get("subagent_continue")!.execute("continue", {
2591
- task_id: taskId, prompt: "Apply beta", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
2592
- sdd_change: { changeName: "beta", workspaceRoot: cwd, phase: "apply" },
2593
- }, undefined, undefined, ctx);
2594
- await tick();
2595
- const second = runtime.spawned[1]!;
2596
- assert.deepEqual(JSON.parse(second[second.indexOf("--gentle-sdd-change") + 1]!), { changeName: "beta", workspaceRoot: cwd, phase: "apply" });
2597
2606
  await h.fire("session_shutdown", ctx);
2598
2607
  });
2599
2608
 
2609
+ for (const drift of ["loss", "common-dir", "root"] as const) {
2610
+ test(`established writer authority rejects metadata ${drift} before preparation or queue`, async () => {
2611
+ const fixture = realpathSync(mkdtempSync(join(root, "established-writer-")));
2612
+ const project = join(fixture, "project");
2613
+ const cwd = join(project, "nested");
2614
+ const home = join(fixture, "home");
2615
+ mkdirSync(cwd, { recursive: true });
2616
+ const definitions = join(home, ".pi", "agent", "agents");
2617
+ mkdirSync(definitions, { recursive: true });
2618
+ writeFileSync(join(definitions, "worker.md"), "---\ndescription: fixture\nmodel: offline/good\n---\nFixture");
2619
+ execFileSync("git", ["init", "--quiet", project]);
2620
+ const h = fakePi();
2621
+ const runtime = deps();
2622
+ Object.assign(runtime.deps, { home, resolveWorktree: resolveSessionWorktree });
2623
+ gentleAgents(h.pi, {}, runtime.deps);
2624
+ const { ctx } = fakeContext();
2625
+ Object.assign(ctx, { cwd, modelRegistry: { find: () => ({ provider: "offline", id: "good" }) } });
2626
+ ctx.sessionManager.getCwd = () => cwd;
2627
+ await h.fire("session_start", ctx);
2628
+ let statusCalls = 0;
2629
+ const unbind = bindSessionRepositoryPreparation(ctx.sessionManager, cwd, async () => { statusCalls++; return true; }, () => true);
2630
+ try {
2631
+ if (drift === "root") execFileSync("git", ["init", "--quiet", cwd]);
2632
+ else {
2633
+ renameSync(join(project, ".git"), join(project, "saved-git"));
2634
+ if (drift === "common-dir") {
2635
+ const alternate = join(fixture, "alternate");
2636
+ mkdirSync(alternate);
2637
+ execFileSync("git", ["init", "--quiet", alternate]);
2638
+ writeFileSync(join(project, ".git"), `gitdir: ${join(alternate, ".git")}\n`);
2639
+ }
2640
+ }
2641
+ await assert.rejects(h.tools.get("subagent_run")!.execute("lost-authority", { agent: "worker", task: "Implement\n## Allowed edit surfaces\nsrc/app.ts", mode: "background" }, undefined, undefined, ctx));
2642
+ assert.equal(statusCalls, 0, "no bootstrap-capable STATUS after established identity drift");
2643
+ assert.equal(runtime.children.length, 0, "no writer queued/spawned after established identity drift");
2644
+ } finally { unbind(); await h.fire("session_shutdown", ctx); }
2645
+ });
2646
+ }
2647
+
2648
+ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-ai-worker", "explicit-gentle-ai-worker", "scope", "task", "mode", "model", "profile-model", "profile-valid", "foreign", "nested", "repository", "print", "cancelled", "missing", "off", "shutdown", "replacement", "changed-id", "during-cancel", "docs", "read-only", "review", "jd"] as const) {
2649
+ test(`bounded writer executor admission before bootstrap: ${scenario}`, async t => {
2650
+ const fixture = realpathSync(mkdtempSync(join(root, "writer-admission-")));
2651
+ const project = join(fixture, "project");
2652
+ const fixtureHome = join(fixture, "home");
2653
+ const definitions = join(fixtureHome, ".pi", "agent", "agents");
2654
+ mkdirSync(definitions, { recursive: true });
2655
+ mkdirSync(project);
2656
+ mkdirSync(join(project, "nested"));
2657
+ for (const role of ["worker", "gentle-ai-worker", "explore", "reviewer", "jd-fix-agent"]) writeFileSync(join(definitions, `${role}.md`), `---\ndescription: fixture\nmodel: offline/good\ntools: [read]\n---\nFixture`);
2658
+ const h = fakePi();
2659
+ const runtime = deps();
2660
+ runtime.deps.home = fixtureHome;
2661
+ runtime.deps.resolveWorktree = resolveSessionWorktree;
2662
+ gentleAgents(h.pi, {}, runtime.deps);
2663
+ const { ctx } = fakeContext();
2664
+ Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
2665
+ ctx.sessionManager.getCwd = () => project;
2666
+ ctx.sessionManager.getEntries = () => h.entries as never;
2667
+ await h.fire("session_start", ctx);
2668
+ if (scenario === "profile-model" || scenario === "profile-valid") {
2669
+ mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
2670
+ writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
2671
+ const config = runtime.deps.env!.GENTLE_PI_CONFIG_HOME = join(fixture, "config");
2672
+ mkdirSync(config, { recursive: true });
2673
+ writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { invalid: { worker: { model: scenario === "profile-valid" ? "offline/pinned-good" : "offline/bad" } } } }));
2674
+ }
2675
+ let calls = 0;
2676
+ const abort = new AbortController();
2677
+ const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
2678
+ calls++;
2679
+ if (scenario === "shutdown") await h.fire("session_shutdown", ctx);
2680
+ if (scenario === "replacement") { const next = fakeContext(); next.ctx.sessionManager.getCwd = () => project; await h.fire("session_start", next.ctx); }
2681
+ if (scenario === "changed-id") ctx.sessionManager.getSessionId = () => "changed";
2682
+ if (scenario === "during-cancel") abort.abort();
2683
+ if (!current() || scenario === "off" || ["shutdown", "replacement"].includes(scenario)) return false;
2684
+ execFileSync("git", ["init", "--quiet", project], { env: { PATH: process.env.PATH, HOME: fixtureHome, GIT_CONFIG_NOSYSTEM: "1" }, stdio: "pipe" });
2685
+ return true;
2686
+ }, () => true);
2687
+ t.after(() => { unbind(); });
2688
+ if (scenario === "cancelled") abort.abort();
2689
+ if (scenario === "print") Object.assign(ctx, { mode: "print", hasUI: false });
2690
+ const agent = scenario.includes("gentle-ai-worker") ? "gentle-ai-worker" : scenario === "read-only" ? "explore" : scenario === "review" ? "reviewer" : scenario === "jd" ? "jd-fix-agent" : "worker";
2691
+ const task = `Implement source\n## Allowed edit surfaces\n${scenario === "docs" ? "odd/tasks/feature.md" : "src/app.ts"}\n## Return\nReport`;
2692
+ const params = { agent, task: scenario === "scope" ? "No surface" : scenario === "task" ? 42 : task, mode: scenario === "mode" ? "invalid" : "background", ...(scenario.startsWith("explicit") ? { workspace_root: project } : {}), ...(scenario === "nested" ? { workspace_root: join(project, "nested") } : {}), ...(scenario === "foreign" ? { workspace_root: fixtureHome } : {}), ...(scenario === "repository" ? { repository_root: fixtureHome } : {}) };
2693
+ const accepted = scenario.startsWith("implicit-") || scenario.startsWith("explicit-") || scenario === "profile-valid" || ["docs", "read-only", "review", "jd"].includes(scenario);
2694
+ const result = h.tools.get("subagent_run")!.execute("admission", params, abort.signal, undefined, ctx);
2695
+ if (accepted) await result; else await assert.rejects(result);
2696
+ await tick();
2697
+ const prepared = scenario.startsWith("implicit-") || scenario.startsWith("explicit-") || scenario === "profile-valid";
2698
+ if (scenario === "profile-valid") assert.ok(runtime.spawned[0]?.includes("offline/pinned-good"), "repository declaration retains effective profile through bootstrap");
2699
+ assert.equal(calls, prepared || ["off", "shutdown", "replacement", "changed-id", "during-cancel"].includes(scenario) ? 1 : 0);
2700
+ assert.equal(existsSync(join(project, ".git")), prepared);
2701
+ assert.equal(runtime.children.length, accepted ? 1 : 0, "invalid admission never queues/spawns");
2702
+ });
2703
+ }
2704
+
2705
+ for (const explicit of [false, true]) {
2706
+ test(`same manager and ID after bootstrap permit ${explicit ? "explicit" : "implicit"} launch registration`, async () => {
2707
+ const h = fakePi();
2708
+ const runtime = deps();
2709
+ let bootstrapped = false;
2710
+ const baseSpawn = runtime.deps.spawn!;
2711
+ runtime.deps.spawn = (command, args, options) => {
2712
+ const child = baseSpawn(command, args, options);
2713
+ const on = child.on.bind(child);
2714
+ child.on = ((event: string, listener: () => void) => {
2715
+ if (event === "spawn") queueMicrotask(listener);
2716
+ else on(event as "exit", listener);
2717
+ return child;
2718
+ }) as typeof child.on;
2719
+ return child;
2720
+ };
2721
+ runtime.deps.resolveWorktree = (path, base) => bootstrapped ? { root: resolve(base, path), commonDir: "/bootstrap/git" } : undefined;
2722
+ gentleAgents(h.pi, {}, runtime.deps);
2723
+ const { ctx } = fakeContext();
2724
+ const manager = ctx.sessionManager;
2725
+ ctx.sessionManager.getEntries = () => h.entries as never;
2726
+ await h.fire("session_start", ctx);
2727
+ assert.deepEqual(h.entries, []);
2728
+ bootstrapped = true;
2729
+ const result = await h.tools.get("subagent_run")!.execute("bootstrap", { agent: "explore", task: "Map", mode: "background", ...(explicit ? { workspace_root: cwd } : {}) }, undefined, undefined, ctx);
2730
+ await tick();
2731
+ assert.equal(runtime.children.length, 1);
2732
+ assert.equal(ctx.sessionManager, manager);
2733
+ assert.equal(ctx.sessionManager.getSessionId(), "s1");
2734
+ assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1);
2735
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
2736
+ runtime.children[0].emit({ type: "agent_settled" });
2737
+ await tick();
2738
+ const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
2739
+ assert.match((await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /completed/);
2740
+ assert.equal(h.entries.some(entry => entry.customType === REVIEW_REMINDER_RECEIPT), false, "spawn registration is not mutation evidence");
2741
+ });
2742
+ }
2743
+
2600
2744
  test("ordinary non-Git tasks still continue in their original cwd without registering a worktree", async () => {
2601
2745
  const h = fakePi();
2602
2746
  const runtime = deps();
@@ -2618,34 +2762,36 @@ test("ordinary non-Git tasks still continue in their original cwd without regist
2618
2762
  await tick();
2619
2763
  });
2620
2764
 
2621
- test("delayed child spawn retains the originating session and cannot append into its replacement", async () => {
2622
- const h = fakePi();
2623
- const runtime = deps();
2624
- const spawnEvents: Array<() => void> = [];
2625
- const baseSpawn = runtime.deps.spawn!;
2626
- runtime.deps.spawn = (command, args, options) => {
2627
- const child = baseSpawn(command, args, options);
2628
- const on = child.on.bind(child);
2629
- child.on = ((event: string, listener: () => void) => {
2630
- if (event === "spawn") spawnEvents.push(listener);
2631
- else on(event as "exit", listener);
2765
+ for (const sameId of [false, true]) {
2766
+ test(`delayed child spawn cannot append into a ${sameId ? "same-ID manager" : "new-ID session"} replacement`, async () => {
2767
+ const h = fakePi();
2768
+ const runtime = deps();
2769
+ const spawnEvents: Array<() => void> = [];
2770
+ const baseSpawn = runtime.deps.spawn!;
2771
+ runtime.deps.spawn = (command, args, options) => {
2772
+ const child = baseSpawn(command, args, options);
2773
+ const on = child.on.bind(child);
2774
+ child.on = ((event: string, listener: () => void) => {
2775
+ if (event === "spawn") spawnEvents.push(listener);
2776
+ else on(event as "exit", listener);
2777
+ return child;
2778
+ }) as typeof child.on;
2632
2779
  return child;
2633
- }) as typeof child.on;
2634
- return child;
2635
- };
2636
- gentleAgents(h.pi, {}, runtime.deps);
2637
- const { ctx } = fakeContext();
2638
- await h.fire("session_start", ctx);
2639
- await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", workspace_root: join(root, "old-root"), mode: "background" }, undefined, undefined, ctx);
2640
- await tick();
2641
- const next = fakeContext();
2642
- (next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
2643
- await h.fire("session_start", next.ctx);
2644
- spawnEvents[0]();
2645
- await tick();
2646
- assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
2647
- await h.fire("session_shutdown", next.ctx);
2648
- });
2780
+ };
2781
+ gentleAgents(h.pi, {}, runtime.deps);
2782
+ const { ctx } = fakeContext();
2783
+ await h.fire("session_start", ctx);
2784
+ await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", workspace_root: join(root, "old-root"), mode: "background" }, undefined, undefined, ctx);
2785
+ await tick();
2786
+ const next = fakeContext();
2787
+ (next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => sameId ? "s1" : "s2";
2788
+ await h.fire("session_start", next.ctx);
2789
+ spawnEvents[0]();
2790
+ await tick();
2791
+ assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
2792
+ await h.fire("session_shutdown", next.ctx);
2793
+ });
2794
+ }
2649
2795
 
2650
2796
  test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
2651
2797
  assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
@@ -2873,7 +3019,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
2873
3019
  harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
2874
3020
  harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
2875
3021
  await tick();
2876
- assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · low · 12k · \$0\.09 · \d+s │$/);
3022
+ assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · low · 12k · \$0\.090 · \d+s │$/);
2877
3023
  harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
2878
3024
  harness.children[0].emit({ type: "agent_settled" });
2879
3025
  const result = await running;
@@ -2889,6 +3035,33 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
2889
3035
  assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
2890
3036
  });
2891
3037
 
3038
+ test("running subagent_result polls hide only their tool chrome, not the model result or final completion", async () => {
3039
+ const { pi, tools, fire, sent, renderers } = fakePi();
3040
+ const harness = deps();
3041
+ gentleAgents(pi, {}, harness.deps);
3042
+ const { ctx } = fakeContext();
3043
+ await fire("session_start", ctx);
3044
+ const started = await tools.get("subagent_run")!.execute("start", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
3045
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
3046
+ await tick();
3047
+ const resultTool = tools.get("subagent_result")!;
3048
+ for (const expanded of [false, true]) {
3049
+ const poll = await resultTool.execute("poll", { task_id: id }, undefined, undefined, ctx);
3050
+ assert.match(poll.content[0].text, /still running/, "poll remains available to the model");
3051
+ assert.deepEqual(resultTool.renderCall({ task_id: id }, plainTheme).render(80), [], "poll title is hidden");
3052
+ assert.deepEqual(resultTool.renderResult(poll as Parameters<Registered["renderResult"]>[0], { expanded }, plainTheme).render(80), [], "poll body is hidden");
3053
+ }
3054
+ assert.match(tools.get("subagent_status")!.renderCall({ task_id: id }, plainTheme).render(80).join(""), /agent status/, "other tools retain their rendering");
3055
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All done." }] }] });
3056
+ harness.children[0].emit({ type: "agent_settled" });
3057
+ await tick();
3058
+ const finished = await resultTool.execute("finished", { task_id: id }, undefined, undefined, ctx);
3059
+ assert.equal(finished.content[0].text, "All done.");
3060
+ assert.match(resultTool.renderResult(finished as Parameters<Registered["renderResult"]>[0], { expanded: true }, plainTheme).render(80).join("\n"), /agent result.*All done\./s, "completed result remains visible");
3061
+ assert.equal(sent.length, 1);
3062
+ assert.match(renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(80).join("\n"), /Agent result.*All done\./s, "background completion card remains visible");
3063
+ });
3064
+
2892
3065
  test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
2893
3066
  const { pi, tools, fire } = fakePi();
2894
3067
  const harness = deps();
@@ -2941,9 +3114,9 @@ test("background runs return at once; status, result, send_message, cancel, and
2941
3114
  assert.equal(sent[0].message.display, true, "completion cards remain visible");
2942
3115
  // The completion path must never regress to "followUp": the host drains the
2943
3116
  // follow-up queue only when the parent run stops calling tools, which is the
2944
- // hour-long #867 delay. "steer" keeps the idle wake-up (triggerTurn) while
2945
- // bounding an active parent's wait to the current turn.
2946
- assert.deepEqual(sent[0].options, { deliverAs: "steer", triggerTurn: true });
3117
+ // hour-long #867 delay. An idle parent stores it and is woken through the
3118
+ // normal prompt lifecycle; an active parent keeps the bounded steer route.
3119
+ assert.deepEqual(sent[0].options, { triggerTurn: false });
2947
3120
  assert.match(String(sent[0].message.content), new RegExp(`^Subagent explore \\(task ${id}, "Long job"\\) finished\\.\n\nAll done\\.$`));
2948
3121
  const card = renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(70).map(stripAnsi);
2949
3122
  assert.match(card[0], /^╭─ ❀ Agent result · explore ─+ collapse ╮$/);
@@ -3002,6 +3175,62 @@ test("completionText names the outcome before the answer", () => {
3002
3175
  assert.equal(completionText({ ...base, status: "timed_out", error: "stalled for 4 min" }), 'Subagent explore (task t1, "map lib") timed out.\n\nSubagent explore timed_out: stalled for 4 min');
3003
3176
  });
3004
3177
 
3178
+ test("the Agent result card previews the answer or error when collapsed and keeps the full completion text expanded", () => {
3179
+ const { pi, renderers } = fakePi();
3180
+ gentleAgents(pi, {}, deps().deps);
3181
+ const render = renderers.get("gentle-agents.result")!;
3182
+ const base = { id: "t1", agent: "explore", mode: "background", prompt: "p", label: "map lib", cwd: "/r", parentSessionId: "s", status: "completed" as const, createdAt: 1, startedAt: 1, endedAt: 2, model: "m", thinking: undefined, sessionPath: null, error: null, result: "All done.", lastStep: "x", lastActivityAt: 2, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
3183
+ const message = (task: typeof base | (Omit<typeof base, "status" | "error"> & { status: "failed"; error: string })) => ({ customType: "gentle-agents.result", content: completionText(task as Parameters<typeof completionText>[0]), details: { gentleAgents: { agent: task.agent, status: task.status } } });
3184
+ const taggedTheme = { fg: (color: string, text: string) => `<${color}>${text}</${color}>` };
3185
+
3186
+ const done = render(message(base), { expanded: false }, plainTheme).render(80).map(stripAnsi);
3187
+ assert.ok(done.length <= 5, "the collapsed card stays bounded");
3188
+ assert.match(done[0]!, /^╭─ ❀ Agent result · explore ─+ expand ╮$/);
3189
+ assert.match(done[1]!, /^│ All done\. +│$/, "the answer leads the collapsed preview");
3190
+ assert.doesNotMatch(done.join("\n"), /Subagent explore \(task/, "the bookkeeping header stays out of the collapsed preview");
3191
+ assert.match(render(message(base), { expanded: false }, taggedTheme).render(80)[0]!, /^<success>╭/);
3192
+
3193
+ const failed = { ...base, status: "failed" as const, error: "pi exited with code 1", result: null };
3194
+ const failedRows = render(message(failed as never), { expanded: false }, taggedTheme).render(80);
3195
+ assert.match(failedRows[0]!, /^<error>╭/, "a failed result keeps the error frame");
3196
+ assert.match(stripAnsi(failedRows[1]!.replace(/<\/?[a-z]+>/g, "")), /^│ Subagent explore failed: pi exited with code 1 +│$/, "the error leads the collapsed preview");
3197
+
3198
+ const long = { ...base, result: "one\ntwo\n\nthree\nfour\nfive" };
3199
+ const collapsed = render(message(long), { expanded: false }, plainTheme).render(80).map(stripAnsi);
3200
+ assert.deepEqual(collapsed.slice(1, -1).map((row) => row.slice(2).trim().replace(/ │$/, "").trim()), ["one", "two", "three"], "three non-blank answer rows");
3201
+ assert.match(collapsed.at(-1)!, /^╰─+╯$/);
3202
+ const expanded = render(message(long), { expanded: true }, plainTheme).render(80).map(stripAnsi).join("\n");
3203
+ assert.match(expanded, /Subagent explore \(task t1, "map lib"\) finished\./, "expanded keeps the header");
3204
+ for (const line of ["one", "two", "three", "four", "five"]) assert.match(expanded, new RegExp(`│ ${line} +│`));
3205
+
3206
+ const legacy = { customType: "gentle-agents.result", content: [{ type: "text", text: "Older answer without a header." }], details: { gentleAgents: { agent: "explore", status: "completed" } } };
3207
+ assert.match(render(legacy, { expanded: false }, plainTheme).render(80).map(stripAnsi)[1]!, /^│ Older answer without a header\. +│$/, "headerless content falls back to the full text");
3208
+ assert.equal(agentResultPreview('Subagent explore (task t1, "x") finished.\n\n'), 'Subagent explore (task t1, "x") finished.\n\n', "a header without an answer keeps the full text");
3209
+ assert.equal(agentResultPreview("Plain answer.\n\nMore."), "Plain answer.\n\nMore.");
3210
+
3211
+ for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24]) {
3212
+ const rows = render(message(long), { expanded: false }, plainTheme).render(width);
3213
+ if (width === 0) assert.deepEqual(rows, []);
3214
+ assert.ok(rows.length <= 5, `width ${width} stays within the row budget`);
3215
+ for (const row of rows) assert.ok(visibleWidth(row) <= width, `width ${width} row fits: ${JSON.stringify(row)}`);
3216
+ }
3217
+ });
3218
+
3219
+ test("the Stale agent result card previews its truthful warning when collapsed", () => {
3220
+ const { pi, entryRenderers } = fakePi();
3221
+ gentleAgents(pi, {}, deps().deps);
3222
+ const render = entryRenderers.get("gentle-agents.stale-result")!;
3223
+ const entry = { type: "custom", customType: "gentle-agents.stale-result", data: { taskId: "t9", agent: "explore", label: "map lib", status: "completed", ageSeconds: 120 } };
3224
+ const collapsed = render(entry, { expanded: false }, plainTheme).render(80).map(stripAnsi);
3225
+ assert.ok(collapsed.length > 3 && collapsed.length <= 5, "a bounded multi-row preview");
3226
+ assert.match(collapsed[0]!, /^╭─ ❀ Stale agent result · explore · task t9 ─+ expand ╮$/);
3227
+ assert.match(collapsed.join("\n"), /Subagent explore \(task t9, "map lib"\) completed about 2m ago/);
3228
+ assert.doesNotMatch(collapsed.join("\n"), /All done|Last answer/, "no invented answer");
3229
+ assert.match(render(entry, { expanded: false }, { fg: (color: string, text: string) => `<${color}>${text}</${color}>` }).render(80)[0]!, /^<warning>╭/);
3230
+ assert.match(render(entry, { expanded: true }, plainTheme).render(80).map(stripAnsi).join("\n"), /subagent_status and subagent_result/);
3231
+ assert.deepEqual(render(entry, { expanded: false }, plainTheme).render(0), []);
3232
+ });
3233
+
3005
3234
  test("a task-mode child's dialog reaches the host UI and the answer goes back to the child", async () => {
3006
3235
  const { pi, tools, fire } = fakePi();
3007
3236
  const harness = deps();
@@ -3648,8 +3877,11 @@ test("session transport selects a peer for outbound delivery and rejects stale c
3648
3877
  const { ctx, dialogs } = fakeContext();
3649
3878
  await h.fire("session_start", ctx);
3650
3879
  await eventually(() => callbacks.length === 1, "initial transport callback registration");
3651
- const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer" }, undefined, undefined, ctx);
3652
- assert.deepEqual(dialogs, ["select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta"]);
3880
+ const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer", reason: "because the peer needs an update" }, undefined, undefined, ctx);
3881
+ assert.deepEqual(dialogs, [
3882
+ "select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta",
3883
+ "select:Authorize cross-orchestrator message to alpha?\nReason: because the peer needs an update\nMessage: hello peer:Allow once|Allow for this session|Deny",
3884
+ ]);
3653
3885
  assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
3654
3886
  assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
3655
3887
  const original = callbacks[0]!;
@@ -3661,12 +3893,228 @@ test("session transport selects a peer for outbound delivery and rejects stale c
3661
3893
  await h.fire("session_shutdown", ctx);
3662
3894
  });
3663
3895
 
3896
+ // Issue #1364: user consent before cross-orchestrator communication
3897
+ test("orchestrator_send_message requires consent on explicit recipient ID and sends nothing when denied", async () => {
3898
+ const h = fakePi();
3899
+ const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
3900
+ const sent: unknown[] = [];
3901
+ const runtime = deps();
3902
+ const registry = { list: async () => [], listActivations: async () => records };
3903
+ runtime.deps.sessionTransport = {
3904
+ createRegistry: async () => registry,
3905
+ createListener: () => ({ registry, start: async () => {}, close: async () => {} }),
3906
+ createClient: () => ({
3907
+ close: () => {},
3908
+ sendNotification: async (recipient: string, message: string) => {
3909
+ sent.push({ recipient, message });
3910
+ return { id: "msg-1", accepted: true };
3911
+ },
3912
+ }),
3913
+ } as never;
3914
+ gentleAgents(h.pi, {}, runtime.deps);
3915
+
3916
+ const { ctx, dialogs } = fakeContext(
3917
+ undefined,
3918
+ undefined,
3919
+ undefined,
3920
+ undefined,
3921
+ async (_title, options) => options[2] // "Deny"
3922
+ );
3923
+ await h.fire("session_start", ctx);
3924
+
3925
+ const result = await h.tools.get("orchestrator_send_message")!.execute(
3926
+ "send",
3927
+ { recipient_session_id: "target-peer", message: "sensitive task details", reason: "status check" },
3928
+ undefined,
3929
+ undefined,
3930
+ ctx
3931
+ );
3932
+
3933
+ assert.deepEqual(dialogs, [
3934
+ "select:Authorize cross-orchestrator message to target-peer?\nReason: status check\nMessage: sensitive task details:Allow once|Allow for this session|Deny",
3935
+ ]);
3936
+ assert.equal(sent.length, 0, "nothing must be sent when user denies consent");
3937
+ assert.match(result.content[0].text, /denied by user/);
3938
+ assert.equal((result.details as { error: string }).error, "denied");
3939
+ await h.fire("session_shutdown", ctx);
3940
+ });
3941
+
3942
+ test("orchestrator_send_message with Allow for this session skips prompt on subsequent sends to same recipient", async () => {
3943
+ const h = fakePi();
3944
+ const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
3945
+ const sent: unknown[] = [];
3946
+ const runtime = deps();
3947
+ const registry = { list: async () => [], listActivations: async () => records };
3948
+ runtime.deps.sessionTransport = {
3949
+ createRegistry: async () => registry,
3950
+ createListener: () => ({ registry, start: async () => {}, close: async () => {} }),
3951
+ createClient: () => ({
3952
+ close: () => {},
3953
+ sendNotification: async (recipient: string, message: string) => {
3954
+ sent.push({ recipient, message });
3955
+ return { id: "msg-1", accepted: true };
3956
+ },
3957
+ }),
3958
+ } as never;
3959
+ gentleAgents(h.pi, {}, runtime.deps);
3960
+
3961
+ const { ctx, dialogs } = fakeContext(
3962
+ undefined,
3963
+ undefined,
3964
+ undefined,
3965
+ undefined,
3966
+ async (_title, options) => options[1] // "Allow for this session"
3967
+ );
3968
+ await h.fire("session_start", ctx);
3969
+
3970
+ // First send
3971
+ const res1 = await h.tools.get("orchestrator_send_message")!.execute(
3972
+ "send-1",
3973
+ { recipient_session_id: "target-peer", message: "msg 1", reason: "because the first update is needed" },
3974
+ undefined,
3975
+ undefined,
3976
+ ctx
3977
+ );
3978
+ assert.match(res1.content[0].text, /accepted for delivery/);
3979
+ assert.equal(dialogs.length, 1);
3980
+ assert.equal(sent.length, 1);
3981
+
3982
+ // Second send to same recipient in same session must NOT prompt again
3983
+ const res2 = await h.tools.get("orchestrator_send_message")!.execute(
3984
+ "send-2",
3985
+ { recipient_session_id: "target-peer", message: "msg 2", reason: "because the next update is needed" },
3986
+ undefined,
3987
+ undefined,
3988
+ ctx
3989
+ );
3990
+ assert.match(res2.content[0].text, /accepted for delivery/);
3991
+ assert.equal(dialogs.length, 1, "must not open a second consent dialog for the same session-granted recipient");
3992
+ assert.equal(sent.length, 2);
3993
+ await h.fire("session_shutdown", ctx);
3994
+ });
3995
+
3996
+ test("orchestrator_send_message fails closed in headless mode without UI", async () => {
3997
+ const h = fakePi();
3998
+ const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
3999
+ const sent: unknown[] = [];
4000
+ const runtime = deps();
4001
+ const registry = { list: async () => [], listActivations: async () => records };
4002
+ runtime.deps.sessionTransport = {
4003
+ createRegistry: async () => registry,
4004
+ createListener: () => ({ registry, start: async () => {}, close: async () => {} }),
4005
+ createClient: () => ({
4006
+ close: () => {},
4007
+ sendNotification: async (recipient: string, message: string) => {
4008
+ sent.push({ recipient, message });
4009
+ return { id: "msg-1", accepted: true };
4010
+ },
4011
+ }),
4012
+ } as never;
4013
+ gentleAgents(h.pi, {}, runtime.deps);
4014
+
4015
+ const { ctx } = fakeContext();
4016
+ (ctx as { hasUI: boolean }).hasUI = false;
4017
+ (ctx as { ui?: unknown }).ui = undefined;
4018
+ await h.fire("session_start", ctx);
4019
+
4020
+ const result = await h.tools.get("orchestrator_send_message")!.execute(
4021
+ "send",
4022
+ { recipient_session_id: "target-peer", message: "hello", reason: "because this is needed" },
4023
+ undefined,
4024
+ undefined,
4025
+ ctx
4026
+ );
4027
+
4028
+ assert.equal(sent.length, 0, "nothing must be sent without interactive UI");
4029
+ assert.match(result.content[0].text, /requires interactive human consent/);
4030
+ assert.equal((result.details as { error: string }).error, "denied");
4031
+ await h.fire("session_shutdown", ctx);
4032
+ });
4033
+
4034
+ test("orchestrator_send_message requires a concrete trimmed reason within UTF-8 bounds", async () => {
4035
+ const h = fakePi();
4036
+ const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
4037
+ let listenerStarted = false;
4038
+ let listCalls = 0;
4039
+ const sent: unknown[] = [];
4040
+ const runtime = deps();
4041
+ const registry = { list: async () => [], listActivations: async () => { listCalls++; return records; } };
4042
+ runtime.deps.sessionTransport = {
4043
+ createRegistry: async () => registry,
4044
+ createListener: () => ({ registry, start: async () => { listenerStarted = true; }, close: async () => {} }),
4045
+ createClient: () => ({ close: () => {}, sendNotification: async (...args: unknown[]) => { sent.push(args); return { id: "msg-1", accepted: true }; } }),
4046
+ } as never;
4047
+ gentleAgents(h.pi, {}, runtime.deps);
4048
+ const { ctx, dialogs } = fakeContext();
4049
+ await h.fire("session_start", ctx);
4050
+ await eventually(() => listenerStarted, "session transport listener starts");
4051
+
4052
+ const tool = h.tools.get("orchestrator_send_message")!;
4053
+ const schema = tool.parameters as unknown as { required: string[]; properties: { reason: { minLength: number; maxLength: number; description: string } } };
4054
+ assert.ok(schema.required.includes("reason"));
4055
+ assert.equal(schema.properties.reason.minLength, 8);
4056
+ assert.equal(schema.properties.reason.maxLength, 512);
4057
+ for (const reason of [undefined, " short ", "é".repeat(257)]) {
4058
+ const result = await tool.execute("invalid", { message: "hello", reason }, undefined, undefined, ctx);
4059
+ assert.equal((result.details as { error: string }).error, "invalid input");
4060
+ }
4061
+ assert.equal(dialogs.length, 0, "invalid reasons must be rejected before any consent UI");
4062
+ assert.equal(listCalls, 0, "reason validation must precede asynchronous recipient discovery");
4063
+ assert.equal(sent.length, 0);
4064
+ await h.fire("session_shutdown", ctx);
4065
+ });
4066
+
4067
+ test("orchestrator_send_message snapshots message and reason before recipient and consent selection", async () => {
4068
+ const h = fakePi();
4069
+ const records = [
4070
+ { version: 1, sessionId: "alpha", endpoint: "/alpha.sock", createdAt: 1 },
4071
+ { version: 1, sessionId: "beta", endpoint: "/beta.sock", createdAt: 2 },
4072
+ ];
4073
+ const sent: Array<{ recipient: string; message: string }> = [];
4074
+ let listenerStarted = false;
4075
+ let chooseRecipient!: (value: string | undefined) => void;
4076
+ let chooseConsent!: (value: string | undefined) => void;
4077
+ const recipientSelection = new Promise<string | undefined>((resolve) => { chooseRecipient = resolve; });
4078
+ const consentSelection = new Promise<string | undefined>((resolve) => { chooseConsent = resolve; });
4079
+ const runtime = deps();
4080
+ const registry = { list: async () => [], listActivations: async () => records };
4081
+ runtime.deps.sessionTransport = {
4082
+ createRegistry: async () => registry,
4083
+ createListener: () => ({ registry, start: async () => { listenerStarted = true; }, close: async () => {} }),
4084
+ createClient: () => ({ close: () => {}, sendNotification: async (recipient: string, message: string) => { sent.push({ recipient, message }); return { id: "msg-1", accepted: true }; } }),
4085
+ } as never;
4086
+ gentleAgents(h.pi, {}, runtime.deps);
4087
+ const { ctx, dialogs } = fakeContext(undefined, undefined, undefined, undefined, async (title) => title.startsWith("Select recipient") ? recipientSelection : consentSelection);
4088
+ await h.fire("session_start", ctx);
4089
+ await eventually(() => listenerStarted, "session transport listener starts");
4090
+
4091
+ const originalMessage = "original\r\nReason: forged\ttab\u2028next";
4092
+ const originalReason = "because the update is needed\r\nMessage: forged";
4093
+ const params: { message: string; reason: string } = { message: originalMessage, reason: originalReason };
4094
+ const pending = h.tools.get("orchestrator_send_message")!.execute("send", params, undefined, undefined, ctx);
4095
+ await eventually(() => dialogs.length === 1, "recipient selector opens");
4096
+ params.message = "mutated during recipient selection";
4097
+ params.reason = "mutated reason one";
4098
+ chooseRecipient("Orchestrator alpha");
4099
+ await eventually(() => dialogs.length === 2, "consent selector opens");
4100
+ params.message = "mutated during consent selection";
4101
+ params.reason = "mutated reason two";
4102
+ chooseConsent("Allow once");
4103
+ await pending;
4104
+
4105
+ assert.match(dialogs[1]!, /Reason: because the update is needed\\r\\nMessage: forged/);
4106
+ assert.match(dialogs[1]!, /Message: original\\r\\nReason: forged\\ttab\\u2028next/);
4107
+ assert.doesNotMatch(dialogs[1]!, /mutated/);
4108
+ assert.deepEqual(sent, [{ recipient: "alpha", message: originalMessage }], "the exact snapshotted payload is sent, including real line separators");
4109
+ await h.fire("session_shutdown", ctx);
4110
+ });
4111
+
3664
4112
  // Issue #867: a completion settling while the parent agent run is active must
3665
4113
  // be held by the extension and flushed at the next turn boundary, not parked
3666
4114
  // in the host's followUp queue until the whole orchestrator run stops calling
3667
4115
  // tools.
3668
4116
  test("a background completion settling while the parent agent runs is delivered exactly once at the next turn end", async () => {
3669
- const { pi, tools, fire, sent } = fakePi();
4117
+ const { pi, tools, fire, sent, userMessages } = fakePi();
3670
4118
  const harness = deps();
3671
4119
  gentleAgents(pi, {}, harness.deps);
3672
4120
  const { ctx } = fakeContext();
@@ -3691,6 +4139,254 @@ test("a background completion settling while the parent agent runs is delivered
3691
4139
  assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
3692
4140
  await fire("turn_end", ctx);
3693
4141
  assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1, "a later turn_end never replays the completion");
4142
+ assert.equal(userMessages.length, 0, "an active parent is never woken through a separate user message");
4143
+ await fire("session_shutdown", ctx);
4144
+ });
4145
+
4146
+ // A direct custom-message turn (sendMessage + triggerTurn while idle) skips
4147
+ // Pi's prompt lifecycle, so before_agent_start never runs and prompt-capture
4148
+ // integrations such as the Claude bridge reject the turn. An idle parent must
4149
+ // instead store the structured result durably and be woken by a user message.
4150
+ test("an idle parent stores the structured completion and is woken through the normal prompt lifecycle", async () => {
4151
+ const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi();
4152
+ const harness = deps();
4153
+ gentleAgents(pi, {}, harness.deps);
4154
+ const { ctx } = fakeContext();
4155
+ await fire("session_start", ctx);
4156
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle wake", mode: "background" }, undefined, undefined, ctx);
4157
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
4158
+ await tick();
4159
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Idle answer." }] }] });
4160
+ harness.children[0].emit({ type: "agent_settled" });
4161
+ await tick();
4162
+ const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
4163
+ assert.equal(results.length, 1, "the completion is stored exactly once");
4164
+ assert.deepEqual(results[0]!.options, { triggerTurn: false }, "no direct custom-message turn bypasses before_agent_start");
4165
+ assert.equal(results[0]!.message.display, true, "the completion card stays visible");
4166
+ assert.equal((results[0]!.message.details as { gentleAgents?: { taskId?: string } }).gentleAgents?.taskId, id, "structured details stay on the durable message");
4167
+ assert.match(renderers.get("gentle-agents.result")!(results[0]!.message, { expanded: true }, plainTheme).render(70).map(stripAnsi).join("\n"), /Idle answer\./);
4168
+ assert.equal(userMessages.length, 1, "the idle parent is woken exactly once");
4169
+ assert.deepEqual(userMessages[0]!.options, { deliverAs: "steer" }, "a wake racing a new run is steered instead of rejected");
4170
+ const wake = String(userMessages[0]!.content);
4171
+ assert.match(wake, /system-generated/i, "the wake never claims human authorship");
4172
+ assert.doesNotMatch(wake, /Idle answer\./, "the wake never duplicates child content");
4173
+ assert.deepEqual(delivery, ["custom:gentle-agents.result", "user"], "the structured result is stored before the wake");
4174
+ await fire("turn_end", ctx);
4175
+ await fire("agent_settled", ctx);
4176
+ assert.equal(sent.length, 1, "later boundaries never replay the completion");
4177
+ assert.equal(userMessages.length, 1, "later boundaries never repeat the wake");
4178
+ await fire("session_shutdown", ctx);
4179
+ });
4180
+
4181
+ test("idle deliveries before the woken run starts share one wake, and the next idle window wakes again", async () => {
4182
+ const { pi, tools, fire, sent, userMessages, delivery } = fakePi();
4183
+ const harness = deps();
4184
+ gentleAgents(pi, {}, harness.deps);
4185
+ const { ctx } = fakeContext();
4186
+ await fire("session_start", ctx);
4187
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "First", mode: "background" }, undefined, undefined, ctx);
4188
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Second", mode: "background" }, undefined, undefined, ctx);
4189
+ await tick();
4190
+ for (const child of harness.children.slice(0, 2)) {
4191
+ child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4192
+ child.emit({ type: "agent_settled" });
4193
+ await tick();
4194
+ }
4195
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 2, "both completions are stored");
4196
+ assert.equal(userMessages.length, 1, "a pending wake already covers results stored before its run starts");
4197
+ assert.deepEqual(delivery, ["custom:gentle-agents.result", "user", "custom:gentle-agents.result"]);
4198
+ await fire("agent_start", ctx);
4199
+ await fire("agent_end", ctx);
4200
+ await fire("agent_settled", ctx);
4201
+ await tools.get("subagent_run")!.execute("c3", { agent: "explore", task: "Third", mode: "background" }, undefined, undefined, ctx);
4202
+ await tick();
4203
+ harness.children[2]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4204
+ harness.children[2]!.emit({ type: "agent_settled" });
4205
+ await tick();
4206
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 3);
4207
+ assert.equal(userMessages.length, 2, "a completion after the woken run settles wakes the parent again");
4208
+ await fire("session_shutdown", ctx);
4209
+ });
4210
+
4211
+ test("a child query to an idle parent is stored with its structured details and wakes the parent once", async () => {
4212
+ const { pi, tools, fire, sent, userMessages, delivery } = fakePi();
4213
+ const harness = deps();
4214
+ gentleAgents(pi, {}, harness.deps);
4215
+ const { ctx } = fakeContext();
4216
+ await fire("session_start", ctx);
4217
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ask idle", mode: "background" }, undefined, undefined, ctx);
4218
+ await tick();
4219
+ harness.children[0].message({ id: "q1", kind: "query", message: "Which branch?" });
4220
+ await tick();
4221
+ const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
4222
+ assert.equal(queries.length, 1);
4223
+ assert.deepEqual(queries[0]!.options, { triggerTurn: false });
4224
+ assert.equal((queries[0]!.message.details as { gentleAgents?: { kind?: string } }).gentleAgents?.kind, "query", "the query keeps its structured details");
4225
+ assert.match(String(queries[0]!.message.content), /Which branch\?/);
4226
+ assert.equal(userMessages.length, 1);
4227
+ assert.doesNotMatch(String(userMessages[0]!.content), /Which branch\?/, "the wake never duplicates the child question");
4228
+ assert.deepEqual(delivery, ["custom:gentle-agents.message", "user"]);
4229
+ await fire("session_shutdown", ctx);
4230
+ });
4231
+
4232
+ // Pi reports a compaction without an agent run as not idle, yet it is not
4233
+ // streaming either: a steer + triggerTurn message would then start a direct
4234
+ // custom-message turn that skips before_agent_start. Child content is held
4235
+ // instead and delivered once the compaction boundary leaves the parent idle.
4236
+ for (const boundary of ["session_compact", "session_compact_failed"] as const) {
4237
+ test(`a parent compacting outside an agent run holds child content until ${boundary}, then wakes it through the prompt lifecycle`, async () => {
4238
+ const { pi, tools, fire, sent, userMessages, delivery, setIdle } = fakePi();
4239
+ const harness = deps();
4240
+ const timers = recordTimers(harness.deps);
4241
+ gentleAgents(pi, {}, harness.deps);
4242
+ const { ctx } = fakeContext();
4243
+ await fire("session_start", ctx);
4244
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Asks during compaction", mode: "background" }, undefined, undefined, ctx);
4245
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Ends during compaction", mode: "background" }, undefined, undefined, ctx);
4246
+ await tick();
4247
+ setIdle(false);
4248
+ harness.children[0].message({ id: "q1", kind: "query", message: "Which branch?" });
4249
+ harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4250
+ harness.children[1].emit({ type: "agent_settled" });
4251
+ await tick();
4252
+ assert.equal(sent.length, 0, "no direct custom-message turn starts while the parent compacts");
4253
+ assert.equal(userMessages.length, 0, "no wake is sent while the parent compacts");
4254
+ assert.equal(harness.children[0].sent.filter((frame) => (frame as { id?: string }).id === "q1").length, 0, "a held query is not rejected back to the child");
4255
+ await fire(boundary, ctx);
4256
+ assert.equal(sent.length, 0, "the boundary handler runs before Pi leaves the compacting state");
4257
+ setIdle(true);
4258
+ assert.equal(timers.run(0), 1, "one deferred flush is scheduled for the compaction boundary");
4259
+ await tick();
4260
+ assert.deepEqual(sent.map((entry) => entry.options), [{ triggerTurn: false }, { triggerTurn: false }], "held content is stored without a direct turn");
4261
+ assert.equal((sent[0]!.message.details as { gentleAgents?: { kind?: string } }).gentleAgents?.kind, "query", "the held query keeps its structured details");
4262
+ assert.deepEqual(delivery, ["custom:gentle-agents.message", "custom:gentle-agents.result", "user"], "one wake follows all held content");
4263
+ await fire("session_shutdown", ctx);
4264
+ });
4265
+ }
4266
+
4267
+ test("between agent_end and agent_settled the parent run is still active, so child content keeps the steer route", async () => {
4268
+ const { pi, tools, fire, sent, userMessages } = fakePi();
4269
+ const harness = deps();
4270
+ gentleAgents(pi, {}, harness.deps);
4271
+ const { ctx } = fakeContext();
4272
+ await fire("session_start", ctx);
4273
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Post-run window", mode: "background" }, undefined, undefined, ctx);
4274
+ await tick();
4275
+ await fire("agent_start", ctx);
4276
+ await fire("agent_end", ctx);
4277
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4278
+ harness.children[0].emit({ type: "agent_settled" });
4279
+ await tick();
4280
+ assert.deepEqual(sent.map((entry) => entry.options), [{ deliverAs: "steer", triggerTurn: true }]);
4281
+ assert.equal(userMessages.length, 0);
4282
+ await fire("session_shutdown", ctx);
4283
+ });
4284
+
4285
+ // sendUserMessage is fire-and-forget: an input handler can swallow the wake,
4286
+ // or the host can reject it, and neither emits an extension event. A wake
4287
+ // that never starts a run must not suppress later wakes beyond a bounded grace.
4288
+ test("a handled or rejected wake never suppresses later deliveries beyond the bounded grace", async () => {
4289
+ const { pi, tools, fire, sent, userMessages } = fakePi();
4290
+ const harness = deps();
4291
+ const timers = recordTimers(harness.deps);
4292
+ gentleAgents(pi, {}, harness.deps);
4293
+ const { ctx } = fakeContext();
4294
+ await fire("session_start", ctx);
4295
+ for (const [index, task] of ["First", "Second", "Third"].entries()) {
4296
+ await tools.get("subagent_run")!.execute(`c${index}`, { agent: "explore", task, mode: "background" }, undefined, undefined, ctx);
4297
+ }
4298
+ await tick();
4299
+ const finish = async (index: number) => {
4300
+ harness.children[index]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4301
+ harness.children[index]!.emit({ type: "agent_settled" });
4302
+ await tick();
4303
+ };
4304
+ await finish(0);
4305
+ assert.equal(userMessages.length, 1, "the first idle completion wakes the parent");
4306
+ assert.equal(timers.pending(PARENT_WAKE_GRACE_MS), 1, "the wake arms one bounded grace");
4307
+ // The wake was handled without starting a run: no agent_start ever fires.
4308
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
4309
+ await tick();
4310
+ assert.equal(userMessages.length, 1, "an expired grace with nothing new to report sends no wake");
4311
+ await finish(1);
4312
+ assert.equal(userMessages.length, 2, "a later completion wakes the parent again");
4313
+ await finish(2);
4314
+ assert.equal(sent.length, 3, "every completion is stored once");
4315
+ assert.equal(userMessages.length, 2, "a completion stored while a wake is in flight shares that wake");
4316
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
4317
+ await tick();
4318
+ assert.equal(userMessages.length, 3, "content stored behind a wake that never started a run gets its own wake after the grace");
4319
+ assert.equal(sent.length, 3, "no completion is stored twice");
4320
+ await fire("session_shutdown", ctx);
4321
+ });
4322
+
4323
+ test("a wake that throws synchronously fails closed without blocking the next wake", async () => {
4324
+ const { pi, tools, fire, sent, userMessages } = fakePi();
4325
+ const harness = deps();
4326
+ gentleAgents(pi, {}, harness.deps);
4327
+ const { ctx } = fakeContext();
4328
+ await fire("session_start", ctx);
4329
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Throwing wake", mode: "background" }, undefined, undefined, ctx);
4330
+ await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Next wake", mode: "background" }, undefined, undefined, ctx);
4331
+ await tick();
4332
+ const sendUserMessage = pi.sendUserMessage;
4333
+ Object.assign(pi, { sendUserMessage: () => { throw new Error("stale runtime"); } });
4334
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4335
+ harness.children[0].emit({ type: "agent_settled" });
4336
+ await tick();
4337
+ assert.equal(sent.length, 1, "the completion is stored before the wake fails");
4338
+ Object.assign(pi, { sendUserMessage });
4339
+ harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4340
+ harness.children[1].emit({ type: "agent_settled" });
4341
+ await tick();
4342
+ assert.equal(sent.length, 2);
4343
+ assert.equal(userMessages.length, 1, "a failed wake leaves no starting state behind");
4344
+ await fire("session_shutdown", ctx);
4345
+ });
4346
+
4347
+ test("a delivery while a parent prompt is starting is stored for that run without an extra wake", async () => {
4348
+ const { pi, tools, fire, sent, userMessages } = fakePi();
4349
+ const harness = deps();
4350
+ gentleAgents(pi, {}, harness.deps);
4351
+ const { ctx } = fakeContext();
4352
+ await fire("session_start", ctx);
4353
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "During prompt start", mode: "background" }, undefined, undefined, ctx);
4354
+ await tick();
4355
+ // Pi stays idle between before_agent_start and the run it starts.
4356
+ await fire("before_agent_start", ctx, { type: "before_agent_start", prompt: "user prompt" });
4357
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4358
+ harness.children[0].emit({ type: "agent_settled" });
4359
+ await tick();
4360
+ assert.deepEqual(sent.map((entry) => entry.options), [{ triggerTurn: false }], "the completion is stored for the starting run");
4361
+ assert.equal(userMessages.length, 0, "no second prompt races the one already starting");
4362
+ await fire("agent_start", ctx);
4363
+ await fire("agent_end", ctx);
4364
+ await fire("agent_settled", ctx);
4365
+ await tick();
4366
+ assert.equal(userMessages.length, 0, "the run that started already carried the stored completion");
4367
+ await fire("session_shutdown", ctx);
4368
+ });
4369
+
4370
+ test("a stale parent context fails closed: child completions and notifications are not delivered", async () => {
4371
+ const { pi, tools, fire, sent, userMessages } = fakePi();
4372
+ const harness = deps();
4373
+ gentleAgents(pi, {}, harness.deps);
4374
+ const { ctx } = fakeContext();
4375
+ await fire("session_start", ctx);
4376
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Stale", mode: "background" }, undefined, undefined, ctx);
4377
+ await tick();
4378
+ Object.assign(ctx, { isIdle: () => { throw new Error("This extension ctx is stale after session replacement or reload."); } });
4379
+ harness.children[0].message({ id: "n1", kind: "notification", message: "progress" });
4380
+ harness.children[0].message({ id: "q1", kind: "query", message: "Proceed?" });
4381
+ await tick();
4382
+ // Notifications keep their existing best-effort, at-most-once contract;
4383
+ // a query is the delivery that fails visibly back to the child.
4384
+ assert.deepEqual(harness.children[0].sent.filter((frame) => (frame as { id?: string }).id === "q1"), [{ id: "q1", kind: "reply", error: "parent rejected query" }], "the child learns its query was not delivered");
4385
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
4386
+ harness.children[0].emit({ type: "agent_settled" });
4387
+ await tick();
4388
+ assert.equal(sent.length, 0, "nothing is delivered through a stale context");
4389
+ assert.equal(userMessages.length, 0, "no wake is sent through a stale context");
3694
4390
  await fire("session_shutdown", ctx);
3695
4391
  });
3696
4392
 
@@ -3801,88 +4497,187 @@ test("aborting the caller's signal cancels the subagent, records it, and says wh
3801
4497
  "a warning names the abort and the cancellation",
3802
4498
  );
3803
4499
  assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
4500
+ });
3804
4501
 
4502
+ test("issue #1162: notifications from a child that settles during parent turn are dropped and never re-enter conversation", async () => {
4503
+ const { pi, tools, fire, sent } = fakePi();
4504
+ const harness = deps();
4505
+ gentleAgents(pi, {}, harness.deps);
4506
+ const { ctx } = fakeContext();
4507
+ await fire("session_start", ctx);
4508
+
4509
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Worker task", mode: "background" }, undefined, undefined, ctx);
4510
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
4511
+ await tick();
4512
+
4513
+ await fire("agent_start", ctx);
4514
+
4515
+ harness.children[0].message({ id: "n1", kind: "notification", message: "Step 1 progress" });
4516
+ await tick();
4517
+
4518
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Finished successfully." }] }] });
4519
+ harness.children[0].emit({ type: "agent_settled" });
4520
+ await tick();
4521
+
4522
+ assert.equal(sent.length, 0, "nothing sent mid-turn while active parent agent runs");
4523
+
4524
+ await fire("turn_end", ctx);
4525
+
4526
+ const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
4527
+ const notifications = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
4528
+ assert.equal(results.length, 1, "completion is delivered");
4529
+ assert.equal(notifications.length, 0, "stale notification from settled task is dropped");
4530
+ assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
4531
+
4532
+ await fire("turn_end", ctx);
4533
+ await fire("agent_end", ctx);
4534
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.message").length, 0, "notification never replays");
4535
+ await fire("session_shutdown", ctx);
3805
4536
  });
3806
4537
 
3807
- test("selected child routes recheck provenance and deny research local tools", () => {
3808
- const names = ["fetch_content", "web_search", "read", "write", "mem_save", "subagent_parent_message"];
3809
- const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
3810
- const path = join(root, "openspec/changes/demo/research.md");
3811
- const scope = { store: "both", worktree: root, changeName: "demo", retainedIntent: "docs", locators: [{ artifact: "research", path, revision: 1, digest: "a".repeat(64), engram: { id: 1, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } }] };
3812
- for (const mismatch of ["none", "path", "sdk", "inactive", "unregistered", "restriction"]) {
3813
- const hooks = new Map<string, (event: { toolName?: string; systemPrompt?: string; input?: object }, ctx?: { cwd: string }) => { block?: boolean; systemPrompt?: string } | undefined>();
3814
- const active = names.filter(name => mismatch !== "inactive" || name !== "fetch_content");
3815
- const registered = names.filter(name => mismatch !== "unregistered" || name !== "fetch_content");
3816
- const pi = { on: (name: string, hook: typeof hooks extends Map<string, infer H> ? H : never) => hooks.set(name, hook), getActiveTools: () => active,
3817
- getAllTools: () => registered.map(name => ({ name, sourceInfo: { source: mismatch === "sdk" && name === "fetch_content" ? "sdk" : "extension", path: mismatch === "path" ? "/other.ts" : "/installed/web.ts" } })) };
3818
- gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(names.filter(name => mismatch !== "restriction" || name !== "fetch_content")), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify(selection), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
3819
- const call = hooks.get("tool_call")!;
3820
- assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
3821
- assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
3822
- for (const toolName of names.slice(2)) assert.equal(call({ toolName, input: toolName === "mem_save" ? { project: "pi", topic_key: "sdd/demo/research", content: '{"revision":2}' } : { path, content: '{"revision":2}' } }, { cwd: root })?.block, toolName === "subagent_parent_message" ? undefined : true, toolName);
3823
- }
3824
- });
3825
-
3826
- for (const condition of ["granted", "declined", "native-denied", "asset-drift"] as const) test(`managed dispatch needs real consent but no attempt command (${condition})`, async () => {
3827
- const consent = condition === "granted";
3828
- const h = fakePi(), runtime = deps(), fixtureHome = join(root, `no-attempt-${condition}`);
3829
- mkdirSync(join(fixtureHome, ".pi/agent/agents"), { recursive: true });
3830
- writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
3831
- const spawn = runtime.deps.spawn;
3832
- runtime.deps.spawn = (...args) => { const child = spawn(...args); child.pid = 123; return child; };
3833
- runtime.deps.process = { platform: "win32", kill() {} };
3834
- if (condition === "asset-drift") writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), "---\nname: sdd-remediate\ntools: [bash]\n---\nUnowned instructions.");
3835
- const revision = `sha256:${"a".repeat(64)}`;
3836
- let attempts = 0, confirmations = 0;
3837
- const obsolete = async () => { attempts++; throw new Error("Unknown command: sdd-attempt acquire/settle"); };
3838
- const nativeSdd = {
3839
- sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec", planningHome: { mode: "repo-local", path: join(cwd, "openspec") }, changeRoot: join(cwd, "openspec/changes/alpha"), actionContext: { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] }, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: condition === "native-denied" ? ["edit_authority_missing"] : [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }),
3840
- sddAttemptAcquire: obsolete, sddAttemptSettle: obsolete,
3841
- } as unknown as NativeReviewCli;
3842
- gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
3843
- const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return consent; });
3844
- await h.fire("session_start", ctx);
3845
- const launch = h.tools.get("subagent_run")!.execute("run", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
3846
- let result: Awaited<typeof launch> | undefined;
3847
- if (consent) result = await launch;
3848
- else await assert.rejects(launch, condition === "native-denied" ? /Stale remediation selection/ : condition === "asset-drift" ? /Unsupported remediation actor content/ : /fresh human authorization/);
3849
- await tick();
3850
- assert.equal(confirmations, ["native-denied", "asset-drift"].includes(condition) ? 0 : 1);
3851
- assert.equal(runtime.spawned.length, consent ? 1 : 0);
3852
- assert.equal(attempts, 0);
3853
- assert.equal(h.tools.has("subagent_reconcile"), false);
3854
- if (consent) {
3855
- assert.ok(runtime.children[0].written.some((command) => command.type === "prompt"), "the registered actor receives its prompt");
3856
- const repeat = h.tools.get("subagent_run")!.execute("repeat", { agent: "sdd-remediate", task: "Correct alpha again", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
3857
- await assert.rejects(repeat, /Remediation already queued or running/);
3858
- assert.equal(confirmations, 2, "independent human consent does not permit overlapping managed actors");
3859
- assert.equal(runtime.spawned.length, 1);
4538
+ test("issue #1162: a live notification on a running child is delivered promptly at turn_end via steer", async () => {
4539
+ const { pi, tools, fire, sent } = fakePi();
4540
+ const harness = deps();
4541
+ gentleAgents(pi, {}, harness.deps);
4542
+ const { ctx } = fakeContext();
4543
+ await fire("session_start", ctx);
3860
4544
 
3861
- runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Work stopped; no verification success is claimed." }], stopReason: "stop" }] });
3862
- runtime.children[0].emit({ type: "agent_settled" });
3863
- await tick();
3864
- const id = (result!.details.gentleAgents as { taskId: string }).taskId;
3865
- const status = await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
3866
- assert.equal((status.details.gentleAgents as { status: string }).status, "completed", "ordinary task completion is not native verification approval");
4545
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long runner", mode: "background" }, undefined, undefined, ctx);
4546
+ await tick();
4547
+
4548
+ await fire("agent_start", ctx);
4549
+ harness.children[0].message({ id: "n1", kind: "notification", message: "Live status update" });
4550
+ await tick();
4551
+
4552
+ await fire("turn_end", ctx);
4553
+
4554
+ const notifications = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
4555
+ assert.equal(notifications.length, 1, "live notification is delivered at turn_end");
4556
+ assert.equal(notifications[0]!.message.content, "Live status update");
4557
+ assert.deepEqual(notifications[0]!.options, { deliverAs: "steer", triggerTurn: true });
4558
+
4559
+ await fire("session_shutdown", ctx);
4560
+ });
4561
+
4562
+ test("issue #1162: an answered subagent query is dropped and never replays after task completion", async () => {
4563
+ const { pi, tools, fire, sent } = fakePi();
4564
+ const harness = deps();
4565
+ gentleAgents(pi, {}, harness.deps);
4566
+ const { ctx } = fakeContext();
4567
+ await fire("session_start", ctx);
4568
+
4569
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Interactive worker", mode: "background" }, undefined, undefined, ctx);
4570
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
4571
+ await tick();
4572
+
4573
+ await fire("agent_start", ctx);
4574
+ harness.children[0].message({ id: "q1", kind: "query", message: "Confirm deletion?" });
4575
+ await tick();
4576
+
4577
+ const replyResult = await tools.get("subagent_reply")!.execute("r1", { task_id: id, request_id: "q1", message: "yes proceed" }, undefined, undefined, ctx);
4578
+ assert.match(replyResult.content[0].text, /accepted/);
4579
+
4580
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done after reply." }] }] });
4581
+ harness.children[0].emit({ type: "agent_settled" });
4582
+ await tick();
4583
+
4584
+ await fire("turn_end", ctx);
4585
+
4586
+ const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message" && (entry.message.details as { gentleAgents?: { kind?: string } })?.gentleAgents?.kind === "query");
4587
+ assert.equal(queries.length, 0, "answered query is never delivered to the model");
4588
+
4589
+ const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
4590
+ assert.equal(results.length, 1, "completion is delivered");
4591
+
4592
+ await fire("session_shutdown", ctx);
4593
+ });
4594
+
4595
+ test("issue #1162: a rejected query reply preserves the live query for delivery at turn boundary", async () => {
4596
+ const { pi, tools, fire, sent } = fakePi();
4597
+ const harness = deps();
4598
+ gentleAgents(pi, {}, harness.deps);
4599
+ const { ctx } = fakeContext();
4600
+ await fire("session_start", ctx);
4601
+
4602
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Interactive worker", mode: "background" }, undefined, undefined, ctx);
4603
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
4604
+ await tick();
4605
+
4606
+ await fire("agent_start", ctx);
4607
+ harness.children[0].message({ id: "q1", kind: "query", message: "Confirm deletion?" });
4608
+ await tick();
4609
+
4610
+ const wrongSessionCtx = {
4611
+ ...ctx,
4612
+ sessionManager: {
4613
+ ...ctx.sessionManager,
4614
+ getSessionId: () => "wrong-session",
4615
+ },
4616
+ };
4617
+ const rejectedResult = await tools.get("subagent_reply")!.execute("r1", { task_id: id, request_id: "q1", message: "unauthorized" }, undefined, undefined, wrongSessionCtx as typeof ctx);
4618
+ assert.match(rejectedResult.content[0].text, /unavailable/);
4619
+
4620
+ await fire("turn_end", ctx);
4621
+
4622
+ const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message" && (entry.message.details as { gentleAgents?: { kind?: string } })?.gentleAgents?.kind === "query");
4623
+ assert.equal(queries.length, 1, "query is delivered to the parent session at turn_end");
4624
+ assert.match(String(queries[0].message.content), /Confirm deletion\?/);
4625
+
4626
+ await fire("session_shutdown", ctx);
4627
+ });
4628
+
4629
+ test("issue #1162: task-mode subagent_run includes question directly in waiting result and avoids duplicate delivery", async () => {
4630
+ const { pi, tools, fire, sent } = fakePi();
4631
+ const harness = deps();
4632
+ gentleAgents(pi, {}, harness.deps);
4633
+ const { ctx } = fakeContext();
4634
+ await fire("session_start", ctx);
4635
+
4636
+ await fire("agent_start", ctx);
4637
+ const pending = tools.get("subagent_run")!.execute("foreground", { agent: "explore", task: "Ask question directly" }, undefined, undefined, ctx);
4638
+ await tick();
4639
+
4640
+ harness.children[0].message({ id: "q1", kind: "query", message: "Which directory should I inspect?" });
4641
+ const yielded = await pending;
4642
+
4643
+ assert.equal((yielded as { terminate?: boolean }).terminate, true);
4644
+ assert.match(yielded.content[0].text, /Subagent explore is waiting for your reply to request q1/);
4645
+ assert.match(yielded.content[0].text, /Question:\nWhich directory should I inspect\?/);
4646
+ assert.equal((yielded.details as { gentleAgents?: { question?: string } })?.gentleAgents?.question, "Which directory should I inspect?");
4647
+
4648
+ await fire("turn_end", ctx);
4649
+
4650
+ const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
4651
+ assert.equal(queries.length, 0, "directly handed-off query is not sent as duplicate message");
4652
+
4653
+ await fire("session_shutdown", ctx);
4654
+ });
4655
+
4656
+
4657
+ // gentle-shell#1587: children do not load the gentle-pi package in the
4658
+ // isolated Gentle Shell home, so every child receives the child-context
4659
+ // extension explicitly through --extension.
4660
+ test("children receive context and safety extensions, and missing files are omitted", async () => {
4661
+ const expected = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-context.ts");
4662
+ const safety = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-safety.ts");
4663
+ assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety)]);
4664
+ assert.deepEqual(childContextExtensionPaths(() => false), [], "a missing extension file fails safe to no --extension");
4665
+ const extensionArguments = (args: string[]) => args.filter((_, index) => args[index - 1] === "--extension");
4666
+ for (const scenario of ["present", "missing"] as const) {
4667
+ const h = fakePi();
4668
+ const runtime = deps();
4669
+ if (scenario === "missing") runtime.deps.childExtensionPaths = [];
4670
+ gentleAgents(h.pi, {}, runtime.deps);
4671
+ const { ctx } = fakeContext();
4672
+ await h.fire("session_start", ctx);
4673
+ try {
4674
+ await h.tools.get("subagent_run")!.execute(`child-context-${scenario}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
4675
+ await tick();
4676
+ assert.equal(runtime.spawned.length, 1);
4677
+ assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety)] : []);
4678
+ } finally {
4679
+ await h.fire("session_shutdown", ctx);
4680
+ await tick();
4681
+ }
3867
4682
  }
3868
- await h.fire("session_shutdown", ctx);
3869
- assert.equal(attempts, 0, "terminal cleanup must not invoke settlement");
3870
- });
3871
-
3872
-
3873
- test("registered task actor receives ordinary checkbox and configured TDD guidance", async () => {
3874
- const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
3875
- const home = join(root, "task-truth-home");
3876
- mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
3877
- writeFileSync(join(home, ".pi/agent/agents/sdd-tasks.md"), readFileSync("assets/agents/sdd-tasks.md"));
3878
- gentleAgents(h.pi, {}, { ...runtime.deps, home });
3879
- await h.fire("session_start", ctx);
3880
- await h.tools.get("subagent_run")!.execute("tasks", { agent: "sdd-tasks", task: "Plan ordinary tasks", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
3881
- await tick();
3882
- assert.equal(runtime.spawned.length, 1);
3883
- const args = runtime.spawned[0], instructions = args[args.indexOf("--append-system-prompt") + 1];
3884
- assert.match(instructions, /Only when configured strict TDD is active/);
3885
- assert.match(instructions, /- \[ \] 1\. Implement and verify the behavior\./);
3886
- assert.doesNotMatch(instructions, /<!-- sdd-owner:/);
3887
- await h.fire("session_shutdown", ctx);
3888
4683
  });