gentle-pi 4.0.0 → 4.2.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 (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -12,20 +12,25 @@ import { SessionChanges, type SessionChangeEvidence } from "../lib/session-chang
12
12
  import test, { after, afterEach, before, mock } from "node:test";
13
13
  import type { TestContext } from "node:test";
14
14
  import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
15
16
  import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
16
17
  import { sidebarState } from "../lib/shell-sidebar.ts";
18
+ import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection } from "../lib/child-package-injection.ts";
17
19
  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";
18
20
  import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
19
21
  import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
20
22
  import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
21
23
  import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
22
- import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
24
+ import { applyTaskEvent, emptyThread, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
23
25
  import { NativePointerScope } from "../lib/native-pointer-region.ts";
24
- import { PresenceCursor, PresencePublisher, listPresence, readActivity } from "../lib/orchestrator-presence.ts";
26
+ import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts";
27
+ import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts";
25
28
  import { stripAnsi } from "../lib/terminal-theme.ts";
26
29
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
27
- import { AgentRunner } from "../lib/agents-runner.ts";
30
+ import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
28
31
  import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
32
+ import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
33
+ import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
29
34
  import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
30
35
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
31
36
  // The card style defaults to float; these assertions pin the outlined (neon)
@@ -48,6 +53,10 @@ interface Registered {
48
53
  }
49
54
 
50
55
  const plainTheme = { fg: (_color: string, text: string) => text };
56
+ // Session shutdown persists finished subagent history asynchronously, so a
57
+ // late write can race a teardown removal; Node retries ENOTEMPTY/EBUSY here
58
+ // instead of failing the test (macOS showed it, #1840).
59
+ const TEST_DIR_REMOVAL = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
51
60
  const fakeTui = { requestRender() {} };
52
61
  const inertSessionTransport: SessionTransportFactory = {
53
62
  createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
@@ -84,6 +93,9 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
84
93
  const activeSessionTeardowns = new Set<() => Promise<void>>();
85
94
  const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
86
95
  afterEach(stopActiveSessions);
96
+ // Every fixture session is "s1": the inherited-profile freeze is process state
97
+ // keyed by session id, so each test starts from an unfrozen session.
98
+ afterEach(() => resetFrozenInheritedProfilesForTesting());
87
99
  // subagent_run's default mode now reads the background-subagents policy
88
100
  // in-process (gentle-pi#background-subagents-default-mode), which falls
89
101
  // back to the real ~/.pi/gentle-ai/background-subagents.json when
@@ -98,7 +110,7 @@ after(async () => {
98
110
  finally {
99
111
  if (previousGentlePiConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
100
112
  else process.env.GENTLE_PI_CONFIG_HOME = previousGentlePiConfigHome;
101
- rmSync(root, { recursive: true, force: true });
113
+ rmSync(root, TEST_DIR_REMOVAL);
102
114
  }
103
115
  });
104
116
  const home = join(root, "home");
@@ -110,19 +122,22 @@ mkdirSync(nonGitCwd, { recursive: true });
110
122
  writeFileSync(join(home, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\ntools: [read, grep]\n---\nYou map things.");
111
123
  writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2, model_profiles: { explore: { effort: "low" } } }));
112
124
 
113
- function fakePi() {
125
+ // Existing prompt-lifecycle regressions exercise the bridge route by default.
126
+ function fakePi(initialProvider: string = "claude-bridge") {
127
+ const transformers: Array<Parameters<ExtensionAPI["registerMarkdownTransformer"]>[0]> = [];
114
128
  const handlers = new Map<string, Handler[]>();
115
129
  const tools = new Map<string, Registered>();
116
130
  const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
117
131
  const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
118
132
  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.
133
+ // `delivery` records custom messages and user wakes in dispatch order.
121
134
  const userMessages: Array<{ content: unknown; options: Record<string, unknown> | undefined }> = [];
122
135
  const delivery: string[] = [];
123
136
  // A live idle flag shared by every context fired through this host, like
124
137
  // Pi's ctx.isIdle(): busy from agent_start until agent_settled. Tests can
125
138
  // force it to simulate compaction or other non-run busy states.
139
+ let selectedProvider: string | undefined = initialProvider;
140
+ const setProvider = (provider: string | undefined) => { selectedProvider = provider; };
126
141
  let parentIdle = true;
127
142
  const setIdle = (idle: boolean) => { parentIdle = idle; };
128
143
  const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
@@ -132,6 +147,7 @@ function fakePi() {
132
147
  const listeners = new Map<string, Set<(data: unknown) => void>>();
133
148
  const pi = {
134
149
  appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
150
+ registerMarkdownTransformer: (transformer: Parameters<ExtensionAPI["registerMarkdownTransformer"]>[0]) => transformers.push(transformer),
135
151
  events: {
136
152
  emit: (name: string, data: unknown) => { events.push({ name, data }); for (const listener of listeners.get(name) ?? []) listener(data); },
137
153
  on: (name: string, listener: (data: unknown) => void) => {
@@ -157,6 +173,10 @@ function fakePi() {
157
173
  const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
158
174
  const results: unknown[] = [];
159
175
  if (!("isIdle" in ctx)) Object.assign(ctx, { isIdle: () => parentIdle });
176
+ if (!("model" in ctx)) Object.defineProperty(ctx, "model", {
177
+ configurable: true,
178
+ get: () => selectedProvider === undefined ? undefined : { provider: selectedProvider },
179
+ });
160
180
  if (event === "agent_start") parentIdle = false;
161
181
  else if (event === "agent_settled") parentIdle = true;
162
182
  try {
@@ -172,7 +192,7 @@ function fakePi() {
172
192
  }
173
193
  }
174
194
  };
175
- return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, renderers, entryRenderers, entries, events, listeners };
195
+ return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, setProvider, renderers, entryRenderers, entries, events, listeners, transformers };
176
196
  }
177
197
 
178
198
  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]) {
@@ -185,7 +205,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
185
205
  cwd,
186
206
  hasUI: true,
187
207
  mode: "tui",
188
- sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [] },
208
+ sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] },
189
209
  ui: {
190
210
  notify: (message: string) => dialogs.push(`notify:${message}`),
191
211
  custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay, options: unknown) =>
@@ -224,6 +244,68 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
224
244
  return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
225
245
  }
226
246
 
247
+ for (const scenario of ["background", "task", "append-failure", "overflow", "bytes", "replacement", "task-replacement", "unsafe-id", "no-transport"] as const) {
248
+ test(`explicit run work publication: ${scenario}`, async t => {
249
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
250
+ gentleAgents(h.pi, {}, runtime.deps);
251
+ await h.fire("session_start", ctx);
252
+ const initial = { objective: "Keep objective", work: { area: "Owner", tasks: { historical: { area: "Old" } } as Record<string, { area: string }> } };
253
+ if (scenario === "overflow") for (let i = 0; i < 7; i++) initial.work.tasks[`old-${i}`] = { area: "Old" };
254
+ if (scenario === "bytes") initial.objective = "x".repeat(1980);
255
+ await h.tools.get("orchestrator_session_id")!.execute("publish", { state: initial }, undefined, undefined, ctx);
256
+ const before = h.entries.length;
257
+ if (scenario === "append-failure") t.mock.method(h.pi, "appendEntry", () => { throw new Error("private credential detail"); });
258
+ let allocated: TaskRecord;
259
+ const run = t.mock.method(AgentRunner.prototype, "run", request => {
260
+ assert.equal(Object.hasOwn(request, "work"), false);
261
+ assert.equal(request.prompt, "Map");
262
+ allocated = { id: scenario === "unsafe-id" ? "constructor" : "actual-allocated-id", agent: "explore", label: "Map", mode: request.mode,
263
+ status: TASK_STATUS.COMPLETED, cwd, prompt: request.prompt, parentSessionId: request.parentSessionId,
264
+ createdAt: 1, startedAt: 1, endedAt: 2, model: "fixture", thinking: undefined,
265
+ sessionPath: null, error: null, result: "Done", lastStep: "Done", lastActivityAt: 2,
266
+ turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
267
+ if (scenario === "replacement") ctx.sessionManager = { ...ctx.sessionManager };
268
+ return allocated;
269
+ });
270
+ t.mock.method(AgentRunner.prototype, "waitForQuery", async () => undefined);
271
+ t.mock.method(AgentRunner.prototype, "waitFor", async () => {
272
+ if (scenario === "task-replacement") ctx.sessionManager = { ...ctx.sessionManager };
273
+ return allocated;
274
+ });
275
+ if (scenario === "no-transport") await h.fire("session_shutdown", ctx);
276
+ const result = await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map",
277
+ mode: scenario === "task" || scenario === "task-replacement" ? "task" : "background", work: { area: "Auth" } }, undefined, undefined, ctx);
278
+ assert.equal(run.mock.callCount(), 1);
279
+ assert.equal((result.details.gentleAgents as { taskId: string }).taskId, allocated!.id);
280
+ assert.equal(Object.hasOwn(allocated!, "work"), false);
281
+ const success = scenario === "background" || scenario === "task";
282
+ assert.equal((result.details.workPublication as { status: string }).status, success ? "recorded" : "unavailable");
283
+ assert.equal(h.entries.length, before + (success || scenario === "task-replacement" ? 1 : 0));
284
+ assert.doesNotMatch(result.content[0].text, /private credential/);
285
+ if (success) {
286
+ const state = (h.entries.at(-1)!.data as { state: typeof initial }).state;
287
+ assert.equal(state.objective, "Keep objective");
288
+ assert.deepEqual(state.work.tasks, { historical: { area: "Old" }, "actual-allocated-id": { area: "Auth" } });
289
+ const unclassified = await h.tools.get("subagent_run")!.execute("plain", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
290
+ assert.equal(unclassified.details.workPublication, undefined);
291
+ assert.equal(h.entries.length, before + 1);
292
+ }
293
+ });
294
+ }
295
+
296
+ test("invalid run work rejects before foreign consent or allocation", async t => {
297
+ const h = fakePi(), runtime = deps(), { ctx, dialogs } = fakeContext();
298
+ gentleAgents(h.pi, {}, runtime.deps);
299
+ await h.fire("session_start", ctx);
300
+ const run = t.mock.method(AgentRunner.prototype, "run", () => { throw new Error("allocation reached"); });
301
+ for (const work of [{}, { tasks: { guessed: { area: "Auth" } } }, { area: "bad\n" }]) {
302
+ await assert.rejects(h.tools.get("subagent_run")!.execute("invalid", { agent: "explore", task: "Map",
303
+ repository_root: "/foreign", work }, undefined, undefined, ctx), /invalid-published-state/);
304
+ }
305
+ assert.equal(run.mock.callCount(), 0);
306
+ assert.deepEqual(dialogs, []);
307
+ });
308
+
227
309
  // Records deps.schedule calls so a test fires exactly the timers it means to;
228
310
  // unrelated runner timers stay pending.
