gentle-pi 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -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,9 +15,10 @@ 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";
@@ -24,9 +26,35 @@ 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);
@@ -293,7 +321,7 @@ test("registered Gentle Review tools preserve result envelopes and preview usefu
293
321
  { manifest: encoded, sha256, cursor: 0 },
294
322
  undefined,
295
323
  undefined,
296
- { cwd: process.cwd() } as ExtensionContext,
324
+ { cwd: process.cwd() } as unknown as ExtensionContext,
297
325
  );
298
326
  const visibleEnvelope = JSON.parse(result.content[0].text);
299
327
  assert.deepEqual(visibleEnvelope, {
@@ -414,14 +442,23 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
414
442
  { provider: "openai", id: "beta" },
415
443
  { provider: "nan", id: "glm5.3" },
416
444
  ];
445
+ let onConfirm: (title: string, message: string) => Promise<boolean> = async () => true;
446
+ const confirmCalls: Array<[string, string]> = [];
417
447
  const ctx = {
418
448
  cwd: root,
419
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" },
420
453
  modelRegistry: {
421
454
  getAvailable: async () => registryModels.filter((model) => model.provider === "openai"),
422
455
  find: (provider: string, id: string) => registryModels.find((model) => model.provider === provider && model.id === id),
423
456
  },
424
457
  ui: {
458
+ confirm: async (title: string, message: string) => {
459
+ confirmCalls.push([title, message]);
460
+ return onConfirm(title, message);
461
+ },
425
462
  notify(message: string, severity: string) { notifications.push({ message, severity }); },
426
463
  input: async (title: string, placeholder?: string) => {
427
464
  inputPrompts.push({ title, placeholder });
@@ -453,6 +490,8 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
453
490
  },
454
491
  refuseSetModel() { setModelResult = false; },
455
492
  rejectThinkingLevel() { thinkingRejects = true; },
493
+ confirmCalls,
494
+ onConfirm(handler: (title: string, message: string) => Promise<boolean>) { onConfirm = handler; },
456
495
  onPanel(action: typeof onPanel) { onPanel = action; },
457
496
  onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
458
497
  answerInputs(...answers: Array<string | undefined>) { inputAnswers.push(...answers); },
@@ -715,7 +754,7 @@ test("session startup reports invalid project routing without mutating the profi
715
754
  registerCommand() {},
716
755
  registerTool() {},
717
756
  } as unknown as ExtensionAPI;
718
- createGentleAiExtension({ nativeReviewCli: null })(pi);
757
+ createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: "0" } })(pi);
719
758
  const sessionStart = handlers.get("session_start");
720
759
  assert.equal(typeof sessionStart, "function");
721
760
  const notifications: Array<{ message: string; severity: string }> = [];
@@ -875,7 +914,7 @@ test("runtime guidance keeps review policy out of the static orchestrator and te
875
914
  }
876
915
 
877
916
  const orchestrator = readFileSync("assets/orchestrator.md", "utf8")
878
- + readFileSync("assets/orchestrator-delegation.md", "utf8");
917
+ + readDelegationDetail();
879
918
  assert.match(orchestrator, /injects the mirrored provider-bundle review execution contract/);
880
919
  assert.match(orchestrator, /this package invents no lifecycle instructions/);
881
920
  for (const lifecycleMarker of ["review-risk", "review-reliability", "review-resilience", "review-readability", "Authority-First Terminal Procedure", "reconcile-terminal-mirrors"]) {
@@ -969,7 +1008,10 @@ test("ordinary START adopts the committed-range selectors the provider just offe
969
1008
  targetStatus: async () => offeredCommittedRangeStatus(baseCommit, baseTree, candidateTree),
970
1009
  start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
971
1010
  } as unknown as NativeReviewCli;
972
- 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);
973
1015
  assert.equal(result.operation, "start");
974
1016
  assert.equal(starts.length, 1);
975
1017
  assert.equal(starts[0]?.baseRef, baseCommit);
@@ -1321,7 +1363,7 @@ test("delivery commands bypass RDD under every mode outcome while command safety
1321
1363
  cwd: process.cwd(),
1322
1364
  hasUI: true,
1323
1365
  ui: { confirm: async () => true },
1324
- } as ExtensionContext;
1366
+ } as unknown as ExtensionContext;
1325
1367
 
