gentle-pi 3.7.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import test from "node:test";
8
8
  import { gzipSync } from "node:zlib";
9
9
  import { initTheme } from "@earendil-works/pi-coding-agent";
10
+ import { validateToolArguments } from "@earendil-works/pi-ai";
10
11
  import type {
11
12
  ExtensionAPI,
12
13
  ExtensionContext,
@@ -14,19 +15,46 @@ import type {
14
15
  ToolCallEventResult,
15
16
  } from "@earendil-works/pi-coding-agent";
16
17
  import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
17
- import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
18
- import type { AgentRoutingEntry } from "../lib/model-routing-authority.ts";
19
- type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
18
+ import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts";
19
+ import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
20
+ import type { AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts";
21
+ type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel" | "getThinkingLevel">;
20
22
  import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
21
23
  import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
22
24
  import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
23
- import { installPackageAssets } from "../lib/sdd-preflight.ts";
25
+ import { installPackageAssets } from "../lib/agent-assets.ts";
24
26
  import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
25
27
  import { stripAnsi } from "../lib/terminal-theme.ts";
26
28
  import { cardBody, cardTitle, cardTone } from "./gentle-card-text.ts";
29
+ import { readDelegationDetail } from "./support/orchestrator-modules.ts";
27
30
 
28
31
  initTheme("dark");
29
32
 
33
+ // Observe the actual UI boundary, not a guessed number of identity-read microtasks.
34
+ function confirmationObserver() {
35
+ let count = 0;
36
+ const listeners = new Set<() => void>();
37
+ return {
38
+ observe() { count++; for (const listener of listeners) listener(); },
39
+ waitFor(expected: number): Promise<void> {
40
+ if (count >= expected) return Promise.resolve();
41
+ return new Promise((resolve, reject) => {
42
+ const deadline = setTimeout(() => {
43
+ listeners.delete(check);
44
+ reject(new Error(`Confirmation deadline: expected ${expected}, observed ${count}`));
45
+ }, 1000);
46
+ const check = () => {
47
+ if (count < expected) return;
48
+ clearTimeout(deadline);
49
+ listeners.delete(check);
50
+ resolve();
51
+ };
52
+ listeners.add(check);
53
+ });
54
+ },
55
+ };
56
+ }
57
+
30
58
  function writeMarkdown(path: string, content: string): void {
31
59
  mkdirSync(dirname(path), { recursive: true });
32
60
  writeFileSync(path, content);
@@ -211,10 +239,10 @@ test("missing package-local binaries give a direct recovery without attributing
211
239
  test("registered Gentle Review tools render reusable rose lifecycle call rows", () => {
212
240
  const tools = registeredGentleTools();
213
241
  const cases = [
214
- ["gentle_review", { operation: "status" }, "review status"],
215
- ["gentle_review", { operation: "future-operation", secret: "/private" }, "review"],
216
- ["gentle_review_scope", {}, "review scope"],
217
- ["gentle_review_capture_group", {}, "review capture group"],
242
+ ["gentle_review", { operation: "status" }, "status"],
243
+ ["gentle_review", { operation: "future-operation", secret: "/private" }, ""],
244
+ ["gentle_review_scope", {}, "scope"],
245
+ ["gentle_review_capture_group", {}, "capture group"],
218
246
  [
219
247
  "gentle_review_capture",
220
248
  {
@@ -224,17 +252,22 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
224
252
  secret: "secret-value",
225
253
  arbitrary: "arbitrary-value",
226
254
  },
227
- "review capture",
255
+ "capture",
228
256
  ],
229
257
  ] as const;
230
258
 
259
+ // This exhaustiveness check is scoped to the gentle_review* lifecycle
260
+ // family this test names, not every gentle_-prefixed tool: gentle_odd_phase
261
+ // is a plain status-report tool with no pending/running/failed rose
262
+ // lifecycle card of its own (see extensions/gentle-ai.ts).
231
263
  assert.deepEqual(
232
264
  [...new Set(cases.map(([name]) => name))].sort(),
233
- [...tools.keys()].filter((name) => name.startsWith("gentle_")).sort(),
265
+ [...tools.keys()].filter((name) => name.startsWith("gentle_review")).sort(),
234
266
  );
235
267
 
236
- for (const [name, args, operationPath] of cases) {
268
+ for (const [name, args, operation] of cases) {
237
269
  const tool = tools.get(name);
270
+ const title = (status: string) => ["🌹 rdd", [status, operation].filter(Boolean).join(" · ")].filter(Boolean).join(" ");
238
271
  assert.ok(tool, `missing ${name}`);
239
272
  const initial = tool.renderCall(args, lifecycleTheme, lifecycleContext());
240
273
  const initialText = renderComponent(initial);
@@ -260,10 +293,14 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
260
293
  assert.strictEqual(initial, running);
261
294
  assert.strictEqual(running, completed);
262
295
  assert.strictEqual(completed, failed);
263
- assert.equal(cardTitle(initialText), `🌹︎ Gentle AI · running · ${operationPath}`); assert.equal(cardTone(initialText), "warning");
264
- assert.equal(cardTitle(runningText), `🌹︎ Gentle AI · running · ${operationPath}`); assert.equal(cardTone(runningText), "warning");
265
- assert.equal(cardTitle(completedText), `🌹︎ Gentle AI · completed · ${operationPath}`); assert.equal(cardTone(completedText), "success");
266
- assert.equal(cardTitle(failedText), `🌹︎ Gentle AI · failed · ${operationPath}`); assert.equal(cardTone(failedText), "error");
296
+ assert.equal(cardTitle(initialText), title("running")); assert.equal(cardTone(initialText), "warning");
297
+ assert.equal(cardTitle(runningText), title("running")); assert.equal(cardTone(runningText), "warning");
298
+ assert.equal(cardTitle(completedText), title("")); assert.equal(cardTone(completedText), "success");
299
+ assert.doesNotMatch(cardTitle(completedText), /completed/);
300
+ assert.match(completedText, /to expand/);
301
+ assert.equal((initialText.match(/╰/g) ?? []).length, 1, "running call owns the closing frame");
302
+ assert.equal((completedText.match(/╰/g) ?? []).length, 0, "final result owns the closing frame");
303
+ assert.equal(cardTitle(failedText), title("failed")); assert.equal(cardTone(failedText), "error");
267
304
  assert.doesNotMatch(renderComponent(failed), /future-operation|secret|private/);
268
305
  for (const forbiddenValue of ["lineage-id", "binding-id", "sha256:hash-value", "secret-value", "arbitrary-value"]) {
269
306
  assert.doesNotMatch(failedText, new RegExp(forbiddenValue));
@@ -271,7 +308,7 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
271
308
  }
272
309
  });
273
310
 
274
- test("registered Gentle Review tools preserve result envelopes and redact collapsed result rendering", async () => {
311
+ test("registered Gentle Review tools preserve result envelopes and preview useful collapsed results", async () => {
275
312
  const tools = registeredGentleTools();
276
313
  const scope = tools.get("gentle_review_scope");
277
314
  const manifest = { version: 1, scopeByMode: { "100644": ["src/file.ts"] }, gitlinks: {} };
@@ -284,7 +321,7 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
284
321
  { manifest: encoded, sha256, cursor: 0 },
285
322
  undefined,
286
323
  undefined,
287
- { cwd: process.cwd() } as ExtensionContext,
324
+ { cwd: process.cwd() } as unknown as ExtensionContext,
288
325
  );
289
326
  const visibleEnvelope = JSON.parse(result.content[0].text);
290
327
  assert.deepEqual(visibleEnvelope, {
@@ -296,7 +333,7 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
296
333
  });
297
334
  assert.deepEqual(result.details, visibleEnvelope);
298
335
 
299
- const resultText = "safe result\x1b[31m\nlineage=secret body=private";
336
+ const resultText = "safe result\x1b[31m\nlineage=secret body=private\nthird useful detail\nfourth expanded detail";
300
337
  for (const name of ["gentle_review", "gentle_review_scope", "gentle_review_capture"]) {
301
338
  const tool = tools.get(name);
302
339
  assert.equal(typeof tool?.renderResult, "function", `${name} must define result rendering`);
@@ -307,14 +344,17 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
307
344
  ]) {
308
345
  const collapsed = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, options, lifecycleTheme, {}));
309
346
  const collapsedBody = cardBody(collapsed);
310
- assert.equal((collapsedBody.match(/\d+ lines?\b/g) ?? []).length, 1, `${name} collapsed output must contain one expand hint`);
311
- assert.match(collapsedBody, /^<dim>\d+ lines?\b<\/dim>/, `${name} collapsed output must start with the hint`);
312
- assert.doesNotMatch(collapsed, /safe result|lineage=secret|private/);
347
+ assert.match(collapsedBody, /safe result[\s\S]*lineage=secret body=private[\s\S]*third useful detail/, `${name} previews actual result content, not redaction`);
348
+ assert.doesNotMatch(collapsedBody, /\d+ lines?\b|fourth expanded detail|to expand|\x1b\[/);
349
+ assert.equal(collapsedBody.split("\n").length, 3, `${name} has three useful collapsed rows`);
350
+ assert.match(collapsed, new RegExp(`<${options.isError ? "error" : options.isPartial ? "warning" : "success"}>│`), "host outcome preserves the semantic frame tone");
351
+ assert.equal((collapsed.match(/╰/g) ?? []).length, options.isPartial ? 0 : 1, "only final results close the frame");
313
352
  }
314
353
  const expanded = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, { expanded: true, isPartial: false, isError: true }, lifecycleTheme, {}));
315
- assert.equal(cardBody(expanded).split("\n")[0], "safe result");
354
+ assert.equal(cardBody(expanded).split("\n")[0], "<error>safe result</error>");
316
355
  assert.match(expanded, /safe result/);
317
- assert.match(expanded, /lineage=secret body=private/);
356
+ assert.match(expanded, /lineage=secret body=private[\s\S]*third useful detail[\s\S]*fourth expanded detail/);
357
+ assert.equal((expanded.match(/╰/g) ?? []).length, 1, "expanded final result closes exactly one frame");
318
358
  assert.doesNotMatch(expanded, /to expand/);
319
359
  assert.doesNotMatch(cardBody(expanded), /\x1b\[/);
320
360
  const nonText = renderComponent(tool.renderResult({ content: [{ type: "image", data: "opaque", mimeType: "image/png" }] }, { expanded: true, isPartial: false }, lifecycleTheme, {}));
@@ -371,11 +411,13 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
371
411
  const liveSwitches: Array<{ kind: "model"; provider: string; id: string } | { kind: "thinking"; level: string }> = [];
372
412
  let setModelResult = true;
373
413
  let thinkingRejects = false;
414
+ let liveThinking = "medium";
374
415
  createGentleAiExtension({ nativeReviewCli: null })({
375
416
  on() {},
376
417
  registerTool() {},
377
418
  registerCommand(name, command) { commands.set(name, command); },
378
419
  setModel: async (model: { provider: string; id: string }) => { liveSwitches.push({ kind: "model", provider: model.provider, id: model.id }); return setModelResult; },
420
+ getThinkingLevel: () => liveThinking,
379
421
  setThinkingLevel: (level: string) => {
380
422
  if (thinkingRejects) throw new Error(`thinking level ${level} is not supported by this model`);
381
423
  liveSwitches.push({ kind: "thinking", level });
@@ -389,21 +431,40 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
389
431
  const panels: string[] = [];
390
432
  let onPanel = () => ({ type: "cancel", config: {} });
391
433
  let onInput: ((panel: RoutingConsumerPanel) => void) | undefined;
434
+ // Scripted answers for ctx.ui.input, consumed in order. The Pi host ignores the
435
+ // placeholder and starts the field empty, so an empty answer is what Enter on an
436
+ // untouched field returns, and undefined is Esc.
437
+ const inputAnswers: Array<string | undefined> = [];
438
+ const inputPrompts: Array<{ title: string; placeholder: string | undefined }> = [];
392
439
  // The orchestrator model is looked up in the registry before switching.
393
440
  const registryModels = [
394
441
  { provider: "openai", id: "alpha" },
395
442
  { provider: "openai", id: "beta" },
396
443
  { provider: "nan", id: "glm5.3" },
397
444
  ];
445
+ let onConfirm: (title: string, message: string) => Promise<boolean> = async () => true;
446
+ const confirmCalls: Array<[string, string]> = [];
398
447
  const ctx = {
399
448
  cwd: root,
400
449
  hasUI: true,
450
+ // A real Pi session always has an id; the profiles panel resolves the
451
+ // session binding through it, so the fixture models one stable session.
452
+ sessionManager: { getSessionId: () => "session-panel" },
401
453
  modelRegistry: {
402
454
  getAvailable: async () => registryModels.filter((model) => model.provider === "openai"),
403
455
  find: (provider: string, id: string) => registryModels.find((model) => model.provider === provider && model.id === id),
404
456
  },
405
457
  ui: {
458
+ confirm: async (title: string, message: string) => {
459
+ confirmCalls.push([title, message]);
460
+ return onConfirm(title, message);
461
+ },
406
462
  notify(message: string, severity: string) { notifications.push({ message, severity }); },
463
+ input: async (title: string, placeholder?: string) => {
464
+ inputPrompts.push({ title, placeholder });
465
+ assert.ok(inputAnswers.length > 0, `unexpected input prompt: ${title}`);
466
+ return inputAnswers.shift();
467
+ },
407
468
  custom: async (factory: (tui: unknown, theme: Theme, keybindings: unknown, done: (result: unknown) => void) => RoutingConsumerPanel) => {
408
469
  let result: unknown;
409
470
  const panel = factory(fixtureTui, { fg: (_color: string, text: string) => text } as unknown as Theme, undefined, (value) => { result = value; });
@@ -423,10 +484,18 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
423
484
  tui: fixtureTui as { terminal: { rows: number } },
424
485
  panelVisits: () => panelVisits,
425
486
  liveSwitches,
487
+ setLiveModel(provider: string, id: string, thinking: string) {
488
+ (ctx as { model?: { provider: string; id: string } }).model = { provider, id };
489
+ liveThinking = thinking;
490
+ },
426
491
  refuseSetModel() { setModelResult = false; },
427
492
  rejectThinkingLevel() { thinkingRejects = true; },
493
+ confirmCalls,
494
+ onConfirm(handler: (title: string, message: string) => Promise<boolean>) { onConfirm = handler; },
428
495
  onPanel(action: typeof onPanel) { onPanel = action; },
429
496
  onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
497
+ answerInputs(...answers: Array<string | undefined>) { inputAnswers.push(...answers); },
498
+ inputPrompts,
430
499
  run: (name: string) => commands.get(name)!.handler("", ctx),
431
500
  };
432
501
  }
@@ -685,7 +754,7 @@ test("session startup reports invalid project routing without mutating the profi
685
754
  registerCommand() {},
686
755
  registerTool() {},
687
756
  } as unknown as ExtensionAPI;
688
- createGentleAiExtension({ nativeReviewCli: null })(pi);
757
+ createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: "0" } })(pi);
689
758
  const sessionStart = handlers.get("session_start");
690
759
  assert.equal(typeof sessionStart, "function");
691
760
  const notifications: Array<{ message: string; severity: string }> = [];
@@ -757,8 +826,8 @@ test("managed routing timeout leaves its profile, agent, and manifest unchanged"
757
826
  });
758
827
 
759
828
  process.env.GENTLE_PI_AGENT_HOME = agentHome;
760
- installPackageAssets(root, false, ["sdd"]);
761
- const agentPath = join(agentHome, "agents", "sdd-apply.md");
829
+ installPackageAssets(root, false, ["delegation"]);
830
+ const agentPath = join(agentHome, "agents", "gentle-ai-worker.md");
762
831
  const manifestPath = join(agentHome, "gentle-ai", "managed-assets.json");
763
832
  const profilePath = join(agentHome, "subagents.json");
764
833
  const profileBefore = "{\n \"unrelated\": true\n}\n";
@@ -771,7 +840,7 @@ test("managed routing timeout leaves its profile, agent, and manifest unchanged"
771
840
  );
772
841
 
773
842
  assert.throws(
774
- () => applyModelConfig(root, { "sdd-apply": { model: "test/managed", thinking: "high" } }),
843
+ () => applyModelConfig(root, { "gentle-ai-worker": { model: "test/managed", thinking: "high" } }),
775
844
  /Timed out acquiring managed-assets lock file/i,
776
845
  );
777
846
  assert.equal(readFileSync(profilePath, "utf8"), profileBefore);
@@ -803,8 +872,8 @@ test("a later alias keeps managed-root precedence and manifest ownership", (t) =
803
872
  process.env.GENTLE_PI_AGENT_HOME = agentHome;
804
873
  process.env.HOME = home;
805
874
  process.env.USERPROFILE = home;
806
- installPackageAssets(cwd, false, ["sdd"]);
807
- writeMarkdown(join(intervening, "sdd-apply.md"), "---\nname: sdd-apply\n---\nintervening override\n");
875
+ installPackageAssets(cwd, false, ["delegation"]);
876
+ writeMarkdown(join(intervening, "gentle-ai-worker.md"), "---\nname: gentle-ai-worker\n---\nintervening override\n");
808
877
  mkdirSync(home, { recursive: true });
809
878
  try {
810
879
  symlinkSync(managed, alias, process.platform === "win32" ? "junction" : "dir");
@@ -813,13 +882,13 @@ test("a later alias keeps managed-root precedence and manifest ownership", (t) =
813
882
  return;
814
883
  }
815
884
 
816
- const selected = __testing.listDiscoverableAgents(cwd).find((agent) => agent.name === "sdd-apply");
817
- assert.equal(selected?.filePath, join(managed, "sdd-apply.md"));
818
- applyModelConfig(cwd, { "sdd-apply": { model: "test/managed", thinking: "high" } });
885
+ const selected = __testing.listDiscoverableAgents(cwd).find((agent) => agent.name === "gentle-ai-worker");
886
+ assert.equal(selected?.filePath, join(managed, "gentle-ai-worker.md"));
887
+ applyModelConfig(cwd, { "gentle-ai-worker": { model: "test/managed", thinking: "high" } });
819
888
  const manifest = JSON.parse(readFileSync(join(agentHome, "gentle-ai", "managed-assets.json"), "utf8")) as { assets: Record<string, string> };
820
- const routed = readFileSync(join(managed, "sdd-apply.md"), "utf8");
889
+ const routed = readFileSync(join(managed, "gentle-ai-worker.md"), "utf8");
821
890
  assert.match(routed, /^model: test\/managed$/m);
822
- assert.equal(manifest.assets["agents/sdd-apply.md"], createHash("sha256").update(routed).digest("hex"));
891
+ assert.equal(manifest.assets["agents/gentle-ai-worker.md"], createHash("sha256").update(routed).digest("hex"));
823
892
  });
824
893
 
825
894
  test("runtime guidance keeps review policy out of the static orchestrator and technical reference", () => {
@@ -845,7 +914,7 @@ test("runtime guidance keeps review policy out of the static orchestrator and te
845
914
  }
846
915
 
847
916
  const orchestrator = readFileSync("assets/orchestrator.md", "utf8")
848
- + readFileSync("assets/orchestrator-delegation.md", "utf8");
917
+ + readDelegationDetail();
849
918
  assert.match(orchestrator, /injects the mirrored provider-bundle review execution contract/);
850
919
  assert.match(orchestrator, /this package invents no lifecycle instructions/);
851
920
  for (const lifecycleMarker of ["review-risk", "review-reliability", "review-resilience", "review-readability", "Authority-First Terminal Procedure", "reconcile-terminal-mirrors"]) {
@@ -939,7 +1008,10 @@ test("ordinary START adopts the committed-range selectors the provider just offe
939
1008
  targetStatus: async () => offeredCommittedRangeStatus(baseCommit, baseTree, candidateTree),
940
1009
  start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
941
1010
  } as unknown as NativeReviewCli;
942
- const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
1011
+ const parameters = validateToolArguments(registeredGentleTools().get("gentle_review"), {
1012
+ type: "toolCall", id: "object-start", name: "gentle_review", arguments: { operation: "start", input: { mode: "ordinary" } },
1013
+ });
1014
+ const result = await __testing.executeReviewControllerOperation(parameters, cwd, native);
943
1015
  assert.equal(result.operation, "start");
944
1016
  assert.equal(starts.length, 1);
945
1017
  assert.equal(starts[0]?.baseRef, baseCommit);
@@ -1077,7 +1149,21 @@ test("ordinary START reports candidate-owner preparation failure as pre-native n
1077
1149
  });
1078
1150
  });
1079
1151
 
1080
- test("agent model discovery prioritizes SDD and Judgment Day agents", (t) => {
1152
+ test("retired SDD startup flag is not registered or imported", () => {
1153
+ const flags: string[] = [];
1154
+ const pi = {
1155
+ on() {},
1156
+ registerCommand() {},
1157
+ registerTool() {},
1158
+ registerFlag(name: string) { flags.push(name); },
1159
+ } as unknown as ExtensionAPI;
1160
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
1161
+ assert.ok(!flags.includes("gentle-sdd-change"));
1162
+ const source = readFileSync(new URL("../extensions/gentle-ai.ts", import.meta.url), "utf8");
1163
+ assert.doesNotMatch(source, /from ["']\.\.\/lib\/sdd-preflight\.ts["']/);
1164
+ });
1165
+
1166
+ test("agent model discovery prioritizes Judgment Day agents", (t) => {
1081
1167
  const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-agents-"));
1082
1168
  t.after(() => rmSync(root, { recursive: true, force: true }));
1083
1169
  writeMarkdown(join(root, "zeta.md"), "name: zeta\n");
@@ -1094,12 +1180,12 @@ test("agent model discovery prioritizes SDD and Judgment Day agents", (t) => {
1094
1180
  assert.deepEqual(
1095
1181
  ordered.map((agent) => agent.name),
1096
1182
  [
1097
- "sdd-init",
1098
- "sdd-apply",
1099
1183
  "jd-judge-a",
1100
1184
  "jd-judge-b",
1101
1185
  "jd-fix-agent",
1102
1186
  "alpha",
1187
+ "sdd-apply",
1188
+ "sdd-init",
1103
1189
  "zeta",
1104
1190
  ],
1105
1191
  );
@@ -1195,6 +1281,34 @@ test("model panel render does not auto-apply the Gentle theme and sanitizes agen
1195
1281
  assert.doesNotMatch(plain, /\[31m/);
1196
1282
  });
1197
1283
 
1284
+ test("model panel fills the terminal height like the profiles panel", () => {
1285
+ const lines = __testing.renderSddModelPanel({}, ["openai/gpt-5.5"], ["safe-agent"], 72, undefined, 40);
1286
+ assert.equal(lines.length, 40);
1287
+ const plain = lines.map(stripAnsi);
1288
+ assert.match(plain[0] ?? "", /^╭─+╮$/);
1289
+ assert.match(plain[39] ?? "", /^╰─+╯$/);
1290
+ for (const line of plain) assert.equal(line.length, 72);
1291
+ });
1292
+
1293
+ test("model panel lists grow with the terminal height instead of a fixed window", () => {
1294
+ const agents = Array.from({ length: 40 }, (_, i) => `agent-${String(i).padStart(2, "0")}`);
1295
+ const models = Array.from({ length: 60 }, (_, i) => `provider/model-${String(i).padStart(2, "0")}`);
1296
+ const agentLines = __testing
1297
+ .renderSddModelPanel({}, models, agents, 100, undefined, 40)
1298
+ .map(stripAnsi);
1299
+ assert.equal(agentLines.length, 40);
1300
+ // 40 rows minus 15 rows of chrome: every remaining row lists an agent or "Set all".
1301
+ assert.equal(agentLines.filter((line) => /(agent-\d\d|Set all agents)\s+model=/.test(line)).length, 25);
1302
+ assert.ok(agentLines.some((line) => /x export/.test(line)));
1303
+
1304
+ const pickerLines = __testing
1305
+ .renderSddModelPanel({}, models, agents, 100, undefined, 40, ["\r"])
1306
+ .map(stripAnsi);
1307
+ assert.equal(pickerLines.length, 40);
1308
+ // 40 rows minus 8 rows of chrome.
1309
+ assert.equal(pickerLines.filter((line) => /provider\/model-\d\d/.test(line)).length, 32);
1310
+ });
1311
+
1198
1312
  test("model panel render uses the Pi-provided current theme when supplied", () => {
1199
1313
  const currentTheme = {
1200
1314
  fg(_color: string, text: string): string {
@@ -1249,7 +1363,7 @@ test("delivery commands bypass RDD under every mode outcome while command safety
1249
1363
  cwd: process.cwd(),
1250
1364
  hasUI: true,
1251
1365
  ui: { confirm: async () => true },
1252
- } as ExtensionContext;
1366
+ } as unknown as ExtensionContext;
1253
1367
 
1254
1368
  for (const command of commands) {
1255
1369
  const result = await toolCall!({ toolName: "bash", input: { command } }, ctx);
@@ -1281,6 +1395,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1281
1395
  const handlers = new Map<string, ToolCallHandler>();
1282
1396
  const emitted: EmittedEvent[] = [];
1283
1397
  const sequence: string[] = [];
1398
+ const confirmation = confirmationObserver();
1284
1399
  let confirm!: () => Promise<boolean>;
1285
1400
  const pi = {
1286
1401
  on(name: string, handler: ToolCallHandler) {
@@ -1291,7 +1406,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1291
1406
  sequence.push(
1292
1407
  channel === "herdr:blocked"
1293
1408
  ? `herdr:${"active" in data && data.active ? "active" : "inactive"}`
1294
- : `event:${data.state}`,
1409
+ : `event:${"state" in data ? data.state : "unknown"}`,
1295
1410
  );
1296
1411
  emitted.push({ channel, data } as EmittedEvent);
1297
1412
  },
@@ -1299,7 +1414,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1299
1414
  registerCommand() {},
1300
1415
  registerTool() {},
1301
1416
  } as unknown as ExtensionAPI;
1302
- createGentleAiExtension({ nativeReviewCli: null })(pi);
1417
+ createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
1303
1418
  const toolCall = handlers.get("tool_call");
1304
1419
  assert.equal(typeof toolCall, "function");
1305
1420
  const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-request-"));
@@ -1310,10 +1425,11 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1310
1425
  ui: {
1311
1426
  confirm: async () => {
1312
1427
  sequence.push("confirm");
1428
+ confirmation.observe();
1313
1429
  return confirm();
1314
1430
  },
1315
1431
  },
1316
- } as ExtensionContext;
1432
+ } as unknown as ExtensionContext;
1317
1433
  let resolveConfirmation!: (approved: boolean) => void;
1318
1434
  confirm = () => new Promise<boolean>((resolve) => { resolveConfirmation = resolve; });
1319
1435
  const denied = toolCall!({
@@ -1321,7 +1437,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1321
1437
  input: { command: "git rebase main --secret-command-content" },
1322
1438
  }, ctx);
1323
1439
 
1324
- await Promise.resolve();
1440
+ await confirmation.waitFor(1);
1325
1441
  assert.equal(emitted[0].channel, "pi-permission-system:permission-request");
1326
1442
  assert.equal(emitted[0].data.state, "waiting");
1327
1443
  assert.deepEqual(emitted[1], {
@@ -1425,6 +1541,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1425
1541
  const createHarness = () => {
1426
1542
  const handlers = new Map<string, ToolCallHandler>();
1427
1543
  const emitted: EmittedEvent[] = [];
1544
+ const confirmation = confirmationObserver();
1428
1545
  const confirmations: Array<(approved: boolean) => void> = [];
1429
1546
  const pi = {
1430
1547
  on(name: string, handler: ToolCallHandler) {
@@ -1434,8 +1551,8 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1434
1551
  registerCommand() {},
1435
1552
  registerTool() {},
1436
1553
  } as unknown as ExtensionAPI;
1437
- createGentleAiExtension({ nativeReviewCli: null })(pi);
1438
- return { handlers, emitted, confirmations };
1554
+ createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
1555
+ return { handlers, emitted, confirmations, confirmation };
1439
1556
  };
1440
1557
  const first = createHarness();
1441
1558
  const second = createHarness();
@@ -1445,12 +1562,15 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1445
1562
  cwd,
1446
1563
  hasUI: true,
1447
1564
  ui: {
1448
- confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
1565
+ confirm: async () => new Promise<boolean>((resolve) => {
1566
+ confirmations.push(resolve);
1567
+ (confirmations === first.confirmations ? first : second).confirmation.observe();
1568
+ }),
1449
1569
  },
1450
- } as ExtensionContext);
1570
+ } as unknown as ExtensionContext);
1451
1571
  const firstRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main" } }, context(first.confirmations));
1452
1572
  const secondRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --another-command" } }, context(first.confirmations));
1453
- await Promise.resolve();
1573
+ await first.confirmation.waitFor(2);
1454
1574
  assert.deepEqual(first.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
1455
1575
  { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
1456
1576
  { channel: "herdr:blocked", state: undefined, active: true },
@@ -1473,7 +1593,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1473
1593
  ]);
1474
1594
 
1475
1595
  const independentRequest = second.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --independent-command" } }, context(second.confirmations));
1476
- await Promise.resolve();
1596
+ await second.confirmation.waitFor(1);
1477
1597
  assert.equal(second.emitted.filter(({ channel }) => channel === "herdr:blocked").length, 1);
1478
1598
  assert.equal(second.emitted.find(({ channel }) => channel === "herdr:blocked")?.data.active, true);
1479
1599
  assert.equal(second.confirmations.length, 1);
@@ -1495,7 +1615,8 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1495
1615
  });
1496
1616
 
1497
1617
 
1498
- test("RPIV questionnaire blockers emit only a private, balanced Herdr projection", () => {
1618
+ for (const channel of ["rpiv:ask-user:blocked", "gentle-pi:ask-user-question:blocked"]) {
1619
+ test(`${channel} emits only a private, balanced Herdr projection`, () => {
1499
1620
  type HerdrBlockedEvent = { active: boolean; label?: string };
1500
1621
  const eventHandlers = new Map<string, (data: unknown) => void>();
1501
1622
  const published: Array<{ channel: string; data: unknown }> = [];
@@ -1518,9 +1639,15 @@ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection
1518
1639
  registerTool() {},
1519
1640
  } as unknown as ExtensionAPI;
1520
1641
  createGentleAiExtension({ nativeReviewCli: null })(pi);
1521
- assert.equal(eventHandlers.size, 2);
1642
+ const privateHostDiscovery = "gentle:yolo:host-ui";
1643
+ assert.equal(eventHandlers.has(privateHostDiscovery), true);
1644
+ assert.deepEqual([...eventHandlers.keys()].filter(name => name !== privateHostDiscovery).sort(), [
1645
+ "gentle-pi:ask-user-choice:blocked", "gentle-pi:ask-user-question:blocked", "rpiv:ask-user:blocked",
1646
+ ], "only the original three blocked channels remain besides the known private host adapter");
1647
+ assert.deepEqual(published, [], "registering host discovery emits no public event or private payload");
1522
1648
  assert.equal(eventHandlers.has("gentle-pi:ask-user-choice:blocked"), true);
1523
1649
  assert.equal(eventHandlers.has("rpiv:ask-user:blocked"), true);
1650
+ assert.equal(eventHandlers.has("gentle-pi:ask-user-question:blocked"), true);
1524
1651
 
1525
1652
  const source = {
1526
1653
  active: true,
@@ -1530,23 +1657,29 @@ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection
1530
1657
  command: "private questionnaire command",
1531
1658
  arbitrary: { nested: "private questionnaire field" },
1532
1659
  };
1533
- pi.events.emit("rpiv:ask-user:blocked", source);
1534
- assert.strictEqual(published[0]?.data, source, "the RPIV event remains the source event");
1660
+ eventHandlers.get(privateHostDiscovery)!(source);
1661
+ assert.deepEqual(published, [], "private discovery ignores questionnaire data instead of relaying it");
1662
+ assert.deepEqual(herdrEvents, []);
1663
+ pi.events.emit(channel, source);
1664
+ assert.strictEqual(published[0]?.data, source, "the questionnaire event remains the source event");
1535
1665
  assert.deepEqual(herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
1536
1666
  assert.doesNotMatch(JSON.stringify(herdrEvents), /private questionnaire|questionnaire-path/i);
1537
-
1538
- pi.events.emit("rpiv:ask-user:blocked", { active: true, duplicate: true });
1539
- pi.events.emit("rpiv:ask-user:blocked", { active: "true" });
1540
- pi.events.emit("rpiv:ask-user:blocked", { active: null });
1541
- pi.events.emit("rpiv:ask-user:blocked", []);
1542
- pi.events.emit("rpiv:ask-user:blocked", null);
1667
+ assert.deepEqual(published.filter(event => event.channel !== channel), [
1668
+ { channel: "herdr:blocked", data: { active: true, label: "Questionnaire awaiting input" } },
1669
+ ], "host discovery adds no new public projection or sensitive-content leakage");
1670
+
1671
+ pi.events.emit(channel, { active: true, duplicate: true });
1672
+ pi.events.emit(channel, { active: "true" });
1673
+ pi.events.emit(channel, { active: null });
1674
+ pi.events.emit(channel, []);
1675
+ pi.events.emit(channel, null);
1543
1676
  pi.events.emit("rpiv:ask-user:other", { active: false });
1544
1677
  assert.deepEqual(herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
1545
1678
 
1546
- pi.events.emit("rpiv:ask-user:blocked", { active: false });
1547
- pi.events.emit("rpiv:ask-user:blocked", { active: false, duplicate: true });
1548
- pi.events.emit("rpiv:ask-user:blocked", { active: true });
1549
- pi.events.emit("rpiv:ask-user:blocked", { active: false });
1679
+ pi.events.emit(channel, { active: false });
1680
+ pi.events.emit(channel, { active: false, duplicate: true });
1681
+ pi.events.emit(channel, { active: true });
1682
+ pi.events.emit(channel, { active: false });
1550
1683
  assert.deepEqual(herdrEvents, [
1551
1684
  { active: true, label: "Questionnaire awaiting input" },
1552
1685
  { active: false },
@@ -1555,7 +1688,9 @@ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection
1555
1688
  ]);
1556
1689
  });
1557
1690
 
1558
- test("Herdr coordinates guarded confirmations and RPIV labels without inactive relabel pulses", async () => {
1691
+ }
1692
+
1693
+ test("Herdr preserves the initial label and balanced edges across overlapping sources", async () => {
1559
1694
  type ToolCallHandler = (
1560
1695
  event: { toolName: string; input: unknown },
1561
1696
  ctx: ExtensionContext,
@@ -1565,6 +1700,7 @@ test("Herdr coordinates guarded confirmations and RPIV labels without inactive r
1565
1700
  const handlers = new Map<string, ToolCallHandler>();
1566
1701
  const eventHandlers = new Map<string, (data: unknown) => void>();
1567
1702
  const herdrEvents: HerdrBlockedEvent[] = [];
1703
+ const confirmation = confirmationObserver();
1568
1704
  const confirmations: Array<(approved: boolean) => void> = [];
1569
1705
  const pi = {
1570
1706
  on(name: string, handler: ToolCallHandler) {
@@ -1584,15 +1720,15 @@ test("Herdr coordinates guarded confirmations and RPIV labels without inactive r
1584
1720
  registerCommand() {},
1585
1721
  registerTool() {},
1586
1722
  } as unknown as ExtensionAPI;
1587
- createGentleAiExtension({ nativeReviewCli: null })(pi);
1723
+ createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
1588
1724
  const context = {
1589
1725
  cwd: process.cwd(),
1590
1726
  hasUI: true,
1591
1727
  ui: {
1592
- confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
1728
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
1593
1729
  },
1594
- } as ExtensionContext;
1595
- return { confirmations, context, herdrEvents, pi, toolCall: handlers.get("tool_call")! };
1730
+ } as unknown as ExtensionContext;
1731
+ return { confirmations, confirmation, context, herdrEvents, pi, toolCall: handlers.get("tool_call")! };
1596
1732
  };
1597
1733
 
1598
1734
  const guardedFirst = createHarness();
@@ -1600,34 +1736,53 @@ test("Herdr coordinates guarded confirmations and RPIV labels without inactive r
1600
1736
  { toolName: "bash", input: { command: "git rebase main" } },
1601
1737
  guardedFirst.context,
1602
1738
  );
1603
- await Promise.resolve();
1739
+ await guardedFirst.confirmation.waitFor(1);
1604
1740
  guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
1605
1741
  guardedFirst.confirmations[0]!(true);
1606
1742
  assert.equal(await guardedRequest, undefined);
1607
1743
  assert.deepEqual(guardedFirst.herdrEvents, [
1608
1744
  { active: true, label: "Guarded command confirmation" },
1609
- { active: true, label: "Questionnaire awaiting input" },
1610
1745
  ]);
1611
1746
  guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
1612
1747
  assert.deepEqual(guardedFirst.herdrEvents, [
1613
1748
  { active: true, label: "Guarded command confirmation" },
1614
- { active: true, label: "Questionnaire awaiting input" },
1615
1749
  { active: false },
1616
1750
  ]);
1617
1751
 
1752
+ // Each event channel is independent, even when native and legacy producers overlap.
1753
+ const channels = ["gentle-pi:ask-user-question:blocked", "rpiv:ask-user:blocked", "gentle-pi:ask-user-choice:blocked"];
1754
+ for (const lastChannel of channels) {
1755
+ const overlap = createHarness();
1756
+ for (const channel of channels) overlap.pi.events.emit(channel, { active: true });
1757
+ const request = overlap.toolCall(
1758
+ { toolName: "bash", input: { command: "git rebase main" } }, overlap.context,
1759
+ );
1760
+ await overlap.confirmation.waitFor(1);
1761
+ for (const channel of channels.filter((channel) => channel !== lastChannel)) {
1762
+ overlap.pi.events.emit(channel, { active: false });
1763
+ overlap.pi.events.emit(channel, { active: false });
1764
+ }
1765
+ overlap.confirmations[0]!(false);
1766
+ await request;
1767
+ assert.deepEqual(overlap.herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
1768
+ overlap.pi.events.emit(lastChannel, { active: false });
1769
+ assert.deepEqual(overlap.herdrEvents, [
1770
+ { active: true, label: "Questionnaire awaiting input" }, { active: false },
1771
+ ]);
1772
+ }
1773
+
1618
1774
  const questionnaireFirst = createHarness();
1619
1775
  questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
1620
1776
  const questionnaireRequest = questionnaireFirst.toolCall(
1621
1777
  { toolName: "bash", input: { command: "git rebase main" } },
1622
1778
  questionnaireFirst.context,
1623
1779
  );
1624
- await Promise.resolve();
1780
+ await questionnaireFirst.confirmation.waitFor(1);
1625
1781
  questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
1626
1782
  questionnaireFirst.confirmations[0]!(false);
1627
1783
  await questionnaireRequest;
1628
1784
  assert.deepEqual(questionnaireFirst.herdrEvents, [
1629
1785
  { active: true, label: "Questionnaire awaiting input" },
1630
- { active: true, label: "Guarded command confirmation" },
1631
1786
  { active: false },
1632
1787
  ]);
1633
1788
  });
@@ -1642,6 +1797,7 @@ test("closed choice blockers retain the visible choice label through guarded-con
1642
1797
  const eventHandlers = new Map<string, (data: unknown) => void>();
1643
1798
  const herdrEvents: HerdrBlockedEvent[] = [];
1644
1799
  const choiceEvents: Array<{ active: boolean }> = [];
1800
+ const confirmation = confirmationObserver();
1645
1801
  const confirmations: Array<(approved: boolean) => void> = [];
1646
1802
  const pi = {
1647
1803
  on(name: string, handler: ToolCallHandler) {
@@ -1661,9 +1817,14 @@ test("closed choice blockers retain the visible choice label through guarded-con
1661
1817
  registerCommand() {},
1662
1818
  registerTool() {},
1663
1819
  } as unknown as ExtensionAPI;
1664
- createGentleAiExtension({ nativeReviewCli: null })(pi);
1820
+ createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
1665
1821
  assert.equal(eventHandlers.has("gentle-pi:ask-user-choice:blocked"), true);
1666
1822
 
1823
+ for (const malformed of [null, [], {}, { active: "true" }]) {
1824
+ pi.events.emit("gentle-pi:ask-user-choice:blocked", malformed);
1825
+ }
1826
+ assert.deepEqual(herdrEvents, []);
1827
+ choiceEvents.length = 0;
1667
1828
  pi.events.emit("gentle-pi:ask-user-choice:blocked", { active: true });
1668
1829
  assert.deepEqual(choiceEvents, [{ active: true }]);
1669
1830
  assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
@@ -1674,11 +1835,11 @@ test("closed choice blockers retain the visible choice label through guarded-con
1674
1835
  cwd: process.cwd(),
1675
1836
  hasUI: true,
1676
1837
  ui: {
1677
- confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
1838
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
1678
1839
  },
1679
- } as ExtensionContext,
1840
+ } as unknown as ExtensionContext,
1680
1841
  );
1681
- await Promise.resolve();
1842
+ await confirmation.waitFor(1);
1682
1843
  assert.equal(confirmations.length, 1);
1683
1844
  assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
1684
1845
 
@@ -1686,7 +1847,6 @@ test("closed choice blockers retain the visible choice label through guarded-con
1686
1847
  assert.deepEqual(choiceEvents, [{ active: true }, { active: false }]);
1687
1848
  assert.deepEqual(herdrEvents, [
1688
1849
  { active: true, label: "Choice awaiting input" },
1689
- { active: true, label: "Guarded command confirmation" },
1690
1850
  ]);
1691
1851
  assert.equal(herdrEvents.some((event) => event.active === false), false);
1692
1852
 
@@ -1694,7 +1854,6 @@ test("closed choice blockers retain the visible choice label through guarded-con
1694
1854
  assert.equal(await guardedRequest, undefined);
1695
1855
  assert.deepEqual(herdrEvents, [
1696
1856
  { active: true, label: "Choice awaiting input" },
1697
- { active: true, label: "Guarded command confirmation" },
1698
1857
  { active: false },
1699
1858
  ]);
1700
1859
  });
@@ -1715,7 +1874,7 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
1715
1874
  registerCommand() {},
1716
1875
  registerTool() {},
1717
1876
  } as unknown as ExtensionAPI;
1718
- createGentleAiExtension({ nativeReviewCli: null })(pi);
1877
+ createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
1719
1878
  const toolCall = handlers.get("tool_call");
1720
1879
  assert.equal(typeof toolCall, "function");
1721
1880
  const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-headless-"));
@@ -1728,12 +1887,12 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
1728
1887
  cwd,
1729
1888
  hasUI: false,
1730
1889
  ui: { confirm },
1731
- } as ExtensionContext), undefined);
1890
+ } as unknown as ExtensionContext), undefined);
1732
1891
  assert.deepEqual(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, {
1733
1892
  cwd,
1734
1893
  hasUI: false,
1735
1894
  ui: { confirm },
1736
- } as ExtensionContext), {
1895
+ } as unknown as ExtensionContext), {
1737
1896
  block: true,
1738
1897
  reason: "Gentle AI safety policy requires interactive confirmation before this command.",
1739
1898
  });
@@ -1747,15 +1906,15 @@ test("registered Gentle Review capture tools name the lens they run", () => {
1747
1906
  const tools = registeredGentleTools();
1748
1907
  const binding = (lens: string) => JSON.stringify({ name: "reviewer_result", captureOperation: "review.capture-result", arguments: [], artifactSubject: { lens } });
1749
1908
  const single = tools.get("gentle_review_capture")!.renderCall({ lineageId: "l", collectBinding: binding("review-risk") }, lifecycleTheme, lifecycleContext({ executionStarted: true }));
1750
- assert.equal(cardTitle(renderComponent(single)), "🌹︎ Gentle AI · running · review capture · risk");
1909
+ assert.equal(cardTitle(renderComponent(single)), "🌹 rdd running · capture · risk");
1751
1910
  const bare = tools.get("gentle_review_capture")!.renderCall({ lineageId: "l", collectBinding: "{not json" }, lifecycleTheme, lifecycleContext({ executionStarted: true }));
1752
- assert.equal(cardTitle(renderComponent(bare)), "🌹︎ Gentle AI · running · review capture");
1911
+ assert.equal(cardTitle(renderComponent(bare)), "🌹 rdd running · capture");
1753
1912
  const group = tools.get("gentle_review_capture_group")!.renderCall(
1754
1913
  { lineageId: "l", collectBindings: [binding("review-risk"), binding("review-resilience"), binding("review-readability"), binding("review-reliability")] },
1755
1914
  lifecycleTheme,
1756
1915
  lifecycleContext({ executionStarted: true }),
1757
1916
  );
1758
- assert.equal(cardTitle(renderComponent(group)), "🌹︎ Gentle AI · running · review capture group · risk · resilience · readability · reliability");
1917
+ assert.equal(cardTitle(renderComponent(group)), "🌹 rdd running · capture group · risk · resilience · readability · reliability");
1759
1918
  });
1760
1919
 
1761
1920
  test("bash tool_call confirms a late guarded npm publish and denies on non-approval", async () => {
@@ -1788,7 +1947,7 @@ test("bash tool_call confirms a late guarded npm publish and denies on non-appro
1788
1947
  return false;
1789
1948
  },
1790
1949
  },
1791
- } as ExtensionContext;
1950
+ } as unknown as ExtensionContext;
1792
1951
 
1793
1952
  const prefix = "noise ".repeat(80);
1794
1953
  const command = `${prefix}npm publish --tag beta`;
@@ -1840,7 +1999,7 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
1840
1999
  cwd: process.cwd(),
1841
2000
  hasUI: true,
1842
2001
  ui: { confirm: async (title: string, message: string) => (confirmArgs.push([title, message]), false) },
1843
- } as ExtensionContext);
2002
+ } as unknown as ExtensionContext);
1844
2003
  assert.deepEqual(result, {
1845
2004
  block: true,
1846
2005
  reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
@@ -1864,9 +2023,11 @@ function applyOnce(
1864
2023
  fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
1865
2024
  ): void {
1866
2025
  let visits = 0;
2026
+ // gentle-shell#1064: Enter now binds the parent session, so the legacy global
2027
+ // apply these tests exercise is the explicit `a` key.
1867
2028
  fixture.onInput((panel) => {
1868
2029
  visits += 1;
1869
- panel.handleInput(visits === 1 ? "\r" : "\x1b");
2030
+ panel.handleInput(visits === 1 ? "a" : "\x1b");
1870
2031
  });
1871
2032
  }
1872
2033
 
@@ -1926,6 +2087,9 @@ test("applying a profile persists its orchestrator and never leaks the key into
1926
2087
  worker: { model: "openai/alpha" },
1927
2088
  },
1928
2089
  });
2090
+ // Every global apply now confirms, so this populated apply approves the
2091
+ // dialog explicitly before running.
2092
+ fixture.onConfirm(async () => true);
1929
2093
  applyOnce(fixture);
1930
2094
  await fixture.run("gentle:profiles");
1931
2095
 
@@ -1951,6 +2115,8 @@ test("applying a profile persists its orchestrator and never leaks the key into
1951
2115
  { kind: "model", provider: "nan", id: "glm5.3" },
1952
2116
  { kind: "thinking", level: "max" },
1953
2117
  ], "the live session switches to the profile's orchestrator");
2118
+ assert.equal(fixture.confirmCalls.length, 1, "every global apply asks for confirmation");
2119
+ assert.match(fixture.confirmCalls[0]?.[0] ?? "", /Apply profile/);
1954
2120
  });
1955
2121
 
1956
2122
  test("applying a profile whose orchestrator model is unknown to the registry persists the default and says the session did not switch", async (t) => {
@@ -2016,193 +2182,841 @@ test("a profile store entry with only the orchestrator key counts zero roles", a
2016
2182
  assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
2017
2183
  });
2018
2184
 
2019
- test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
2020
- const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2185
+ test("applying an empty profile asks for confirmation and aborts when declined", async (t) => {
2186
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2021
2187
  writeSettings();
2022
- // Routing materialized earlier (a previous profile, /gentle:models, or a
2023
- // migration) for an agent the new profile does not mention.
2024
- const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2025
- writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
2188
+ mkdirSync(fixture.configHome, { recursive: true });
2189
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2026
2190
  const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2027
- writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2028
- writeStore({ team: { worker: { model: "openai/alpha" } } });
2191
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2192
+ writeStore({
2193
+ empty: {},
2194
+ team: { worker: { model: "openai/alpha" } },
2195
+ }, "team");
2196
+ const before = {
2197
+ models: readFileSync(fixture.globalPath, "utf8"),
2198
+ subagents: readFileSync(subagentsPath, "utf8"),
2199
+ store: readFileSync(storePath, "utf8"),
2200
+ settings: readFileSync(settingsPath, "utf8"),
2201
+ };
2202
+
2203
+ fixture.onConfirm(async () => false);
2204
+
2029
2205
  applyOnce(fixture);
2030
2206
  await fixture.run("gentle:profiles");
2031
2207
 
2032
- const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
2033
- assert.deepEqual(profiles.model_profiles, { worker: { model: "openai/alpha" } }, "omitted agents lose their materialized route");
2034
- const helper = readFileSync(helperPath, "utf8");
2035
- assert.doesNotMatch(helper, /^model:/m);
2036
- assert.doesNotMatch(helper, /^thinking:/m);
2037
- assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
2038
- // models.json stays the profile itself, not a padded copy.
2039
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2040
- const applied = fixture.notifications.at(-1)?.message ?? "";
2041
- assert.match(applied, /applied profile "team"/);
2208
+ assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an empty profile");
2209
+ const [title, message] = fixture.confirmCalls[0];
2210
+ assert.equal(title, "Apply empty profile?");
2211
+ assert.match(message, /has no routing entries/);
2212
+ assert.match(message, /replace global routing/);
2213
+ assert.match(message, /empty configuration/);
2214
+
2215
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
2216
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
2217
+ assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
2218
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
2219
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2220
+ const models = JSON.parse(before.models);
2221
+ assert.deepEqual(models, { worker: { model: "openai/alpha" } }, "global routing must NOT be wiped when declined");
2222
+
2223
+ const store = JSON.parse(before.store);
2224
+ assert.equal(store.active, "team", "active profile must not change when declined");
2042
2225
  });
2043
2226
 
2044
- test("a failed apply restores the previous profile's routing with the same replacement semantics", async (t) => {
2045
- const { fixture, settingsPath, writeStore } = profilesStoreFixture(t);
2046
- // An unreadable settings.json makes the orchestrator write fail after the
2047
- // routing was already materialized, which triggers the rollback path.
2048
- writeFileSync(settingsPath, "{ not json\n");
2049
- const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2050
- writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
2051
- const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2052
- writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta" } } }, null, 2)}\n`);
2227
+ test("applying an empty profile with explicit confirmation replaces global routing with empty config", async (t) => {
2228
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2229
+ writeSettings();
2053
2230
  mkdirSync(fixture.configHome, { recursive: true });
2054
- writeFileSync(fixture.globalPath, `${JSON.stringify({ helper: { model: "openai/beta" } }, null, 2)}\n`);
2055
- // "team" is listed first, so Enter applies it while "old" stays the active one.
2231
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2232
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2233
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2056
2234
  writeStore({
2057
- team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } },
2058
- old: { helper: { model: "openai/beta" } },
2059
- }, "old");
2235
+ empty: {},
2236
+ team: { worker: { model: "openai/alpha" } },
2237
+ }, "team");
2238
+
2239
+ fixture.onConfirm(async () => true);
2240
+
2060
2241
  applyOnce(fixture);
2061
2242
  await fixture.run("gentle:profiles");
2062
2243
 
2063
- const warning = fixture.notifications.find((entry) => /could not apply profile "team"/.test(entry.message));
2064
- assert.ok(warning, "the failed apply is reported");
2065
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { helper: { model: "openai/beta" } });
2066
- const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
2067
- assert.deepEqual(profiles.model_profiles, { helper: { model: "openai/beta" } }, "the previous routing is materialized again and the failed profile's routes are cleared");
2068
- assert.match(readFileSync(helperPath, "utf8"), /model: openai\/beta/);
2069
- assert.doesNotMatch(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /^model:/m);
2070
- const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2071
- assert.equal(store.active, "old");
2244
+ assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an empty profile");
2245
+ const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
2246
+ assert.deepEqual(models, {}, "global routing must be replaced with empty config when confirmed");
2247
+
2248
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2249
+ assert.equal(store.active, "empty", "active profile must be set to empty when confirmed");
2250
+
2251
+ const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
2252
+ assert.deepEqual(subagents, {}, "confirmed empty apply clears the seeded omitted worker route (shared clearing semantics drop the empty key)");
2072
2253
  });
2073
2254
 
2074
- test("s snapshots current routing in place without applying or reopening the profiles panel", async (t) => {
2075
- const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2255
+ test("applying an orchestrator-only profile asks for confirmation and aborts when declined", async (t) => {
2256
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2076
2257
  writeSettings();
2077
2258
  mkdirSync(fixture.configHome, { recursive: true });
2078
- writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "minimal" } }, null, 2)}\n`);
2259
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2079
2260
  const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2080
2261
  writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2081
- const workerPath = join(fixture.root, ".pi", "agents", "worker.md");
2262
+ writeStore({
2263
+ orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
2264
+ team: { worker: { model: "openai/alpha" } },
2265
+ }, "team");
2082
2266
  const before = {
2083
2267
  models: readFileSync(fixture.globalPath, "utf8"),
2084
2268
  subagents: readFileSync(subagentsPath, "utf8"),
2085
- worker: readFileSync(workerPath, "utf8"),
2269
+ store: readFileSync(storePath, "utf8"),
2086
2270
  settings: readFileSync(settingsPath, "utf8"),
2087
2271
  };
2088
- writeStore({
2089
- "a-target": {},
2090
- "z-active": { worker: { model: "openai/beta" } },
2091
- }, "z-active");
2092
2272
 
2093
- let firstPanel: RoutingConsumerPanel | undefined;
2094
- fixture.onInput((panel) => {
2095
- if (firstPanel === undefined) {
2096
- firstPanel = panel;
2097
- panel.handleInput("s");
2098
- panel.handleInput("\x1b");
2099
- } else {
2100
- panel.handleInput("\x1b");
2101
- }
2102
- });
2103
- await fixture.run("gentle:profiles");
2104
-
2105
- const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2106
- assert.deepEqual(store.profiles["a-target"], {
2107
- worker: { model: "openai/alpha", thinking: "minimal" },
2108
- orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2109
- });
2110
- assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
2111
- assert.equal(store.active, "z-active");
2112
- assert.match(fixture.panels[0] ?? "", /enter apply · c create · s snapshot/);
2113
- assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
2114
- assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
2115
- assert.equal(readFileSync(workerPath, "utf8"), before.worker);
2116
- assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
2117
- assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
2118
- assert.ok(firstPanel);
2119
- const targetRow = renderComponent(firstPanel!).split("\n");
2120
- const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
2121
- assert.ok(targetIndex >= 0, "selected profile remains in the list");
2122
- assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
2123
- assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
2124
- });
2273
+ fixture.onConfirm(async () => false);
2125
2274
 
2126
- // /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
2127
- // by the snapshot `/gentle:profiles` performs with `s` on the current profile.
2128
- function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
2129
- // Rows: "Set all agents", the two provider review roles, then "worker".
2130
- for (let step = 0; step < 3; step += 1) panel.handleInput("j");
2131
- assert.match(renderComponent(panel), /▸ worker/);
2132
- panel.handleInput("\r");
2133
- for (const character of "alpha") panel.handleInput(character);
2134
- panel.handleInput("\r");
2135
- panel.handleInput("u");
2136
- }
2275
+ applyOnce(fixture);
2276
+ await fixture.run("gentle:profiles");
2137
2277
 
2138
- test("u saves global routing from /gentle:models and updates the active profile", async (t) => {
2139
- const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2278
+ assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an orchestrator-only profile");
2279
+ const [title, message] = fixture.confirmCalls[0];
2280
+ assert.equal(title, "Apply orchestrator-only profile?");
2281
+ assert.match(message, /has an orchestrator entry but no agent routing entries/);
2282
+ assert.match(message, /clear every materialized agent route/);
2283
+ assert.match(message, /inherit/);
2284
+ assert.match(message, /orchestrator entry/);
2285
+ assert.match(message, /settings\.json/);
2286
+ assert.match(message, /attempt to switch this session/, "the dialog must not promise an unconditional live switch; registry/auth refusal can keep the session model");
2287
+ assert.doesNotMatch(message, /empty configuration/, "orchestrator-only must not promise an entirely empty config");
2288
+
2289
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
2290
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
2291
+ assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
2292
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
2293
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2294
+
2295
+ const store = JSON.parse(before.store);
2296
+ assert.equal(store.active, "team", "active profile must not change when declined");
2297
+ });
2298
+
2299
+ test("applying an orchestrator-only profile with explicit confirmation updates orchestrator and active profile", async (t) => {
2300
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2140
2301
  writeSettings();
2302
+ mkdirSync(fixture.configHome, { recursive: true });
2303
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2304
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2305
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2141
2306
  writeStore({
2142
- team: { worker: { model: "openai/beta" } },
2143
- other: { worker: { model: "openai/beta", thinking: "high" } },
2307
+ orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
2308
+ team: { worker: { model: "openai/alpha" } },
2144
2309
  }, "team");
2145
- fixture.onInput((panel) => {
2146
- assert.match(renderComponent(panel), /Current profile: team/);
2147
- assert.match(renderComponent(panel), /u update profile/);
2148
- pickWorkerModelThenUpdateProfile(panel);
2149
- });
2150
- await fixture.run("gentle:models");
2151
2310
 
2152
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2311
+ fixture.onConfirm(async () => true);
2312
+
2313
+ applyOnce(fixture);
2314
+ await fixture.run("gentle:profiles");
2315
+
2316
+ assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an orchestrator-only profile");
2317
+ const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
2318
+ assert.deepEqual(models, { orchestrator: { model: "nan/glm5.3", thinking: "high" } }, "global routing must hold only orchestrator");
2319
+
2153
2320
  const store = JSON.parse(readFileSync(storePath, "utf8"));
2154
- assert.deepEqual(store.profiles.team, {
2155
- worker: { model: "openai/alpha" },
2156
- orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2157
- });
2158
- assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
2159
- assert.equal(store.active, "team");
2160
- assert.equal(fixture.panelVisits(), 1, "u finishes the interaction");
2161
- assert.ok(
2162
- fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
2163
- "the global save is still reported",
2164
- );
2165
- assert.ok(
2166
- fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
2167
- `profile update is reported: ${JSON.stringify(fixture.notifications)}`,
2168
- );
2321
+ assert.equal(store.active, "orchOnly", "active profile must be set to orchOnly when confirmed");
2322
+
2323
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
2324
+ assert.equal(settings.defaultProvider, "nan");
2325
+ assert.equal(settings.defaultModel, "glm5.3");
2326
+ assert.equal(settings.defaultThinkingLevel, "high");
2327
+
2328
+ const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
2329
+ assert.deepEqual(subagents, {}, "confirmed orchestrator-only apply clears the seeded omitted worker route (shared clearing semantics drop the empty key)");
2330
+ assert.equal("orchestrator" in subagents, false, "the orchestrator key never materializes as an agent route");
2331
+ assert.deepEqual(fixture.liveSwitches, [
2332
+ { kind: "model", provider: "nan", id: "glm5.3" },
2333
+ { kind: "thinking", level: "high" },
2334
+ ], "the live session switches to the profile's orchestrator");
2169
2335
  });
2170
2336
 
2171
- test("u updates the pinned profile instead of the active one inside a pinned repository", async (t) => {
2172
- const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
2337
+ test("applying a populated profile asks for confirmation naming the diff and aborts when declined", async (t) => {
2338
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2173
2339
  writeSettings();
2340
+ mkdirSync(fixture.configHome, { recursive: true });
2341
+ // Current global routing: "worker" will be replaced, "helper" will be
2342
+ // cleared back to inherit, and "tracer" only exists in the profile.
2343
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "high" }, helper: { model: "openai/beta" } }, null, 2)}\n`);
2344
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2345
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" }, helper: { model: "openai/beta" } } }, null, 2)}\n`);
2346
+ const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2347
+ writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
2174
2348
  writeStore({
2175
- team: { worker: { model: "openai/beta" } },
2176
- other: { worker: { model: "openai/beta", thinking: "high" } },
2177
- }, "team");
2178
- writePin(localPinPath, "other");
2179
- fixture.onInput((panel) => {
2180
- assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
2181
- pickWorkerModelThenUpdateProfile(panel);
2349
+ team: {
2350
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2351
+ worker: { model: "openai/beta", thinking: "max" },
2352
+ tracer: { model: "openai/alpha" },
2353
+ },
2182
2354
  });
2183
- await fixture.run("gentle:models");
2355
+ const before = {
2356
+ models: readFileSync(fixture.globalPath, "utf8"),
2357
+ subagents: readFileSync(subagentsPath, "utf8"),
2358
+ store: readFileSync(storePath, "utf8"),
2359
+ settings: readFileSync(settingsPath, "utf8"),
2360
+ };
2184
2361
 
2185
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2186
- const store = JSON.parse(readFileSync(storePath, "utf8"));
2187
- assert.deepEqual(store.profiles.other, {
2188
- worker: { model: "openai/alpha" },
2189
- orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2190
- });
2191
- assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
2192
- assert.equal(store.active, "team");
2193
- assert.ok(
2194
- fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
2195
- `pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
2196
- );
2362
+ fixture.onConfirm(async () => false);
2363
+
2364
+ applyOnce(fixture);
2365
+ await fixture.run("gentle:profiles");
2366
+
2367
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2368
+ const [title, message] = fixture.confirmCalls[0];
2369
+ assert.equal(title, 'Apply profile "team"?');
2370
+ assert.match(message, /replace global routing/, "the dialog must name the file whose routing is replaced");
2371
+ assert.match(message, /models\.json/);
2372
+ assert.match(message, /worker: openai\/alpha · high → openai\/beta · max/, "the replaced route must name both the old and the new route");
2373
+ assert.match(message, /helper: openai\/beta → inherit/, "the cleared agent must be named with its return to inherit");
2374
+ assert.match(message, /tracer: openai\/alpha \(added\)/, "the added agent must be named");
2375
+
2376
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
2377
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
2378
+ assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
2379
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
2380
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2197
2381
  });
2198
2382
 
2199
- test("u keeps the global save and reports when no profile is current", async (t) => {
2200
- const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2383
+ test("applying a populated profile whose routes already match skips the confirmation and applies", async (t) => {
2384
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2201
2385
  writeSettings();
2202
- writeStore({ team: { worker: { model: "openai/beta" } } });
2203
- const before = readFileSync(storePath, "utf8");
2204
- fixture.onInput((panel) => {
2205
- assert.match(renderComponent(panel), /Current profile: none/);
2386
+ mkdirSync(fixture.configHome, { recursive: true });
2387
+ // The profile's agent routes are identical to the effective current routing
2388
+ // and the profile carries no orchestrator entry, so applying changes nothing:
2389
+ // the dialog is pure friction and the apply must proceed without it.
2390
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2391
+ writeStore({ team: { worker: { model: "openai/alpha" } } });
2392
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2393
+
2394
+ applyOnce(fixture);
2395
+ await fixture.run("gentle:profiles");
2396
+
2397
+ assert.equal(fixture.confirmCalls.length, 0, "a no-op populated apply must not ask for confirmation");
2398
+ assert.ok(
2399
+ fixture.notifications.some((entry) => entry.severity === "info" && /already matches the current global routing/.test(entry.message)),
2400
+ `the no-op apply is disclosed with an informational notice: ${JSON.stringify(fixture.notifications)}`,
2401
+ );
2402
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2403
+ assert.equal(store.active, "team", "the apply still claims the profile as active");
2404
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } }, "the routing is unchanged");
2405
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings.json is untouched");
2406
+ });
2407
+
2408
+ test("applying a populated profile whose routes match and whose orchestrator is already set skips the confirmation", async (t) => {
2409
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2410
+ // writeSettings defaults to nan/deepseek-v4-flash · high; the profile's
2411
+ // orchestrator entry says the same, and the live session still runs on it,
2412
+ // so nothing changes anywhere.
2413
+ writeSettings();
2414
+ fixture.setLiveModel("nan", "deepseek-v4-flash", "high");
2415
+ mkdirSync(fixture.configHome, { recursive: true });
2416
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2417
+ writeStore({ team: { orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" }, worker: { model: "openai/alpha" } } });
2418
+
2419
+ applyOnce(fixture);
2420
+ await fixture.run("gentle:profiles");
2421
+
2422
+ assert.equal(fixture.confirmCalls.length, 0, "an already-active profile must not ask for confirmation");
2423
+ assert.deepEqual(fixture.liveSwitches, [], "the no-op apply performs no live switch: the orchestrator is already there");
2424
+ assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
2425
+ });
2426
+
2427
+ test("applying a populated profile keeps the confirmation when the live session runs a different model", async (t) => {
2428
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2429
+ // settings.json and the profile agree on openai/alpha · high, but the live
2430
+ // session was switched to openai/beta mid-session: applying would move the
2431
+ // live session, so the no-op skip must not fire and the dialog must stay.
2432
+ writeSettings({ defaultProvider: "openai", defaultModel: "alpha", defaultThinkingLevel: "high" });
2433
+ fixture.setLiveModel("openai", "beta", "high");
2434
+ mkdirSync(fixture.configHome, { recursive: true });
2435
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2436
+ writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" }, worker: { model: "openai/alpha" } } });
2437
+
2438
+ fixture.onConfirm(async () => false);
2439
+
2440
+ applyOnce(fixture);
2441
+ await fixture.run("gentle:profiles");
2442
+
2443
+ assert.equal(fixture.confirmCalls.length, 1, "a live-session orchestrator move is a real change and must confirm");
2444
+ assert.deepEqual(fixture.liveSwitches, [], "declining must not switch the live session");
2445
+ assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
2446
+ });
2447
+
2448
+ test("applying a populated profile keeps the confirmation when settings.json holds an invalid thinking level", async (t) => {
2449
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2450
+ // The stored defaultThinkingLevel is not a valid level: readOrchestratorSettings
2451
+ // drops it from the entry, but applyOrchestratorSettings would delete the key
2452
+ // and rewrite the file, so "unchanged" cannot be proven and the dialog stays.
2453
+ writeSettings({ defaultThinkingLevel: "banana" });
2454
+ mkdirSync(fixture.configHome, { recursive: true });
2455
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2456
+ writeStore({ team: { orchestrator: { model: "nan/deepseek-v4-flash" }, worker: { model: "openai/alpha" } } });
2457
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2458
+
2459
+ fixture.onConfirm(async () => false);
2460
+
2461
+ applyOnce(fixture);
2462
+ await fixture.run("gentle:profiles");
2463
+
2464
+ assert.equal(fixture.confirmCalls.length, 1, "an invalid stored thinking level cannot prove a no-op");
2465
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "declining preserves the invalid key byte-identically");
2466
+ assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
2467
+ });
2468
+
2469
+ test("applying a populated profile keeps the confirmation when settings.json is unreadable and the profile has an orchestrator", async (t) => {
2470
+ const { fixture, storePath, writeStore, settingsPath } = profilesStoreFixture(t);
2471
+ writeFileSync(settingsPath, "{ not json\n");
2472
+ mkdirSync(fixture.configHome, { recursive: true });
2473
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2474
+ writeStore({ team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } } });
2475
+
2476
+ fixture.onConfirm(async () => false);
2477
+
2478
+ applyOnce(fixture);
2479
+ await fixture.run("gentle:profiles");
2480
+
2481
+ assert.equal(fixture.confirmCalls.length, 1, "an unreadable settings.json cannot prove an orchestrator no-op");
2482
+ assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
2483
+ });
2484
+
2485
+ test("applying a populated profile with a thinking-only orchestrator entry skips the confirmation", async (t) => {
2486
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2487
+ writeSettings();
2488
+ mkdirSync(fixture.configHome, { recursive: true });
2489
+ // An orchestrator entry without a model is a no-op for the orchestrator:
2490
+ // applyOrchestratorSettings treats a missing model as "leave settings.json
2491
+ // alone", so the apply moves nothing and must not confirm (issue #1683).
2492
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2493
+ writeStore({ team: { orchestrator: { thinking: "max" }, worker: { model: "openai/alpha" } } });
2494
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2495
+
2496
+ applyOnce(fixture);
2497
+ await fixture.run("gentle:profiles");
2498
+
2499
+ assert.equal(fixture.confirmCalls.length, 0, "a thinking-only orchestrator entry changes nothing and must not confirm");
2500
+ assert.ok(
2501
+ fixture.notifications.some((entry) => entry.severity === "info" && /already matches the current global routing/.test(entry.message)),
2502
+ `the no-op apply is disclosed with an informational notice: ${JSON.stringify(fixture.notifications)}`,
2503
+ );
2504
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings.json is untouched");
2505
+ assert.deepEqual(fixture.liveSwitches, [], "a thinking-only orchestrator entry never switches the live session");
2506
+ assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
2507
+ });
2508
+
2509
+ test("applying a populated profile with matching routes but a different orchestrator still confirms", async (t) => {
2510
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2511
+ writeSettings();
2512
+ mkdirSync(fixture.configHome, { recursive: true });
2513
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2514
+ writeStore({ team: { orchestrator: { model: "nan/glm5.3", thinking: "max" }, worker: { model: "openai/alpha" } } });
2515
+
2516
+ fixture.onConfirm(async () => false);
2517
+
2518
+ applyOnce(fixture);
2519
+ await fixture.run("gentle:profiles");
2520
+
2521
+ assert.equal(fixture.confirmCalls.length, 1, "an orchestrator change is a real change and must confirm");
2522
+ assert.match(fixture.confirmCalls[0]?.[0] ?? "", /Apply profile/);
2523
+ assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "declining leaves the store untouched");
2524
+ });
2525
+
2526
+ test("profile replacement with 23 routes changes files only after confirmation", async (t) => {
2527
+ for (const approved of [false, true]) {
2528
+ await t.test(approved ? "confirmed replacement" : "declined replacement", async (t) => {
2529
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2530
+ writeSettings();
2531
+ writeStore({ team: { worker: { model: "openai/alpha" } } });
2532
+ const names = ["worker", "review-refuter", ...Array.from({ length: 21 }, (_, i) => `helper-${i + 1}`)];
2533
+ const profiles = Object.fromEntries(names.map((name) => [name, { model: "openai/beta", effort: "high" }]));
2534
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2535
+ writeFileSync(subagentsPath, `${JSON.stringify({ max_concurrency: 3, user_setting: { keep: true }, model_profiles: profiles })}\n`);
2536
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/beta", thinking: "high" } })}\n`);
2537
+ const markdown = names.map((name) => join(fixture.root, ".pi", "agents", `${name}.md`));
2538
+ for (const [i, path] of markdown.entries()) writeMarkdown(path, `---\nname: ${names[i]}\ndescription: User agent\nmodel: openai/beta\nthinking: high\n---\nUser instruction for ${names[i]}.\n`);
2539
+ const files = [fixture.globalPath, subagentsPath, storePath, settingsPath, ...markdown];
2540
+ const before = files.map((path) => readFileSync(path, "utf8"));
2541
+ fixture.onConfirm(async () => approved);
2542
+ applyOnce(fixture);
2543
+ await fixture.run("gentle:profiles");
2544
+ assert.equal(fixture.confirmCalls.length, 1);
2545
+ assert.equal(fixture.confirmCalls[0][0], 'Apply profile "team"?');
2546
+ for (const name of names.slice(2)) assert.ok(fixture.confirmCalls[0][1].includes(`${name}: openai/beta · high → inherit`), `confirmation names ${name}`);
2547
+ assert.deepEqual(fixture.liveSwitches, [], "no orchestrator changes were requested");
2548
+ assert.equal(readFileSync(settingsPath, "utf8"), before[3]);
2549
+ if (!approved) {
2550
+ assert.deepEqual(files.map((path) => readFileSync(path, "utf8")), before, "declining preserves all 23 routes and Markdown, stores and settings byte-identically");
2551
+ return;
2552
+ }
2553
+ assert.deepEqual(JSON.parse(readFileSync(subagentsPath, "utf8")), { max_concurrency: 3, user_setting: { keep: true }, model_profiles: { worker: { model: "openai/alpha" }, "review-refuter": profiles["review-refuter"] } });
2554
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2555
+ assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
2556
+ for (const [i, path] of markdown.entries()) {
2557
+ const stored = readFileSync(path, "utf8");
2558
+ assert.ok(stored.endsWith(`User instruction for ${names[i]}.\n`));
2559
+ if (i === 0) { assert.match(stored, /^model: openai\/alpha$/m); assert.doesNotMatch(stored, /^thinking:/m); }
2560
+ else if (i === 1) assert.equal(stored, before[4 + i], "provider review role is not cleared");
2561
+ else { assert.doesNotMatch(stored, /^model:/m); assert.doesNotMatch(stored, /^thinking:/m); }
2562
+ }
2563
+ });
2564
+ }
2565
+ });
2566
+
2567
+ test("applying a populated profile names materialized-only routes it would clear before asking", async (t) => {
2568
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2569
+ writeSettings();
2570
+ mkdirSync(fixture.configHome, { recursive: true });
2571
+ // The saved global routing matches the profile exactly: "helper" is routed
2572
+ // only where the runtime actually resolves it (agent frontmatter and
2573
+ // subagents.json model profiles), and the apply clears it because the profile
2574
+ // omits it. models.json alone never speaks for "helper".
2575
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2576
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2577
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2578
+ const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2579
+ writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
2580
+ writeStore({
2581
+ team: { worker: { model: "openai/alpha" } },
2582
+ });
2583
+ const before = {
2584
+ models: readFileSync(fixture.globalPath, "utf8"),
2585
+ subagents: readFileSync(subagentsPath, "utf8"),
2586
+ store: readFileSync(storePath, "utf8"),
2587
+ settings: readFileSync(settingsPath, "utf8"),
2588
+ };
2589
+
2590
+ fixture.onConfirm(async () => false);
2591
+
2592
+ applyOnce(fixture);
2593
+ await fixture.run("gentle:profiles");
2594
+
2595
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2596
+ const [title, message] = fixture.confirmCalls[0];
2597
+ assert.equal(title, 'Apply profile "team"?');
2598
+ assert.match(message, /helper: openai\/beta · high → inherit/, "the materialized-only route the apply clears must be named");
2599
+ assert.doesNotMatch(message, /already match/, "routes matching the saved routing must not hide a cleared materialized-only route");
2600
+
2601
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
2602
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
2603
+ assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
2604
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
2605
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2606
+ });
2607
+
2608
+ test("applying a populated profile clears the materialized-only route it named when confirmed", async (t) => {
2609
+ const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2610
+ writeSettings();
2611
+ mkdirSync(fixture.configHome, { recursive: true });
2612
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2613
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2614
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2615
+ const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2616
+ writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
2617
+ writeStore({
2618
+ team: { worker: { model: "openai/alpha" } },
2619
+ });
2620
+
2621
+ fixture.onConfirm(async () => true);
2622
+
2623
+ applyOnce(fixture);
2624
+ await fixture.run("gentle:profiles");
2625
+
2626
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2627
+ const [, message] = fixture.confirmCalls[0];
2628
+ assert.match(message, /helper: openai\/beta · high → inherit/, "the dialog must name the materialized-only route before it is cleared");
2629
+ const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
2630
+ assert.equal("helper" in subagents.model_profiles, false, "confirming the apply clears the materialized-only route the dialog named");
2631
+ assert.doesNotMatch(readFileSync(helperPath, "utf8"), /^model:/m, "the materialized-only frontmatter route is dropped");
2632
+ });
2633
+
2634
+ test("applying a populated profile with an orchestrator entry discloses the settings and live-session effects", async (t) => {
2635
+ const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2636
+ writeSettings();
2637
+ mkdirSync(fixture.configHome, { recursive: true });
2638
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2639
+ writeStore({
2640
+ team: {
2641
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2642
+ worker: { model: "openai/beta" },
2643
+ },
2644
+ });
2645
+
2646
+ fixture.onConfirm(async () => false);
2647
+
2648
+ applyOnce(fixture);
2649
+ await fixture.run("gentle:profiles");
2650
+
2651
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2652
+ const [, message] = fixture.confirmCalls[0];
2653
+ assert.match(message, /replace global routing/, "the readable variant must keep naming the routing diff");
2654
+ assert.match(message, /settings\.json/, "the dialog must disclose that the orchestrator entry is set in settings.json");
2655
+ assert.match(message, /attempt to switch this session/, "the dialog must disclose the attempted live-session switch");
2656
+ assert.doesNotMatch(message, /will switch this session/, "the dialog must not promise an unconditional live switch");
2657
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2658
+ });
2659
+
2660
+ test("applying a populated profile with an orchestrator entry discloses the effects when the routing is unreadable", async (t) => {
2661
+ const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2662
+ writeSettings();
2663
+ mkdirSync(fixture.configHome, { recursive: true });
2664
+ // Malformed models.json keeps the unreadable-authority disclosure branch in
2665
+ // charge: the orchestrator effects must be added there, not swap it out.
2666
+ writeFileSync(fixture.globalPath, "{ not json\n");
2667
+ writeStore({
2668
+ team: {
2669
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2670
+ worker: { model: "openai/alpha" },
2671
+ },
2672
+ });
2673
+
2674
+ fixture.onConfirm(async () => false);
2675
+
2676
+ applyOnce(fixture);
2677
+ await fixture.run("gentle:profiles");
2678
+
2679
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2680
+ const [, message] = fixture.confirmCalls[0];
2681
+ assert.match(message, /could not be read/, "the unreadable-authority disclosure must stay authoritative");
2682
+ assert.match(message, /settings\.json/, "the dialog must disclose that the orchestrator entry is set in settings.json");
2683
+ assert.match(message, /attempt to switch this session/, "the dialog must disclose the attempted live-session switch");
2684
+ assert.doesNotMatch(message, /will switch this session/, "the dialog must not promise an unconditional live switch");
2685
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2686
+ });
2687
+
2688
+ test("applying a populated profile with an unreadable routing authority discloses the replace effect instead of added labels", async (t) => {
2689
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2690
+ writeSettings();
2691
+ mkdirSync(fixture.configHome, { recursive: true });
2692
+ // Malformed JSON makes readModelRoutingAuthorityAsync report the global
2693
+ // models.json as invalid: real routes exist on disk but cannot be diffed.
2694
+ writeFileSync(fixture.globalPath, "{ not json\n");
2695
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2696
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" } } }, null, 2)}\n`);
2697
+ writeStore({
2698
+ team: {
2699
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2700
+ worker: { model: "openai/alpha" },
2701
+ },
2702
+ });
2703
+ const before = {
2704
+ models: readFileSync(fixture.globalPath, "utf8"),
2705
+ subagents: readFileSync(subagentsPath, "utf8"),
2706
+ store: readFileSync(storePath, "utf8"),
2707
+ settings: readFileSync(settingsPath, "utf8"),
2708
+ };
2709
+
2710
+ fixture.onConfirm(async () => false);
2711
+
2712
+ applyOnce(fixture);
2713
+ await fixture.run("gentle:profiles");
2714
+
2715
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2716
+ const [title, message] = fixture.confirmCalls[0];
2717
+ assert.equal(title, 'Apply profile "team"?');
2718
+ assert.match(message, /could not be read/, "the dialog must disclose that the current global routing is unreadable");
2719
+ assert.match(message, /replace global routing/, "the dialog must name the global replacement");
2720
+ assert.match(message, /inherit/, "the dialog must disclose the clear-back-to-inherit effect");
2721
+ assert.doesNotMatch(message, /\(added\)/, "unread current routes must not be presented as merely added");
2722
+
2723
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
2724
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
2725
+ assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
2726
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
2727
+ assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
2728
+ });
2729
+
2730
+ test("applying a populated profile with an unreadable routing authority still applies when confirmed", async (t) => {
2731
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2732
+ writeSettings();
2733
+ mkdirSync(fixture.configHome, { recursive: true });
2734
+ writeFileSync(fixture.globalPath, "{ not json\n");
2735
+ writeStore({
2736
+ team: {
2737
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2738
+ worker: { model: "openai/alpha" },
2739
+ },
2740
+ });
2741
+
2742
+ fixture.onConfirm(async () => true);
2743
+
2744
+ applyOnce(fixture);
2745
+ await fixture.run("gentle:profiles");
2746
+
2747
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2748
+ const [, message] = fixture.confirmCalls[0];
2749
+ assert.match(message, /could not be read/, "the dialog must disclose that the current global routing is unreadable");
2750
+ const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
2751
+ assert.deepEqual(models, {
2752
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2753
+ worker: { model: "openai/alpha" },
2754
+ }, "confirmed apply proceeds as before despite the unreadable authority");
2755
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2756
+ assert.equal(store.active, "team", "active profile must be set to team when confirmed");
2757
+ });
2758
+
2759
+ test("applying a populated profile with explicit confirmation applies the routing diff", async (t) => {
2760
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2761
+ writeSettings();
2762
+ mkdirSync(fixture.configHome, { recursive: true });
2763
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "high" }, helper: { model: "openai/beta" } }, null, 2)}\n`);
2764
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2765
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" }, helper: { model: "openai/beta" } } }, null, 2)}\n`);
2766
+ const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2767
+ writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
2768
+ const tracerPath = join(fixture.root, ".pi", "agents", "tracer.md");
2769
+ writeMarkdown(tracerPath, "---\nname: tracer\ndescription: Tracer\n---\nbody\n");
2770
+ writeStore({
2771
+ team: {
2772
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2773
+ worker: { model: "openai/beta", thinking: "max" },
2774
+ tracer: { model: "openai/alpha" },
2775
+ },
2776
+ });
2777
+
2778
+ fixture.onConfirm(async () => true);
2779
+
2780
+ applyOnce(fixture);
2781
+ await fixture.run("gentle:profiles");
2782
+
2783
+ assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
2784
+ const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
2785
+ assert.deepEqual(models, {
2786
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2787
+ worker: { model: "openai/beta", thinking: "max" },
2788
+ tracer: { model: "openai/alpha" },
2789
+ }, "confirmed apply replaces global routing with the profile");
2790
+ const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
2791
+ assert.equal("helper" in subagents.model_profiles, false, "the cleared agent loses its materialized route");
2792
+ assert.equal(subagents.model_profiles.worker?.model, "openai/beta", "the replaced route is materialized");
2793
+ assert.equal(subagents.model_profiles.tracer?.model, "openai/alpha", "the added agent is materialized");
2794
+ assert.doesNotMatch(readFileSync(helperPath, "utf8"), /^model:/m, "the cleared agent's frontmatter route is dropped");
2795
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2796
+ assert.equal(store.active, "team", "active profile must be set to team when confirmed");
2797
+ assert.deepEqual(fixture.liveSwitches, [
2798
+ { kind: "model", provider: "nan", id: "glm5.3" },
2799
+ { kind: "thinking", level: "max" },
2800
+ ], "the live session switches to the profile's orchestrator");
2801
+ });
2802
+
2803
+ test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
2804
+ const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2805
+ writeSettings();
2806
+ // Routing materialized earlier (a previous profile, /gentle:models, or a
2807
+ // migration) for an agent the new profile does not mention.
2808
+ const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2809
+ writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
2810
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2811
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2812
+ writeStore({ team: { worker: { model: "openai/alpha" } } });
2813
+ applyOnce(fixture);
2814
+ await fixture.run("gentle:profiles");
2815
+
2816
+ const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
2817
+ assert.deepEqual(profiles.model_profiles, { worker: { model: "openai/alpha" } }, "omitted agents lose their materialized route");
2818
+ const helper = readFileSync(helperPath, "utf8");
2819
+ assert.doesNotMatch(helper, /^model:/m);
2820
+ assert.doesNotMatch(helper, /^thinking:/m);
2821
+ assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
2822
+ // models.json stays the profile itself, not a padded copy.
2823
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2824
+ const applied = fixture.notifications.at(-1)?.message ?? "";
2825
+ assert.match(applied, /applied profile "team"/);
2826
+ });
2827
+
2828
+ test("a failed apply restores the previous profile's routing with the same replacement semantics", async (t) => {
2829
+ const { fixture, settingsPath, writeStore } = profilesStoreFixture(t);
2830
+ // An unreadable settings.json makes the orchestrator write fail after the
2831
+ // routing was already materialized, which triggers the rollback path.
2832
+ writeFileSync(settingsPath, "{ not json\n");
2833
+ const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2834
+ writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
2835
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2836
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta" } } }, null, 2)}\n`);
2837
+ mkdirSync(fixture.configHome, { recursive: true });
2838
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ helper: { model: "openai/beta" } }, null, 2)}\n`);
2839
+ // "team" is listed first, so Enter applies it while "old" stays the active one.
2840
+ writeStore({
2841
+ team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } },
2842
+ old: { helper: { model: "openai/beta" } },
2843
+ }, "old");
2844
+ applyOnce(fixture);
2845
+ await fixture.run("gentle:profiles");
2846
+
2847
+ const warning = fixture.notifications.find((entry) => /could not apply profile "team"/.test(entry.message));
2848
+ assert.ok(warning, "the failed apply is reported");
2849
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { helper: { model: "openai/beta" } });
2850
+ const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
2851
+ assert.deepEqual(profiles.model_profiles, { helper: { model: "openai/beta" } }, "the previous routing is materialized again and the failed profile's routes are cleared");
2852
+ assert.match(readFileSync(helperPath, "utf8"), /model: openai\/beta/);
2853
+ assert.doesNotMatch(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /^model:/m);
2854
+ const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2855
+ assert.equal(store.active, "old");
2856
+ });
2857
+
2858
+ test("s snapshots current routing in place without applying or reopening the profiles panel", async (t) => {
2859
+ const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2860
+ writeSettings();
2861
+ mkdirSync(fixture.configHome, { recursive: true });
2862
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "minimal" } }, null, 2)}\n`);
2863
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2864
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2865
+ const workerPath = join(fixture.root, ".pi", "agents", "worker.md");
2866
+ const before = {
2867
+ models: readFileSync(fixture.globalPath, "utf8"),
2868
+ subagents: readFileSync(subagentsPath, "utf8"),
2869
+ worker: readFileSync(workerPath, "utf8"),
2870
+ settings: readFileSync(settingsPath, "utf8"),
2871
+ };
2872
+ writeStore({
2873
+ "a-target": {},
2874
+ "z-active": { worker: { model: "openai/beta" } },
2875
+ }, "z-active");
2876
+
2877
+ let firstPanel: RoutingConsumerPanel | undefined;
2878
+ fixture.onInput((panel) => {
2879
+ if (firstPanel === undefined) {
2880
+ firstPanel = panel;
2881
+ panel.handleInput("s");
2882
+ panel.handleInput("\x1b");
2883
+ } else {
2884
+ panel.handleInput("\x1b");
2885
+ }
2886
+ });
2887
+ await fixture.run("gentle:profiles");
2888
+
2889
+ const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2890
+ assert.deepEqual(store.profiles["a-target"], {
2891
+ worker: { model: "openai/alpha", thinking: "minimal" },
2892
+ orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2893
+ });
2894
+ assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
2895
+ assert.equal(store.active, "z-active");
2896
+ assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/);
2897
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
2898
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
2899
+ assert.equal(readFileSync(workerPath, "utf8"), before.worker);
2900
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
2901
+ assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
2902
+ assert.ok(firstPanel);
2903
+ const targetRow = renderComponent(firstPanel!).split("\n");
2904
+ const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
2905
+ assert.ok(targetIndex >= 0, "selected profile remains in the list");
2906
+ assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
2907
+ assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
2908
+ });
2909
+
2910
+ test("s snapshots the live session's orchestrator over the settings defaults", async (t) => {
2911
+ const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2912
+ writeSettings();
2913
+ writeStore({
2914
+ "a-target": {},
2915
+ "z-active": { worker: { model: "openai/beta" } },
2916
+ }, "z-active");
2917
+ // The session runs live on openai/omega at low effort while settings.json still
2918
+ // defaults to nan/deepseek-v4-flash at high: the snapshot must capture the
2919
+ // session the user is actually in, not the default new sessions would get.
2920
+ fixture.setLiveModel("openai", "omega", "low");
2921
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2922
+ fixture.onInput((panel) => {
2923
+ panel.handleInput("s");
2924
+ panel.handleInput("\x1b");
2925
+ });
2926
+ await fixture.run("gentle:profiles");
2927
+ const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2928
+ assert.deepEqual(
2929
+ store.profiles["a-target"].orchestrator,
2930
+ { model: "openai/omega", thinking: "low" },
2931
+ "the orchestrator entry comes from the live session, not settings.json",
2932
+ );
2933
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings are untouched");
2934
+ });
2935
+
2936
+ // /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
2937
+ // by the snapshot `/gentle:profiles` performs with `s` on the current profile.
2938
+ function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
2939
+ // Rows: "Set all agents", the two provider review roles, then "worker".
2940
+ for (let step = 0; step < 3; step += 1) panel.handleInput("j");
2941
+ assert.match(renderComponent(panel), /▸ worker/);
2942
+ panel.handleInput("\r");
2943
+ for (const character of "alpha") panel.handleInput(character);
2944
+ panel.handleInput("\r");
2945
+ panel.handleInput("u");
2946
+ }
2947
+
2948
+ test("u saves global routing and captures live session orchestrator instead of defaults", async (t) => {
2949
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2950
+ writeSettings();
2951
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2952
+ fixture.setLiveModel("openai", "alpha", "low");
2953
+ writeStore({
2954
+ team: { worker: { model: "openai/beta" } },
2955
+ other: { worker: { model: "openai/beta", thinking: "high" } },
2956
+ }, "team");
2957
+ fixture.onInput((panel) => {
2958
+ assert.match(renderComponent(panel), /Current profile: team/);
2959
+ assert.match(renderComponent(panel), /u capture session in "team"/);
2960
+ pickWorkerModelThenUpdateProfile(panel);
2961
+ });
2962
+ await fixture.run("gentle:models");
2963
+
2964
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2965
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2966
+ assert.deepEqual(store.profiles.team, {
2967
+ worker: { model: "openai/alpha" },
2968
+ orchestrator: { model: "openai/alpha", thinking: "low" },
2969
+ });
2970
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "global defaults remain unchanged");
2971
+ assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
2972
+ assert.equal(store.active, "team");
2973
+ assert.equal(fixture.panelVisits(), 1, "u finishes the interaction");
2974
+ assert.ok(
2975
+ fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
2976
+ "the global save is still reported",
2977
+ );
2978
+ assert.ok(
2979
+ fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
2980
+ `profile update is reported: ${JSON.stringify(fixture.notifications)}`,
2981
+ );
2982
+ });
2983
+
2984
+ test("u updates the pinned profile instead of the active one inside a pinned repository", async (t) => {
2985
+ const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
2986
+ writeSettings();
2987
+ fixture.setLiveModel("openai", "beta", "medium");
2988
+ writeStore({
2989
+ team: { worker: { model: "openai/beta" } },
2990
+ other: { worker: { model: "openai/beta", thinking: "high" } },
2991
+ }, "team");
2992
+ writePin(localPinPath, "other");
2993
+ fixture.onInput((panel) => {
2994
+ assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
2995
+ pickWorkerModelThenUpdateProfile(panel);
2996
+ });
2997
+ await fixture.run("gentle:models");
2998
+
2999
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
3000
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
3001
+ assert.deepEqual(store.profiles.other, {
3002
+ worker: { model: "openai/alpha" },
3003
+ orchestrator: { model: "openai/beta", thinking: "medium" },
3004
+ });
3005
+ assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
3006
+ assert.equal(store.active, "team");
3007
+ assert.ok(
3008
+ fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
3009
+ `pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
3010
+ );
3011
+ });
3012
+
3013
+ test("u keeps the global save and reports when no profile is current", async (t) => {
3014
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
3015
+ writeSettings();
3016
+ writeStore({ team: { worker: { model: "openai/beta" } } });
3017
+ const before = readFileSync(storePath, "utf8");
3018
+ fixture.onInput((panel) => {
3019
+ assert.match(renderComponent(panel), /Current profile: none/);
2206
3020
  pickWorkerModelThenUpdateProfile(panel);
2207
3021
  });
2208
3022
  await fixture.run("gentle:models");
@@ -2393,6 +3207,116 @@ test("j and k scroll the detail pane one line at a time, like the agents view",
2393
3207
  assert.equal(firstAgentRow(body()), firstAgentRow(before), "k must scroll the detail back up");
2394
3208
  });
2395
3209
 
3210
+ // The name prompt behind c, d, and r: the Pi host starts the field empty and
3211
+ // ignores the placeholder, and every outcome must show in the reopened panel,
3212
+ // because the fullscreen overlay hides notifications until it closes.
3213
+ async function runProfilesNameAction(
3214
+ fixture: ReturnType<typeof routingConsumerFixture>,
3215
+ keys: string | string[],
3216
+ ...answers: Array<string | undefined>
3217
+ ): Promise<string> {
3218
+ fixture.answerInputs(...answers);
3219
+ let visits = 0;
3220
+ fixture.onInput((panel) => {
3221
+ visits += 1;
3222
+ if (visits > 1) return panel.handleInput("\x1b");
3223
+ for (const key of [keys].flat()) panel.handleInput(key);
3224
+ });
3225
+ await fixture.run("gentle:profiles");
3226
+ assert.equal(fixture.panels.length, 2, "the panel reopens once after the action");
3227
+ return fixture.panels[1];
3228
+ }
3229
+
3230
+ /** The selected profile, read from the detail pane title of a rendered panel. */
3231
+ function selectedProfileTitle(rendered: string): string {
3232
+ return (rendered.split("\n")[1]?.split("│")[2] ?? "").replace("(active)", "").trim();
3233
+ }
3234
+
3235
+ function profilesPanelFooter(rendered: string): string {
3236
+ return rendered.split("\n").at(-2) ?? "";
3237
+ }
3238
+
3239
+ test("c creates the named profile, selects it, and reports it in the reopened panel", async (t) => {
3240
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3241
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3242
+ const reopened = await runProfilesNameAction(fixture, "c", " deep-work ");
3243
+
3244
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
3245
+ assert.deepEqual(Object.keys(store.profiles), ["team", "deep-work"]);
3246
+ assert.equal(selectedProfileTitle(reopened), "deep-work");
3247
+ assert.match(profilesPanelFooter(reopened), /Profile "deep-work" created\./);
3248
+ });
3249
+
3250
+ test("c with an empty name creates nothing and says so in the reopened panel", async (t) => {
3251
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3252
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3253
+ const before = readFileSync(storePath, "utf8");
3254
+ const reopened = await runProfilesNameAction(fixture, "c", " ");
3255
+
3256
+ assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3257
+ assert.equal(selectedProfileTitle(reopened), "team");
3258
+ assert.match(profilesPanelFooter(reopened), /No profile created: no name entered\./);
3259
+ });
3260
+
3261
+ test("d with an empty name duplicates to the suggested <name>-copy and selects it", async (t) => {
3262
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3263
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3264
+ const reopened = await runProfilesNameAction(fixture, "d", "");
3265
+
3266
+ // The host never shows the placeholder, so the suggestion lives in the title.
3267
+ assert.match(fixture.inputPrompts[0]?.title ?? "", /Duplicate profile "team" as \(empty = team-copy\)/);
3268
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
3269
+ assert.deepEqual(store.profiles["team-copy"], { worker: { model: "openai/alpha" } });
3270
+ assert.equal(selectedProfileTitle(reopened), "team-copy");
3271
+ assert.match(profilesPanelFooter(reopened), /Profile "team" duplicated as "team-copy"\./);
3272
+ });
3273
+
3274
+ test("d onto an existing name writes nothing and shows the conflict in the reopened panel", async (t) => {
3275
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3276
+ writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
3277
+ const before = readFileSync(storePath, "utf8");
3278
+ const reopened = await runProfilesNameAction(fixture, "d", "other");
3279
+
3280
+ assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3281
+ assert.equal(selectedProfileTitle(reopened), "team");
3282
+ assert.match(profilesPanelFooter(reopened), /Profile not duplicated: Profile already exists: other\./);
3283
+ });
3284
+
3285
+ test("r with an empty name leaves the profile unchanged and says so in the reopened panel", async (t) => {
3286
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3287
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3288
+ const before = readFileSync(storePath, "utf8");
3289
+ const reopened = await runProfilesNameAction(fixture, "r", "");
3290
+
3291
+ assert.match(fixture.inputPrompts[0]?.title ?? "", /Rename profile "team" to \(empty = keep team\)/);
3292
+ assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3293
+ assert.equal(selectedProfileTitle(reopened), "team");
3294
+ assert.match(profilesPanelFooter(reopened), /Profile "team" unchanged: no new name entered\./);
3295
+ });
3296
+
3297
+ test("r renames the profile, keeps it selected under the new name, and reports it", async (t) => {
3298
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3299
+ writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
3300
+ // Rename the second row, so the reopened selection cannot land on it by default.
3301
+ const reopened = await runProfilesNameAction(fixture, ["\x1b[B", "r"], "focus");
3302
+
3303
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
3304
+ assert.deepEqual(Object.keys(store.profiles), ["team", "focus"]);
3305
+ assert.equal(store.active, "team");
3306
+ assert.equal(selectedProfileTitle(reopened), "focus");
3307
+ assert.match(profilesPanelFooter(reopened), /Profile "other" renamed to "focus"\./);
3308
+ });
3309
+
3310
+ test("escape on the name prompt cancels without writing and says so in the reopened panel", async (t) => {
3311
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3312
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3313
+ const before = readFileSync(storePath, "utf8");
3314
+ const reopened = await runProfilesNameAction(fixture, "d", undefined);
3315
+
3316
+ assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3317
+ assert.match(profilesPanelFooter(reopened), /Duplicate cancelled\./);
3318
+ });
3319
+
2396
3320
  // The pin is the per-repository layer of the profiles command: `p` writes the
2397
3321
  // clone-scoped pin inside the Git common directory, `P` writes the committable
2398
3322
  // per-worktree declaration. Neither writes routing.
@@ -2577,6 +3501,7 @@ test("applying a profile in a pinned repository re-pins the clone and writes no
2577
3501
  assert.equal(readFileSync(fixture.globalPath, "utf8"), modelsBefore, "no global routing was written");
2578
3502
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "no orchestrator was written");
2579
3503
  assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized routing was written");
3504
+ assert.equal(fixture.confirmCalls.length, 0, "a repo-pinned apply never asks for confirmation");
2580
3505
  assert.match(fixture.notifications.at(-1)?.message ?? "", /repo-scoped/);
2581
3506
  });
2582
3507
 
@@ -2772,3 +3697,269 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => {
2772
3697
  const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
2773
3698
  assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model.");
2774
3699
  });
3700
+
3701
+ /** Reads the fixture's profiles store or fails the test: every panel-action fixture writes a valid store before acting. */
3702
+ function readValidProfilesStore(path: string) {
3703
+ const result = readProfilesFileResult(path);
3704
+ if (result.status !== "valid") throw new Error(`profiles store at ${path} is ${result.status}, expected valid`);
3705
+ return result.file;
3706
+ }
3707
+
3708
+ test("Enter switches the live orchestrator, binds the profile, and writes nothing", async (t) => {
3709
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3710
+ writeSettings();
3711
+ writeStore({ team: { orchestrator: { model: "openai/beta", thinking: "high" }, worker: { model: "openai/alpha" } }, old: {} }, "old");
3712
+ const before = {
3713
+ store: readFileSync(storePath, "utf8"),
3714
+ settings: readFileSync(settingsPath, "utf8"),
3715
+ };
3716
+ resetSessionProfileBindingsForTesting();
3717
+ const notifications: Array<{ message: string; severity: string }> = [];
3718
+ const ctx = {
3719
+ cwd: fixture.root,
3720
+ hasUI: true,
3721
+ ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } },
3722
+ sessionManager: { getSessionId: () => "session-panel" },
3723
+ modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) },
3724
+ } as unknown as ExtensionContext;
3725
+ const models: unknown[] = [];
3726
+ const thinking: ThinkingLevel[] = [];
3727
+ const live = {
3728
+ setModel: async (model: unknown) => { models.push(model); return true; },
3729
+ setThinkingLevel(level: ThinkingLevel) { thinking.push(level); },
3730
+ getThinkingLevel(): ThinkingLevel { return "medium"; },
3731
+ };
3732
+ const file = readValidProfilesStore(storePath);
3733
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
3734
+ assert.deepEqual(models, [{ provider: "openai", id: "beta" }]);
3735
+ assert.deepEqual(thinking, ["high"]);
3736
+ const binding = readSessionProfileBinding("session-panel");
3737
+ assert.equal(binding?.name, "team");
3738
+ assert.equal(binding?.modelProfiles.worker?.model, "openai/alpha");
3739
+ assert.equal(readFileSync(storePath, "utf8"), before.store, "the profiles store is untouched");
3740
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "Pi settings are untouched");
3741
+ assert.equal(existsSync(fixture.globalPath), false, "no global models.json is written");
3742
+ assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written");
3743
+ const applied = notifications.at(-1)?.message ?? "";
3744
+ assert.match(applied, /bound profile "team" to this session/);
3745
+ assert.match(applied, /shown as "team \(session\)"/);
3746
+ assert.match(applied, /This session now runs on openai\/beta · high/);
3747
+ assert.match(applied, /Subagents and reviewers use this session's routing snapshot/);
3748
+ assert.match(applied, /global routing, pins, and materialized stores are untouched/);
3749
+ assert.doesNotMatch(applied, /launch routing is unchanged/);
3750
+ resetSessionProfileBindingsForTesting();
3751
+ });
3752
+
3753
+ test("Enter switches only the selecting session's orchestrator and leaves shared defaults untouched", async (t) => {
3754
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3755
+ writeSettings();
3756
+ writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" }, worker: { model: "openai/beta" } }, old: {} }, "old");
3757
+ const beforeStore = readFileSync(storePath, "utf8");
3758
+ const beforeSettings = readFileSync(settingsPath, "utf8");
3759
+ resetSessionProfileBindingsForTesting();
3760
+ t.after(() => resetSessionProfileBindingsForTesting());
3761
+ bindSessionProfile("other-session", "old", { worker: { model: "openai/old" } });
3762
+ const switches: string[] = [];
3763
+ const thinking: ThinkingLevel[] = [];
3764
+ const ctx = {
3765
+ cwd: fixture.root, hasUI: true,
3766
+ ui: { notify() {} },
3767
+ sessionManager: { getSessionId: () => "selecting-session" },
3768
+ modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
3769
+ } as unknown as ExtensionContext;
3770
+ const live = {
3771
+ setModel: async (model: { provider: string; id: string }) => { switches.push(`${model.provider}/${model.id}`); return true; },
3772
+ setThinkingLevel: (level: ThinkingLevel) => { thinking.push(level); },
3773
+ getThinkingLevel: (): ThinkingLevel => "medium",
3774
+ } as unknown as LiveSession;
3775
+ await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" });
3776
+ assert.deepEqual(switches, ["openai/alpha"]);
3777
+ assert.deepEqual(thinking, ["high"]);
3778
+ assert.equal(readSessionProfileBinding("selecting-session")?.name, "team");
3779
+ assert.equal(readSessionProfileBinding("other-session")?.modelProfiles.worker?.model, "openai/old");
3780
+ assert.equal(readSessionProfileBinding("unbound-session"), undefined);
3781
+ assert.equal(readFileSync(storePath, "utf8"), beforeStore);
3782
+ assert.equal(readFileSync(settingsPath, "utf8"), beforeSettings);
3783
+ assert.equal(existsSync(fixture.globalPath), false);
3784
+ assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false);
3785
+ });
3786
+
3787
+ test("reviewer routing uses the selecting session snapshot without leaking to other sessions", (t) => {
3788
+ const { fixture, writeStore } = profilesStoreFixture(t);
3789
+ writeStore({ team: {} }, "team");
3790
+ mkdirSync(fixture.configHome, { recursive: true });
3791
+ writeFileSync(fixture.globalPath, JSON.stringify({ "review-risk": { model: "openai/global" }, "review-reliability": { model: "openai/global" } }));
3792
+ resetSessionProfileBindingsForTesting();
3793
+ t.after(() => resetSessionProfileBindingsForTesting());
3794
+ bindSessionProfile("selecting-session", "team", { "review-risk": { model: "openai/session", thinking: "high" } });
3795
+ const selected = __testing.readReviewerModelConfig(fixture.root, "selecting-session");
3796
+ assert.equal(selected["review-risk"]?.model, "openai/session");
3797
+ assert.equal(selected["review-reliability"], undefined, "a complete session snapshot never falls back per role");
3798
+ assert.equal(__testing.readReviewerModelConfig(fixture.root, "other-session")["review-risk"]?.model, "openai/global");
3799
+ assert.equal(__testing.readReviewerModelConfig(fixture.root, undefined)["review-risk"]?.model, "openai/global");
3800
+ });
3801
+
3802
+ test("Enter preserves session-only routing when the orchestrator is absent or cannot switch", async (t) => {
3803
+ for (const rejected of [false, true]) {
3804
+ const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3805
+ writeSettings();
3806
+ writeStore({ team: {
3807
+ worker: { model: "openai/alpha" },
3808
+ ...(rejected ? { orchestrator: { model: "openai/beta", thinking: "high" as const } } : {}),
3809
+ } }, undefined);
3810
+ const storeBefore = readFileSync(storePath, "utf8");
3811
+ const settingsBefore = readFileSync(settingsPath, "utf8");
3812
+ resetSessionProfileBindingsForTesting();
3813
+ t.after(() => resetSessionProfileBindingsForTesting());
3814
+ const notifications: string[] = [];
3815
+ let modelCalls = 0;
3816
+ let thinkingCalls = 0;
3817
+ const ctx = {
3818
+ cwd: fixture.root,
3819
+ hasUI: true,
3820
+ ui: { notify(message: string) { notifications.push(message); } },
3821
+ sessionManager: { getSessionId: () => "session-panel" },
3822
+ modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) },
3823
+ } as unknown as ExtensionContext;
3824
+ const live = {
3825
+ setModel: async () => { modelCalls++; return false; },
3826
+ setThinkingLevel() { thinkingCalls++; },
3827
+ getThinkingLevel(): ThinkingLevel { return "medium"; },
3828
+ };
3829
+ await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" }, {});
3830
+ assert.equal(modelCalls, rejected ? 1 : 0);
3831
+ assert.equal(thinkingCalls, 0);
3832
+ assert.equal(readSessionProfileBinding("session-panel")?.modelProfiles.worker?.model, "openai/alpha");
3833
+ assert.equal(readFileSync(storePath, "utf8"), storeBefore);
3834
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3835
+ assert.equal(existsSync(fixture.globalPath), false);
3836
+ assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false);
3837
+ if (rejected) assert.match(notifications.at(-1) ?? "", /no authentication is configured for openai; this session keeps its current model/);
3838
+ else assert.doesNotMatch(notifications.at(-1) ?? "", /This session now runs/);
3839
+ }
3840
+ });
3841
+
3842
+ test("a keeps the legacy global apply semantics", async (t) => {
3843
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3844
+ writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, "old");
3845
+ const ctx = {
3846
+ cwd: fixture.root,
3847
+ hasUI: true,
3848
+ ui: { notify() {}, confirm: async () => true },
3849
+ sessionManager: { getSessionId: () => "session-panel" },
3850
+ } as unknown as ExtensionContext;
3851
+ const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
3852
+ const file = readValidProfilesStore(storePath);
3853
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team" }, {});
3854
+ assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "the global store claims the profile");
3855
+ assert.ok(existsSync(fixture.globalPath), "the global routing is materialized");
3856
+ resetSessionProfileBindingsForTesting();
3857
+ });
3858
+
3859
+ test("Enter with a winning pin binds the session and never touches the pin layers", async (t) => {
3860
+ const { fixture, storePath, writeStore, writePin, localPinPath } = profilesStoreFixture(t);
3861
+ writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, undefined);
3862
+ writePin(localPinPath, "old");
3863
+ const pinBefore = readFileSync(localPinPath, "utf8");
3864
+ const storeBefore = readFileSync(storePath, "utf8");
3865
+ resetSessionProfileBindingsForTesting();
3866
+ const ctx = {
3867
+ cwd: fixture.root,
3868
+ hasUI: true,
3869
+ ui: { notify() {} },
3870
+ sessionManager: { getSessionId: () => "session-panel" },
3871
+ } as unknown as ExtensionContext;
3872
+ const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
3873
+ const file = readValidProfilesStore(storePath);
3874
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
3875
+ assert.equal(readSessionProfileBinding("session-panel")?.name, "team");
3876
+ assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched");
3877
+ assert.equal(readFileSync(storePath, "utf8"), storeBefore, "the store is untouched");
3878
+ assert.equal(existsSync(fixture.globalPath), false);
3879
+ resetSessionProfileBindingsForTesting();
3880
+ });
3881
+
3882
+ test("a session-bound panel renders the binding snapshot as the current routing", async (t) => {
3883
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3884
+ writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
3885
+ mkdirSync(fixture.configHome, { recursive: true });
3886
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
3887
+ t.after(() => resetSessionProfileBindingsForTesting());
3888
+ bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
3889
+ fixture.onInput((panel) => {
3890
+ const rendered = renderComponent(panel);
3891
+ assert.match(rendered, /Current routing \(effective\)/);
3892
+ assert.match(rendered, /openai\/gamma/, "the current routing is the session binding's snapshot");
3893
+ assert.doesNotMatch(rendered, /openai\/alpha/, "the global routing stays out of a bound session's panel");
3894
+ assert.match(
3895
+ rendered,
3896
+ /team \(session\) — launches resolve it ahead of pins/,
3897
+ "the session line states what the binding governs post-#1558 (the panel truncates long lines)",
3898
+ );
3899
+ assert.doesNotMatch(rendered, /launch routing is unchanged/, "no stale slice-1 claim survives in the panel");
3900
+ panel.handleInput("\x1b");
3901
+ });
3902
+ await fixture.run("gentle:profiles");
3903
+ resetSessionProfileBindingsForTesting();
3904
+ });
3905
+
3906
+ test("the now line reports the live session's orchestrator over the settings defaults", async (t) => {
3907
+ const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
3908
+ writeSettings();
3909
+ writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
3910
+ // The session runs live on openai/omega at low effort while settings.json still
3911
+ // defaults to nan/deepseek-v4-flash at high: the panel's now line must report
3912
+ // what this session actually runs, not the default new sessions would get.
3913
+ fixture.setLiveModel("openai", "omega", "low");
3914
+ fixture.onInput((panel) => {
3915
+ const rendered = renderComponent(panel);
3916
+ assert.match(rendered, /now\s+openai\/omega/, "the now line shows the live session's orchestrator");
3917
+ assert.doesNotMatch(rendered, /now\s+nan\//, "the settings default stays off the now line when a live model runs");
3918
+ panel.handleInput("\x1b");
3919
+ });
3920
+ await fixture.run("gentle:profiles");
3921
+ });
3922
+
3923
+ test("the (session) marker survives a snapshot refresh of the panel list", async (t) => {
3924
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3925
+ writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
3926
+ mkdirSync(fixture.configHome, { recursive: true });
3927
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
3928
+ t.after(() => resetSessionProfileBindingsForTesting());
3929
+ bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
3930
+ fixture.onInput((panel) => {
3931
+ assert.match(renderComponent(panel), /team \(active\) \(session\)/);
3932
+ panel.handleInput("s");
3933
+ assert.match(renderComponent(panel), /Snapshot saved; live routing unchanged\./);
3934
+ assert.match(renderComponent(panel), /team \(active\) \(session\)/, "the refreshed list keeps the session marker");
3935
+ panel.handleInput("\x1b");
3936
+ });
3937
+ await fixture.run("gentle:profiles");
3938
+ // The snapshot must capture the routing the panel showed as current, so the
3939
+ // saved team profile carries the session binding's model, never the global
3940
+ // layer underneath it.
3941
+ const saved = readValidProfilesStore(storePath);
3942
+ assert.equal(saved.profiles.team?.worker?.model, "openai/gamma", "the saved profile carries the session-bound routing");
3943
+ assert.notEqual(saved.profiles.team?.worker?.model, "openai/alpha", "the global routing never leaks into the saved snapshot");
3944
+ resetSessionProfileBindingsForTesting();
3945
+ });
3946
+
3947
+ test("Enter without a parent session id fails loud and writes nothing", async (t) => {
3948
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3949
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined);
3950
+ const before = readFileSync(storePath, "utf8");
3951
+ const notifications: Array<{ message: string; severity: string }> = [];
3952
+ const ctx = {
3953
+ cwd: fixture.root,
3954
+ hasUI: true,
3955
+ ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } },
3956
+ } as unknown as ExtensionContext;
3957
+ const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
3958
+ const file = readValidProfilesStore(storePath);
3959
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
3960
+ assert.equal(readSessionProfileBinding(undefined), undefined);
3961
+ assert.equal(readFileSync(storePath, "utf8"), before);
3962
+ assert.equal(existsSync(fixture.globalPath), false);
3963
+ assert.equal(notifications.at(-1)?.severity, "warning");
3964
+ resetSessionProfileBindingsForTesting();
3965
+ });