229
311
  function recordTimers(target: Partial<AgentsDeps>) {
@@ -236,6 +318,14 @@ function recordTimers(target: Partial<AgentsDeps>) {
236
318
  const due = (ms: number) => pending.filter((timer) => timer.ms === ms && !timer.cancelled);
237
319
  return {
238
320
  pending: (ms: number) => due(ms).length,
321
+ // Capture a known newly armed timer before other same-duration timers
322
+ // (for example child query expiry) are added.
323
+ takeLast: (ms: number) => {
324
+ const timer = due(ms).at(-1);
325
+ assert.ok(timer, `expected a pending ${ms}ms timer`);
326
+ pending.splice(pending.indexOf(timer), 1);
327
+ return () => { if (!timer.cancelled) timer.fn(); };
328
+ },
239
329
  run: (ms: number) => {
240
330
  const timers = due(ms);
241
331
  for (const timer of timers) {
@@ -267,7 +357,9 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
267
357
  pi: { command: "pi", args: [] },
268
358
  home,
269
359
  resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
270
- env: { PATH: "/bin" },
360
+ // The launch reads the global active profile from the config home; keep it
361
+ // on the scratch directory instead of the developer's own profiles.json.
362
+ env: { PATH: "/bin", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME },
271
363
  sessionTransport: inertSessionTransport,
272
364
  },
273
365
  };
@@ -341,15 +433,17 @@ test("cache warming follows actual Gentle Agents ownership and completion lifecy
341
433
  await decide(undefined);
342
434
  });
343
435
 
344
- const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
436
+ // `pi -p` and `pi --mode json` share pi's one-shot runner: it disposes the
437
+ // runtime once the prompt returns, so neither can receive a background result.
438
+ const SINGLE_SHOT_BACKGROUND_ERROR = "Background subagents are unavailable in single-shot modes: pi -p and pi --mode json exit before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
345
439
 
346
- for (const continuation of [false, true]) {
347
- test(`print mode rejects background ${continuation ? "continuation" : "launch"} before allocating a task`, async (t) => {
440
+ for (const hostMode of ["print", "json"] as const) for (const continuation of [false, true]) {
441
+ test(`${hostMode} mode rejects background ${continuation ? "continuation" : "launch"} before allocating a task`, async (t) => {
348
442
  const h = fakePi();
349
443
  const runtime = deps();
350
444
  gentleAgents(h.pi, {}, runtime.deps);
351
445
  const { ctx } = fakeContext();
352
- Object.assign(ctx, { mode: "print", hasUI: false });
446
+ Object.assign(ctx, { mode: hostMode, hasUI: false });
353
447
  await h.fire("session_start", ctx);
354
448
  let taskId: string | undefined;
355
449
  if (continuation) {
@@ -368,7 +462,7 @@ for (const continuation of [false, true]) {
368
462
  const tool = h.tools.get(continuation ? "subagent_continue" : "subagent_run")!;
369
463
  await assert.rejects(tool.execute("denied", continuation
370
464
  ? { task_id: taskId, prompt: "Follow up", mode: "background" }
371
- : { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx), { message: PRINT_BACKGROUND_ERROR });
465
+ : { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx), { message: SINGLE_SHOT_BACKGROUND_ERROR });
372
466
  await tick();
373
467
  assert.equal(run.mock.callCount(), 0, "rejection must precede runner task ID allocation");
374
468
  assert.equal(runtime.spawned.length, spawnedBefore, "no child spawned");
@@ -378,8 +472,9 @@ for (const continuation of [false, true]) {
378
472
  });
379
473
  }
380
474
 
381
- for (const mode of ["print", "tui", "rpc"] as const) {
382
- test(`${mode} preserves ${mode === "print" ? "bounded task" : "background"} execution`, async () => {
475
+ for (const mode of ["print", "json", "tui", "rpc"] as const) {
476
+ const singleShot = mode === "print" || mode === "json";
477
+ test(`${mode} preserves ${singleShot ? "bounded task" : "background"} execution`, async () => {
383
478
  const h = fakePi();
384
479
  const runtime = deps();
385
480
  gentleAgents(h.pi, {}, runtime.deps);
@@ -387,17 +482,17 @@ for (const mode of ["print", "tui", "rpc"] as const) {
387
482
  Object.assign(ctx, { mode, hasUI: mode === "tui" });
388
483
  await h.fire("session_start", ctx);
389
484
  let resolved = false;
390
- const pending = h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: mode === "print" ? "task" : "background" }, undefined, undefined, ctx).then(result => { resolved = true; return result; });
485
+ const pending = h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: singleShot ? "task" : "background" }, undefined, undefined, ctx).then(result => { resolved = true; return result; });
391
486
  await tick();
392
487
  assert.equal(runtime.spawned.length, 1);
393
- assert.equal(resolved, mode !== "print", "only task mode waits for completion");
488
+ assert.equal(resolved, !singleShot, "only task mode waits for completion");
394
489
  runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
395
490
  runtime.children[0].emit({ type: "agent_settled" });
396
491
  const result = await pending;
397
- if (mode === "print") assert.match(result.content[0].text, /mapped/);
398
492
  const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
399
493
  assert.ok(taskId);
400
- if (mode !== "print") {
494
+ if (singleShot) assert.equal(result.content[0].text, `Subagent explore (task ${taskId}, "Map") finished.\n\nmapped`, "the task result names its real id for subagent_continue");
495
+ if (!singleShot) {
401
496
  await tick();
402
497
  assert.match((await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /completed · background/);
403
498
  assert.equal(h.sent.length, 1, "settlement delivers exactly one completion");
@@ -652,7 +747,7 @@ test("first handoff failure keeps ordinary completion, later failure retains yie
652
747
  firstHarness.children[0].message({ id: "q1", kind: "query", message: "q" });
653
748
  firstHarness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "ordinary" }], stopReason: "stop" }] });
654
749
  firstHarness.children[0].emit({ type: "agent_settled" });
655
- assert.equal((await ordinary).content[0].text, "ordinary");
750
+ assert.match((await ordinary).content[0].text, /^Subagent explore \(task [^,]+, "fail handoff"\) finished\.\n\nordinary$/);
656
751
 
657
752
  const second = fakePi();
658
753
  const secondHarness = deps();
@@ -1134,7 +1229,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
1134
1229
  return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
1135
1230
  source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1136
1231
  } };
1137
- const env: NodeJS.ProcessEnv = {};
1232
+ const env: NodeJS.ProcessEnv = { GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
1138
1233
  const profile = join(root, `metrics-${boundary}`);
1139
1234
  mkdirSync(join(profile, "agents"), { recursive: true });
1140
1235
  writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
@@ -1230,10 +1325,16 @@ function liveProfile(name: string): string {
1230
1325
  return profile;
1231
1326
  }
1232
1327
 
1233
- function liveInstance(t: test.TestContext, profile: string, sessionId: string) {
1328
+ function liveInstance(t: test.TestContext, profile: string, sessionId: string, metadata = false) {
1234
1329
  const h = fakePi();
1235
1330
  const runtime = deps();
1236
1331
  const context = fakeContext();
1332
+ if (metadata) {
1333
+ runtime.deps.sessionTransport = {
1334
+ ...inertSessionTransport,
1335
+ createListener: registry => ({ registry, record: { version: 1, sessionId, endpoint: `/fixture/${sessionId}`, createdAt: 1 }, start: async () => {}, close: async () => {} }),
1336
+ };
1337
+ }
1237
1338
  Object.assign(context.ctx.sessionManager, {
1238
1339
  getSessionId: () => sessionId,
1239
1340
  getSessionName: () => sessionId,
@@ -1262,8 +1363,8 @@ async function liveOverlay(instance: ReturnType<typeof liveInstance>) {
1262
1363
 
1263
1364
  test("live-only extension instances discover same-profile peers across cwd boundaries without importing their tasks", async (t) => {
1264
1365
  const profile = liveProfile("live-peer-profile");
1265
- const local = liveInstance(t, profile, "local-live");
1266
- const peer = liveInstance(t, profile, "peer-live");
1366
+ const local = liveInstance(t, profile, "local-live", true);
1367
+ const peer = liveInstance(t, profile, "peer-live", true);
1267
1368
  assert.equal(listPresence(profile).entries.length, 0, "factory construction starts no presence resources");
1268
1369
  await local.fire("session_start", local.ctx, { reason: "startup" });
1269
1370
  await peer.fire("session_start", peer.ctx, { reason: "startup" });
@@ -1278,6 +1379,10 @@ test("live-only extension instances discover same-profile peers across cwd bound
1278
1379
  };
1279
1380
  await run(local, "local");
1280
1381
  const peerId = await run(peer, "peer");
1382
+ await eventually(() => listPresence(profile).entries.some(header => readDiscovery(profile, header)?.tasks.some(task => task.id === peerId)), "admitted runtime-owned tasks publish without subsequent child events");
1383
+ const scope = listPresence(profile).entries.map(header => readDiscovery(profile, header)?.scope).find(scope => scope?.tasks.some(task => task.id === peerId));
1384
+ assert.equal(scope?.tasks.find(task => task.id === peerId)?.repository.root, peer.ctx.sessionManager.getCwd());
1385
+ assert.equal(scope?.host.cloneHash, scope?.tasks.find(task => task.id === peerId)?.repository.cloneHash);
1281
1386
  await tick();
1282
1387
  peer.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "peer streamed text" } });
1283
1388
  await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.summary.id === peerId && row.thread.items.some((item) => item.text === "peer streamed text"))), "task deltas, not just status changes, must publish peer activity");
@@ -1482,6 +1587,68 @@ test("live-only directory traverses presence overflow, excludes expired and othe
1482
1587
  await panel.opened;
1483
1588
  });
1484
1589
 
1590
+ // #1690: Pi drops unknown --tools names without a diagnostic, so the child
1591
+ // compares the runner's requested list with its own tools at the first
1592
+ // before_agent_start, after every extension's session_start has registered.
1593
+ function missingToolsChild(env: NodeJS.ProcessEnv, toolNames: string[] | (() => string[])) {
1594
+ const child = fakePi();
1595
+ Object.assign(child.pi, { getAllTools: () => (typeof toolNames === "function" ? toolNames() : toolNames).map((name) => ({ name })) });
1596
+ gentleAgents(child.pi, { GENTLE_PI_AGENTS_CHILD: "1", ...env });
1597
+ const notes: Array<{ message: string; type: unknown }> = [];
1598
+ const ctx = { hasUI: true, ui: { notify: (message: string, type?: unknown) => notes.push({ message, type }) } } as unknown as ExtensionContext;
1599
+ return {
1600
+ start: () => child.fire("session_start", ctx, { type: "session_start", reason: "startup" }),
1601
+ prompt: () => child.fire("before_agent_start", ctx, { type: "before_agent_start", prompt: "go", systemPrompt: "base", systemPromptOptions: {} }),
1602
+ notes,
1603
+ };
1604
+ }
1605
+
1606
+ test("a child warns once about requested tools it does not have", async () => {
1607
+ const child = missingToolsChild({ [REQUESTED_TOOLS_ENV]: "read,gentle_review_scope,grep,codegraph,subagent_parent_message" }, ["read", "grep", "subagent_parent_message"]);
1608
+ await child.start();
1609
+ assert.deepEqual(child.notes, [], "session_start runs before later extensions register their tools");
1610
+ assert.deepEqual(await child.prompt(), [undefined], "the check never alters the prompt");
1611
+ assert.deepEqual(child.notes, [{ message: `${MISSING_TOOLS_NOTE_PREFIX} gentle_review_scope, codegraph`, type: "warning" }]);
1612
+ await child.start();
1613
+ assert.deepEqual(await child.prompt(), [undefined]);
1614
+ assert.equal(child.notes.length, 1, "later prompts and session replacements do not repeat the warning");
1615
+ });
1616
+
1617
+ test("a child does not report a tool that a later extension registers in its own session_start", async () => {
1618
+ const tools = ["read", "grep"];
1619
+ const child = missingToolsChild({ [REQUESTED_TOOLS_ENV]: "read,late_tool" }, () => tools);
1620
+ await child.start();
1621
+ // Pi runs session_start handlers in load order; an extension loaded after
1622
+ // gentle-agents registers late_tool only now.
1623
+ tools.push("late_tool");
1624
+ const results = await child.prompt();
1625
+ assert.deepEqual(child.notes, []);
1626
+ assert.deepEqual(results, [undefined]);
1627
+ });
1628
+
1629
+ test("a child stays quiet when every checkable requested tool exists or nothing was requested", async () => {
1630
+ for (const [label, env] of [
1631
+ ["all present", { [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }],
1632
+ ["MCP names register late", { [REQUESTED_TOOLS_ENV]: "read,mcp__github__search,mcp__x" }],
1633
+ ["frontmatter wildcard entries are not tool names", { [REQUESTED_TOOLS_ENV]: "read,\"*\": false,*, ," }],
1634
+ ["no requested list", {}],
1635
+ ["empty requested list", { [REQUESTED_TOOLS_ENV]: "" }],
1636
+ ] as const) {
1637
+ const child = missingToolsChild(env, ["read", "grep", "subagent_parent_message"]);
1638
+ await child.start();
1639
+ const results = await child.prompt();
1640
+ assert.ok(results.every((result) => result === undefined), label);
1641
+ assert.deepEqual(child.notes, [], label);
1642
+ }
1643
+ });
1644
+
1645
+ test("the missing-tools check never throws into before_agent_start", async () => {
1646
+ const child = missingToolsChild({ [REQUESTED_TOOLS_ENV]: "read,codegraph" }, () => { throw new Error("tool registry unavailable"); });
1647
+ await child.start();
1648
+ assert.deepEqual(await child.prompt(), [undefined]);
1649
+ assert.deepEqual(child.notes, []);
1650
+ });
1651
+
1485
1652
  test("retired managed SDD child envelopes deny all tools without registering a delegation host", () => {
1486
1653
  for (const env of [{ GENTLE_PI_RESEARCH_TOOLS: '["read"]' }, { GENTLE_PI_RESEARCH_SELECTION: '{}' }, { GENTLE_PI_RESEARCH_ARTIFACT: '{}' }, { GENTLE_PI_SDD_REMEDIATION_PLAN: '{}' }]) {
1487
1654
  const hooks = new Map<string, (event: { toolName: string }) => { block: boolean }>();
@@ -1678,9 +1845,9 @@ test("C1 investigation: the relay mechanism is correct when every guard input is
1678
1845
  await h.fire("session_shutdown", ctx);
1679
1846
  await tick();
1680
1847
  } finally {
1681
- rmSync(repoRoot, { recursive: true, force: true });
1682
- rmSync(gitHome, { recursive: true, force: true });
1683
- rmSync(gitHooksDir, { recursive: true, force: true });
1848
+ rmSync(repoRoot, TEST_DIR_REMOVAL);
1849
+ rmSync(gitHome, TEST_DIR_REMOVAL);
1850
+ rmSync(gitHooksDir, TEST_DIR_REMOVAL);
1684
1851
  }
1685
1852
  });
1686
1853
 
@@ -2053,7 +2220,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2053
2220
  mkdirSync(gitTemplate);
2054
2221
  execFileSync("git", ["init", "--quiet", `--template=${gitTemplate}`, canonicalGitCwd]);
2055
2222
  } catch (error) {
2056
- rmSync(canonicalGitFixture, { recursive: true, force: true });
2223
+ rmSync(canonicalGitFixture, TEST_DIR_REMOVAL);
2057
2224
  throw error;
2058
2225
  }
2059
2226
  childProcess.spawn = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
@@ -2066,6 +2233,8 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2066
2233
  try {
2067
2234
  const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
2068
2235
  const h = fakePi();
2236
+ // The launch reads the global active profile; keep it on the scratch config home.
2237
+ env = { ...env, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
2069
2238
  gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
2070
2239
  const { ctx } = fakeContext();
2071
2240
  (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
@@ -2097,7 +2266,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2097
2266
  assert.equal(captured[index]?.command, "/fixture/pi");
2098
2267
  assert.deepEqual(captured[index]?.args, args);
2099
2268
  assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
2100
- assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}) });
2269
+ assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
2101
2270
  assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
2102
2271
  assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
2103
2272
  assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
@@ -2110,7 +2279,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
2110
2279
  try {
2111
2280
  await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
2112
2281
  } finally {
2113
- rmSync(canonicalGitFixture, { recursive: true, force: true });
2282
+ rmSync(canonicalGitFixture, TEST_DIR_REMOVAL);
2114
2283
  }
2115
2284
  }
2116
2285
  });
@@ -2226,6 +2395,9 @@ test("foreign clone tool requires consent before queueing and never enters paren
2226
2395
  await tick();
2227
2396
  assert.deepEqual(spawned, [foreign]);
2228
2397
  assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
2398
+ // The parent session froze "no profile" (no pin, no global active), so the
2399
+ // foreign launch keeps the foreign repository's own pin (gentle-shell#1064
2400
+ // 3b-ii). Dropping `foreignRepository` from the launch wiring breaks this.
2229
2401
  assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
2230
2402
  assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
2231
2403
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
@@ -2273,7 +2445,35 @@ test("foreign clone tool requires consent before queueing and never enters paren
2273
2445
  await tick();
2274
2446
  assert.equal(runtime.children.length, 3, "stale queued foreign task must fail before OS spawn");
2275
2447
  await h.fire("session_shutdown", successor);
2276
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2448
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2449
+ });
2450
+
2451
+ test("a frozen session profile routes a foreign repository launch over that repository's pin", async (t) => {
2452
+ const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-frozen-")));
2453
+ t.after(() => rmSync(fixture, TEST_DIR_REMOVAL));
2454
+ const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
2455
+ mkdirSync(template);
2456
+ for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
2457
+ const configHome = join(fixture, "config");
2458
+ mkdirSync(configHome);
2459
+ writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "session", profiles: {
2460
+ session: { explore: { model: "openai/session-model", thinking: "low" } },
2461
+ pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } },
2462
+ } }));
2463
+ mkdirSync(join(foreign, ".git", "gentle-ai"));
2464
+ writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
2465
+ const h = fakePi(), runtime = deps();
2466
+ runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
2467
+ runtime.deps.resolveWorktree = resolveSessionWorktree;
2468
+ gentleAgents(h.pi, {}, runtime.deps);
2469
+ const { ctx } = fakeContext();
2470
+ ctx.sessionManager.getCwd = () => parent;
2471
+ await h.fire("session_start", ctx);
2472
+ t.after(async () => { await h.fire("session_shutdown", ctx); await tick(); });
2473
+ assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "session", "the parent froze the global active profile");
2474
+ await h.tools.get("subagent_run")!.execute("foreign-frozen", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
2475
+ await tick();
2476
+ assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/session-model:low", "the session profile wins over the foreign pin");
2277
2477
  });
2278
2478
 
2279
2479
  test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
@@ -2357,7 +2557,7 @@ test("foreign child Changes require successful target-bound tool evidence, never
2357
2557
  assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3, "session replacement during a tool cannot attribute its result");
2358
2558
  assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 0);
2359
2559
  await h.fire("session_shutdown", successor);
2360
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2560
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2361
2561
  });
2362
2562
 
2363
2563
  test("foreign task mode waits for the child and continuation reuses its live grant", async () => {
@@ -2405,7 +2605,7 @@ test("foreign task mode waits for the child and continuation reuses its live gra
2405
2605
  assert.equal(((await continued).details.gentleAgents as { cwd: string }).cwd, foreign);
2406
2606
  assert.equal(runtime.spawned.length, 2);
2407
2607
  await h.fire("session_shutdown", ctx);
2408
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2608
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2409
2609
  });
2410
2610
 
2411
2611
  test("aborting during foreign consent cannot grant or queue a background child", async () => {
@@ -2430,7 +2630,7 @@ test("aborting during foreign consent cannot grant or queue a background child",
2430
2630
  assert.equal(runtime.children.length, 0);
2431
2631
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
2432
2632
  await h.fire("session_shutdown", ctx);
2433
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2633
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2434
2634
  });
2435
2635
 
2436
2636
  test("an interactive non-Git umbrella can target an independent repository without registering it", async () => {
@@ -2450,7 +2650,7 @@ test("an interactive non-Git umbrella can target an independent repository witho
2450
2650
  assert.equal((launched.details.gentleAgents as { cwd: string }).cwd, foreign);
2451
2651
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
2452
2652
  await h.fire("session_shutdown", ctx);
2453
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2653
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2454
2654
  });
2455
2655
 
2456
2656
  test("foreign selector rejects RPC and retired SDD selections before consent", async () => {
@@ -2486,7 +2686,7 @@ test("foreign selector rejects RPC and retired SDD selections before consent", a
2486
2686
  assert.equal(dialogs.length, 0);
2487
2687
  assert.equal(runtime.children.length, 0);
2488
2688
  await h.fire("session_shutdown", ctx);
2489
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2689
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2490
2690
  });
2491
2691
 
2492
2692
  test("foreign clone rejects aliases, absent UI, decline and changed session before any child starts", async () => {
@@ -2520,7 +2720,7 @@ test("foreign clone rejects aliases, absent UI, decline and changed session befo
2520
2720
  assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 2);
2521
2721
  assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
2522
2722
  await h.fire("session_shutdown", ctx);
2523
- } finally { rmSync(fixture, { recursive: true, force: true }); }
2723
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
2524
2724
  });
2525
2725
 
2526
2726
  test("explicit child roots launch and continue in the actual cwd, persist without shell, and reject other clones", async () => {
@@ -2548,6 +2748,7 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
2548
2748
  await h.fire("session_start", ctx);
2549
2749
  const run = h.tools.get("subagent_run")!;
2550
2750
  await assert.rejects(run.execute("bad", { agent: "explore", task: "Map", workspace_root: "/other-clone", mode: "background" }, undefined, undefined, ctx), /same Git clone/);
2751
+ await assert.rejects(run.execute("bad", { agent: "explore", task: "Map", workspace_root: "/other-clone", mode: "background" }, undefined, undefined, ctx), /subagent_run with repository_root/);
2551
2752
  assert.deepEqual(launched, []);
2552
2753
  const result = await run.execute("one", { agent: "explore", task: "Map /other-clone mentioned in prose", workspace_root: childRoot, mode: "background" }, undefined, undefined, ctx);
2553
2754
  await tick();
@@ -2565,12 +2766,12 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
2565
2766
  await tick();
2566
2767
  assert.deepEqual(launched, [childRoot, childRoot]);
2567
2768
  spawnEvents[1]();
2568
- assert.equal(h.entries.length, 1, "continuation dedupes the original root");
2769
+ assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1, "continuation dedupes the original root");
2569
2770
  const status = await h.tools.get("subagent_status")!.execute("status", { task_id: details.taskId }, undefined, undefined, ctx);
2570
2771
  assert.match(status.content[0].text, /cwd:/);
2571
2772
  await h.fire("session_shutdown", ctx);
2572
2773
  spawnEvents[1]();
2573
- assert.equal(h.entries.length, 1, "late process events after shutdown cannot write session state");
2774
+ assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1, "late process events after shutdown cannot register roots");
2574
2775
  await tick();
2575
2776
  });
2576
2777
 
@@ -2664,7 +2865,8 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
2664
2865
  Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
2665
2866
  ctx.sessionManager.getCwd = () => project;
2666
2867
  ctx.sessionManager.getEntries = () => h.entries as never;
2667
- await h.fire("session_start", ctx);
2868
+ // The repository declaration exists before the session starts: the session
2869
+ // freezes it at session_start (gentle-shell#1064 slice 3b-ii).
2668
2870
  if (scenario === "profile-model" || scenario === "profile-valid") {
2669
2871
  mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
2670
2872
  writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
@@ -2672,6 +2874,7 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
2672
2874
  mkdirSync(config, { recursive: true });
2673
2875
  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
2876
  }
2877
+ await h.fire("session_start", ctx);
2675
2878
  let calls = 0;
2676
2879
  const abort = new AbortController();
2677
2880
  const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
@@ -2702,6 +2905,52 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
2702
2905
  });
2703
2906
  }
2704
2907
 