1326
1368
  for (const command of commands) {
1327
1369
  const result = await toolCall!({ toolName: "bash", input: { command } }, ctx);
@@ -1353,6 +1395,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1353
1395
  const handlers = new Map<string, ToolCallHandler>();
1354
1396
  const emitted: EmittedEvent[] = [];
1355
1397
  const sequence: string[] = [];
1398
+ const confirmation = confirmationObserver();
1356
1399
  let confirm!: () => Promise<boolean>;
1357
1400
  const pi = {
1358
1401
  on(name: string, handler: ToolCallHandler) {
@@ -1363,7 +1406,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1363
1406
  sequence.push(
1364
1407
  channel === "herdr:blocked"
1365
1408
  ? `herdr:${"active" in data && data.active ? "active" : "inactive"}`
1366
- : `event:${data.state}`,
1409
+ : `event:${"state" in data ? data.state : "unknown"}`,
1367
1410
  );
1368
1411
  emitted.push({ channel, data } as EmittedEvent);
1369
1412
  },
@@ -1382,10 +1425,11 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1382
1425
  ui: {
1383
1426
  confirm: async () => {
1384
1427
  sequence.push("confirm");
1428
+ confirmation.observe();
1385
1429
  return confirm();
1386
1430
  },
1387
1431
  },
1388
- } as ExtensionContext;
1432
+ } as unknown as ExtensionContext;
1389
1433
  let resolveConfirmation!: (approved: boolean) => void;
1390
1434
  confirm = () => new Promise<boolean>((resolve) => { resolveConfirmation = resolve; });
1391
1435
  const denied = toolCall!({
@@ -1393,7 +1437,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
1393
1437
  input: { command: "git rebase main --secret-command-content" },
1394
1438
  }, ctx);
1395
1439
 
1396
- await Promise.resolve();
1440
+ await confirmation.waitFor(1);
1397
1441
  assert.equal(emitted[0].channel, "pi-permission-system:permission-request");
1398
1442
  assert.equal(emitted[0].data.state, "waiting");
1399
1443
  assert.deepEqual(emitted[1], {
@@ -1497,6 +1541,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1497
1541
  const createHarness = () => {
1498
1542
  const handlers = new Map<string, ToolCallHandler>();
1499
1543
  const emitted: EmittedEvent[] = [];
1544
+ const confirmation = confirmationObserver();
1500
1545
  const confirmations: Array<(approved: boolean) => void> = [];
1501
1546
  const pi = {
1502
1547
  on(name: string, handler: ToolCallHandler) {
@@ -1507,7 +1552,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1507
1552
  registerTool() {},
1508
1553
  } as unknown as ExtensionAPI;
1509
1554
  createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
1510
- return { handlers, emitted, confirmations };
1555
+ return { handlers, emitted, confirmations, confirmation };
1511
1556
  };
1512
1557
  const first = createHarness();
1513
1558
  const second = createHarness();
@@ -1517,12 +1562,15 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1517
1562
  cwd,
1518
1563
  hasUI: true,
1519
1564
  ui: {
1520
- 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
+ }),
1521
1569
  },
1522
- } as ExtensionContext);
1570
+ } as unknown as ExtensionContext);
1523
1571
  const firstRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main" } }, context(first.confirmations));
1524
1572
  const secondRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --another-command" } }, context(first.confirmations));
1525
- await Promise.resolve();
1573
+ await first.confirmation.waitFor(2);
1526
1574
  assert.deepEqual(first.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
1527
1575
  { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
1528
1576
  { channel: "herdr:blocked", state: undefined, active: true },
@@ -1545,7 +1593,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
1545
1593
  ]);
1546
1594
 
1547
1595
  const independentRequest = second.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --independent-command" } }, context(second.confirmations));
1548
- await Promise.resolve();
1596
+ await second.confirmation.waitFor(1);
1549
1597
  assert.equal(second.emitted.filter(({ channel }) => channel === "herdr:blocked").length, 1);
1550
1598
  assert.equal(second.emitted.find(({ channel }) => channel === "herdr:blocked")?.data.active, true);
1551
1599
  assert.equal(second.confirmations.length, 1);
@@ -1652,6 +1700,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
1652
1700
  const handlers = new Map<string, ToolCallHandler>();
1653
1701
  const eventHandlers = new Map<string, (data: unknown) => void>();
1654
1702
  const herdrEvents: HerdrBlockedEvent[] = [];
1703
+ const confirmation = confirmationObserver();
1655
1704
  const confirmations: Array<(approved: boolean) => void> = [];
1656
1705
  const pi = {
1657
1706
  on(name: string, handler: ToolCallHandler) {
@@ -1676,10 +1725,10 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
1676
1725
  cwd: process.cwd(),
1677
1726
  hasUI: true,
1678
1727
  ui: {
1679
- confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
1728
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
1680
1729
  },
1681
- } as ExtensionContext;
1682
- 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")! };
1683
1732
  };
1684
1733
 
1685
1734
  const guardedFirst = createHarness();
@@ -1687,7 +1736,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
1687
1736
  { toolName: "bash", input: { command: "git rebase main" } },
1688
1737
  guardedFirst.context,
1689
1738
  );
1690
- await Promise.resolve();
1739
+ await guardedFirst.confirmation.waitFor(1);
1691
1740
  guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
1692
1741
  guardedFirst.confirmations[0]!(true);
1693
1742
  assert.equal(await guardedRequest, undefined);
@@ -1708,7 +1757,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
1708
1757
  const request = overlap.toolCall(
1709
1758
  { toolName: "bash", input: { command: "git rebase main" } }, overlap.context,
1710
1759
  );
1711
- await Promise.resolve();
1760
+ await overlap.confirmation.waitFor(1);
1712
1761
  for (const channel of channels.filter((channel) => channel !== lastChannel)) {
1713
1762
  overlap.pi.events.emit(channel, { active: false });
1714
1763
  overlap.pi.events.emit(channel, { active: false });
@@ -1728,7 +1777,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
1728
1777
  { toolName: "bash", input: { command: "git rebase main" } },
1729
1778
  questionnaireFirst.context,
1730
1779
  );
1731
- await Promise.resolve();
1780
+ await questionnaireFirst.confirmation.waitFor(1);
1732
1781
  questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
1733
1782
  questionnaireFirst.confirmations[0]!(false);
1734
1783
  await questionnaireRequest;
@@ -1748,6 +1797,7 @@ test("closed choice blockers retain the visible choice label through guarded-con
1748
1797
  const eventHandlers = new Map<string, (data: unknown) => void>();
1749
1798
  const herdrEvents: HerdrBlockedEvent[] = [];
1750
1799
  const choiceEvents: Array<{ active: boolean }> = [];
1800
+ const confirmation = confirmationObserver();
1751
1801
  const confirmations: Array<(approved: boolean) => void> = [];
1752
1802
  const pi = {
1753
1803
  on(name: string, handler: ToolCallHandler) {
@@ -1785,11 +1835,11 @@ test("closed choice blockers retain the visible choice label through guarded-con
1785
1835
  cwd: process.cwd(),
1786
1836
  hasUI: true,
1787
1837
  ui: {
1788
- confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
1838
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
1789
1839
  },
1790
- } as ExtensionContext,
1840
+ } as unknown as ExtensionContext,
1791
1841
  );
1792
- await Promise.resolve();
1842
+ await confirmation.waitFor(1);
1793
1843
  assert.equal(confirmations.length, 1);
1794
1844
  assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
1795
1845
 
@@ -1837,12 +1887,12 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
1837
1887
  cwd,
1838
1888
  hasUI: false,
1839
1889
  ui: { confirm },
1840
- } as ExtensionContext), undefined);
1890
+ } as unknown as ExtensionContext), undefined);
1841
1891
  assert.deepEqual(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, {
1842
1892
  cwd,
1843
1893
  hasUI: false,
1844
1894
  ui: { confirm },
1845
- } as ExtensionContext), {
1895
+ } as unknown as ExtensionContext), {
1846
1896
  block: true,
1847
1897
  reason: "Gentle AI safety policy requires interactive confirmation before this command.",
1848
1898
  });
@@ -1897,7 +1947,7 @@ test("bash tool_call confirms a late guarded npm publish and denies on non-appro
1897
1947
  return false;
1898
1948
  },
1899
1949
  },
1900
- } as ExtensionContext;
1950
+ } as unknown as ExtensionContext;
1901
1951
 
1902
1952
  const prefix = "noise ".repeat(80);
1903
1953
  const command = `${prefix}npm publish --tag beta`;
@@ -1949,7 +1999,7 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
1949
1999
  cwd: process.cwd(),
1950
2000
  hasUI: true,
1951
2001
  ui: { confirm: async (title: string, message: string) => (confirmArgs.push([title, message]), false) },
1952
- } as ExtensionContext);
2002
+ } as unknown as ExtensionContext);
1953
2003
  assert.deepEqual(result, {
1954
2004
  block: true,
1955
2005
  reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
@@ -1973,9 +2023,11 @@ function applyOnce(
1973
2023
  fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
1974
2024
  ): void {
1975
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.
1976
2028
  fixture.onInput((panel) => {
1977
2029
  visits += 1;
1978
- panel.handleInput(visits === 1 ? "\r" : "\x1b");
2030
+ panel.handleInput(visits === 1 ? "a" : "\x1b");
1979
2031
  });
1980
2032
  }
1981
2033
 
@@ -2035,6 +2087,9 @@ test("applying a profile persists its orchestrator and never leaks the key into
2035
2087
  worker: { model: "openai/alpha" },
2036
2088
  },
2037
2089
  });