2908
+ // gentle-shell#1558 (barbatdev review): the non-git writer admission and the
2909
+ // launch path must resolve the same model. Before the session-binding layer,
2910
+ // both read pin/global only; with a binding present the launch resolves it
2911
+ // first while admission still read the declaration, so a bound session killed
2912
+ // its own writer mid-preparation with a false "profile or session changed".
2913
+ test("a session binding keeps writer admission and launch resolution in agreement", async t => {
2914
+ t.after(() => resetSessionProfileBindingsForTesting());
2915
+ const fixture = realpathSync(mkdtempSync(join(root, "writer-session-binding-")));
2916
+ const project = join(fixture, "project");
2917
+ const fixtureHome = join(fixture, "home");
2918
+ const definitions = join(fixtureHome, ".pi", "agent", "agents");
2919
+ mkdirSync(definitions, { recursive: true });
2920
+ mkdirSync(project);
2921
+ writeFileSync(join(definitions, "worker.md"), "---\ndescription: fixture\nmodel: offline/good\ntools: [read]\n---\nFixture");
2922
+ // The unversioned project declaration routes the writer at one model and the
2923
+ // session binding at another: only agreement between the two resolutions can
2924
+ // let this launch through, and both must pick the binding's model.
2925
+ mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
2926
+ writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "declared" }));
2927
+ const configHome = join(fixture, "config");
2928
+ mkdirSync(configHome, { recursive: true });
2929
+ writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { declared: { worker: { model: "offline/pinned-good" } }, bound: { worker: { model: "offline/bound-good" } } } }));
2930
+ bindSessionProfile("s1", "bound", { worker: { model: "offline/bound-good" } });
2931
+ const h = fakePi();
2932
+ const runtime = deps();
2933
+ runtime.deps.home = fixtureHome;
2934
+ runtime.deps.env!.GENTLE_PI_CONFIG_HOME = configHome;
2935
+ runtime.deps.resolveWorktree = resolveSessionWorktree;
2936
+ gentleAgents(h.pi, {}, runtime.deps);
2937
+ const { ctx } = fakeContext();
2938
+ Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => ({ provider: "offline", id: model }) } });
2939
+ ctx.sessionManager.getCwd = () => project;
2940
+ await h.fire("session_start", ctx);
2941
+ const unbind = bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
2942
+ if (!current()) return false;
2943
+ execFileSync("git", ["init", "--quiet", project], { env: { PATH: process.env.PATH, HOME: fixtureHome, GIT_CONFIG_NOSYSTEM: "1" }, stdio: "pipe" });
2944
+ return true;
2945
+ }, () => true);
2946
+ try {
2947
+ await h.tools.get("subagent_run")!.execute("session-binding-admission", { agent: "worker", task: "Implement source\n## Allowed edit surfaces\nsrc/app.ts\n## Return\nReport", mode: "background" }, undefined, undefined, ctx);
2948
+ await tick();
2949
+ assert.equal(runtime.children.length, 1, "the bound session's writer launches instead of dying as a false profile/session drift");
2950
+ assert.ok(runtime.spawned[0]?.includes("offline/bound-good"), "admission and launch both resolve the binding ahead of the project declaration");
2951
+ } finally { unbind(); await h.fire("session_shutdown", ctx); await tick(); }
2952
+ });
2953
+
2705
2954
  for (const explicit of [false, true]) {
2706
2955
  test(`same manager and ID after bootstrap permit ${explicit ? "explicit" : "implicit"} launch registration`, async () => {
2707
2956
  const h = fakePi();
@@ -2757,7 +3006,7 @@ test("ordinary non-Git tasks still continue in their original cwd without regist
2757
3006
  await h.tools.get("subagent_continue")!.execute("continue", { task_id: taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx);
2758
3007
  await tick();
2759
3008
  assert.equal(runtime.children.length, 2);
2760
- assert.deepEqual(h.entries, []);
3009
+ assert.deepEqual(h.entries.filter(entry => entry.customType !== "gentle-agents.wake-identity"), []);
2761
3010
  await h.fire("session_shutdown", ctx);
2762
3011
  await tick();
2763
3012
  });
@@ -2788,7 +3037,7 @@ for (const sameId of [false, true]) {
2788
3037
  await h.fire("session_start", next.ctx);
2789
3038
  spawnEvents[0]();
2790
3039
  await tick();
2791
- assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
3040
+ assert.deepEqual(h.entries.filter(entry => entry.customType !== "gentle-agents.wake-identity"), [], "captured registry is closed instead of appending worktree state to the new bound API");
2792
3041
  await h.fire("session_shutdown", next.ctx);
2793
3042
  });
2794
3043
  }
@@ -2887,8 +3136,8 @@ function pinFixture(name: string) {
2887
3136
  declarationPath,
2888
3137
  writePin: (profile: string) => writePinText(localPinPath, profile),
2889
3138
  writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
2890
- writeStore: (profiles: Record<string, unknown>) => {
2891
- writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
3139
+ writeStore: (profiles: Record<string, unknown>, active?: string) => {
3140
+ writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, ...(active === undefined ? {} : { active }), profiles }, null, 2)}\n`);
2892
3141
  },
2893
3142
  };
2894
3143
  }
@@ -2965,6 +3214,170 @@ test("a stale or unreadable pin degrades to the global routing instead of failin
2965
3214
  assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:low");
2966
3215
  });
2967
3216
 
3217
+ // gentle-shell#1558 (barbatdev review): the two registered launch-seam gaps.
3218
+ // The pure helpers were covered, but the real subagent_run seam had no
3219
+ // committed test for the session layer.
3220
+ test("a session binding outranks the repository pin at the launch seam", async t => {
3221
+ t.after(() => resetSessionProfileBindingsForTesting());
3222
+ const base = pinFixture("session-over-pin");
3223
+ base.writeStore({
3224
+ pinned: { explore: { model: "openai/alpha", thinking: "minimal" } },
3225
+ bound: { explore: { model: "openai/beta" } },
3226
+ });
3227
+ base.writePin("pinned");
3228
+ // fakeContext's session id is "s1"; the binding is process state, so it is
3229
+ // bound before the launch and reset by t.after so later pin tests stay pure.
3230
+ bindSessionProfile("s1", "bound", { explore: { model: "openai/beta" } });
3231
+ assert.equal(await launchPinned(base), "openai/beta:high", "the session binding wins over a winning repository pin");
3232
+ });
3233
+
3234
+ test("a queued launch keeps the session routing frozen across a rebind", async t => {
3235
+ t.after(() => resetSessionProfileBindingsForTesting());
3236
+ const base = pinFixture("queue-freeze");
3237
+ base.writeStore({
3238
+ first: { explore: { model: "openai/alpha", thinking: "minimal" } },
3239
+ second: { explore: { model: "openai/beta" } },
3240
+ });
3241
+ bindSessionProfile("s1", "first", { explore: { model: "openai/alpha", thinking: "minimal" } });
3242
+ const harness = deps();
3243
+ harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
3244
+ harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome };
3245
+ const { pi, tools, fire } = fakePi();
3246
+ gentleAgents(pi, {}, harness.deps);
3247
+ const { ctx } = fakeContext();
3248
+ await fire("session_start", ctx);
3249
+ try {
3250
+ const model = (args: string[]) => args[args.indexOf("--model") + 1];
3251
+ // The first launch freezes first's routing into its task request and its
3252
+ // spawned child. Rebinding the session must leave that request untouched;
3253
+ // only a request created afterwards resolves the new binding.
3254
+ await tools.get("subagent_run")!.execute("freeze-1", { agent: "explore", task: "Map first", mode: "background" }, undefined, undefined, ctx);
3255
+ bindSessionProfile("s1", "second", { explore: { model: "openai/beta" } });
3256
+ await tools.get("subagent_run")!.execute("freeze-2", { agent: "explore", task: "Map second", mode: "background" }, undefined, undefined, ctx);
3257
+ await tick();
3258
+ assert.equal(model(harness.spawned[0]), "openai/alpha:minimal", "the request created before the rebind keeps its frozen routing");
3259
+ assert.equal(model(harness.spawned[1]), "openai/beta:high", "only requests created after the rebind resolve the new binding");
3260
+ } finally {
3261
+ await fire("session_shutdown", ctx);
3262
+ await tick();
3263
+ }
3264
+ });
3265
+
3266
+ // gentle-shell#1064 slice 3b-ii: a parent session without an explicit Enter
3267
+ // binding freezes p → P → G at session_start; later default changes only show
3268
+ // a drift notice once per change. GENTLE_PI_PROFILE_FOLLOW=1 keeps the live
3269
+ // resolution.
3270
+ async function launchSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
3271
+ const harness = deps();
3272
+ harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
3273
+ harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
3274
+ const { pi, tools, fire } = fakePi();
3275
+ gentleAgents(pi, {}, harness.deps);
3276
+ const { ctx, dialogs } = fakeContext();
3277
+ await fire("session_start", ctx);
3278
+ let count = 0;
3279
+ return {
3280
+ dialogs,
3281
+ driftNotices: () => dialogs.filter((dialog) => dialog.includes("the default profile changed")),
3282
+ launch: async () => {
3283
+ await tools.get("subagent_run")!.execute(`drift-${count}`, { agent: "explore", task: `Map ${count}`, mode: "background" }, undefined, undefined, ctx);
3284
+ await tick();
3285
+ const args = harness.spawned[count];
3286
+ // Settle the child so the next launch is not held by the concurrency cap.
3287
+ const child = harness.children[count++]!;
3288
+ child.emit({ type: "agent_end", messages: [] });
3289
+ child.emit({ type: "agent_settled" });
3290
+ child.exit(0);
3291
+ await tick();
3292
+ return args[args.indexOf("--model") + 1];
3293
+ },
3294
+ shutdown: async () => {
3295
+ await fire("session_shutdown", ctx);
3296
+ await tick();
3297
+ },
3298
+ };
3299
+ }
3300
+
3301
+ const DRIFT_PROFILES = {
3302
+ frontier: { explore: { model: "openai/alpha", thinking: "minimal" } },
3303
+ local: { explore: { model: "openai/beta" } },
3304
+ };
3305
+
3306
+ test("session_start freezes the inherited pin: a later pin change does not move the open session", async t => {
3307
+ const base = pinFixture("freeze-pin");
3308
+ base.writeStore(DRIFT_PROFILES);
3309
+ base.writePin("frontier");
3310
+ const session = await launchSession(base);
3311
+ t.after(session.shutdown);
3312
+ assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "frontier", "frozen at session_start, before any launch");
3313
+ base.writePin("local");
3314
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3315
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3316
+ assert.deepEqual(session.driftNotices(), [
3317
+ 'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
3318
+ ], "one notice for one change, across two launches");
3319
+ base.writeStore(DRIFT_PROFILES, "local");
3320
+ base.writeDeclaration("local");
3321
+ rmSync(base.localPinPath);
3322
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3323
+ assert.equal(session.driftNotices().length, 1, "the same default reached through another layer is not a new change");
3324
+ });
3325
+
3326
+ test("an unpinned session routes through the global active profile and freezes it", async t => {
3327
+ const base = pinFixture("freeze-global");
3328
+ base.writeStore(DRIFT_PROFILES, "frontier");
3329
+ const session = await launchSession(base);
3330
+ t.after(session.shutdown);
3331
+ base.writeStore(DRIFT_PROFILES, "local");
3332
+ assert.equal(await session.launch(), "openai/alpha:minimal", "the global active profile replaces the materialized routing");
3333
+ assert.equal(session.driftNotices().length, 1);
3334
+ assert.match(session.driftNotices()[0]!, /changed to "local" \(global\), this session keeps "frontier" \(global\)/);
3335
+ });
3336
+
3337
+ test("a session with no profile anywhere keeps today's routing even after a pin appears", async t => {
3338
+ const base = pinFixture("freeze-none");
3339
+ base.writeStore(DRIFT_PROFILES);
3340
+ const session = await launchSession(base);
3341
+ t.after(session.shutdown);
3342
+ assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low", "today's materialized routing");
3343
+ base.writePin("frontier");
3344
+ assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
3345
+ assert.deepEqual(session.driftNotices(), [
3346
+ 'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
3347
+ ]);
3348
+ });
3349
+
3350
+ test("an explicit Enter binding wins over the frozen profile and never shows the drift notice", async t => {
3351
+ t.after(() => resetSessionProfileBindingsForTesting());
3352
+ const base = pinFixture("freeze-explicit");
3353
+ base.writeStore(DRIFT_PROFILES);
3354
+ base.writePin("frontier");
3355
+ const session = await launchSession(base);
3356
+ t.after(session.shutdown);
3357
+ bindSessionProfile("s1", "local", { explore: { model: "openai/beta" } });
3358
+ base.writePin("local");
3359
+ base.writeStore(DRIFT_PROFILES, "frontier");
3360
+ rmSync(base.localPinPath);
3361
+ assert.equal(await session.launch(), "openai/beta:high");
3362
+ assert.deepEqual(session.driftNotices(), []);
3363
+ });
3364
+
3365
+ test("follow mode re-resolves the defaults on every launch without a notice; Enter makes it explicit", async t => {
3366
+ t.after(() => resetSessionProfileBindingsForTesting());
3367
+ const base = pinFixture("follow");
3368
+ base.writeStore(DRIFT_PROFILES);
3369
+ base.writePin("frontier");
3370
+ const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
3371
+ t.after(session.shutdown);
3372
+ assert.equal(readFrozenInheritedProfile("s1"), undefined, "follow never freezes");
3373
+ assert.equal(await session.launch(), "openai/alpha:minimal");
3374
+ base.writePin("local");
3375
+ assert.equal(await session.launch(), "openai/beta:high");
3376
+ bindSessionProfile("s1", "frontier", { explore: { model: "openai/alpha", thinking: "minimal" } });
3377
+ assert.equal(await session.launch(), "openai/alpha:minimal", "the explicit binding stops following");
3378
+ assert.deepEqual(session.driftNotices(), []);
3379
+ });
3380
+
2968
3381
  test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
2969
3382
  assert.equal(agentsEnabled({}), true);
2970
3383
  assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
@@ -2997,13 +3410,42 @@ test("while pi-subagents-j0k3r is still installed the tools stay unregistered an
2997
3410
  assert.match(notices[0] ?? "", /^warning:❀ Gentle Agents is waiting: remove the old package first with "pi remove npm:pi-subagents-j0k3r"/);
2998
3411
  });
2999
3412
 
3413
+ test("subagent_list_agents publishes each agent's declared tool inventory so routing can check capabilities", async () => {
3414
+ const fixture = mkdtempSync(join(tmpdir(), "capability-catalog-"));
3415
+ const agentHome = join(fixture, "agent-home");
3416
+ mkdirSync(join(agentHome, ".pi", "agent", "agents"), { recursive: true });
3417
+ writeFileSync(join(agentHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\ntools: [read, grep, find, codegraph]\n---\nYou map things.");
3418
+ writeFileSync(join(agentHome, ".pi", "agent", "agents", "open.md"), "---\ndescription: no allowlist\n---\nYou are open.");
3419
+ writeFileSync(join(agentHome, ".pi", "agent", "agents", "unknown.md"), "---\ndescription: unverified name\ntools: [glob]\n---\nYou explore.");
3420
+ writeFileSync(join(agentHome, ".pi", "agent", "agents", "lens.md"), "---\ndescription: review lane\ntools:\n - \"*\": false\n - read\n - grep\n - gentle_review_scope\n---\nYou review.");
3421
+ writeFileSync(join(agentHome, ".pi", "agent", "agents", "denied.md"), "---\ndescription: only a wildcard\ntools:\n - \"*\": false\n---\nYou are denied.");
3422
+ try {
3423
+ const h = fakePi(), runtime = deps();
3424
+ runtime.deps.home = agentHome;
3425
+ gentleAgents(h.pi, {}, runtime.deps);
3426
+ const { ctx } = fakeContext();
3427
+ await h.fire("session_start", ctx);
3428
+ const listed = await h.tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
3429
+ assert.match(listed.content[0].text, /- explore \(global\): maps things \[declared tools \(not verified\): read, grep, find, codegraph\]/);
3430
+ // gentle-shell#1269: a definition without an allowlist keeps Pi's default
3431
+ // tool set, so the catalog must never render it as having no tools at all.
3432
+ assert.match(listed.content[0].text, /- open \(global\): no allowlist \[declared tools: Pi defaults \(no allowlist\)\]/);
3433
+ // The review lane declares the `"*": false` wildcard, which Pi drops: it is
3434
+ // not a capability and must not appear as one in the routing surface.
3435
+ assert.match(listed.content[0].text, /- lens \(global\): review lane \[declared tools \(not verified\): read, grep, gentle_review_scope\]/);
3436
+ assert.doesNotMatch(listed.content[0].text, /false/);
3437
+ assert.match(listed.content[0].text, /- unknown \(global\): unverified name \[declared tools \(not verified\): glob\]/);
3438
+ assert.match(listed.content[0].text, /- denied \(global\): only a wildcard \[declared tools: no tool names declared\]/);
3439
+ } finally { rmSync(fixture, TEST_DIR_REMOVAL); }
3440
+ });
3441
+
3000
3442
  test("subagent_list_agents and subagent_run in task mode launch a child with the resolved profile and return its answer", async () => {
3001
3443
  const { pi, tools, fire } = fakePi();
3002
3444
  const harness = deps();
3003
3445
  gentleAgents(pi, {}, harness.deps);
3004
3446
  const { ctx, widget } = fakeContext();
3005
3447
  await fire("session_start", ctx);
3006
- assert.deepEqual([...tools.keys()].sort(), ["orchestrator_list", "orchestrator_send_message", "orchestrator_session_id", "subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
3448
+ assert.deepEqual([...tools.keys()].sort(), ["bash_background", "job_list", "job_stop", "monitor", "orchestrator_consult", "orchestrator_list", "orchestrator_send_message", "orchestrator_session_id", "subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
3007
3449
  const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
3008
3450
  assert.match(listed.content[0].text, /- explore \(global\): maps things/);
3009
3451
 
@@ -3023,7 +3465,9 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
3023
3465
  harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
3024
3466
  harness.children[0].emit({ type: "agent_settled" });
3025
3467
  const result = await running;
3026
- assert.equal(result.content[0].text, "lib has three agent files.");
3468
+ const runId = (result.details.gentleAgents as { taskId: string }).taskId;
3469
+ assert.equal(result.content[0].text, `Subagent explore (task ${runId}, "map lib modules") finished.\n\nlib has three agent files.`, "the model sees the real task id and label, not only details");
3470
+ assert.deepEqual(tools.get("subagent_run")!.renderResult(result as Parameters<Registered["renderResult"]>[0], { expanded: false }, plainTheme).render(80).map((line) => line.trimEnd()), ["lib has three agent files."], "the collapsed card still leads with the answer");
3027
3471
  assert.equal((result.details.gentleAgents as { status: string }).status, "completed");
3028
3472
  assert.match(widget()![1], /✓ explore map lib modules/);
3029
3473
  const orphan = tools.get("subagent_run")!.execute("c9", { agent: "explore", task: "Orphan", mode: "background" }, undefined, undefined, ctx);
@@ -3129,9 +3573,15 @@ test("background runs return at once; status, result, send_message, cancel, and
3129
3573
  await tick();
3130
3574
  harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Summary." }] }] });
3131
3575
  harness.children[1].emit({ type: "agent_settled" });
3132
- assert.equal((await resumed).content[0].text, "Summary.");
3576
+ const summary = await resumed;
3577
+ assert.equal(summary.content[0].text, `Subagent explore (task ${(summary.details.gentleAgents as { taskId: string }).taskId}, "Now summarize") finished.\n\nSummary.`);
3133
3578
  assert.match((await tools.get("subagent_cancel")!.execute("c9", { task_id: id }, undefined, undefined, ctx)).content[0].text, /not running/);
3134
3579
  assert.match((await tools.get("subagent_status")!.execute("c10", { task_id: "nope" }, undefined, undefined, ctx)).content[0].text, /Error: no task nope/);
3580
+ // gentle-shell#1713: a guessed id ("1") must point back to real ids.
3581
+ // Review follow-up: a call without a context still gets the structured error.
3582
+ assert.match((await tools.get("subagent_status")!.execute("c10c", { task_id: "1" }, undefined, undefined, undefined as never)).content[0].text, /^Error: no task 1\./);
3583
+ for (const name of ["subagent_status", "subagent_result"]) assert.match((await tools.get(name)!.execute("c10a", { task_id: "1" }, undefined, undefined, ctx)).content[0].text, new RegExp(`^Error: no task 1\\. Recent task ids: .*${id} \\(explore\\)`), name);
3584
+ assert.match((await tools.get("subagent_continue")!.execute("c10b", { task_id: "1", prompt: "more" }, undefined, undefined, ctx)).content[0].text, new RegExp(`^Error: no task 1\\. Recent task ids: .*${id} \\(explore\\)`));
3135
3585
  assert.match((await tools.get("subagent_run")!.execute("c11", { agent: "ghost", task: "x" }, undefined, undefined, ctx)).content[0].text, /no subagent named "ghost"\. Known: explore/);
3136
3586
  });
3137
3587
 
@@ -3329,7 +3779,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
3329
3779
  const harness = deps();
3330
3780
  gentleAgents(pi, {}, harness.deps);
3331
3781
  const { ctx, overlays, customCompletions } = fakeContext();
3332
- (ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } }).sessionManager = { getSessionId: () => "footer-session", getCwd: () => cwd };
3782
+ Object.assign(ctx.sessionManager, { getSessionId: () => "footer-session" });
3333
3783
  await fire("session_start", ctx);
3334
3784
  await tools.get("subagent_run")!.execute("c1", { agent: "寿司", task: "Footer target", mode: "background" }, undefined, undefined, ctx);
3335
3785
  await tick();
@@ -3530,17 +3980,45 @@ test("the overlay explains that stopping a waiting subagent dismisses its questi
3530
3980
  await opened;
3531
3981
  });
3532
3982
 
3533
- test("restored task history cannot enter the live panel or execute stop even with the current session ID", async () => {
3983
+ test("restored task history cannot enter the live panel or discovery during synchronous summary callbacks", async (t) => {
3534
3984
  const { pi, fire, commands, tools } = fakePi();
3535
3985
  const harness = deps();
3536
3986
  const historyHome = join(root, "history-home");
3537
3987
  const historical: TaskRecord = { id: "history-running", agent: "explore", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "s1", status: TASK_STATUS.RUNNING, createdAt: 1, startedAt: 1, endedAt: null, model: "m", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "working", lastActivityAt: 1, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
3538
- await saveTask(historyDir(historyHome), historical, emptyThread());
3539
3988
  harness.deps.home = historyHome;
3989
+ const profile = liveProfile("restored-discovery");
3990
+ await saveTask(historyDir(historyHome, profile), historical, emptyThread());
3991
+ chmodSync(join(profile, "gentle-agents"), 0o755);
3992
+ harness.deps.agentHome = profile;
3993
+ const registry = { list: async () => [], listActivations: async () => [] };
3994
+ harness.deps.sessionTransport = {
3995
+ createRegistry: async () => registry,
3996
+ createListener: (_, sessionId) => ({ registry, record: { version: 1, sessionId, endpoint: "/fixture/restore", createdAt: 1 }, start: async () => {}, close: async () => {} }),
3997
+ createClient: inertSessionTransport.createClient,
3998
+ };
3999
+ let checked = false;
4000
+ let leaked: unknown;
4001
+ const update = PresencePublisher.prototype.update;
4002
+ let snapshot: unknown;
4003
+ t.mock.method(PresencePublisher.prototype, "update", function (this: PresencePublisher, input: Parameters<PresencePublisher["update"]>[0]) {
4004
+ snapshot = input.map(row => row.task.id);
4005
+ return update.call(this, input);
4006
+ });
4007
+ const restore = TaskStore.prototype.restore;
4008
+ t.mock.method(TaskStore.prototype, "restore", function (this: TaskStore, ...args: Parameters<TaskStore["restore"]>) {
4009
+ const result = restore.apply(this, args);
4010
+ leaked = snapshot;
4011
+ checked = true;
4012
+ return result;
4013
+ });
3540
4014
  gentleAgents(pi, {}, harness.deps);
3541
4015
  const { ctx, dialogs, overlays } = fakeContext();
3542
4016
  await fire("session_start", ctx);
3543
- await tools.get("subagent_status")!.execute("restore", { task_id: historical.id }, undefined, undefined, ctx);
4017
+ await tick();
4018
+ await fire("session_start", ctx, { reason: "resume" });
4019
+ await eventually(() => checked, "restoration callback checked");
4020
+ assert.deepEqual(leaked, [], "summary callback must not publish restored running history");
4021
+ assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.tasks, []);
3544
4022
  const opened = commands.get("gentle:agents")!.handler("", ctx);
3545
4023
  for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
3546
4024
  assert.doesNotMatch(stripAnsi(overlays[0]!.render(80).join("\n")), /Stop selected|Subagent explore/);
@@ -3690,8 +4168,8 @@ test("the card follows the active session: after /new the earlier session's task
3690
4168
  await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
3691
4169
  await tick();
3692
4170
  assert.match(widget()![1], /◐ explore Long job/);
3693
- const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } };
3694
- sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd };
4171
+ const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getBranch(): []; getEntries(): [] } };
4172
+ sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] };
3695
4173
  await fire("session_start", ctx, { type: "session_start", reason: "new" });
3696
4174
  assert.deepEqual(widget(), [], "the new session starts with an empty card");
3697
4175
  assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/);
@@ -3703,7 +4181,7 @@ test("the card follows the active session: after /new the earlier session's task
3703
4181
  assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator");
3704
4182
  overlay.handleInput("\x1b");
3705
4183
  await opened;
3706
- sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd };
4184
+ sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] };
3707
4185
  await fire("session_start", ctx, { type: "session_start", reason: "resume" });
3708
4186
  assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again");
3709
4187
  });
@@ -3792,6 +4270,405 @@ test("session transport startup failure cleans the constructed Windows-capable t
3792
4270
  assert.match((await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx)).content[0].text, /not ready/);
3793
4271
  });
3794
4272
 
4273
+ test("registered task aliases persist independently of human names and curated state", async () => {
4274
+ const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
4275
+ const profile = realpathSync(mkdtempSync(join(root, "aliases-runtime-")));
4276
+ runtime.deps.agentHome = profile;
4277
+ const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/aliases.sock", createdAt: 1 };
4278
+ const registry = { list: async () => [], listActivations: async () => [peer] };
4279
+ runtime.deps.sessionTransport = {
4280
+ createRegistry: async () => registry,
4281
+ createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
4282
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
4283
+ };
4284
+ let name = "Human label";
4285
+ Object.assign(h.pi, { setSessionName: () => { throw new Error("named session must not be renamed"); } });
4286
+ let branch = h.entries;
4287
+ Object.assign(ctx.sessionManager, { getSessionName: () => name, getBranch: () => branch });
4288
+ gentleAgents(h.pi, {}, runtime.deps);
4289
+ await h.fire("session_start", ctx);
4290
+ const tool = h.tools.get("orchestrator_session_id")!;
4291
+ const declare = (params: unknown = {}, context = ctx) => tool.execute("aliases", params, undefined, undefined, context);
4292
+ const aliases = () => readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
4293
+ const first = await declare({ subject: "Task A", state: { progress: "Starting", work: { area: "Auth" } } });
4294
+ const initial = h.entries.at(-1)!;
4295
+ assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
4296
+ assert.match(first.content[0].text, /Current alias \(CURRENT TASK\): Task A/);
4297
+ assert.match(first.content[0].text, /Initial alias \(FIRST TASK\): Task A/);
4298
+ const recordedAt = readDiscovery(profile, listPresence(profile).entries[0])?.state?.recordedAt;
4299
+ const second = await declare({ subject: "Task B" });
4300
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.recordedAt, recordedAt);
4301
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.progress, "Starting");
4302
+ assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task B" });
4303
+ assert.match(second.content[0].text, /Session name: Human label/);
4304
+ assert.equal((second.details.gentleAgents as { senderSessionId: string }).senderSessionId, "s1");
4305
+ assert.equal(listPresence(profile).entries[0].label, name);
4306
+ const list = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
4307
+ assert.match(list.content[0].text, /Task A/);
4308
+ assert.match(list.content[0].text, /Task B/);
4309
+ const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "s1" }, undefined, undefined, ctx);
4310
+ assert.deepEqual(JSON.parse(consult.content[0].text).snapshot.aliases, aliases());
4311
+ const filtered = await h.tools.get("orchestrator_list")!.execute("filter", { filter: { text: "Task B" } }, undefined, undefined, ctx);
4312
+ assert.equal(JSON.parse(filtered.content[0].text).matches[0].label, "Task B");
4313
+ name = "Human rename";
4314
+ await declare({ state: { progress: "Replacement" } });
4315
+ await declare({ state: null });
4316
+ const writes = h.entries.length;
4317
+ await declare();
4318
+ assert.equal(h.entries.length, writes);
4319
+ assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task B" });
4320
+ await h.fire("session_start", ctx, { reason: "resume" });
4321
+ assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task B" });
4322
+ branch = [initial];
4323
+ await h.fire("session_tree", ctx);
4324
+ assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
4325
+ branch = [{ ...initial, data: { ...(initial.data as object), aliases: undefined } }];
4326
+ // An actual legacy envelope has no alias field, not a malformed undefined field.
4327
+ delete (branch[0].data as { aliases?: unknown }).aliases;
4328
+ await h.fire("session_start", ctx, { reason: "reload" });
4329
+ assert.equal(aliases(), undefined);
4330
+ await declare({ subject: "Legacy current" });
4331
+ assert.deepEqual(aliases(), { initialAlias: null, currentAlias: "Legacy current" });
4332
+ const replacement = fakeContext().ctx;
4333
+ await h.fire("session_start", replacement);
4334
+ assert.match((await declare({ subject: "Stale" }, ctx)).content[0].text, /not ready/);
4335
+ assert.equal(aliases(), undefined);
4336
+ assert.equal(runtime.spawned.length + h.sent.length + h.userMessages.length, 0);
4337
+ });
4338
+
4339
+ test("registered session identity declares subjects and refreshes canonical idle renames", async (t) => {
4340
+ const h = fakePi();
4341
+ const runtime = deps();
4342
+ const profile = realpathSync(mkdtempSync(join(root, "subject-runtime-")));
4343
+ runtime.deps.agentHome = profile;
4344
+ let ready = false;
4345
+ const registry = { list: async () => [], listActivations: async () => [] };
4346
+ runtime.deps.sessionTransport = {
4347
+ createRegistry: async () => registry,
4348
+ createListener: (_registry, sessionId) => ({ registry,
4349
+ record: { version: 1 as const, sessionId, endpoint: "/fixture/subject.sock", createdAt: 1 },
4350
+ start: async () => { ready = true; }, close: async () => {} }),
4351
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
4352
+ };
4353
+ const heartbeats: (() => void)[] = [];
4354
+ const interval = globalThis.setInterval;
4355
+ t.mock.method(globalThis, "setInterval", (callback: () => void, ms: number) => {
4356
+ if (ms === 5000) heartbeats.push(callback);
4357
+ return interval(callback, ms);
4358
+ });
4359
+ let name = "";
4360
+ const names: string[] = [];
4361
+ Object.assign(h.pi, { setSessionName: (value: string) => { name = value; names.push(value); } });
4362
+ gentleAgents(h.pi, {}, runtime.deps);
4363
+ const { ctx } = fakeContext();
4364
+ const registrations = [{ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId: "s1", root: cwd, evidence: "fixture" } }];
4365
+ Object.assign(ctx.sessionManager, { getSessionName: () => name, getEntries: () => registrations });
4366
+ await h.fire("session_start", ctx);
4367
+ await eventually(() => ready, "subject transport ready");
4368
+ const tool = h.tools.get("orchestrator_session_id")!;
4369
+ const declare = (subject?: unknown, context = ctx) => tool.execute("id", { subject }, undefined, undefined, context);
4370
+ const initialWrites = h.entries.length;
4371
+ await assert.rejects(() => tool.execute("invalid-work", { subject: "No effect", state: { work: { topic: "Login" } } }, undefined, undefined, ctx), /invalid/);
4372
+ assert.equal(name, "");
4373
+ assert.equal(h.entries.length, initialWrites);
4374
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state, undefined);
4375
+ const result = await declare("\u001b[31m Fix\n auth\u202e ");
4376
+ assert.equal(name, "Fix auth");
4377
+ assert.match(result.content[0].text, /Active session ID: s1.*\n.*Fix auth/);
4378
+ assert.deepEqual(result.details.gentleAgents, { senderSessionId: "s1", alias: "Fix auth", sessionName: "Fix auth", initialAlias: "Fix auth", currentAlias: "Fix auth" });
4379
+ const before = listPresence(profile).entries[0]!;
4380
+ assert.equal(before.label, "Fix auth");
4381
+ assert.equal(readDiscovery(profile, before)?.state?.state, null, "no implicit summary");
4382
+ Object.assign(ctx.sessionManager, { getBranch: () => h.entries });
4383
+ const work = { area: "Auth", topic: "Login", tags: ["Review"], refs: [
4384
+ { kind: "issue", repository: "github.com/Owner/Repo", id: "12" },
4385
+ ] };
4386
+ await tool.execute("publish", { state: { objective: "Verify auth", decisions: "Advisory only", work } }, undefined, undefined, ctx);
4387
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
4388
+ assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.work, work);
4389
+ const published = h.entries.at(-1)!;
4390
+ const writes = h.entries.length;
4391
+ await assert.rejects(() => tool.execute("invalid", { subject: "No effect", state: { grant: "yes" } }, undefined, undefined, ctx), /invalid/);
4392
+ assert.equal(h.entries.length, writes);
4393
+ assert.equal(name, "Fix auth");
4394
+ await declare();
4395
+ assert.equal(h.entries.length, writes, "omission leaves notes unchanged");
4396
+ assert.equal(readDiscovery(profile, before)?.workspace, cwd);
4397
+ assert.equal(readDiscovery(profile, before)?.scope?.host.root, cwd);
4398
+ assert.equal(readDiscovery(profile, before)?.scope?.registered[0]?.root, cwd);
4399
+ name = "Human rename";
4400
+ heartbeats[0](); // Existing publisher heartbeat, without any task/model activity.
4401
+ const renamed = listPresence(profile).entries[0]!;
4402
+ assert.equal(renamed.label, name);
4403
+ assert.equal(renamed.generation, before.generation);
4404
+ assert.deepEqual(readActivity(profile, renamed).activity?.tasks, []);
4405
+ assert.equal(readDiscovery(profile, renamed)?.scope?.host.resolvedAt, readDiscovery(profile, before)?.scope?.host.resolvedAt);
4406
+ registrations.push({ ...registrations[0], data: { ...registrations[0].data, root: join(cwd, "registered") } });
4407
+ h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "s1" });
4408
+ assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.registered.map(fact => fact.root), [cwd, join(cwd, "registered")]);
4409
+ await declare("Do not overwrite");
4410
+ assert.deepEqual(names, ["Fix auth"]);
4411
+ assert.match((await declare()).content[0].text, /Human rename/);
4412
+ name = "";
4413
+ await declare("\u0000\u001b[31m");
4414
+ assert.equal(name, "", "control-only subject never names a session");
4415
+ await declare("😀".repeat(130));
4416
+ assert.equal(Array.from(name).length, 120);
4417
+ await tool.execute("withdraw", { state: null }, undefined, undefined, ctx);
4418
+ await h.fire("session_start", ctx, { reason: "reload" });
4419
+ await eventually(() => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state === null, "withdrawal reload");
4420
+ Object.assign(ctx.sessionManager, { getBranch: () => [published] });
4421
+ await h.fire("session_tree", ctx);
4422
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
4423
+ const sameId = fakeContext().ctx;
4424
+ Object.assign(sameId.sessionManager, { getSessionName: () => name, getBranch: () => [] });
4425
+ await h.fire("session_start", sameId, { reason: "resume" });
4426
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state, undefined);
4427
+ const replacement = fakeContext().ctx;
4428
+ Object.assign(replacement.sessionManager, { getSessionId: () => "s2", getSessionName: () => "Replacement" });
4429
+ await h.fire("session_start", replacement, { reason: "new" });
4430
+ heartbeats[0]();
4431
+ assert.equal(listPresence(profile).entries.length, 1);
4432
+ assert.equal(listPresence(profile).entries[0].label, "Replacement");
4433
+ assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.host.root, cwd);
4434
+ const stale = await declare("Stale subject", ctx);
4435
+ assert.match(stale.content[0].text, /not ready/);
4436
+ assert.equal(names.length, 2);
4437
+ assert.equal(runtime.spawned.length, 0);
4438
+ assert.equal(h.sent.length, 0);
4439
+ assert.equal(h.userMessages.length, 0);
4440
+ });
4441
+
4442
+ test("registered orchestrator_list joins peer metadata without child launches or messages", async () => {
4443
+ const h = fakePi();
4444
+ const runtime = deps();
4445
+ const profile = realpathSync(mkdtempSync(join(root, "discovery-runtime-")));
4446
+ const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
4447
+ const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Auth review", activity: [] });
4448
+ try {
4449
+ const scope = new OrchestratorScopeCache(path => ({ root: path, commonDir: "/clone" })).project("/repo", [{ id: "child", cwd: "/repo-child" }], ["/repo"]);
4450
+ const tasks = [{ id: "child", label: "Check auth", status: "waiting", cwd: "/repo-child" },
4451
+ ...Array.from({ length: 9 }, (_, i) => ({ id: `extra${i}`, label: `Extra ${i}`, status: "running", cwd: `/child/${i}` }))];
4452
+ publisher.updateDiscovery(peer, { workspace: "/repo", tasks, registered: Array.from({ length: 10 }, (_, i) => `/registered/${i}`), scope,
4453
+ state: { schema: 2, sessionId: "peer", cwd: "/repo", recordedAt: 1, source: "owner-curated",
4454
+ ownerReply: false, authority: "none", state: { work: { tasks: { child: { area: "Auth" }, extra8: { area: "Billing" } } } } },
4455
+ });
4456
+ let ready = false;
4457
+ let probes = 0;
4458
+ const resolveWorktree = runtime.deps.resolveWorktree;
4459
+ runtime.deps.resolveWorktree = (...args) => { probes++; return resolveWorktree(...args); };
4460
+ let gate: (() => Promise<void>) | undefined;
4461
+ let scans = 0;
4462
+ const registry = { list: async () => [], listActivations: async () => { scans++; await gate?.(); return [peer]; } };
4463
+ runtime.deps.agentHome = profile;
4464
+ runtime.deps.sessionTransport = {
4465
+ createRegistry: async () => registry,
4466
+ createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
4467
+ createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
4468
+ };
4469
+ gentleAgents(h.pi, {}, runtime.deps);
4470
+ const { ctx } = fakeContext();
4471
+ await h.fire("session_start", ctx);
4472
+ await eventually(() => ready, "discovery transport ready");
4473
+ ctx.ui.select = async () => { throw new Error("metadata consultation must not ask for consent"); };
4474
+ const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
4475
+ assert.match(result.content[0].text, /peer.*Auth review.*recorded workspace: \/repo/);
4476
+ assert.match(result.content[0].text, /Check auth \[waiting\].*launch workspace: \/repo-child/);
4477
+ assert.match(result.content[0].text, /reachability is unknown/);
4478
+ assert.equal((result.details.gentleAgents as any).candidates[0].scope.host.root, "/repo");
4479
+ assert.equal((result.details.gentleAgents as any).candidates[0].scope.tasks[0].repository.root, "/repo-child");
4480
+ assert.match(result.content[0].text, /clone: [a-f0-9]{64}/);
4481
+ const cursor = (result.details.gentleAgents as any).candidates[0].catalog.cursor;
4482
+ const next = await h.tools.get("orchestrator_list")!.execute("next", { recipient_session_id: "peer", cursor }, undefined, undefined, ctx);
4483
+ assert.deepEqual((next.details.gentleAgents as any).candidates[0].catalog.tasks.map((t: any) => t.id), ["extra7", "extra8"]);
4484
+ assert.deepEqual((next.details.gentleAgents as any).candidates[0].catalog.registered, ["/registered/8", "/registered/9"]);
4485
+ assert.equal(h.userMessages.length, 0);
4486
+ const selectedWork = await h.tools.get("orchestrator_list")!.execute("selected-work", {
4487
+ filter: {}, recipient_session_id: "peer", cursor,
4488
+ }, undefined, undefined, ctx);
4489
+ assert.deepEqual(JSON.parse(selectedWork.content[0].text).matches.map((row: any) => row.taskId), ["extra8"]);
4490
+ const firstWorkPage = await h.tools.get("orchestrator_list")!.execute("first-work", {
4491
+ filter: {}, recipient_session_id: "peer",
4492
+ }, undefined, undefined, ctx);
4493
+ assert.deepEqual(JSON.parse(firstWorkPage.content[0].text).matches.map((row: any) => row.taskId), ["child"]);
4494
+ publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], state: {
4495
+ schema: 2, sessionId: "peer", cwd: "/repo", recordedAt: 1, source: "owner-curated",
4496
+ ownerReply: false, authority: "none", state: { progress: "Explicit summary", work: { area: "Auth" } },
4497
+ } });
4498
+ const noted = await h.tools.get("orchestrator_list")!.execute("note", { recipient_session_id: "peer" }, undefined, undefined, ctx);
4499
+ assert.match(noted.content[0].text, /Explicit summary/);
4500
+ assert.equal((noted.details.gentleAgents as any).candidates[0].state.recordedAt, 1);
4501
+ assert.deepEqual((noted.details.gentleAgents as any).candidates[0].state.state.work, { area: "Auth" });
4502
+ const list = h.tools.get("orchestrator_list")!;
4503
+ const workResult = await list.execute("work", { filter: {} }, undefined, undefined, ctx);
4504
+ const work = JSON.parse(workResult.content[0].text);
4505
+ assert.deepEqual(JSON.parse(JSON.stringify((workResult.details.gentleAgents as any).workSearch)), work);
4506
+ assert.equal(work.schema, 1);
4507
+ assert.deepEqual(work.matches[0].work, { area: "Auth" });
4508
+ assert.equal(work.coverage.exhaustive, false);
4509
+ assert.doesNotMatch(workResult.content[0].text, /Explicit summary|endpoint|activation|cursor|capabilities/);
4510
+ const defaultList = await list.execute("default", {}, undefined, undefined, ctx);
4511
+ assert.doesNotMatch(defaultList.content[0].text, /Explicit summary|"work"/);
4512
+ assert.equal((defaultList.details.gentleAgents as any).candidates[0].state, undefined);
4513
+ const beforeInvalid = scans;
4514
+ for (const invalid of [null, [], { unexpected: true }, { filter: null }, { filter: { topic: "Login" } },
4515
+ { filter: { area: 1 } }, { filter: { area: "é".repeat(33) } }, { filter: { text: "bad\u0000text" } },
4516
+ { filter: { ref: { kind: "issue", repository: "github.com/A/B", id: "01" } } },
4517
+ { filter: { related_to: { session_id: "peer", extra: true } } }]) {
4518
+ await assert.rejects(list.execute("invalid", invalid, undefined, undefined, ctx), /Invalid orchestrator list/);
4519
+ }
4520
+ for (const args of [{ cursor: "x" }, { filter: {}, cursor: "x" }]) {
4521
+ const invalidCursor = await list.execute("cursor", args, undefined, undefined, ctx);
4522
+ assert.equal(invalidCursor.details.error, "invalid-cursor");
4523
+ assert.equal(invalidCursor.content[0].text, "Error: cursor requires recipient_session_id.");
4524
+ }
4525
+ assert.equal(scans, beforeInvalid, "invalid arguments never reach peer/profile discovery");
4526
+ const consult = h.tools.get("orchestrator_consult")!;
4527
+ const probesBeforeConsult = probes;
4528
+ const receipt = JSON.parse((await consult.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx)).content[0].text);
4529
+ assert.equal(receipt.snapshot.state.state.progress, "Explicit summary");
4530
+ assert.equal(receipt.snapshot.state.recordedAt, 1);
4531
+ assert.deepEqual(receipt.snapshot.state.state.work, { area: "Auth" });
4532
+ assert.equal(receipt.ownerReply, false);
4533
+ assert.equal(receipt.authority, "none");
4534
+ assert.equal(receipt.targetSessionId, "peer");
4535
+ assert.doesNotMatch(JSON.stringify(receipt), /endpoint|activation|prompt|thread/);
4536
+ for (const invalid of [{ recipient_session_id: "peer", kind: "reasoning" }, { recipient_session_id: "peer", question: "secret" }, {}]) {
4537
+ await assert.rejects(consult.execute("invalid", invalid, undefined, undefined, ctx), /Invalid metadata/);
4538
+ }
4539
+ assert.equal(probes, probesBeforeConsult);
4540
+ publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], state: {
4541
+ schema: 2, sessionId: "peer", cwd: "/repo", recordedAt: 2, source: "owner-curated",
4542
+ ownerReply: false, authority: "none", state: { work: { topic: "Missing area" } },
4543
+ } });
4544
+ const malformed = await h.tools.get("orchestrator_list")!.execute("malformed", { recipient_session_id: "peer" }, undefined, undefined, ctx);
4545
+ assert.equal((malformed.details.gentleAgents as any).candidates[0].sessionId, "peer");
4546
+ assert.equal((malformed.details.gentleAgents as any).candidates[0].state, undefined);
4547
+ assert.ok(readActivity(profile, listPresence(profile).entries[0]).activity);
4548
+ publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [] });
4549
+ const legacy = await h.tools.get("orchestrator_list")!.execute("legacy", {}, undefined, undefined, ctx);
4550
+ assert.match(legacy.content[0].text, /repository: unknown/);
4551
+ assert.equal((legacy.details.gentleAgents as any).candidates[0].sessionId, "peer");
4552
+ let release!: () => void;
4553
+ gate = () => new Promise<void>(resolve => { release = resolve; });
4554
+ const pending = consult.execute("pending", { recipient_session_id: "peer" }, undefined, undefined, ctx);
4555
+ await h.fire("session_shutdown", ctx);
4556
+ release();
4557
+ assert.equal(JSON.parse((await pending).content[0].text).status, "unavailable");
4558
+ assert.equal(runtime.spawned.length, 0);
4559
+ assert.equal(h.sent.length, 0);
4560
+ assert.equal(h.userMessages.length, 0);
4561
+ await h.fire("session_shutdown", ctx);
4562
+ } finally { publisher.dispose(); }
4563
+ });
4564
+
4565
+ test("registered reasoning and revocation use live SDK host and published source only", async () => {
4566
+ const h = fakePi(), runtime = deps();
4567
+ const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));
4568
+ const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
4569
+ const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Published peer", activity: [] });
4570
+ const publish = (progress = "Recorded") => publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], state: {
4571
+ schema: 1, sessionId: "peer", cwd: "/repo", recordedAt: 1, source: "owner-curated",
4572
+ ownerReply: false, authority: "none", state: { progress },
4573
+ } });
4574
+ publish(); let ready = false, dialogs = 0, calls = 0, choice = "Decline";
4575
+ let complete: (() => void) | undefined, hang = false;
4576
+ let peers = [peer];
4577
+ let duringDialog: (() => Promise<void> | void) | undefined;
4578
+ const registry = { list: async () => [], listActivations: async () => peers };
4579
+ runtime.deps.agentHome = profile;
4580
+ runtime.deps.sessionTransport = {
4581
+ createRegistry: async () => registry,
4582
+ createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
4583
+ createClient: () => ({ close() {}, sendNotification: async () => { throw Error("no owner message"); } }),
4584
+ };
4585
+ gentleAgents(h.pi, {}, runtime.deps);
4586
+ const { ctx } = fakeContext();
4587
+ const answer = { role: "assistant", api: "fixture", provider: "fixture", model: "local", timestamp: 1,
4588
+ stopReason: "stop", content: [{ type: "text", text: "Published advice" }], usage: {
4589
+ input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
4590
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as AssistantMessage;
4591
+ Object.assign(ctx, { model: { id: "local", provider: "fixture", maxTokens: 1024 }, modelRegistry: {
4592
+ streamSimple(_model: unknown, context: { tools: unknown[]; messages: unknown[] }, options: { maxRetries: number }) {
4593
+ calls++; assert.deepEqual(context.tools, []); assert.equal(context.messages.length, 1);
4594
+ assert.equal(options.maxRetries, 0);
4595
+ const stream = createAssistantMessageEventStream();
4596
+ stream.result = hang ? () => new Promise(resolve => { complete = () => resolve(answer); }) : async () => answer;
4597
+ return stream;
4598
+ },
4599
+ } });
4600
+ // Test-simulated supported UI choices, NOT evidence of actual human approval.
4601
+ ctx.ui.select = async (title, options) => {
4602
+ dialogs++; assert.match(title, /Model-cost permission only/);
4603
+ assert.deepEqual(options, ["Allow once", "Allow this target + model for this session", "Decline"]);
4604
+ await duringDialog?.(); return choice;
4605
+ };
4606
+ try {
4607
+ await h.fire("session_start", ctx); await eventually(() => ready, "reasoning transport ready");
4608
+ const tool = h.tools.get("orchestrator_consult")!;
4609
+ const run = async (kind = "reasoning", patch = {}, caller = ctx, signal?: AbortSignal) => JSON.parse((await tool.execute("c",
4610
+ { recipient_session_id: "peer", kind, ...(kind === "reasoning" ? { question: "What is published?" } : {}), ...patch }, signal, undefined, caller)).content[0].text);
4611
+ assert.equal((await run("metadata")).status, "available"); assert.deepEqual([dialogs, calls], [0, 0]);
4612
+ assert.equal((await run()).code, "permission-required"); assert.deepEqual([dialogs, calls], [1, 0]);
4613
+ choice = "Allow once"; const advice = await run();
4614
+ assert.equal(advice.source, "helper_advice"); assert.equal(advice.ownerReply, false); assert.equal(advice.authority, "none");
4615
+ assert.equal(advice.requestCaps.maxTokens, 512); assert.equal(advice.usage.totalTokens, 2);
4616
+ assert.deepEqual(advice.actualModel, { provider: "fixture", id: "local" }); assert.deepEqual([dialogs, calls], [2, 1]);
4617
+ choice = "Allow this target + model for this session"; await run();
4618
+ publisher.updateDiscovery(peer, { workspace: "/updated-public", tasks: [] }); await run();
4619
+ assert.deepEqual([dialogs, calls], [3, 3]);
4620
+ Object.assign(ctx, { hasUI: false });
4621
+ assert.equal((await run("revoke-reasoning")).status, "revoked");
4622
+ assert.deepEqual([dialogs, calls], [3, 3]); Object.assign(ctx, { hasUI: true });
4623
+ choice = "Decline"; assert.equal((await run()).code, "permission-required");
4624
+ for (const patch of [{ hasUI: false }, { mode: "print" }, { mode: "json" }]) {
4625
+ Object.assign(ctx, patch); assert.equal((await run()).code, "permission-required");
4626
+ }
4627
+ Object.assign(ctx, { hasUI: true, mode: "rpc" }); choice = "Allow once";
4628
+ assert.equal((await run()).status, "available");
4629
+ const before = [dialogs, calls];
4630
+ for (const args of [{ question: "implicit" }, { kind: "reasoning" }, { kind: "revoke-reasoning", cursor: "x" },
4631
+ { kind: "metadata", question: "irrelevant" }, { kind: "reasoning", question: "é".repeat(513) },
4632
+ { kind: null }, { kind: "unknown" }, { cursor: "x".repeat(1025) }, { unexpected: true }]) {
4633
+ await assert.rejects(tool.execute("bad", { recipient_session_id: "peer", ...args }, undefined, undefined, ctx), /Invalid metadata/);
4634
+ }
4635
+ assert.deepEqual([dialogs, calls], before);
4636
+ choice = "Allow this target + model for this session"; await run();
4637
+ for (const event of ["session_tree", "resources_discover", "session_before_switch", "session_before_fork", "session_before_tree", "model_select", "session_start"]) {
4638
+ await h.fire(event, ctx); choice = "Decline";
4639
+ assert.equal((await run()).code, "permission-required");
4640
+ choice = "Allow this target + model for this session"; await run();
4641
+ }
4642
+ await run("revoke-reasoning"); choice = "Allow once"; hang = true;
4643
+ for (const change of ["public", "activation", "unavailable", "private", "abort", "revoke"]) {
4644
+ publish(); peers = [peer]; const controller = new AbortController();
4645
+ const pending = run("reasoning", {}, ctx, controller.signal);
4646
+ await eventually(() => !!complete, "helper pending");
4647
+ if (change === "public") publish("Changed progress");
4648
+ if (change === "activation") peers = [{ ...peer, endpoint: "/fixture/replaced.sock" }];
4649
+ if (change === "unavailable") peers = [];
4650
+ if (change === "private") { publisher.update([]); publisher.refreshLabel(); }
4651
+ if (change === "abort") controller.abort();
4652
+ if (change === "revoke") await run("revoke-reasoning");
4653
+ complete!(); complete = undefined;
4654
+ const outcome = await pending;
4655
+ assert.equal(outcome.status, change === "private" ? "available" : "unavailable");
4656
+ }
4657
+ hang = false; peers = [peer];
4658
+ for (const change of [() => Object.assign(ctx, { model: { ...ctx.model! } }),
4659
+ () => publish("Changed after dialog"),
4660
+ () => h.fire("session_tree", ctx), () => run("revoke-reasoning"),
4661
+ () => h.fire("session_shutdown", ctx),
4662
+ () => h.fire("session_start", fakeContext().ctx, { reason: "new" })]) {
4663
+ await h.fire("session_start", ctx); await new Promise(resolve => setImmediate(resolve));
4664
+ publish(); duringDialog = async () => { await change(); }; const count = calls;
4665
+ assert.equal((await run()).status, "unavailable"); assert.equal(calls, count);
4666
+ }
4667
+ assert.equal((await run("metadata", {}, ctx)).status, "unavailable");
4668
+ assert.equal(runtime.spawned.length, 0); assert.equal(h.sent.length, 0); assert.equal(h.userMessages.length, 0);
4669
+ } finally { await h.fire("session_shutdown", ctx); publisher.dispose(); }
4670
+ });
4671
+
3795
4672
  test("session transport adds host tools, forwards notifications, and closes on shutdown", async () => {
3796
4673
  const h = fakePi();
3797
4674
  const runtime = deps();
@@ -3799,7 +4676,7 @@ test("session transport adds host tools, forwards notifications, and closes on s
3799
4676
  let listenerStarts = 0;
3800
4677
  let listenerCloses = 0;
3801
4678
  let clientCloses = 0;
3802
- const registry = { list: async () => [{ sessionId: "peer", reachability: "unknown" }], listActivations: async () => [] };
4679
+ const registry = { list: async () => [{ sessionId: "peer", reachability: "unknown" }], listActivations: async () => [{ version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 }] };
3803
4680
  runtime.deps.sessionTransport = {
3804
4681
  createRegistry: async () => registry,
3805
4682
  createListener: (_registry, _sessionId, received) => {
@@ -3818,10 +4695,20 @@ test("session transport adds host tools, forwards notifications, and closes on s
3818
4695
  assert.ok(h.tools.has("orchestrator_send_message"));
3819
4696
  assert.match((await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx)).content[0].text, /peer/);
3820
4697
  assert.ok(callback, "listener receives the inbound callback");
4698
+ Object.assign(ctx.sessionManager, { getSessionName: () => "Integration" });
3821
4699
  await callback!({ id: "message-1", senderSessionId: "peer", message: "\u001b[31mraw model content" });
3822
4700
  assert.equal(h.sent.at(-1)?.message.customType, "gentle-agents.orchestrator-message");
3823
4701
  assert.match(String(h.sent.at(-1)?.message.content), /\u001b\[31mraw model content/);
3824
4702
  assert.deepEqual(h.sent.at(-1)?.options, { deliverAs: "followUp", triggerTurn: true });
4703
+ const renderer = h.renderers.get("gentle-agents.orchestrator-message")!;
4704
+ const compact = renderer(h.sent.at(-1)!.message, { expanded: false }, plainTheme).render(80).join("\n");
4705
+ assert.match(compact, /Message received/);
4706
+ assert.match(compact, /🤖 Orchestrator → 🤖 Integration/);
4707
+ assert.match(compact, /raw model content/);
4708
+ assert.doesNotMatch(compact, /Session message from|correlation|message-1|\u001b/);
4709
+ const expanded = renderer(h.sent.at(-1)!.message, { expanded: true }, plainTheme).render(80).join("\n");
4710
+ assert.match(expanded, /message-1/);
4711
+ assert.match(expanded, /Sender session: peer/);
3825
4712
  await h.fire("session_shutdown", ctx);
3826
4713
  assert.equal(clientCloses, 1);
3827
4714
  assert.equal(listenerCloses, 1);
@@ -3877,6 +4764,7 @@ test("session transport selects a peer for outbound delivery and rejects stale c
3877
4764
  const { ctx, dialogs } = fakeContext();
3878
4765
  await h.fire("session_start", ctx);
3879
4766
  await eventually(() => callbacks.length === 1, "initial transport callback registration");
4767
+ Object.assign(ctx.sessionManager, { getSessionName: () => "Backend" });
3880
4768
  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
4769
  assert.deepEqual(dialogs, [
3882
4770
  "select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta",
@@ -3884,6 +4772,20 @@ test("session transport selects a peer for outbound delivery and rejects stale c
3884
4772
  ]);
3885
4773
  assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
3886
4774
  assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
4775
+ const tool = h.tools.get("orchestrator_send_message")!;
4776
+ assert.equal(tool.renderShell, "self", "do not nest the card in Pi's default tool box");
4777
+ const args = { message: "hello peer", reason: "because the peer needs an update" };
4778
+ const renderContext = { args, state: {}, expanded: false, isPartial: false };
4779
+ const call = (tool.renderCall as Function)(args, plainTheme, renderContext);
4780
+ const output = (tool.renderResult as Function)(result, { expanded: false }, plainTheme, renderContext);
4781
+ const compact = [...call.render(80), ...output.render(80)].join("\n");
4782
+ assert.match(compact, /Message queued/);
4783
+ assert.match(compact, /🤖 Backend → 🤖 Orchestrator/);
4784
+ assert.match(compact, /hello peer/);
4785
+ assert.doesNotMatch(compact, /accepted-1|Sender session:|Recipient session:|because the peer/);
4786
+ const expanded = (tool.renderResult as Function)(result, { expanded: true }, plainTheme, { ...renderContext, expanded: true }).render(80).join("\n");
4787
+ assert.match(expanded, /accepted-1/);
4788
+ assert.match(expanded, /not a delivery or read receipt/);
3887
4789
  const original = callbacks[0]!;
3888
4790
  (ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
3889
4791
  await h.fire("session_start", ctx);
@@ -3893,6 +4795,17 @@ test("session transport selects a peer for outbound delivery and rejects stale c
3893
4795
  await h.fire("session_shutdown", ctx);
3894
4796
  });
3895
4797
 
4798
+ test("all orchestrator tools own compact and expanded card rendering", () => {
4799
+ const h = fakePi();
4800
+ gentleAgents(h.pi, {}, deps().deps);
4801
+ for (const name of ["orchestrator_session_id", "orchestrator_consult", "orchestrator_list", "orchestrator_send_message"]) {
4802
+ const tool = h.tools.get(name)!;
4803
+ assert.equal(tool.renderShell, "self", name);
4804
+ assert.equal(typeof tool.renderCall, "function", name);
4805
+ assert.equal(typeof tool.renderResult, "function", name);
4806
+ }
4807
+ });
4808
+
3896
4809
  // Issue #1364: user consent before cross-orchestrator communication
3897
4810
  test("orchestrator_send_message requires consent on explicit recipient ID and sends nothing when denied", async () => {
3898
4811
  const h = fakePi();
@@ -4143,10 +5056,166 @@ test("a background completion settling while the parent agent runs is delivered
4143
5056
  await fire("session_shutdown", ctx);
4144
5057
  });
4145
5058
 
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.
5059
+ test("Bridge identity is durable, session-owned and registered once across session changes", async () => {
5060
+ const h = fakePi();
5061
+ const runtime = deps();
5062
+ gentleAgents(h.pi, {}, runtime.deps);
5063
+ const { ctx } = fakeContext();
5064
+ ctx.sessionManager.getEntries = () => h.entries as unknown as ReturnType<ExtensionContext["sessionManager"]["getEntries"]>;
5065
+ await h.fire("session_start", ctx);
5066
+ await h.tools.get("subagent_run")!.execute("one", { agent: "explore", task: "Wake identity", mode: "background" }, undefined, undefined, ctx);
5067
+ await tick();
5068
+ runtime.children[0].message({ id: "q1", kind: "query", message: "Question" });
5069
+ await tick();
5070
+ const wake = String(h.userMessages[0].content);
5071
+ const transform = h.transformers[0];
5072
+ const context = { messageType: "user" as const, isStreaming: false, availableWidth: 80 };
5073
+ assert.equal(transform(wake, context), "");
5074
+ assert.equal(h.entries.filter(entry => entry.customType === "gentle-agents.wake-identity").length, 1);
5075
+ await h.fire("session_start", ctx, { reason: "reload" });
5076
+ assert.equal(transform(wake, context), "", "reload reconstructs ownership");
5077
+ ctx.sessionManager.getSessionId = () => "other-session";
5078
+ await h.fire("session_start", ctx, { reason: "new" });
5079
+ assert.equal(transform(wake, context), wake, "another session cannot claim the old identity");
5080
+ ctx.sessionManager.getSessionId = () => "s1";
5081
+ await h.fire("session_start", ctx, { reason: "resume" });
5082
+ assert.equal(transform(wake, context), "");
5083
+ assert.equal(h.transformers.length, 1, "session changes do not register again");
5084
+ assert.equal(transform(`Quoted: ${wake}`, context), `Quoted: ${wake}`);
5085
+ assert.equal(transform(` ${wake}`, context), ` ${wake}`);
5086
+ await h.fire("session_shutdown", ctx);
5087
+ assert.equal(transform(wake, context), wake, "shutdown releases the old owner");
5088
+ });
5089
+
5090
+ for (const failure of ["missing-api", "persistence", "warning-ui"] as const) {
5091
+ test(`Bridge ${failure} fallback preserves continuation and flags visible wake`, async () => {
5092
+ const h = fakePi();
5093
+ const runtime = deps();
5094
+ if (failure !== "persistence") delete (h.pi as Partial<ExtensionAPI>).registerMarkdownTransformer;
5095
+ gentleAgents(h.pi, {}, runtime.deps);
5096
+ const { ctx, dialogs } = fakeContext();
5097
+ if (failure === "warning-ui") ctx.ui.notify = () => { throw new Error("UI unavailable"); };
5098
+ await h.fire("session_start", ctx);
5099
+ if (failure === "persistence") h.pi.appendEntry = () => { throw new Error("Persistence unavailable"); };
5100
+ await h.tools.get("subagent_run")!.execute("fallback", { agent: "explore", task: "Fallback", mode: "background" }, undefined, undefined, ctx);
5101
+ await tick();
5102
+ runtime.children[0].message({ id: "q1", kind: "query", message: "Question" });
5103
+ await tick();
5104
+ assert.equal(h.userMessages.length, 1);
5105
+ assert.equal(h.sent.length, 1, "payload is stored once despite compatibility fallback");
5106
+ assert.doesNotMatch(String(h.userMessages[0].content), /gentle-agents wake:/);
5107
+ if (failure !== "warning-ui") assert.ok(dialogs.some(message => message.includes("cannot hide Claude Bridge")));
5108
+ });
5109
+ }
5110
+
5111
+ for (const provider of ["openai", "anthropic", "custom-extension", undefined, "claude-bridge"]) {
5112
+ for (const kind of ["completion", "query"] as const) {
5113
+ test(`idle ${kind} wake uses the selected ${provider ?? "missing"} provider route`, async () => {
5114
+ const h = fakePi();
5115
+ h.setProvider(provider);
5116
+ const harness = deps();
5117
+ gentleAgents(h.pi, {}, harness.deps);
5118
+ const { ctx } = fakeContext();
5119
+ await h.fire("session_start", ctx);
5120
+ await h.tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Route wake", mode: "background" }, undefined, undefined, ctx);
5121
+ await tick();
5122
+ if (kind === "query") harness.children[0].message({ id: "q1", kind: "query", message: "Child payload" });
5123
+ else {
5124
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Child payload" }] }] });
5125
+ harness.children[0].emit({ type: "agent_settled" });
5126
+ }
5127
+ await tick();
5128
+ assert.deepEqual(h.sent[0]!.options, { triggerTurn: false });
5129
+ assert.match(String(h.sent[0]!.message.content), /Child payload/);
5130
+ if (provider === "claude-bridge") {
5131
+ assert.equal(h.sent.length, 1);
5132
+ assert.equal(h.userMessages.length, 1);
5133
+ assert.match(String(h.userMessages[0].content), /\[gentle-agents wake: [0-9a-f-]+\]$/);
5134
+ assert.deepEqual(h.userMessages[0].options, { deliverAs: "steer" });
5135
+ assert.equal(h.transformers[0](String(h.userMessages[0].content), { messageType: "user", isStreaming: false, availableWidth: 80 }), "");
5136
+ assert.equal(h.delivery.at(-1), "user");
5137
+ } else {
5138
+ assert.equal(h.userMessages.length, 0, "native and unclassified providers never receive a synthetic user turn");
5139
+ assert.equal(h.sent.length, 2, "the stored payload still gets a continuation turn");
5140
+ assert.deepEqual(h.sent[1]!.options, { deliverAs: "steer", triggerTurn: true });
5141
+ assert.equal(h.sent[1]!.message.display, false);
5142
+ assert.equal(h.sent[1]!.message.customType, "gentle-agents.wake");
5143
+ assert.equal(h.sent[1]!.message.content, "Review the delivered subagent output and continue.");
5144
+ assert.deepEqual(h.delivery, [`custom:gentle-agents.${kind === "query" ? "message" : "result"}`, "custom:gentle-agents.wake"]);
5145
+ }
5146
+ await h.fire("session_shutdown", ctx);
5147
+ });
5148
+ }
5149
+ }
5150
+
5151
+ for (const [from, to] of [["openai", "claude-bridge"], ["claude-bridge", "openai"]]) {
5152
+ test(`wake reads the live provider after storage: ${from} to ${to}`, async () => {
5153
+ const h = fakePi(from);
5154
+ const harness = deps();
5155
+ gentleAgents(h.pi, {}, harness.deps);
5156
+ const { ctx } = fakeContext();
5157
+ await h.fire("session_start", ctx);
5158
+ await h.tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Switch before dispatch", mode: "background" }, undefined, undefined, ctx);
5159
+ await tick();
5160
+ const sendMessage = h.pi.sendMessage;
5161
+ Object.assign(h.pi, { sendMessage: (...args: Parameters<ExtensionAPI["sendMessage"]>) => {
5162
+ sendMessage(...args);
5163
+ // Storage happens synchronously, dispatch in the queued microtask.
5164
+ if (args[0].customType === "gentle-agents.message") h.setProvider(to);
5165
+ } });
5166
+ harness.children[0].message({ id: "q1", kind: "query", message: "Switch?" });
5167
+ await tick();
5168
+ assert.equal(h.userMessages.length, to === "claude-bridge" ? 1 : 0);
5169
+ assert.equal(h.sent.filter((entry) => entry.message.customType === "gentle-agents.wake").length, to === "openai" ? 1 : 0);
5170
+ await h.fire("session_shutdown", ctx);
5171
+ });
5172
+ }
5173
+
5174
+ for (const boundary of ["session_compact", "session_compact_failed"] as const) {
5175
+ test(`native wake preserves holds, coalescing and grace after ${boundary}`, async () => {
5176
+ const h = fakePi("openai");
5177
+ const harness = deps();
5178
+ const timers = recordTimers(harness.deps);
5179
+ gentleAgents(h.pi, {}, harness.deps);
5180
+ const { ctx } = fakeContext();
5181
+ await h.fire("session_start", ctx);
5182
+ for (const task of ["Ask", "Finish"]) await h.tools.get("subagent_run")!.execute(task, { agent: "explore", task, mode: "background" }, undefined, undefined, ctx);
5183
+ await tick();
5184
+ h.setIdle(false);
5185
+ harness.children[0].message({ id: "q1", kind: "query", message: "Held question" });
5186
+ harness.children[1].emit({ type: "agent_settled" });
5187
+ await tick();
5188
+ assert.equal(h.sent.length, 0);
5189
+ await h.fire(boundary, ctx);
5190
+ assert.equal(h.sent.length, 0);
5191
+ h.setIdle(true);
5192
+ assert.equal(timers.run(0), 1);
5193
+ await tick();
5194
+ assert.deepEqual(h.delivery, ["custom:gentle-agents.message", "custom:gentle-agents.result", "custom:gentle-agents.wake"]);
5195
+ assert.deepEqual(h.sent.map((entry) => entry.options), [{ triggerTurn: false }, { triggerTurn: false }, { deliverAs: "steer", triggerTurn: true }]);
5196
+ const expireWakeGrace = timers.takeLast(PARENT_WAKE_GRACE_MS);
5197
+ harness.children[0].message({ id: "q2", kind: "query", message: "Later question" });
5198
+ await tick();
5199
+ assert.equal(h.sent.length, 4, "content arriving in the grace window shares the pending wake");
5200
+ expireWakeGrace();
5201
+ await tick();
5202
+ assert.equal(h.sent.length, 5, "an unstarted wake cannot suppress new content beyond grace");
5203
+ assert.equal(h.sent[4]!.message.display, false);
5204
+ assert.deepEqual(h.sent[4]!.options, { deliverAs: "steer", triggerTurn: true });
5205
+ await h.fire("agent_start", ctx);
5206
+ harness.children[0].message({ id: "q3", kind: "query", message: "Busy question" });
5207
+ await tick();
5208
+ assert.equal(h.sent.length, 5, "busy content stays queued until the turn boundary");
5209
+ await h.fire("turn_end", ctx);
5210
+ assert.equal(h.sent.at(-1)!.message.customType, "gentle-agents.message");
5211
+ assert.deepEqual(h.sent.at(-1)!.options, { deliverAs: "steer", triggerTurn: true }, "busy native delivery keeps the original steering route");
5212
+ assert.equal(h.userMessages.length, 0);
5213
+ await h.fire("session_shutdown", ctx);
5214
+ });
5215
+ }
5216
+
5217
+ // Bridge custom-message turns skip the prompt lifecycle needed for capture.
5218
+ // Store the structured result durably and wake the bridge by a user message.
4150
5219
  test("an idle parent stores the structured completion and is woken through the normal prompt lifecycle", async () => {
4151
5220
  const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi();
4152
5221
  const harness = deps();
@@ -4390,7 +5459,7 @@ test("a stale parent context fails closed: child completions and notifications a
4390
5459
  await fire("session_shutdown", ctx);
4391
5460
  });
4392
5461
 
4393
- test("a completion held past the stale window becomes transcript-only content and never re-enters the conversation", async () => {
5462
+ test("a completion held past the stale window keeps its report out of the conversation and shows the human a stale card", async () => {
4394
5463
  const { pi, tools, fire, sent, entries, entryRenderers } = fakePi();
4395
5464
  const harness = deps();
4396
5465
  let clock = 1000;
@@ -4419,41 +5488,206 @@ test("a completion held past the stale window becomes transcript-only content an
4419
5488
  await fire("session_shutdown", ctx);
4420
5489
  });
4421
5490
 
4422
- test("a completion the parent already pulled is dropped silently at the next turn end", async () => {
5491
+ // Issue #1821/#1092: the parent is told never to poll, so an unread stale
5492
+ // completion must still tell the model a result is waiting, without replaying
5493
+ // the report itself.
5494
+ test("an unread completion held past the stale window steers a compact notice into the running parent", async () => {
4423
5495
  const { pi, tools, fire, sent } = fakePi();
4424
5496
  const harness = deps();
5497
+ let clock = 1000;
5498
+ harness.deps.now = () => clock;
4425
5499
  gentleAgents(pi, {}, harness.deps);
4426
5500
  const { ctx } = fakeContext();
4427
5501
  await fire("session_start", ctx);
4428
- const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled early", mode: "background" }, undefined, undefined, ctx);
5502
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long tool call", mode: "background" }, undefined, undefined, ctx);
4429
5503
  const id = (started.details.gentleAgents as { taskId: string }).taskId;
4430
5504
  await tick();
4431
5505
  await fire("agent_start", ctx);
4432
- harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] });
5506
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Late answer." }] }] });
4433
5507
  harness.children[0].emit({ type: "agent_settled" });
4434
5508
  await tick();
5509
+ clock += STALE_COMPLETION_MS + 1_000;
5510
+ await fire("turn_end", ctx);
5511
+ const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice");
5512
+ assert.equal(notices.length, 1, "the model is told once that an unread result is waiting");
5513
+ assert.deepEqual(notices[0]!.options, { deliverAs: "steer", triggerTurn: true });
5514
+ const content = String(notices[0]!.message.content);
5515
+ assert.match(content, new RegExp(id));
5516
+ assert.match(content, /subagent_result/);
5517
+ assert.doesNotMatch(content, /Late answer\./, "the stale report itself is never replayed");
4435
5518
  assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
4436
- assert.match((await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /Pulled answer\./);
4437
5519
  await fire("turn_end", ctx);
4438
- assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a consumed completion is dropped instead of replayed");
5520
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice").length, 1, "the notice is sent at most once");
4439
5521
  await fire("session_shutdown", ctx);
4440
5522
  });
4441
5523
 
4442
- test("a session restart never replays a completion still pending from before it", async () => {
4443
- const { pi, tools, fire, sent } = fakePi();
5524
+ test("an unread stale completion flushed at an idle boundary stores its notice and wakes the parent", async () => {
5525
+ const { pi, tools, fire, sent, userMessages, setIdle } = fakePi();
4444
5526
  const harness = deps();
5527
+ let clock = 1000;
5528
+ harness.deps.now = () => clock;
4445
5529
  gentleAgents(pi, {}, harness.deps);
4446
5530
  const { ctx } = fakeContext();
4447
5531
  await fire("session_start", ctx);
4448
- await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Restarted", mode: "background" }, undefined, undefined, ctx);
5532
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ends idle", mode: "background" }, undefined, undefined, ctx);
4449
5533
  await tick();
4450
5534
  await fire("agent_start", ctx);
4451
- harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Unclaimed answer." }] }] });
5535
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Late answer." }] }] });
4452
5536
  harness.children[0].emit({ type: "agent_settled" });
4453
5537
  await tick();
4454
- assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
4455
- await fire("session_start", ctx, { reason: "resume" });
4456
- await fire("turn_end", ctx);
5538
+ clock += STALE_COMPLETION_MS + 1_000;
5539
+ setIdle(true);
5540
+ await fire("agent_end", ctx);
5541
+ await tick();
5542
+ const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice");
5543
+ assert.equal(notices.length, 1);
5544
+ assert.deepEqual(notices[0]!.options, { triggerTurn: false });
5545
+ assert.equal(userMessages.length, 1, "an idle parent is woken instead of waiting forever");
5546
+ await fire("session_shutdown", ctx);
5547
+ });
5548
+
5549
+ test("a completion whose forwarding throws stays queued and is delivered at the next boundary", async () => {
5550
+ const { pi, tools, fire, sent } = fakePi();
5551
+ const harness = deps();
5552
+ gentleAgents(pi, {}, harness.deps);
5553
+ const { ctx } = fakeContext();
5554
+ await fire("session_start", ctx);
5555
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Flaky host", mode: "background" }, undefined, undefined, ctx);
5556
+ await tick();
5557
+ await fire("agent_start", ctx);
5558
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Retried answer." }] }] });
5559
+ harness.children[0].emit({ type: "agent_settled" });
5560
+ await tick();
5561
+ const sendMessage = pi.sendMessage;
5562
+ Object.assign(pi, { sendMessage: () => { throw new Error("host busy"); } });
5563
+ await fire("turn_end", ctx);
5564
+ assert.equal(sent.length, 0);
5565
+ Object.assign(pi, { sendMessage });
5566
+ await fire("turn_end", ctx);
5567
+ const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
5568
+ assert.equal(results.length, 1, "a failed forward is retried, not lost");
5569
+ assert.match(String(results[0]!.message.content), /Retried answer\./);
5570
+ await fire("turn_end", ctx);
5571
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1, "a delivered completion is never sent twice");
5572
+ await fire("session_shutdown", ctx);
5573
+ });
5574
+
5575
+ test("a completion whose forwarding throws while the parent is idle is retried after the bounded grace", async () => {
5576
+ const { pi, tools, fire, sent, userMessages } = fakePi();
5577
+ const harness = deps();
5578
+ const timers = recordTimers(harness.deps);
5579
+ gentleAgents(pi, {}, harness.deps);
5580
+ const { ctx } = fakeContext();
5581
+ await fire("session_start", ctx);
5582
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle flaky host", mode: "background" }, undefined, undefined, ctx);
5583
+ await tick();
5584
+ const sendMessage = pi.sendMessage;
5585
+ Object.assign(pi, { sendMessage: () => { throw new Error("host busy"); } });
5586
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Idle retried answer." }] }] });
5587
+ harness.children[0].emit({ type: "agent_settled" });
5588
+ await tick();
5589
+ assert.equal(sent.length, 0);
5590
+ assert.equal(userMessages.length, 0);
5591
+ Object.assign(pi, { sendMessage });
5592
+ // No turn boundary is coming for an idle parent, so the retry is a timer.
5593
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1, "one bounded retry flush is armed");
5594
+ await tick();
5595
+ const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
5596
+ assert.equal(results.length, 1, "the requeued completion reaches the idle parent");
5597
+ assert.match(String(results[0]!.message.content), /Idle retried answer\./);
5598
+ assert.equal(userMessages.length, 1, "and the idle parent is woken for it");
5599
+ // Only the dispatched wake's own grace remains; expiring it resends nothing.
5600
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
5601
+ await tick();
5602
+ assert.equal(sent.length, 1, "a successful retry never delivers the completion twice");
5603
+ assert.equal(userMessages.length, 1);
5604
+ await fire("session_shutdown", ctx);
5605
+ });
5606
+
5607
+ test("retries for a failing idle delivery stop after the bounded limit", async () => {
5608
+ const { pi, tools, fire, sent, userMessages } = fakePi();
5609
+ const harness = deps();
5610
+ const timers = recordTimers(harness.deps);
5611
+ gentleAgents(pi, {}, harness.deps);
5612
+ const { ctx } = fakeContext();
5613
+ await fire("session_start", ctx);
5614
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Broken host", mode: "background" }, undefined, undefined, ctx);
5615
+ await tick();
5616
+ Object.assign(pi, { sendUserMessage: () => { throw new Error("host rejected prompt"); } });
5617
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
5618
+ harness.children[0].emit({ type: "agent_settled" });
5619
+ await tick();
5620
+ let retries = 0;
5621
+ while (timers.run(PARENT_WAKE_GRACE_MS) > 0) {
5622
+ retries += 1;
5623
+ await tick();
5624
+ assert.ok(retries <= 3, "a permanently failing wake never retries forever");
5625
+ }
5626
+ assert.equal(retries, 3);
5627
+ assert.equal(sent.length, 1, "retries never store the completion twice");
5628
+ assert.equal(userMessages.length, 0);
5629
+ await fire("session_shutdown", ctx);
5630
+ });
5631
+
5632
+ test("a wake that throws stays owed and is retried after the bounded grace", async () => {
5633
+ const { pi, tools, fire, sent, userMessages } = fakePi();
5634
+ const harness = deps();
5635
+ const timers = recordTimers(harness.deps);
5636
+ gentleAgents(pi, {}, harness.deps);
5637
+ const { ctx } = fakeContext();
5638
+ await fire("session_start", ctx);
5639
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Lost wake", mode: "background" }, undefined, undefined, ctx);
5640
+ await tick();
5641
+ const sendUserMessage = pi.sendUserMessage;
5642
+ Object.assign(pi, { sendUserMessage: () => { throw new Error("host rejected prompt"); } });
5643
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
5644
+ harness.children[0].emit({ type: "agent_settled" });
5645
+ await tick();
5646
+ assert.equal(sent.length, 1, "the completion is stored before the wake fails");
5647
+ assert.equal(userMessages.length, 0);
5648
+ Object.assign(pi, { sendUserMessage });
5649
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1, "one bounded retry is armed");
5650
+ await tick();
5651
+ assert.equal(userMessages.length, 1, "the idle parent is finally woken");
5652
+ assert.equal(sent.length, 1, "the retry wakes without storing the completion again");
5653
+ await fire("session_shutdown", ctx);
5654
+ });
5655
+
5656
+ test("a completion the parent already pulled is dropped silently at the next turn end", async () => {
5657
+ const { pi, tools, fire, sent } = fakePi();
5658
+ const harness = deps();
5659
+ gentleAgents(pi, {}, harness.deps);
5660
+ const { ctx } = fakeContext();
5661
+ await fire("session_start", ctx);
5662
+ const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled early", mode: "background" }, undefined, undefined, ctx);
5663
+ const id = (started.details.gentleAgents as { taskId: string }).taskId;
5664
+ await tick();
5665
+ await fire("agent_start", ctx);
5666
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] });
5667
+ harness.children[0].emit({ type: "agent_settled" });
5668
+ await tick();
5669
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
5670
+ assert.match((await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /Pulled answer\./);
5671
+ await fire("turn_end", ctx);
5672
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a consumed completion is dropped instead of replayed");
5673
+ await fire("session_shutdown", ctx);
5674
+ });
5675
+
5676
+ test("a session restart never replays a completion still pending from before it", async () => {
5677
+ const { pi, tools, fire, sent } = fakePi();
5678
+ const harness = deps();
5679
+ gentleAgents(pi, {}, harness.deps);
5680
+ const { ctx } = fakeContext();
5681
+ await fire("session_start", ctx);
5682
+ await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Restarted", mode: "background" }, undefined, undefined, ctx);
5683
+ await tick();
5684
+ await fire("agent_start", ctx);
5685
+ harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Unclaimed answer." }] }] });
5686
+ harness.children[0].emit({ type: "agent_settled" });
5687
+ await tick();
5688
+ assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
5689
+ await fire("session_start", ctx, { reason: "resume" });
5690
+ await fire("turn_end", ctx);
4457
5691
  assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a resumed session starts with an empty completion queue");
4458
5692
  await fire("session_shutdown", ctx);
4459
5693
  });
@@ -4660,7 +5894,10 @@ test("issue #1162: task-mode subagent_run includes question directly in waiting
4660
5894
  test("children receive context and safety extensions, and missing files are omitted", async () => {
4661
5895
  const expected = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-context.ts");
4662
5896
  const safety = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-safety.ts");
4663
- assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety)]);
5897
+ // gentle-shell#1731 T32: the nan provider is registered by a gentle-pi
5898
+ // extension, so a child routed to nan/* could not resolve its model.
5899
+ const nanProvider = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "nan-provider.ts");
5900
+ assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety), resolve(nanProvider)]);
4664
5901
  assert.deepEqual(childContextExtensionPaths(() => false), [], "a missing extension file fails safe to no --extension");
4665
5902
  const extensionArguments = (args: string[]) => args.filter((_, index) => args[index - 1] === "--extension");
4666
5903
  for (const scenario of ["present", "missing"] as const) {
@@ -4674,10 +5911,592 @@ test("children receive context and safety extensions, and missing files are omit
4674
5911
  await h.tools.get("subagent_run")!.execute(`child-context-${scenario}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
4675
5912
  await tick();
4676
5913
  assert.equal(runtime.spawned.length, 1);
4677
- assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety)] : []);
5914
+ assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety), resolve(nanProvider)] : []);
5915
+ } finally {
5916
+ await h.fire("session_shutdown", ctx);
5917
+ await tick();
5918
+ }
5919
+ }
5920
+ });
5921
+
5922
+ // gentle-shell#1713 (review R3-001): prove the subagent_continue wiring end to
5923
+ // end, not only the helper. A writer follow-up without its own section reaches
5924
+ // the child with the surfaces its original launch was admitted with.
5925
+ test("a writer continuation without its own section inherits the admitted surfaces end to end", async () => {
5926
+ const h = fakePi();
5927
+ const runtime = deps();
5928
+ const profile = mkdtempSync(join(tmpdir(), "gentle-agents-continue-"));
5929
+ mkdirSync(join(profile, "agents"), { recursive: true });
5930
+ writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
5931
+ writeFileSync(join(profile, "subagents.json"), JSON.stringify({ model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
5932
+ const env: NodeJS.ProcessEnv = {};
5933
+ gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile });
5934
+ const { ctx } = fakeContext();
5935
+ await h.fire("session_start", ctx);
5936
+ const launched = h.tools.get("subagent_run")!.execute("run", { agent: "gentle-ai-worker", task: "Do T2.\n\n## Allowed edit surfaces\n- src/app.ts\n- `docs/with space.md`\n\n## Return\nReport", mode: "task" }, undefined, undefined, ctx);
5937
+ await tick();
5938
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "partial" }] }] });
5939
+ runtime.children[0].emit({ type: "agent_settled" });
5940
+ const taskId = ((await launched).details.gentleAgents as { taskId: string }).taskId;
5941
+ const continued = h.tools.get("subagent_continue")!.execute("follow", { task_id: taskId, prompt: "Continue with the remaining specs.", mode: "task" }, undefined, undefined, ctx);
5942
+ await tick();
5943
+ assert.equal(runtime.children.length, 2, "the continuation is admitted instead of rejected");
5944
+ const prompt = String(runtime.children[1].written.find(frame => typeof frame.message === "string")?.message);
5945
+ assert.match(prompt, /^Continue with the remaining specs\.\n\n## Allowed edit surfaces\n`docs\/with space\.md`\n`src\/app\.ts`\n/);
5946
+ runtime.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
5947
+ runtime.children[1].emit({ type: "agent_settled" });
5948
+ await continued;
5949
+ await h.fire("session_shutdown", ctx);
5950
+ rmSync(profile, TEST_DIR_REMOVAL);
5951
+ });
5952
+
5953
+ // #1690: the launcher's injection signal in the host env replaces the curated
5954
+ // child entries; without a valid signal children keep the curated entries.
5955
+ test("children receive the launcher's package injection instead of the curated entries", async () => {
5956
+ const curated = ["/curated/child-context.ts", "/curated/child-safety.ts"];
5957
+ const takeover = ["/agent/npm/node_modules/other", "/agent/extensions/a b.ts", "/pkg"];
5958
+ const scenarios: Array<{ name: string; value: string | undefined; expected: string[] }> = [
5959
+ { name: "takeover", value: encodeChildPackageInjection({ noExtensions: true, extensionPaths: takeover }), expected: ["--no-extensions", ...takeover.flatMap((path) => ["--extension", path])] },
5960
+ { name: "package-root", value: encodeChildPackageInjection({ noExtensions: false, extensionPaths: ["/pkg"] }), expected: ["--extension", "/pkg"] },
5961
+ { name: "absent", value: undefined, expected: curated.flatMap((path) => ["--extension", path]) },
5962
+ { name: "malformed", value: JSON.stringify({ version: 1, noExtensions: true, extensionPaths: ["relative/pkg"] }), expected: curated.flatMap((path) => ["--extension", path]) },
5963
+ ];
5964
+ for (const scenario of scenarios) {
5965
+ const h = fakePi();
5966
+ const runtime = deps();
5967
+ runtime.deps.childExtensionPaths = curated;
5968
+ runtime.deps.env = { PATH: "/bin", ...(scenario.value === undefined ? {} : { [CHILD_PACKAGE_INJECTION_ENV]: scenario.value }) };
5969
+ gentleAgents(h.pi, {}, runtime.deps);
5970
+ const { ctx } = fakeContext();
5971
+ await h.fire("session_start", ctx);
5972
+ try {
5973
+ await h.tools.get("subagent_run")!.execute(`injection-${scenario.name}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
5974
+ await tick();
5975
+ assert.equal(runtime.spawned.length, 1, scenario.name);
5976
+ const argv = runtime.spawned[0]!;
5977
+ const start = argv.indexOf("--session-dir") + 2;
5978
+ assert.deepEqual(argv.slice(start, start + scenario.expected.length), scenario.expected, scenario.name);
5979
+ assert.equal(argv[start + scenario.expected.length], "--model", `${scenario.name}: no other extension arguments`);
5980
+ assert.equal(argv[argv.indexOf("--tools") + 1], "read,grep,subagent_parent_message", scenario.name);
4678
5981
  } finally {
4679
5982
  await h.fire("session_shutdown", ctx);
4680
5983
  await tick();
4681
5984
  }
4682
5985
  }
4683
5986
  });
5987
+
5988
+ // gentle-shell#1731 T4 (S2, AC6): subagent_run and subagent_continue admit a
5989
+ // writer only while no live writer in the same worktree claims an overlapping
5990
+ // `## Allowed edit surfaces` entry; read-only agents are never registered.
5991
+ test("parallel writers are admitted only with disjoint Allowed edit surfaces end to end", async () => {
5992
+ const h = fakePi();
5993
+ const runtime = deps();
5994
+ const profile = mkdtempSync(join(tmpdir(), "gentle-agents-parallel-writers-"));
5995
+ mkdirSync(join(profile, "agents"), { recursive: true });
5996
+ writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
5997
+ writeFileSync(join(profile, "agents", "explore.md"), "---\ndescription: maps things\ntools: [read, grep]\n---\nYou map things.");
5998
+ writeFileSync(join(profile, "subagents.json"), JSON.stringify({ max_concurrency: 5, model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
5999
+ const env: NodeJS.ProcessEnv = {};
6000
+ gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile });
6001
+ const { ctx } = fakeContext();
6002
+ await h.fire("session_start", ctx);
6003
+ const scoped = (surface: string) => `Write it.\n\n## Allowed edit surfaces\n${surface}\n\n## Return\nReport`;
6004
+ const run = (agent: string, task: string) => h.tools.get("subagent_run")!.execute("run", { agent, task, mode: "background" }, undefined, undefined, ctx);
6005
+ const taskId = (result: { details: Record<string, unknown> }) => (result.details.gentleAgents as { taskId: string }).taskId;
6006
+ const finish = async (child: FakeChild, id: string) => {
6007
+ child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
6008
+ child.emit({ type: "agent_settled" });
6009
+ for (let attempt = 0; attempt < 40; attempt++) {
6010
+ const status = await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
6011
+ if ((status.details.gentleAgents as { status?: string } | undefined)?.status === TASK_STATUS.COMPLETED) return;
6012
+ await tick();
6013
+ }
6014
+ assert.fail(`task ${id} never completed`);
6015
+ };
6016
+ try {
6017
+ const app = taskId(await run("gentle-ai-worker", scoped("src/app.ts")));
6018
+ const other = taskId(await run("gentle-ai-worker", scoped("`src/other.ts`")));
6019
+ await tick();
6020
+ assert.equal(runtime.children.length, 2, "disjoint writers run concurrently");
6021
+ await assert.rejects(run("gentle-ai-worker", scoped("src/*.ts")), (error: Error) => {
6022
+ assert.match(error.message, new RegExp(`task ${app}`));
6023
+ assert.match(error.message, new RegExp(`task ${other}`));
6024
+ assert.match(error.message, /`src\/\*\.ts` overlaps `src\/app\.ts`/);
6025
+ return true;
6026
+ });
6027
+ await run("explore", "Map src/app.ts and src/other.ts");
6028
+ await tick();
6029
+ assert.equal(runtime.children.length, 3, "read-only agents are never blocked by live writers");
6030
+ await finish(runtime.children[0], app);
6031
+ // The continuation inherits src/app.ts and is admitted again only while no
6032
+ // live writer claims an overlapping entry.
6033
+ await assert.rejects(run("gentle-ai-worker", scoped("src/**")), new RegExp(`task ${other}`), "src/** still overlaps the live src/other.ts writer");
6034
+ const blocking = taskId(await run("gentle-ai-worker", scoped("src/app.ts")));
6035
+ await assert.rejects(h.tools.get("subagent_continue")!.execute("follow", { task_id: app, prompt: "Continue.", mode: "background" }, undefined, undefined, ctx), new RegExp(`task ${blocking}`));
6036
+ await finish(runtime.children[3], blocking);
6037
+ await h.tools.get("subagent_continue")!.execute("follow", { task_id: app, prompt: "Continue.", mode: "background" }, undefined, undefined, ctx);
6038
+ await tick();
6039
+ assert.equal(runtime.children.length, 5, "the continuation is admitted once its surfaces are free");
6040
+ } finally {
6041
+ await h.fire("session_shutdown", ctx);
6042
+ rmSync(profile, TEST_DIR_REMOVAL);
6043
+ }
6044
+ });
6045
+
6046
+ // Second verify A1: writers claim surfaces under the canonical worktree root,
6047
+ // so a writer spawned in the session's subdirectory cwd and one sent to the
6048
+ // worktree root through workspace_root are compared.
6049
+ test("a subdirectory session cwd and workspace_root of the same worktree share one writer key", async () => {
6050
+ const h = fakePi();
6051
+ const runtime = deps();
6052
+ const profile = mkdtempSync(join(tmpdir(), "gentle-agents-writer-root-"));
6053
+ mkdirSync(join(profile, "agents"), { recursive: true });
6054
+ writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
6055
+ writeFileSync(join(profile, "subagents.json"), JSON.stringify({ max_concurrency: 5, model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
6056
+ const sub = join(cwd, "sub");
6057
+ mkdirSync(sub, { recursive: true });
6058
+ // Git semantics: any path inside the project resolves to the project root.
6059
+ const resolveWorktree = (path: string, base: string) => {
6060
+ const target = resolve(base, path);
6061
+ return { root: target === cwd || target.startsWith(`${cwd}${sep}`) ? cwd : target, commonDir: "/fixture/common" };
6062
+ };
6063
+ const env: NodeJS.ProcessEnv = {};
6064
+ gentleAgents(h.pi, env, { ...runtime.deps, resolveWorktree, env, agentHome: profile });
6065
+ const { ctx } = fakeContext();
6066
+ Object.assign(ctx, { cwd: sub, sessionManager: { getSessionId: () => "s1", getCwd: () => sub, getEntries: () => [], getBranch: () => [] } });
6067
+ await h.fire("session_start", ctx);
6068
+ const task = "Write it.\n\n## Allowed edit surfaces\nsrc/app.ts\n\n## Return\nReport";
6069
+ try {
6070
+ const first = await h.tools.get("subagent_run")!.execute("run", { agent: "gentle-ai-worker", task, mode: "background" }, undefined, undefined, ctx);
6071
+ const firstId = (first.details.gentleAgents as { taskId: string }).taskId;
6072
+ await assert.rejects(h.tools.get("subagent_run")!.execute("run", { agent: "gentle-ai-worker", task, workspace_root: cwd, mode: "background" }, undefined, undefined, ctx), new RegExp(`task ${firstId}`));
6073
+ } finally {
6074
+ await h.fire("session_shutdown", ctx);
6075
+ rmSync(profile, TEST_DIR_REMOVAL);
6076
+ }
6077
+ });
6078
+
6079
+ // Background jobs share the subagent parent delivery router: the exit notice
6080
+ // is stored and woken like an idle completion, or steered into a running turn.
6081
+ function fakeJobShell() {
6082
+ const runs: Array<{ command: string; onData(data: Buffer): void; signal?: AbortSignal; exit(code: number): void }> = [];
6083
+ const shell = () => ({
6084
+ operations: {
6085
+ exec: (command: string, _cwd: string, options: { onData(data: Buffer): void; signal?: AbortSignal }) => new Promise<{ exitCode: number | null }>((done, fail) => {
6086
+ options.signal?.addEventListener("abort", () => fail(new Error("aborted")), { once: true });
6087
+ runs.push({ command, onData: options.onData, signal: options.signal, exit: (code) => done({ exitCode: code }) });
6088
+ }),
6089
+ },
6090
+ });
6091
+ const dir = mkdtempSync(join(tmpdir(), "gentle-jobs-agents-"));
6092
+ return { runs, shell, dir, cleanup: () => rmSync(dir, TEST_DIR_REMOVAL) };
6093
+ }
6094
+ // A job settles only after its log file closes, which takes real I/O turns.
6095
+ const jobIo = () => new Promise((done) => setTimeout(done, 50));
6096
+ const toolText = (result: { content: Array<{ text?: string }> }) => result.content.map((part) => part.text ?? "").join("\n");
6097
+
6098
+ test("standalone bash sleep is blocked only while background work is active (#1903)", async () => {
6099
+ const { pi, tools, fire, sent } = fakePi();
6100
+ const jobs = fakeJobShell();
6101
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6102
+ const { ctx } = fakeContext();
6103
+ const call = async (toolName: string, command: string) =>
6104
+ (await fire("tool_call", ctx, { toolCallId: "t", toolName, input: { command } })).find((result) => result !== undefined) as { block?: boolean; reason?: string } | undefined;
6105
+ try {
6106
+ await fire("session_start", ctx);
6107
+ // Without background work, sleep is ordinary shell use.
6108
+ assert.equal(await call("bash", "sleep 10"), undefined);
6109
+
6110
+ await tools.get("bash_background")!.execute("b1", { command: "gh run watch 42 --exit-status", label: "CI" }, undefined, undefined, ctx);
6111
+ for (const command of ["sleep 10", " sleep 2m ", "sleep 0.5;", "sleep 5 && sleep 5"]) {
6112
+ const blocked = await call("bash", command);
6113
+ assert.equal(blocked?.block, true, command);
6114
+ assert.match(blocked!.reason!, /background work is active/);
6115
+ assert.match(blocked!.reason!, /End the turn/);
6116
+ }
6117
+ // Real wait conditions, mixed commands and the sanctioned background tools stay allowed.
6118
+ for (const command of ["until curl -s x; do sleep 1; done", "sleep 5 && gh run view 42", "for i in 1 2; do sleep 1; echo $i; done", "sleep $N"]) {
6119
+ assert.equal(await call("bash", command), undefined, command);
6120
+ }
6121
+ assert.equal(await call("bash_background", "sleep 100"), undefined);
6122
+ assert.equal(await call("monitor", "sleep 100"), undefined);
6123
+
6124
+ jobs.runs[0]!.exit(0);
6125
+ await eventually(() => sent.some((entry) => entry.message.customType === "gentle-jobs.notice"), "the job must settle before sleep is allowed again");
6126
+ assert.equal(await call("bash", "sleep 10"), undefined, "sleep is allowed again once background work settles");
6127
+ } finally {
6128
+ await fire("session_shutdown", ctx);
6129
+ jobs.cleanup();
6130
+ }
6131
+ });
6132
+
6133
+ test("bash_background returns at once, and an idle parent gets one stored exit notice and one wake", async () => {
6134
+ const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi();
6135
+ const jobs = fakeJobShell();
6136
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6137
+ const { ctx } = fakeContext();
6138
+ try {
6139
+ await fire("session_start", ctx);
6140
+ const started = toolText(await tools.get("bash_background")!.execute("b1", { command: "gh run watch 42 --exit-status", label: "CI" }, undefined, undefined, ctx));
6141
+ assert.match(started, /job-1/);
6142
+ assert.ok(started.includes(join(jobs.dir, "job-1.log")), "the output file path is returned");
6143
+ assert.match(started, /notified once/i);
6144
+ assert.equal(jobs.runs[0]!.command, "gh run watch 42 --exit-status");
6145
+ assert.equal(sent.length, 0, "nothing reaches the parent while the job runs");
6146
+ jobs.runs[0]!.onData(Buffer.from("check build: fail\n"));
6147
+ jobs.runs[0]!.exit(1);
6148
+ await eventually(
6149
+ () => sent.some((entry) => entry.message.customType === "gentle-jobs.notice") && userMessages.length > 0,
6150
+ "the closed job log must deliver its exit notice and wake before assertions",
6151
+ );
6152
+ const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
6153
+ assert.equal(notices.length, 1, "the exit is reported exactly once");
6154
+ assert.deepEqual(notices[0]!.options, { triggerTurn: false }, "an idle parent stores the notice without a direct turn");
6155
+ const content = String(notices[0]!.message.content);
6156
+ for (const expected of [/job-1/, /"CI"/, /exited with code 1/, /gh run watch 42 --exit-status/, /check build: fail/, /job-1\.log/]) assert.match(content, expected);
6157
+ assert.equal(userMessages.length, 1, "the idle parent is woken exactly once");
6158
+ assert.deepEqual(delivery, ["custom:gentle-jobs.notice", "user"], "the notice is stored before the wake");
6159
+ assert.match(renderers.get("gentle-jobs.notice")!(notices[0]!.message, { expanded: true }, plainTheme).render(70).map(stripAnsi).join("\n"), /exited with code 1/);
6160
+ await fire("turn_end", ctx);
6161
+ await fire("agent_settled", ctx);
6162
+ assert.equal(sent.length, 1, "later boundaries never replay the notice");
6163
+ assert.equal(userMessages.length, 1, "later boundaries never repeat the wake");
6164
+ } finally {
6165
+ await fire("session_shutdown", ctx);
6166
+ jobs.cleanup();
6167
+ }
6168
+ });
6169
+
6170
+ test("a busy parent gets the exit notice steered into its run at the next turn boundary", async () => {
6171
+ const { pi, tools, fire, sent, userMessages } = fakePi();
6172
+ const jobs = fakeJobShell();
6173
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6174
+ const { ctx } = fakeContext();
6175
+ try {
6176
+ await fire("session_start", ctx);
6177
+ await fire("agent_start", ctx);
6178
+ await tools.get("bash_background")!.execute("b1", { command: "make build" }, undefined, undefined, ctx);
6179
+ jobs.runs[0]!.exit(0);
6180
+ await jobIo();
6181
+ assert.equal(sent.length, 0, "a busy parent is not interrupted mid-tool-call");
6182
+ await fire("turn_end", ctx);
6183
+ assert.equal(sent.length, 1);
6184
+ assert.deepEqual(sent[0]!.options, { deliverAs: "steer", triggerTurn: true });
6185
+ assert.match(String(sent[0]!.message.content), /job-1 \("make build"\) exited with code 0/);
6186
+ assert.equal(userMessages.length, 0, "a running parent needs no wake");
6187
+ } finally {
6188
+ await fire("session_shutdown", ctx);
6189
+ jobs.cleanup();
6190
+ }
6191
+ });
6192
+
6193
+ test("job_list and job_stop cover this session's jobs; stopped jobs send no notice and shutdown stops the rest", async () => {
6194
+ const { pi, tools, fire, sent } = fakePi();
6195
+ const jobs = fakeJobShell();
6196
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6197
+ const { ctx } = fakeContext();
6198
+ try {
6199
+ await fire("session_start", ctx);
6200
+ assert.match(toolText(await tools.get("job_list")!.execute("l0", {}, undefined, undefined, ctx)), /No background jobs/);
6201
+ await tools.get("bash_background")!.execute("b1", { command: "sleep 100", label: "long" }, undefined, undefined, ctx);
6202
+ await tools.get("bash_background")!.execute("b2", { command: "npm run dev" }, undefined, undefined, ctx);
6203
+ const listed = toolText(await tools.get("job_list")!.execute("l1", {}, undefined, undefined, ctx));
6204
+ assert.match(listed, /job-1 · running · long/);
6205
+ assert.match(listed, /job-2 · running · npm run dev/);
6206
+ assert.match(toolText(await tools.get("job_stop")!.execute("s1", { job_id: "job-1" }, undefined, undefined, ctx)), /Stopped job-1/);
6207
+ assert.equal(jobs.runs[0]!.signal?.aborted, true, "the job's process tree is killed");
6208
+ await assert.rejects(async () => tools.get("job_stop")!.execute("s2", { job_id: "job-9" }, undefined, undefined, ctx), /No background job job-9 in this session/);
6209
+ await jobIo();
6210
+ assert.equal(sent.length, 0, "a stopped job sends no exit notice");
6211
+ assert.match(toolText(await tools.get("job_list")!.execute("l2", {}, undefined, undefined, ctx)), /job-1 · stopped · long/);
6212
+ await fire("session_shutdown", ctx);
6213
+ assert.equal(jobs.runs[1]!.signal?.aborted, true, "session shutdown stops every running job");
6214
+ await jobIo();
6215
+ assert.equal(sent.length, 0);
6216
+ } finally {
6217
+ jobs.cleanup();
6218
+ }
6219
+ });
6220
+
6221
+ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, and the footer counts running jobs", async () => {
6222
+ const { pi, tools, commands, fire, sent } = fakePi();
6223
+ const jobs = fakeJobShell();
6224
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6225
+ const { ctx, overlays } = fakeContext();
6226
+ const statuses: Array<string | undefined> = [];
6227
+ const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number }> } }> = [];
6228
+ const unsubscribe = pi.events.on("gentle-ai:jobs-sidebar", (value) => { snapshots.push(value as typeof snapshots[number]); });
6229
+ Object.assign(ctx.ui, { setStatus: (key: string, value: string | undefined) => { if (key === "gentle-jobs") statuses.push(value); } });
6230
+ try {
6231
+ await fire("session_start", ctx);
6232
+ await tools.get("bash_background")!.execute("b1", { command: "gh run watch 42 --exit-status", label: "CI" }, undefined, undefined, ctx);
6233
+ assert.equal(statuses.at(-1), "⧗ 1 job");
6234
+ assert.equal(snapshots.at(-1)?.sessionId, ctx.sessionManager.getSessionId());
6235
+ assert.deepEqual(snapshots.at(-1)?.snapshot.jobs.map(({ id, label }) => ({ id, label })), [{ id: "job-1", label: "CI" }]);
6236
+ const opened = commands.get("gentle:jobs")!.handler("", ctx);
6237
+ await tick();
6238
+ const view = overlays.at(-1)!;
6239
+ const screen = view.render(100).map(stripAnsi).join("\n");
6240
+ assert.match(screen, /Jobs · 1 running · 0 finished/);
6241
+ assert.match(screen, /gh run watch 42 --exit-status/);
6242
+ view.handleInput("s");
6243
+ assert.equal(jobs.runs[0]!.signal?.aborted, true, "s stops the selected job");
6244
+ assert.equal(statuses.at(-1), undefined, "the footer count clears when nothing runs");
6245
+ assert.deepEqual(snapshots.at(-1)?.snapshot.jobs, [], "the Status descriptions clear when nothing runs");
6246
+ await jobIo();
6247
+ const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
6248
+ assert.equal(notices.length, 1, "the agent was told it would be notified, so a human stop is reported once");
6249
+ assert.match(String(notices[0]!.message.content), /job-1 \("CI"\) was stopped by the user/);
6250
+ view.handleInput("q");
6251
+ await opened;
6252
+ } finally {
6253
+ unsubscribe();
6254
+ await fire("session_shutdown", ctx);
6255
+ jobs.cleanup();
6256
+ }
6257
+ });
6258
+
6259
+ test("a job exit notice whose forwarding throws while the parent is idle is retried after the bounded grace", async () => {
6260
+ const { pi, tools, fire, sent, userMessages } = fakePi();
6261
+ const harness = deps();
6262
+ const timers = recordTimers(harness.deps);
6263
+ const jobs = fakeJobShell();
6264
+ gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6265
+ const { ctx } = fakeContext();
6266
+ try {
6267
+ await fire("session_start", ctx);
6268
+ await tools.get("bash_background")!.execute("b1", { command: "make ci" }, undefined, undefined, ctx);
6269
+ const sendMessage = pi.sendMessage;
6270
+ Object.assign(pi, { sendMessage: () => { throw new Error("host busy"); } });
6271
+ jobs.runs[0]!.exit(0);
6272
+ await jobIo();
6273
+ assert.equal(sent.length, 0);
6274
+ assert.equal(userMessages.length, 0);
6275
+ Object.assign(pi, { sendMessage });
6276
+ // No turn boundary is coming for an idle parent, so the retry is a timer.
6277
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1, "one bounded retry flush is armed");
6278
+ await tick();
6279
+ const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
6280
+ assert.equal(notices.length, 1, "the requeued notice reaches the idle parent");
6281
+ assert.match(String(notices[0]!.message.content), /job-1 \("make ci"\) exited with code 0/);
6282
+ assert.equal(userMessages.length, 1, "and the idle parent is woken for it");
6283
+ assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
6284
+ await tick();
6285
+ assert.equal(sent.length, 1, "a successful retry never delivers the notice twice");
6286
+ } finally {
6287
+ await fire("session_shutdown", ctx);
6288
+ jobs.cleanup();
6289
+ }
6290
+ });
6291
+
6292
+ for (const boundary of ["session_compact", "session_compact_failed"] as const) {
6293
+ test(`a parent compacting outside an agent run holds a job exit notice until ${boundary}, then wakes it`, async () => {
6294
+ const { pi, tools, fire, sent, userMessages, delivery, setIdle } = fakePi();
6295
+ const harness = deps();
6296
+ const timers = recordTimers(harness.deps);
6297
+ const jobs = fakeJobShell();
6298
+ gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6299
+ const { ctx } = fakeContext();
6300
+ try {
6301
+ await fire("session_start", ctx);
6302
+ await tools.get("bash_background")!.execute("b1", { command: "make ci" }, undefined, undefined, ctx);
6303
+ setIdle(false);
6304
+ jobs.runs[0]!.exit(1);
6305
+ await jobIo();
6306
+ assert.equal(sent.length, 0, "no direct custom-message turn starts while the parent compacts");
6307
+ assert.equal(userMessages.length, 0, "no wake is sent while the parent compacts");
6308
+ await fire(boundary, ctx);
6309
+ assert.equal(sent.length, 0, "the boundary handler runs before Pi leaves the compacting state");
6310
+ setIdle(true);
6311
+ assert.equal(timers.run(0), 1, "one deferred flush is scheduled for the compaction boundary");
6312
+ await tick();
6313
+ assert.deepEqual(sent.map((entry) => entry.options), [{ triggerTurn: false }], "the held notice is stored without a direct turn");
6314
+ assert.deepEqual(delivery, ["custom:gentle-jobs.notice", "user"], "one wake follows the held notice");
6315
+ } finally {
6316
+ await fire("session_shutdown", ctx);
6317
+ jobs.cleanup();
6318
+ }
6319
+ });
6320
+ }
6321
+
6322
+ test("session shutdown removes the default background job log directory", async () => {
6323
+ const { pi, tools, fire } = fakePi();
6324
+ const jobs = fakeJobShell();
6325
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell });
6326
+ const { ctx } = fakeContext();
6327
+ try {
6328
+ await fire("session_start", ctx);
6329
+ const started = toolText(await tools.get("bash_background")!.execute("b1", { command: "sleep 100" }, undefined, undefined, ctx));
6330
+ const outputPath = /Output: (.+)$/m.exec(started)![1]!;
6331
+ assert.ok(existsSync(dirname(outputPath)), "the job log directory exists while the session runs");
6332
+ await fire("session_shutdown", ctx);
6333
+ assert.equal(existsSync(dirname(outputPath)), false, "the job log directory is removed with the session");
6334
+ } finally {
6335
+ jobs.cleanup();
6336
+ }
6337
+ });
6338
+
6339
+ test("session shutdown never hangs on a job whose process ignores the stop", { timeout: 5000 }, async () => {
6340
+ const { pi, tools, fire } = fakePi();
6341
+ const dir = mkdtempSync(join(tmpdir(), "gentle-jobs-stuck-"));
6342
+ // A process that never exits, even after its abort signal.
6343
+ const stuck = () => ({ operations: { exec: () => new Promise<{ exitCode: number | null }>(() => {}) } });
6344
+ const schedule: AgentsDeps["schedule"] = (fn, ms) => { const timer = setTimeout(fn, Math.min(ms, 10)); timer.unref(); return () => clearTimeout(timer); };
6345
+ gentleAgents(pi, {}, { ...deps().deps, schedule, jobShell: stuck });
6346
+ const { ctx } = fakeContext();
6347
+ try {
6348
+ await fire("session_start", ctx);
6349
+ await tools.get("bash_background")!.execute("b1", { command: "sleep 100" }, undefined, undefined, ctx);
6350
+ await fire("session_shutdown", ctx);
6351
+ } finally {
6352
+ rmSync(dir, TEST_DIR_REMOVAL);
6353
+ }
6354
+ });
6355
+
6356
+ // A monitor reports output lines while it runs, through the same router.
6357
+ test("monitor delivers line events to an idle parent with one wake, and reports its end with the event count", async () => {
6358
+ const { pi, tools, fire, sent, userMessages, renderers } = fakePi();
6359
+ const harness = deps();
6360
+ const timers = recordTimers(harness.deps);
6361
+ const jobs = fakeJobShell();
6362
+ gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6363
+ const { ctx } = fakeContext();
6364
+ try {
6365
+ await fire("session_start", ctx);
6366
+ const started = toolText(await tools.get("monitor")!.execute("m1", { command: "gh run watch 7", label: "CI checks", timeout_seconds: 900 }, undefined, undefined, ctx));
6367
+ assert.match(started, /Started monitor job-1 \("CI checks"\)/);
6368
+ assert.match(started, /900s/);
6369
+ jobs.runs[0]!.onData(Buffer.from("lint: pass\nunit: fail\n"));
6370
+ assert.equal(sent.length, 0, "lines wait for the batch window");
6371
+ assert.equal(timers.run(200), 1);
6372
+ await tick();
6373
+ const events = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
6374
+ assert.equal(events.length, 1);
6375
+ assert.deepEqual(events[0]!.options, { triggerTurn: false });
6376
+ assert.match(String(events[0]!.message.content), /Monitor job-1 \("CI checks"\) reported 2 new lines:\nlint: pass\nunit: fail/);
6377
+ assert.equal(userMessages.length, 1, "the idle parent is woken for the events");
6378
+ assert.match(renderers.get("gentle-jobs.notice")!(events[0]!.message, { expanded: true }, plainTheme).render(70).map(stripAnsi).join("\n"), /unit: fail/);
6379
+ jobs.runs[0]!.onData(Buffer.from("e2e: pass\n"));
6380
+ jobs.runs[0]!.exit(1);
6381
+ await jobIo();
6382
+ const contents = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice").map((entry) => String(entry.message.content));
6383
+ assert.equal(contents.length, 3, "pending lines are delivered before the end notice");
6384
+ assert.match(contents[1]!, /reported 1 new line:\ne2e: pass/);
6385
+ assert.match(contents[2]!, /Monitor job-1 \("CI checks"\) exited with code 1 after .* 3 events\./);
6386
+ } finally {
6387
+ await fire("session_shutdown", ctx);
6388
+ jobs.cleanup();
6389
+ }
6390
+ });
6391
+
6392
+ test("monitor events wait for a busy parent's turn boundary and coalesce into one notice", async () => {
6393
+ const { pi, tools, fire, sent } = fakePi();
6394
+ const harness = deps();
6395
+ const timers = recordTimers(harness.deps);
6396
+ const jobs = fakeJobShell();
6397
+ gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6398
+ const { ctx } = fakeContext();
6399
+ try {
6400
+ await fire("session_start", ctx);
6401
+ await fire("agent_start", ctx);
6402
+ await tools.get("monitor")!.execute("m1", { command: "tail -f log", timeout_seconds: 60 }, undefined, undefined, ctx);
6403
+ jobs.runs[0]!.onData(Buffer.from("ERROR a\n"));
6404
+ timers.run(200);
6405
+ jobs.runs[0]!.onData(Buffer.from("ERROR b\n"));
6406
+ timers.run(200);
6407
+ assert.equal(sent.length, 0, "a busy parent is not interrupted mid-tool-call");
6408
+ await fire("turn_end", ctx);
6409
+ assert.equal(sent.length, 1, "both batches arrive as one notice");
6410
+ assert.deepEqual(sent[0]!.options, { deliverAs: "steer", triggerTurn: true });
6411
+ assert.match(String(sent[0]!.message.content), /reported 2 new lines:\nERROR a\nERROR b/);
6412
+ // job_stop ends the monitor without any further notice.
6413
+ assert.match(toolText(await tools.get("job_stop")!.execute("s1", { job_id: "job-1" }, undefined, undefined, ctx)), /Stopped job-1/);
6414
+ jobs.runs[0]!.onData(Buffer.from("ERROR c\n"));
6415
+ timers.run(200);
6416
+ timers.run(60_000);
6417
+ await jobIo();
6418
+ await fire("turn_end", ctx);
6419
+ assert.equal(sent.length, 1, "a stopped monitor reports nothing more");
6420
+ } finally {
6421
+ await fire("session_shutdown", ctx);
6422
+ jobs.cleanup();
6423
+ }
6424
+ });
6425
+
6426
+ test("monitor rejects a missing or out-of-range timeout before starting anything", async () => {
6427
+ const { pi, tools, fire } = fakePi();
6428
+ const jobs = fakeJobShell();
6429
+ gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6430
+ const { ctx } = fakeContext();
6431
+ try {
6432
+ await fire("session_start", ctx);
6433
+ for (const params of [{ command: "x" }, { command: "x", timeout_seconds: 0 }, { command: "x", timeout_seconds: 3600 }]) {
6434
+ await assert.rejects(async () => tools.get("monitor")!.execute("m", params, undefined, undefined, ctx), /timeout_seconds must be a whole number from 1 to 1800/);
6435
+ }
6436
+ assert.equal(jobs.runs.length, 0);
6437
+ } finally {
6438
+ await fire("session_shutdown", ctx);
6439
+ jobs.cleanup();
6440
+ }
6441
+ });
6442
+
6443
+ test("events from two interleaved monitors coalesce per monitor while the parent is busy", async () => {
6444
+ const { pi, tools, fire, sent } = fakePi();
6445
+ const harness = deps();
6446
+ const timers = recordTimers(harness.deps);
6447
+ const jobs = fakeJobShell();
6448
+ gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6449
+ const { ctx } = fakeContext();
6450
+ try {
6451
+ await fire("session_start", ctx);
6452
+ await fire("agent_start", ctx);
6453
+ await tools.get("monitor")!.execute("m1", { command: "watch a", label: "A", timeout_seconds: 60 }, undefined, undefined, ctx);
6454
+ await tools.get("monitor")!.execute("m2", { command: "watch b", label: "B", timeout_seconds: 60 }, undefined, undefined, ctx);
6455
+ for (const round of [1, 2, 3]) {
6456
+ jobs.runs[0]!.onData(Buffer.from(`a${round}\n`));
6457
+ jobs.runs[1]!.onData(Buffer.from(`b${round}\n`));
6458
+ timers.run(200);
6459
+ }
6460
+ await fire("turn_end", ctx);
6461
+ const contents = sent.map((entry) => String(entry.message.content));
6462
+ assert.equal(contents.length, 2, "one notice per monitor, not one per batch");
6463
+ assert.match(contents[0]!, /\("A"\) reported 3 new lines:\na1\na2\na3/);
6464
+ assert.match(contents[1]!, /\("B"\) reported 3 new lines:\nb1\nb2\nb3/);
6465
+ } finally {
6466
+ await fire("session_shutdown", ctx);
6467
+ jobs.cleanup();
6468
+ }
6469
+ });
6470
+
6471
+ test("a human stop of a monitor from /gentle:jobs delivers its pending lines, then one stop notice, then nothing", async () => {
6472
+ const { pi, tools, commands, fire, sent } = fakePi();
6473
+ const harness = deps();
6474
+ const timers = recordTimers(harness.deps);
6475
+ const jobs = fakeJobShell();
6476
+ gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
6477
+ const { ctx, overlays } = fakeContext();
6478
+ try {
6479
+ await fire("session_start", ctx);
6480
+ await fire("agent_start", ctx);
6481
+ await tools.get("monitor")!.execute("m1", { command: "tail -f log", label: "log", timeout_seconds: 60 }, undefined, undefined, ctx);
6482
+ jobs.runs[0]!.onData(Buffer.from("ERROR seen\n"));
6483
+ const opened = commands.get("gentle:jobs")!.handler("", ctx);
6484
+ await tick();
6485
+ overlays.at(-1)!.handleInput("s");
6486
+ overlays.at(-1)!.handleInput("q");
6487
+ await opened;
6488
+ assert.equal(jobs.runs[0]!.signal?.aborted, true);
6489
+ jobs.runs[0]!.onData(Buffer.from("ERROR after stop\n"));
6490
+ timers.run(200);
6491
+ timers.run(60_000);
6492
+ await jobIo();
6493
+ await fire("turn_end", ctx);
6494
+ const contents = sent.map((entry) => String(entry.message.content));
6495
+ assert.equal(contents.length, 2, `notices: ${JSON.stringify(contents)}`);
6496
+ assert.match(contents[0]!, /reported 1 new line:\nERROR seen/);
6497
+ assert.match(contents[1]!, /Monitor job-1 \("log"\) was stopped by the user after .*, 1 event\./);
6498
+ } finally {
6499
+ await fire("session_shutdown", ctx);
6500
+ jobs.cleanup();
6501
+ }
6502
+ });