2090
+ // Every global apply now confirms, so this populated apply approves the
2091
+ // dialog explicitly before running.
2092
+ fixture.onConfirm(async () => true);
2038
2093
  applyOnce(fixture);
2039
2094
  await fixture.run("gentle:profiles");
2040
2095
 
@@ -2060,6 +2115,8 @@ test("applying a profile persists its orchestrator and never leaks the key into
2060
2115
  { kind: "model", provider: "nan", id: "glm5.3" },
2061
2116
  { kind: "thinking", level: "max" },
2062
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/);
2063
2120
  });
2064
2121
 
2065
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) => {
@@ -2125,6 +2182,624 @@ test("a profile store entry with only the orchestrator key counts zero roles", a
2125
2182
  assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
2126
2183
  });
2127
2184
 
2185
+ test("applying an empty profile asks for confirmation and aborts when declined", async (t) => {
2186
+ const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
2187
+ writeSettings();
2188
+ mkdirSync(fixture.configHome, { recursive: true });
2189
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2190
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
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
+
2205
+ applyOnce(fixture);
2206
+ await fixture.run("gentle:profiles");
2207
+
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");
2225
+ });
2226
+
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();
2230
+ mkdirSync(fixture.configHome, { recursive: true });
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`);
2234
+ writeStore({
2235
+ empty: {},
2236
+ team: { worker: { model: "openai/alpha" } },
2237
+ }, "team");
2238
+
2239
+ fixture.onConfirm(async () => true);
2240
+
2241
+ applyOnce(fixture);
2242
+ await fixture.run("gentle:profiles");
2243
+
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)");
2253
+ });
2254
+
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);
2257
+ writeSettings();
2258
+ mkdirSync(fixture.configHome, { recursive: true });
2259
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
2260
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
2261
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
2262
+ writeStore({
2263
+ orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
2264
+ team: { worker: { model: "openai/alpha" } },
2265
+ }, "team");
2266
+ const before = {
2267
+ models: readFileSync(fixture.globalPath, "utf8"),
2268
+ subagents: readFileSync(subagentsPath, "utf8"),
2269
+ store: readFileSync(storePath, "utf8"),
2270
+ settings: readFileSync(settingsPath, "utf8"),
2271
+ };
2272
+
2273
+ fixture.onConfirm(async () => false);
2274
+
2275
+ applyOnce(fixture);
2276
+ await fixture.run("gentle:profiles");
2277
+
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);
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`);
2306
+ writeStore({
2307
+ orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
2308
+ team: { worker: { model: "openai/alpha" } },
2309
+ }, "team");
2310
+
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
+
2320
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
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");
2335
+ });
2336
+
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);
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");
2348
+ writeStore({
2349
+ team: {
2350
+ orchestrator: { model: "nan/glm5.3", thinking: "max" },
2351
+ worker: { model: "openai/beta", thinking: "max" },
2352
+ tracer: { model: "openai/alpha" },
2353
+ },
2354
+ });
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
+ };
2361
+
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");
2381
+ });
2382
+
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);
2385
+ writeSettings();
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
+
2128
2803
  test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
2129
2804
  const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2130
2805
  writeSettings();
@@ -2218,7 +2893,7 @@ test("s snapshots current routing in place without applying or reopening the pro
2218
2893
  });
2219
2894
  assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
2220
2895
  assert.equal(store.active, "z-active");
2221
- assert.match(fixture.panels[0] ?? "", /enter apply · c create · s snapshot/);
2896
+ assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/);
2222
2897
  assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
2223
2898
  assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
2224
2899
  assert.equal(readFileSync(workerPath, "utf8"), before.worker);
@@ -2232,6 +2907,32 @@ test("s snapshots current routing in place without applying or reopening the pro
2232
2907
  assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
2233
2908
  });
2234
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
+
2235
2936
  // /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
2236
2937
  // by the snapshot `/gentle:profiles` performs with `s` on the current profile.
2237
2938
  function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
@@ -2800,6 +3501,7 @@ test("applying a profile in a pinned repository re-pins the clone and writes no
2800
3501
  assert.equal(readFileSync(fixture.globalPath, "utf8"), modelsBefore, "no global routing was written");
2801
3502
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "no orchestrator was written");
2802
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");
2803
3505
  assert.match(fixture.notifications.at(-1)?.message ?? "", /repo-scoped/);
2804
3506
  });
2805
3507
 
@@ -2995,3 +3697,269 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => {
2995
3697
  const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
2996
3698
  assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model.");
2997
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
+ });