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
@@ -4,18 +4,23 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
4
4
  import { createRequire } from "node:module";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import test, { after } from "node:test";
7
+ import test, { after, afterEach } from "node:test";
8
8
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
9
- import { CURSOR_MARKER, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
10
- import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
9
+ import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
10
+ import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
11
11
  import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
12
- import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts";
12
+ import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
13
+ import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
14
+ import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
15
+
16
+ // Fixture sessions share ids across tests; the inherited-profile freeze is
17
+ // process state keyed by session id (gentle-shell#1064 slice 3b-ii).
18
+ afterEach(() => resetFrozenInheritedProfilesForTesting());
13
19
  import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
14
- import { CHANGE_STATUS } from "../lib/shell-changes.ts";
20
+ import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE } from "../lib/shell-changes.ts";
15
21
  import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
16
22
  import { renderTodoCard } from "../lib/shell-todo.ts";
17
23
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
18
- import { stripAnsi } from "../lib/terminal-theme.ts";
19
24
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
20
25
  import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
21
26
  import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
@@ -24,11 +29,15 @@ import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-custom
24
29
  import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
25
30
  import { resolveVimPolicy, writeVimPolicy } from "../lib/vim-policy.ts";
26
31
  import { resolveHistoryCapturePolicy, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts";
32
+ import { setBundledInstallForTesting } from "../lib/gentle-shell-bundled.ts";
27
33
  import { readBannerConfig } from "../extensions/startup-banner.ts";
28
34
  import { listVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
29
35
  import { oddPhaseRegistry } from "../lib/odd-phase.ts";
30
36
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
31
37
  import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
38
+ import { CARD_CONTENT, cardContent, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
39
+ import { claimNotificationOwner } from "../lib/notification-service.ts";
40
+ import { DEFAULT_NOTIFICATION_SETTINGS } from "../lib/notification-policy.ts";
32
41
 
33
42
 
34
43
  // Vim fixtures claim the installed pi-tui release, which the adapter gate
@@ -52,7 +61,7 @@ function isolatedEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
52
61
  isolatedConfigHomes.push(home);
53
62
  return { ...env, GENTLE_PI_CONFIG_HOME: home };
54
63
  }
55
- const gentleShell: typeof installGentleShell = (pi, env, deps) => installGentleShell(pi, isolatedEnv(env), { resolveWorktree, gitRunner: (cwd) => async (args) => pi.exec("git", ["-C", cwd, ...args], { timeout: 5000 }), ...deps });
64
+ const gentleShell: typeof installGentleShell = (pi, env, deps) => installGentleShell(pi, isolatedEnv(env), { resolveWorktree, gitRunner: (cwd) => async (args) => pi.exec("git", ["-C", cwd, ...args], { timeout: 5000 }), rootExists: () => true, ...deps });
56
65
 
57
66
  const plainTheme = {
58
67
  fg(_color: string, value: string) {
@@ -151,6 +160,8 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commands
151
160
  async exec(_command: string, args: string[]) {
152
161
  git.push(args);
153
162
  if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
163
+ // Roots outside the worktree list read as a detached HEAD.
164
+ if (args.includes("symbolic-ref")) return { stdout: "", stderr: "", code: 1, killed: false };
154
165
  const isNumstat = args.includes("diff");
155
166
  const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
156
167
  return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
@@ -163,7 +174,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
163
174
  for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
164
175
  }
165
176
 
166
- function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
177
+ function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
167
178
  const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
168
179
  let resolveOverlay: () => void;
169
180
  const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
@@ -184,7 +195,7 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
184
195
  modelRegistry: { isUsingOAuth: () => options.oauth ?? true, getApiKeyForProvider: async () => options.token },
185
196
  getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
186
197
  ui: {
187
- theme: plainTheme,
198
+ theme: options.theme ?? plainTheme,
188
199
  getAllThemes: () => [{ name: "dark", path: undefined }, { name: "light", path: undefined }],
189
200
  getTheme: (name: string) => name === "dark" || name === "light" ? { name } : undefined,
190
201
  setTheme: (name: string) => ({ success: name === "dark" || name === "light" }),
@@ -280,6 +291,63 @@ test("buildShellBarModel shortens the home directory and hides effort for non-re
280
291
  assert.equal(built.branch, null);
281
292
  });
282
293
 
294
+ test("buildShellBarModel reports auto effort for NaN models that manage their own depth", () => {
295
+ const { pi } = fakePi();
296
+ const { ctx } = fakeContext();
297
+ const model = ctx.model as unknown as { id: string; provider: string };
298
+ model.provider = "nan";
299
+ model.id = "deepseek-v4-flash";
300
+ const footerData = {
301
+ getGitBranch: () => null,
302
+ getExtensionStatuses: () => new Map(),
303
+ getAvailableProviderCount: () => 1,
304
+ onBranchChange: () => () => {},
305
+ };
306
+ assert.equal(buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" }).effort, "auto");
307
+ // A NaN model that honors the level keeps reporting the selected one.
308
+ model.id = "glm5.3";
309
+ assert.equal(buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" }).effort, "medium");
310
+ });
311
+
312
+ test("a self-managing NaN model announces its automatic depth once per session", async () => {
313
+ const { pi, handlers } = fakePi();
314
+ gentleShell(pi, {});
315
+ const { ctx, ui } = fakeContext();
316
+ const adaptive = { id: "qwen3.8-flash", provider: "nan", name: "Qwen 3.8 Flash", reasoning: true };
317
+ const budget = { id: "glm5.3", provider: "nan", name: "GLM 5.3", reasoning: true };
318
+ (ctx as unknown as { model: unknown }).model = adaptive;
319
+ const emit = async (event: string, payload: unknown) => {
320
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
321
+ };
322
+ await emit("thinking_level_select", { type: "thinking_level_select", level: "high", previousLevel: "medium" });
323
+ assert.deepEqual(ui.notices.filter((notice) => notice.includes("automatically")), [
324
+ "Qwen 3.8 Flash chooses its reasoning depth automatically; the selected thinking level is accepted but does not change it.",
325
+ ]);
326
+ // Re-selecting the same model or level never repeats the notice.
327
+ await emit("model_select", { type: "model_select", model: adaptive, previousModel: budget, source: "set" });
328
+ assert.equal(ui.notices.filter((notice) => notice.includes("automatically")).length, 1);
329
+ // A model that applies the level never announces.
330
+ (ctx as unknown as { model: unknown }).model = budget;
331
+ await emit("model_select", { type: "model_select", model: budget, previousModel: adaptive, source: "set" });
332
+ assert.equal(ui.notices.filter((notice) => notice.includes("automatically")).length, 1);
333
+ });
334
+
335
+ test("a session that starts on a self-managing NaN model announces it once", async () => {
336
+ const { pi, handlers } = fakePi();
337
+ gentleShell(pi, {});
338
+ const { ctx, ui } = fakeContext();
339
+ const adaptive = { id: "mimo-v2.6-flash", provider: "nan", name: "MiMo V2.6 Flash", reasoning: true };
340
+ (ctx as unknown as { model: unknown }).model = adaptive;
341
+ for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx);
342
+ assert.deepEqual(ui.notices.filter((notice) => notice.includes("automatically")), [
343
+ "MiMo V2.6 Flash chooses its reasoning depth automatically; the selected thinking level is accepted but does not change it.",
344
+ ]);
345
+ // A session that starts on a model which honors the level stays silent.
346
+ (ctx as unknown as { model: unknown }).model = { id: "gemma4", provider: "nan", name: "Gemma 4", reasoning: true };
347
+ for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx);
348
+ assert.equal(ui.notices.filter((notice) => notice.includes("automatically")).length, 1);
349
+ });
350
+
283
351
  test("gentleShell installs the footer on session_start when a UI exists", () => {
284
352
  const { pi, handlers } = fakePi();
285
353
  gentleShell(pi, {});
@@ -430,6 +498,71 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown
430
498
  }
431
499
  });
432
500
 
501
+ test("jobs snapshots populate Status, use one compact fallback, and reject stale sessions", async () => {
502
+ const { pi, handlers } = fakePi();
503
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
504
+ const { ctx, ui } = fakeContext();
505
+ await fire(handlers, "session_start", ctx);
506
+ const statuses = new Map([["gentle-jobs", "⧗ 1 job"], ["mcp", "MCP: 3 servers"]]);
507
+ const data = { getGitBranch: () => "main", getExtensionStatuses: () => statuses, getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
508
+ const tui = { mode: "fullscreen", terminal: { rows: 40, columns: 160 }, requestRender() {} };
509
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
510
+ const component = factory(tui, plainTheme, data);
511
+ const state = sidebarState(tui as unknown as TUI);
512
+ const rail = state.parts.get("footer")!;
513
+ const header = state.parts.get("header")!;
514
+ const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } });
515
+ try {
516
+ publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
517
+ assert.match(rail.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
518
+ assert.match(rail.render(46).join("\n"), /MCP: 3 servers/);
519
+ assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "Jobs replaces the generic integration counter");
520
+ state.active = true;
521
+ state.ownsHost = () => true;
522
+ assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible Status card owns jobs details");
523
+ state.active = false;
524
+ tui.terminal.columns = 80;
525
+ assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count");
526
+ const before = rail.digest?.();
527
+ publish("foreign", []);
528
+ publish("shell-session", [{ label: "invalid" }]);
529
+ assert.equal(rail.digest?.(), before);
530
+ publish("shell-session", []);
531
+ assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR/);
532
+ assert.doesNotMatch(header.render(80).join("\n"), /1 job/);
533
+ publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]);
534
+ await fire(handlers, "session_start", ctx);
535
+ assert.doesNotMatch(rail.render(46).join("\n"), /Build runtime/);
536
+ await fire(handlers, "session_shutdown", ctx);
537
+ publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]);
538
+ assert.doesNotMatch(rail.render(46).join("\n"), /late/);
539
+ } finally { component.dispose(); }
540
+ });
541
+
542
+ test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => {
543
+ const home = scopedDoubleEscCancelConfigHome(t);
544
+ writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home });
545
+ const { pi, handlers } = fakePi();
546
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
547
+ const { ctx, ui } = fakeContext();
548
+ await fire(handlers, "session_start", ctx);
549
+ const tui = { mode: "regular", terminal: { rows: 40, columns: 160 }, requestRender() {} };
550
+ const data = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
551
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
552
+ const component = factory(tui, plainTheme, data);
553
+ try {
554
+ pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
555
+ assert.equal(component.render(80).join("\n"), "⧗ 1 job");
556
+ assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/);
557
+ tui.mode = "fullscreen";
558
+ const header = sidebarState(tui as unknown as TUI).parts.get("header")!;
559
+ assert.match(header.render(160).join("\n"), /⧗ 1 job/);
560
+ assert.doesNotMatch(component.render(160).join("\n"), /1 job/, "the hidden fullscreen footer does not duplicate the topbar count");
561
+ pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [] } });
562
+ assert.doesNotMatch(header.render(160).join("\n"), /jobs?/);
563
+ } finally { component.dispose(); await fire(handlers, "session_shutdown", ctx); }
564
+ });
565
+
433
566
  test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => {
434
567
  const { pi, handlers } = fakePi();
435
568
  let profile: string | undefined = "team";
@@ -622,7 +755,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop
622
755
  t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; });
623
756
  const { pi, handlers } = fakePi();
624
757
  let resolutions = 0;
625
- gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, {
758
+ // follow mode keeps the live p → P → G resolution, so a pin edit reaches the
759
+ // open session through the poll (gentle-shell#1064 slice 3b-ii).
760
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, {
626
761
  resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; },
627
762
  });
628
763
  const first = fakeContext();
@@ -736,6 +871,16 @@ test("T2 float prompt installed editor reads live toolSuccessBg and preserves Es
736
871
  } finally { themeHost.theme = originalTheme; editor.dispose(); setCardStyle(previous); }
737
872
  });
738
873
 
874
+ // Herdr's native Pi manifest detects activity from Pi's standard loader row
875
+ // ("⠋ Working..."); the petal frame alone reads as idle to it.
876
+ test("gentleShell keeps Pi's native Working row inside Herdr so native detection sees activity", () => {
877
+ const { pi, handlers } = fakePi();
878
+ gentleShell(pi, { HERDR_ENV: "1" });
879
+ const { ctx, ui } = fakeContext();
880
+ installedPrompt(ctx, ui, handlers);
881
+ assert.notEqual(ui.workingVisible, false, "Herdr needs Pi's standard Working row");
882
+ });
883
+
739
884
  test("gentleShell frames the editor with the petal prompt and a hint while empty", () => {
740
885
  const { pi, handlers } = fakePi();
741
886
  gentleShell(pi, {});
@@ -1499,6 +1644,49 @@ test("GentlePromptEditor keeps visual selection when autocomplete offers printab
1499
1644
  } finally { editor.dispose(); }
1500
1645
  });
1501
1646
 
1647
+ // gentle-shell#1565: in ordinary editing the Gentle Agents alt+a shortcut
1648
+ // must win over prompt select-all, for empty and non-empty drafts alike.
1649
+ test("GentlePromptEditor gives extension shortcuts precedence over selection chords", () => {
1650
+ const { pi, handlers } = fakePi(); gentleShell(pi, {});
1651
+ const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers);
1652
+ try {
1653
+ const seen: string[] = [];
1654
+ editor.onExtensionShortcut = (data) => { seen.push(data); return matchesKey(data, "alt+a"); };
1655
+ for (const altA of ["\x1ba", "\x1b[97;3u", "\x1b[97;3:1u"]) {
1656
+ seen.length = 0;
1657
+ editor.setText("");
1658
+ editor.handleInput(altA);
1659
+ assert.deepEqual(seen, [altA], "empty draft dispatches the shortcut");
1660
+ seen.length = 0;
1661
+ editor.setText("abc\ndef");
1662
+ editor.handleInput(altA);
1663
+ assert.deepEqual(seen, [altA], "non-empty draft dispatches the shortcut");
1664
+ assert.doesNotMatch(editor.render(40).join("\n"), /\x1b\[7mabc/);
1665
+ editor.handleInput("\x7f");
1666
+ assert.equal(editor.getText(), "abc\nde", "the draft was not selected");
1667
+ }
1668
+ // alt+e is the dedicated select-all chord while alt+a belongs to Agents.
1669
+ editor.setText("abc\ndef");
1670
+ editor.handleInput("\x1be");
1671
+ assert.match(editor.render(40).join("\n"), /\x1b\[7mabc/);
1672
+ editor.handleInput("\x7f");
1673
+ assert.equal(editor.getText(), "");
1674
+ } finally { editor.dispose(); }
1675
+ });
1676
+
1677
+ test("GentlePromptEditor keeps alt+a select-all when no extension claims it", () => {
1678
+ const { pi, handlers } = fakePi(); gentleShell(pi, {});
1679
+ const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers);
1680
+ try {
1681
+ editor.onExtensionShortcut = () => false;
1682
+ editor.setText("abc\ndef");
1683
+ editor.handleInput("\x1ba");
1684
+ assert.match(editor.render(40).join("\n"), /\x1b\[7mabc/);
1685
+ editor.handleInput("\x7f");
1686
+ assert.equal(editor.getText(), "");
1687
+ } finally { editor.dispose(); }
1688
+ });
1689
+
1502
1690
  test("VISUAL autocomplete leaves app shortcut ownership ahead of c and p", () => {
1503
1691
  const { pi, handlers } = fakePi(); gentleShell(pi, {});
1504
1692
  const { ctx, ui } = fakeContext(); const editor = installedPrompt(ctx, ui, handlers);
@@ -1618,6 +1806,8 @@ function reverseColumns(row: string): number[] {
1618
1806
  let inverse = false;
1619
1807
  let column = 0;
1620
1808
  const selected: number[] = [];
1809
+ const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
1810
+ row = host.resolveFakeCursors.call({ showHardwareCursor: false }, [row])[0]!;
1621
1811
  const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
1622
1812
  assert.equal(tokens.join(""), row);
1623
1813
  for (const token of tokens) {
@@ -1833,8 +2023,7 @@ test("GentlePromptEditor autocomplete respects narrow frame widths", () => {
1833
2023
  test("registered prompt stays transparent while idle, working, and queued", () => {
1834
2024
  const { pi, handlers, tools } = fakePi();
1835
2025
  gentleShell(pi, {});
1836
- const { ctx, ui } = fakeContext();
1837
- ctx.ui.theme = { ...plainTheme, getBgAnsi: () => "\x1b[44m" } as typeof ctx.ui.theme;
2026
+ const { ctx, ui } = fakeContext({ theme: { ...plainTheme, getBgAnsi: () => "\x1b[44m" } });
1838
2027
  const editor = installedPrompt(ctx, ui, handlers);
1839
2028
  try {
1840
2029
  for (const state of ["idle", "working", "queued"]) {
@@ -1895,8 +2084,8 @@ test("visual customization and Vim register once and remain independently discov
1895
2084
  assert.equal(JSON.parse(readFileSync(join(home, "vim.json"), "utf8")).policy, "on");
1896
2085
  });
1897
2086
 
1898
- test("actual Pi 1.0.0 enables a live prompt and enters NORMAL without a compatibility fallback", async () => {
1899
- assert.equal(INSTALLED_PI, "1.0.0", "the shell audit must run against actual installed Pi 1.0.0");
2087
+ test(`actual Pi ${INSTALLED_PI} enables a live prompt and enters NORMAL without a compatibility fallback`, async () => {
2088
+ assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `actual installed Pi ${INSTALLED_PI} must be audited`);
1900
2089
  const configHome = mkdtempSync(join(tmpdir(), "gentle-vim-shell-"));
1901
2090
  const { pi, handlers, commands } = fakePi();
1902
2091
  gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
@@ -2810,7 +2999,7 @@ function scopedDoubleEscCancelConfigHome(t: { after(callback: () => void): void
2810
2999
  function findCustomizeRow(ui: FakeUi, label: string, width = 90): boolean {
2811
3000
  const view = ui.overlayView!;
2812
3001
  view.handleInput("\x1b[D");
2813
- for (let category = 0; category < 10; category++) {
3002
+ for (let category = 0; category < 11; category++) {
2814
3003
  view.handleInput("\x1b[C");
2815
3004
  for (let index = 0; index < 35; index++) {
2816
3005
  if (view.render(width).some((line) => line.includes(`▸ ${label}`))) return true;
@@ -2830,6 +3019,61 @@ async function customizeAction(ui: FakeUi, label: string): Promise<void> {
2830
3019
  assert.ok(ui.notices.length > notices, `action did not finish: ${label}`);
2831
3020
  }
2832
3021
 
3022
+ test("customize Notifications applies audio controls directly in the same overlay, without nested menus or visual writes", async (t) => {
3023
+ const home = scopedDoubleEscCancelConfigHome(t);
3024
+ const { pi, commands } = fakePi();
3025
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }); // Shell loads BEFORE owner.
3026
+ const child = process.env.GENTLE_PI_AGENTS_CHILD;
3027
+ process.env.GENTLE_PI_AGENTS_CHILD = "0";
3028
+ t.after(() => { if (child === undefined) delete process.env.GENTLE_PI_AGENTS_CHILD; else process.env.GENTLE_PI_AGENTS_CHILD = child; });
3029
+ let nested = 0; let writes = 0;
3030
+ const { ctx, ui, overlayReady } = fakeContext({ select: async () => { nested++; return undefined; } });
3031
+ const uiSpies = ctx.ui as unknown as Record<string, unknown>;
3032
+ uiSpies.input = async () => { nested++; return undefined; };
3033
+ uiSpies.confirm = async () => { nested++; return false; };
3034
+ const owner = claimNotificationOwner(() => {}, { env: {},
3035
+ read: () => ({ settings: structuredClone(DEFAULT_NOTIFICATION_SETTINGS), source: "default", globalFile: join(home, "notifications.json"), malformed: false, readError: false }),
3036
+ write: () => { writes++; return join(home, "notifications.json"); } });
3037
+ owner.attach(ctx); t.after(() => owner.retire());
3038
+ let settled = false;
3039
+ const pending = commands.get("gentle:customize")!.handler("", ctx).then(() => { settled = true; });
3040
+ await overlayReady;
3041
+ try {
3042
+ assert.ok(findCustomizeRow(ui, "Audio notifications: off"), "global switch is a direct row");
3043
+ assert.ok(findCustomizeRow(ui, "Audio: unmuted"), "mute is a direct row");
3044
+ assert.equal(findCustomizeRow(ui, "Audio availability: check"), false, "availability row is removed from the card");
3045
+ assert.equal(findCustomizeRow(ui, "Audio: restore preset"), false, "restore row is removed from the card");
3046
+ assert.ok(findCustomizeRow(ui, "Success:"), "Success group is a direct row");
3047
+ assert.ok(findCustomizeRow(ui, "Error:"), "Error group is a direct row");
3048
+ assert.ok(findCustomizeRow(ui, "Attention:"), "Attention group is a direct row");
3049
+ assert.match(ui.overlayView!.render(90).join("\n"), /Audio notifications/);
3050
+ assert.equal(settled, false, "the overlay never closes to open a panel");
3051
+ // Per-event exceptions stay folded until Advanced is expanded in the same overlay.
3052
+ assert.ok(findCustomizeRow(ui, "Advanced:"), "Advanced toggle is a direct row");
3053
+ assert.doesNotMatch(ui.overlayView!.render(90).join("\n"), /agent\.failed:/, "per-event rows stay folded by default");
3054
+ ui.overlayView!.handleInput("\r");
3055
+ // Let the toggle's busy microtask settle before activating a revealed row.
3056
+ await new Promise<void>((resolve) => setImmediate(resolve));
3057
+ const notices = ui.notices.length;
3058
+ assert.ok(findCustomizeRow(ui, "agent.failed:"));
3059
+ ui.overlayView!.handleInput("\r");
3060
+ for (let attempt = 0; attempt < 100 && ui.notices.length === notices; attempt++) await new Promise<void>((resolve) => setTimeout(resolve, 5));
3061
+ assert.ok(ui.notices.length > notices, "direct Enter action finished");
3062
+ assert.equal(writes, 1);
3063
+ assert.match(ui.overlayView!.render(90).join("\n"), /agent\.failed: builtin:attention/);
3064
+ assert.equal(nested, 0, "no ctx.ui.select/input/confirm anywhere in the flow");
3065
+ assert.equal(settled, false, "the notification action never closed the customize overlay");
3066
+ assert.equal(existsSync(join(home, "visual.json")), false);
3067
+ assert.equal(existsSync(join(home, "notifications.json")), false, "stubbed write left no file");
3068
+ } finally { ui.closeOverlay?.(); await pending; }
3069
+ });
3070
+
3071
+ test("production customize never wires the legacy notification panel", () => {
3072
+ const source = readFileSync(new URL("../extensions/gentle-shell.ts", import.meta.url), "utf8");
3073
+ assert.doesNotMatch(source, /openNotificationPanel|notification-ui\.ts/);
3074
+ assert.match(source, /buildNotificationRows/);
3075
+ });
3076
+
2833
3077
  test("customize Editor rows preview global preference without applying until Enter or Space", async (t) => {
2834
3078
  const home = scopedDoubleEscCancelConfigHome(t);
2835
3079
  const { pi, commands } = fakePi();
@@ -2886,7 +3130,7 @@ test("customize Cards rows persist the card style and switch live conversation c
2886
3130
  const pending = commands.get("gentle:customize")!.handler("", ctx);
2887
3131
  await overlayReady;
2888
3132
  assert.ok(findCustomizeRow(ui, "Card style: float (current)"));
2889
- assert.match(ui.overlayView!.render(90).join("\n"), /Cards · 2\/2/);
3133
+ assert.match(ui.overlayView!.render(90).join("\n"), /Cards · 2\/4/);
2890
3134
  assert.ok(findCustomizeRow(ui, "Card style: neon"));
2891
3135
  assert.ok(!ui.overlayView!.render(90).some((line) => line.includes("▸ Card style: neon (current)")), "neon is not current without a saved preference");
2892
3136
  assert.equal(existsSync(join(home, "card-style.json")), false, "highlighting never applies");
@@ -2910,6 +3154,32 @@ test("customize Cards rows persist the card style and switch live conversation c
2910
3154
  ui.overlayView!.handleInput("\x1b"); await pending;
2911
3155
  });
2912
3156
 
3157
+ test("customize Cards rows persist the card content level and switch the live quiet tools", async (t) => {
3158
+ const home = scopedDoubleEscCancelConfigHome(t);
3159
+ const previous = cardContent();
3160
+ t.after(() => setCardContent(previous));
3161
+ const { pi, commands } = fakePi();
3162
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home });
3163
+ assert.equal(cardContent(), CARD_CONTENT.DEFAULT, "no preference file means default");
3164
+ const { ctx, ui, overlayReady } = fakeContext();
3165
+ const pending = commands.get("gentle:customize")!.handler("", ctx);
3166
+ await overlayReady;
3167
+ assert.ok(findCustomizeRow(ui, "Card content: default (current)"));
3168
+ assert.ok(findCustomizeRow(ui, "Card content: minimal"));
3169
+ assert.equal(existsSync(join(home, "card-content.json")), false, "highlighting never applies");
3170
+ await customizeAction(ui, "Card content: minimal");
3171
+ await new Promise<void>(resolve => setImmediate(resolve));
3172
+ assert.equal(resolveCardContent({ gentlePiConfigHome: home }).content, "minimal");
3173
+ assert.equal(cardContent(), CARD_CONTENT.MINIMAL, "the live slot follows the choice");
3174
+ assert.match(ui.notices.at(-1)!, /Card content: minimal/);
3175
+ assert.ok(findCustomizeRow(ui, "Card content: minimal (current)"));
3176
+ assert.ok(findCustomizeRow(ui, "Card content: default"));
3177
+ await customizeAction(ui, "Card content: default");
3178
+ assert.equal(resolveCardContent({ gentlePiConfigHome: home }).content, "default");
3179
+ assert.equal(cardContent(), CARD_CONTENT.DEFAULT);
3180
+ ui.overlayView!.handleInput("\x1b"); await pending;
3181
+ });
3182
+
2913
3183
  test("T3 live style Cards action refreshes cached TODO, header, footer and prompt in both directions", async (t) => {
2914
3184
  const home = scopedDoubleEscCancelConfigHome(t);
2915
3185
  const previous = cardStyle();
@@ -3032,6 +3302,20 @@ test("the saved card style applies at startup and on every session start", async
3032
3302
  assert.equal(cardStyle(), CARD_STYLE.FLOAT);
3033
3303
  });
3034
3304
 
3305
+ test("the saved card content applies at startup and on every session start", async (t) => {
3306
+ const home = scopedDoubleEscCancelConfigHome(t);
3307
+ const found = cardContent();
3308
+ t.after(() => setCardContent(found));
3309
+ writeCardContent("minimal", { gentlePiConfigHome: home });
3310
+ const { pi, handlers } = fakePi();
3311
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home });
3312
+ assert.equal(cardContent(), CARD_CONTENT.MINIMAL);
3313
+ writeCardContent("default", { gentlePiConfigHome: home });
3314
+ const { ctx } = fakeContext({ hasUI: false });
3315
+ for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx);
3316
+ assert.equal(cardContent(), CARD_CONTENT.DEFAULT);
3317
+ });
3318
+
3035
3319
  test("customize Cards rows refuse to overwrite a malformed preference", async (t) => {
3036
3320
  const home = scopedDoubleEscCancelConfigHome(t);
3037
3321
  const found = cardStyle();
@@ -3143,7 +3427,7 @@ test("customize Vim reports a persistence error without changing the live prompt
3143
3427
  chmodSync(home, 0o500);
3144
3428
  try {
3145
3429
  await customizeAction(ui, "Vim: enable");
3146
- assert.match(ui.notices.at(-1)!, /Visual customization:/);
3430
+ assert.match(ui.notices.at(-1)!, /Customization:/);
3147
3431
  assert.equal(editor.effectiveVimPolicy, "off");
3148
3432
  assert.equal(resolveVimPolicy({ gentlePiConfigHome: home }).policy, "off");
3149
3433
  } finally {
@@ -3277,7 +3561,7 @@ test("T4d unified float footer uses one live owner and restores legacy Changes o
3277
3561
  const home = scopedDoubleEscCancelConfigHome(t);
3278
3562
  writeCardStyle("float", { gentlePiConfigHome: home });
3279
3563
  writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, headerPlacement: "below-input" }, { gentlePiConfigHome: home });
3280
- const { pi, handlers, commands } = fakePi();
3564
+ const { pi, handlers, commands } = fakePi([{ numstat: "2\t0\tlib/live.ts\n", porcelain: " M lib/live.ts\0" }, { numstat: "3\t0\tlib/live.ts\n", porcelain: " M lib/live.ts\0" }]);
3281
3565
  gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home });
3282
3566
  const { ctx, ui, overlayReady } = fakeContext();
3283
3567
  await fire(handlers, "session_start", ctx);
@@ -3301,8 +3585,8 @@ test("T4d unified float footer uses one live owner and restores legacy Changes o
3301
3585
  assert.equal(footer.handleMouse?.(click(initialUsageX, 3, 139)), undefined);
3302
3586
  assert.equal(footer.handleMouse?.(click(initialUsageX, 2, 139))?.handled, true);
3303
3587
  ui.closeOverlay?.();
3304
- sessionChange(ctx, "t4d-a", "/repo", "lib/live.ts", "", "one\ntwo\n");
3305
3588
  await fire(handlers, "agent_end", ctx);
3589
+ await changesSettled();
3306
3590
  const changes = widget("gentle-shell-changes");
3307
3591
  for (const width of [139, 140, 240]) {
3308
3592
  tui.terminal.columns = width;
@@ -3325,9 +3609,9 @@ test("T4d unified float footer uses one live owner and restores legacy Changes o
3325
3609
  statuses.set("mcp", "MCP updated");
3326
3610
  assert.notEqual(railHeader.digest!(), digest, "live status changes update the grouped digest");
3327
3611
  const statusDigest = railHeader.digest!();
3328
- sessionChange(ctx, "t4d-b", "/repo", "lib/live.ts", "one\ntwo\n", "one\ntwo\nthree\n");
3329
3612
  await fire(handlers, "agent_end", ctx);
3330
- assert.notEqual(railHeader.digest!(), statusDigest, "captured changes update the grouped digest");
3613
+ await changesSettled();
3614
+ assert.notEqual(railHeader.digest!(), statusDigest, "Git changes update the grouped digest");
3331
3615
  assert.match(stripAnsi(footer.render(240).join("\n")), /\+3 −0/);
3332
3616
  assert.match(stripAnsi(footer.render(240).join("\n")), /MCP updated/);
3333
3617
  state.active = true;
@@ -4390,49 +4674,69 @@ function renderFooter(ui: FakeUi): string {
4390
4674
  return factory(fakeTui, plainTheme, footerData).render(160)[0];
4391
4675
  }
4392
4676
 
4393
- function sessionChange(ctx: ExtensionContext, id: string, root: string, path: string, before = "", after = "agent\n"): void {
4394
- const entries = ctx.sessionManager.getEntries() as any[];
4395
- entries.push({ type: "custom", customType: "gentle-pi.session-change/v1", data: {
4396
- sessionId: ctx.sessionManager.getSessionId(),
4397
- evidence: { id, root, path, before: before ? {kind:"text",text:before} : {kind:"absent"}, after:{kind:"text",text:after} },
4398
- } });
4399
- }
4677
+ // Changes refreshes from Git off the turn-end path; let the fake scan land.
4678
+ const changesSettled = () => backgroundChangeRefreshesSettled();
4400
4679
 
4401
- test("captured changes update the widget and bar without repository scans", async () => {
4402
- const { pi, handlers, git } = fakePi([{numstat:"999\t0\tforeign.ts\n",porcelain:"?? foreign.ts\0"}]);
4680
+ // Changes reads each root's Git state and nothing else: worktree labels,
4681
+ // status and numstat against HEAD, and a lazy diff of the selected file.
4682
+ const gitStateOnly = (git: readonly string[][]) => git.length > 0 && git.every((args) => {
4683
+ const command = args[0] === "-C" ? args.slice(2) : args;
4684
+ return (command[0] === "worktree" && command[1] === "list") || (command[0] === "status" && command.includes("--porcelain=v1"))
4685
+ || (command[0] === "diff" && command.includes("HEAD")) || command[0] === "symbolic-ref" || (command[0] === "rev-parse" && command.includes("--verify"));
4686
+ });
4687
+
4688
+ test("Changes lists the session root's Git state at turn end, never at startup", async () => {
4689
+ const { pi, handlers, git } = fakePi([{numstat:"2\t0\tlib/b.ts\n",porcelain:" M lib/b.ts\0?? other.ts\0"},{numstat:"3\t0\tlib/b.ts\n",porcelain:" M lib/b.ts\0?? other.ts\0"}]);
4403
4690
  gentleShell(pi,{});
4404
4691
  const {ctx,ui}=fakeContext();
4405
4692
  await fire(handlers,"session_start",ctx);
4406
4693
  assert.equal(git.length,0);
4407
4694
  assert.equal(ui.widgets.has("gentle-shell-changes"),false);
4408
- sessionChange(ctx,"a","/repo","lib/b.ts","","one\ntwo\n");
4409
- pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
4410
- await new Promise(resolve=>setImmediate(resolve));
4695
+ await fire(handlers,"agent_end",ctx);
4696
+ await changesSettled();
4411
4697
  const factory=ui.widgets.get("gentle-shell-changes") as any;
4412
- assert.match(factory(fakeTui,plainTheme).render(140)[0],/1 file · \+2 −0/);
4413
- assert.match(renderFooter(ui),/main ±1/);
4698
+ assert.match(factory(fakeTui,plainTheme).render(140)[0],/2 files · \+2 −0/, "files no agent wrote are listed too: Git decides");
4699
+ assert.match(renderFooter(ui),/main ±2/);
4414
4700
  const rail = sidebarState(fakeTui as unknown as TUI).parts.get("footer")!;
4415
4701
  const digest = rail.digest!();
4416
- assert.match(rail.render(46).join("\n"), /1 file · \+2 −0/);
4417
- sessionChange(ctx,"b","/repo","lib/b.ts","one\ntwo\n","one\ntwo\nthree\n");
4702
+ assert.match(rail.render(46).join("\n"), /2 files · \+2 −0/);
4418
4703
  await fire(handlers,"agent_end",ctx);
4704
+ await changesSettled();
4419
4705
  assert.notEqual(rail.digest!(), digest, "same-count line changes invalidate the unified Status");
4420
- assert.match(rail.render(46).join("\n"), /1 file · \+3 −0/);
4421
- assert.equal(git.length,0);
4706
+ assert.match(rail.render(46).join("\n"), /2 files · \+3 −0/);
4707
+ assert.ok(gitStateOnly(git), JSON.stringify(git));
4708
+ assert.ok(git.every((args) => args[1] === "/repo"), "only the session's own root is scanned");
4422
4709
  await fire(handlers,"session_shutdown",ctx);
4423
4710
  });
4424
4711
 
4712
+ test("committed work leaves the widget and the footer counter at the next turn end", async () => {
4713
+ const { handlers, git, pi } = fakePi([{numstat:"1\t0\tlib/b.ts\n",porcelain:" M lib/b.ts\0"},{numstat:"",porcelain:""}]);
4714
+ gentleShell(pi,{});
4715
+ const {ctx,ui}=fakeContext();
4716
+ await fire(handlers,"session_start",ctx);
4717
+ assert.equal(git.length,0,"startup never scans");
4718
+ await fire(handlers,"agent_end",ctx);
4719
+ await changesSettled();
4720
+ assert.match(renderFooter(ui),/main ±1/);
4721
+ await fire(handlers,"agent_end",ctx);
4722
+ await changesSettled();
4723
+ assert.doesNotMatch(renderFooter(ui),/±/);
4724
+ assert.equal(ui.widgets.has("gentle-shell-changes"),false);
4725
+ await fire(handlers,"session_shutdown",ctx);
4726
+ });
4425
4727
 
4426
- // The changes overlay may ask Git for each captured root's HEAD label, and
4427
- // nothing else: no status, diff, numstat or worktree scan behind the user's
4428
- // back. Labelling is metadata, scanning is the behaviour these tests forbid.
4429
- const onlyHeadLabels = (git: readonly string[][]) => git.every((args) => {
4430
- const command = args[0] === "-C" ? args.slice(2) : args;
4431
- return command[0] === "symbolic-ref" || (command[0] === "rev-parse" && command.includes("--verify"));
4728
+ test("a turn end never waits for Git", async () => {
4729
+ const { pi, handlers } = fakePi();
4730
+ (pi as unknown as { exec: () => Promise<never> }).exec = () => new Promise<never>(() => {});
4731
+ gentleShell(pi,{});
4732
+ const {ctx}=fakeContext();
4733
+ await fire(handlers,"session_start",ctx);
4734
+ await Promise.race([fire(handlers,"agent_end",ctx), new Promise((_, reject) => setTimeout(() => reject(new Error("agent_end waited for Git")), 100))]);
4735
+ await fire(handlers,"session_shutdown",ctx);
4432
4736
  });
4433
4737
 
4434
- test("same-session explicit registration after bootstrap does not claim Changes", async () => {
4435
- const { pi, handlers, commands, tools, git } = fakePi();
4738
+ test("explicit registration validates the clone, and a clean registered root shows no Changes", async () => {
4739
+ const { pi, handlers, commands, tools } = fakePi();
4436
4740
  let bootstrapped = false;
4437
4741
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, {
4438
4742
  resolveWorktree: (path) => bootstrapped ? { root: path === "." ? "/repo" : path, commonDir: path === "/foreign" ? "/foreign/git" : "/repo/.git" } : undefined,
@@ -4446,70 +4750,45 @@ test("same-session explicit registration after bootstrap does not claim Changes"
4446
4750
  await register.execute("after", { path: "/repo" }, undefined, undefined, ctx);
4447
4751
  await register.execute("dedup", { path: "/repo" }, undefined, undefined, ctx);
4448
4752
  await assert.rejects(register.execute("foreign", { path: "/foreign" }, undefined, undefined, ctx), /same Git clone/);
4753
+ await assert.rejects(register.execute("foreign", { path: "/foreign" }, undefined, undefined, ctx), /subagent_run with repository_root/);
4449
4754
  assert.equal(ctx.sessionManager, manager);
4450
4755
  assert.equal(ctx.sessionManager.getEntries().length, 1);
4451
4756
  await commands.get("gentle:changes")!.handler("", ctx);
4452
- assert.match(ui.notices.join("\n"), /No captured agent changes/);
4757
+ assert.match(ui.notices.join("\n"), /No uncommitted changes/);
4453
4758
  assert.equal(ui.overlay, undefined);
4454
- assert.deepEqual(git, []);
4455
4759
  await fire(handlers, "session_shutdown", ctx);
4456
4760
  });
4457
4761
 
4458
- test("Changes opens only for captured mutations, not registered dirty roots", async () => {
4459
- const {pi,handlers,commands,tools,git}=fakePi();
4762
+ test("a registered worktree opens Changes as its own group", async () => {
4763
+ const {pi,handlers,commands,tools,git}=fakePi([{numstat:"",porcelain:" M file.ts\0?? dirty.ts\0"}]);
4460
4764
  gentleShell(pi,{});
4461
4765
  const {ctx,ui,overlayReady}=fakeContext();
4462
4766
  await fire(handlers,"session_start",ctx);
4463
4767
  await tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,ctx);
4464
- await commands.get("gentle:changes")!.handler("",ctx);
4465
- assert.match(ui.notices.join("\n"),/No captured agent changes/);
4466
- assert.equal(ui.overlay,undefined);
4467
- sessionChange(ctx,"a","/linked","file.ts");
4468
- await fire(handlers,"agent_end",ctx);
4469
- const opened=commands.get("gentle:changes")!.handler("",ctx);
4470
- await overlayReady;
4471
- assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
4472
- assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
4473
- ui.closeOverlay?.(); await opened;
4474
- await fire(handlers,"session_shutdown",ctx);
4475
- });
4476
-
4477
- test("overlay groups captured roots and refreshes same-count diffs without HEAD or external files", async () => {
4478
- const {pi,handlers,commands,git}=fakePi();
4479
- gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:"5"});
4480
- const {ctx,ui,overlayReady}=fakeContext();
4481
- sessionChange(ctx,"a","/repo","same.ts","old\n","first\n");
4482
- sessionChange(ctx,"child:a","/linked","same.ts","old\n","child\n");
4483
- await fire(handlers,"session_start",ctx);
4484
4768
  const opened=commands.get("gentle:changes")!.handler("",ctx);
4485
4769
  await overlayReady;
4486
4770
  try {
4487
- ui.overlayView!.handleInput("\r"); ui.overlayView!.handleInput("j");
4488
- await new Promise(resolve=>setTimeout(resolve,10));
4489
- assert.match(ui.overlayView!.render(140).join("\n"),/first/);
4490
- sessionChange(ctx,"b","/repo","same.ts","first\n","second\n");
4491
- pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
4492
- await new Promise(resolve=>setTimeout(resolve,20));
4493
- assert.match(ui.overlayView!.render(140).join("\n"),/second/);
4494
- assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
4495
- assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
4771
+ const rendered=ui.overlayView!.render(140).join("\n");
4772
+ assert.match(rendered,/main · repo/);
4773
+ assert.match(rendered,/detached · linked/, "a root outside the worktree list asks for its own HEAD");
4774
+ assert.ok(gitStateOnly(git), JSON.stringify(git));
4496
4775
  } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
4497
4776
  });
4498
4777
 
4499
- test("new sessions ignore inherited captures, while reload restores the same session", async () => {
4778
+ test("new sessions do not inherit another session's worktrees", async () => {
4500
4779
  const h=fakePi(); gentleShell(h.pi,{});
4501
4780
  const first=fakeContext();
4502
- sessionChange(first.ctx,"a","/repo","own.ts");
4503
- await fire(h.handlers,"session_start",first.ctx);
4504
- assert.match(renderFooter(first.ui),/±1/);
4505
4781
  await fire(h.handlers,"session_start",first.ctx);
4506
- assert.match(renderFooter(first.ui),/±1/);
4782
+ await h.tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,first.ctx);
4783
+ await fire(h.handlers,"session_shutdown",first.ctx);
4507
4784
  const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
4508
4785
  (next.ctx.sessionManager as any).getSessionId=()=>"new-session";
4509
4786
  await fire(h.handlers,"session_start",next.ctx);
4510
- h.pi.events.emit("gentle-pi:session-change",{sessionId:"shell-session"});
4511
- assert.doesNotMatch(renderFooter(next.ui),/±1/);
4512
- assert.equal(h.git.length,0);
4787
+ h.git.length=0;
4788
+ await fire(h.handlers,"agent_end",next.ctx);
4789
+ await changesSettled();
4790
+ assert.ok(h.git.length>0);
4791
+ assert.ok(h.git.every((args)=>args[1]==="/repo"), JSON.stringify(h.git));
4513
4792
  await fire(h.handlers,"session_shutdown",next.ctx);
4514
4793
  });
4515
4794
 
@@ -4551,7 +4830,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
4551
4830
  installGentleShell(h.pi, isolatedEnv({ GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }), { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
4552
4831
  await fire(h.handlers, "session_start", ctx);
4553
4832
  t.after(() => fire(h.handlers, "session_shutdown", ctx));
4554
- assert.equal(ui.widgets.has("gentle-shell-changes"), false, "preexisting dirty files are not agent changes");
4833
+ assert.equal(ui.widgets.has("gentle-shell-changes"), false, "startup never scans the repository");
4555
4834
  const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
4556
4835
  assert.match(diff, /\+selected change/);
4557
4836
  assert.doesNotMatch(diff, /foreign change/);
@@ -4565,8 +4844,15 @@ test("registered canonical root governs real Git discovery, status and diff desp
4565
4844
  assert.equal(largeDiff.code, 0);
4566
4845
  assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
4567
4846
  assert.match(largeDiff.stdout, /\+selected final marker/);
4568
- await h.commands.get("gentle:changes")!.handler("", ctx);
4569
- assert.equal(ui.overlay, undefined);
4847
+ const bounded = shellGitRunner(selected, poisoned, execFile, DIFF_MAX_BYTES);
4848
+ assert.equal((await bounded(["diff", "HEAD", "--", "tracked.txt"])).truncated, true, "the diff runner stops at its bound instead of buffering everything");
4849
+ assert.ok(await loadFileDiff(bounded, { path: "tracked.txt", added: 70_001, deleted: 1, status: CHANGE_STATUS.MODIFIED }) === DIFF_TOO_LARGE);
4850
+ const opened = h.commands.get("gentle:changes")!.handler("", ctx);
4851
+ await overlayReady;
4852
+ const trees = (ui.overlayView as unknown as { trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }).trees;
4853
+ assert.deepEqual(trees.map((tree) => [tree.root, tree.model.files.map((file) => file.path)]), [[selected, ["selected-only.txt", "tracked.txt"]]], "inherited routing never lists the foreign repository");
4854
+ ui.closeOverlay?.();
4855
+ await opened;
4570
4856
  });
4571
4857
 
4572
4858
  test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
@@ -4583,9 +4869,20 @@ test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked fi
4583
4869
  ]);
4584
4870
  });
4585
4871
 
4872
+ test("loadFileDiff replaces one oversized diff with a short open-in-editor line", async () => {
4873
+ const answer = (stdout: string, truncated?: boolean) => async () => ({ stdout, code: 0, ...(truncated ? { truncated } : {}) });
4874
+ const file = { path: "big.ts", added: 1, deleted: 0, status: CHANGE_STATUS.MODIFIED };
4875
+ // assert.ok keeps a failure from printing megabytes of diff.
4876
+ assert.ok(await loadFileDiff(answer("+x\n".repeat(DIFF_MAX_LINES + 1)), file) === DIFF_TOO_LARGE, "too many lines");
4877
+ assert.ok(await loadFileDiff(answer("+" + "x".repeat(DIFF_MAX_BYTES)), file) === DIFF_TOO_LARGE, "too many bytes");
4878
+ assert.ok(await loadFileDiff(answer("+partial", true), file) === DIFF_TOO_LARGE, "a runner that hit its buffer bound");
4879
+ assert.match(DIFF_TOO_LARGE, /too large.*o.*editor/i);
4880
+ assert.equal(await loadFileDiff(answer("+x\n".repeat(10)), file), "+x\n".repeat(10));
4881
+ });
4882
+
4586
4883
  test("shell Git runner hides initial and repeated background polling children", async () => {
4587
4884
  const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
4588
- const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
4885
+ const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
4589
4886
  calls.push({ command, args, options });
4590
4887
  callback(null, "", "");
4591
4888
  }) as typeof import("node:child_process").execFile;
@@ -4638,23 +4935,24 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
4638
4935
  const shortcut = shortcuts.get("alt+g");
4639
4936
  assert.ok(shortcut, "alt+g not registered");
4640
4937
  await shortcut.handler(ctx);
4641
- assert.match(ui.notices.join("\n"), /No captured agent changes/);
4938
+ assert.match(ui.notices.join("\n"), /No uncommitted changes/);
4642
4939
 
4643
4940
  const silent = fakePi();
4644
4941
  gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
4645
4942
  assert.equal(silent.shortcuts.has("alt+g"), false, "the changes shortcut must not register when disabled");
4646
4943
  });
4647
4944
 
4648
- test("external edits do not pollute Changes or trigger background Git scans", async () => {
4945
+ test("Changes never polls Git in the background; external edits appear at turn end", async () => {
4649
4946
  const {pi,handlers,git}=fakePi([{numstat:"4\t2\texternal.ts\n",porcelain:" M external.ts\0"}]);
4650
4947
  gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_WATCH_MS:"5"});
4651
4948
  const {ctx,ui}=fakeContext();
4652
4949
  await fire(handlers,"session_start",ctx);
4653
4950
  await new Promise(resolve=>setTimeout(resolve,30));
4654
- await fire(handlers,"agent_end",ctx);
4655
4951
  assert.equal(git.length,0);
4656
4952
  assert.equal(ui.widgets.has("gentle-shell-changes"),false);
4657
- assert.doesNotMatch(renderFooter(ui),/±/);
4953
+ await fire(handlers,"agent_end",ctx);
4954
+ await changesSettled();
4955
+ assert.match(renderFooter(ui),/±1/);
4658
4956
  await fire(handlers,"session_shutdown",ctx);
4659
4957
  });
4660
4958
 
@@ -5059,6 +5357,28 @@ test("gentleShell records SSE rate-limit headers from provider responses", async
5059
5357
  assert.doesNotMatch(renderFooter(ui), /codex/);
5060
5358
  });
5061
5359
 
5360
+ test("gentleShell ignores incomplete Codex headers and retains the last real quota without assuming 5h", async () => {
5361
+ const { pi, handlers } = fakePi();
5362
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
5363
+ const { ctx, ui } = fakeContext();
5364
+ await fire(handlers, "session_start", ctx);
5365
+ const respond = (headers: Record<string, string>) => {
5366
+ for (const handler of handlers.get("after_provider_response") ?? []) handler({ status: 200, headers }, ctx);
5367
+ };
5368
+ respond({ "x-codex-primary-used-percent": "0" });
5369
+ assert.doesNotMatch(renderFooter(ui), /codex|0m/, "missing duration is not a real quota");
5370
+ respond({ "x-codex-secondary-used-percent": "31", "x-codex-secondary-window-minutes": "10080" });
5371
+ const valid = renderFooter(ui);
5372
+ assert.match(valid, /codex week .*31%/);
5373
+ assert.doesNotMatch(valid, /5h|0m/);
5374
+ for (const minutes of [undefined, "0", "invalid"]) {
5375
+ const headers: Record<string, string> = { "x-codex-primary-used-percent": "0" };
5376
+ if (minutes !== undefined) headers["x-codex-primary-window-minutes"] = minutes;
5377
+ respond(headers);
5378
+ assert.equal(renderFooter(ui), valid, "a malformed response must not replace the known weekly quota");
5379
+ }
5380
+ });
5381
+
5062
5382
  test("gentleShell registers /gentle:usage and opens the subscriptions overlay", async () => {
5063
5383
  const { pi, handlers, commands } = fakePi();
5064
5384
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch().fetchFn, now: () => 1_788_600_000_000 });
@@ -5209,14 +5529,14 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei
5209
5529
  assert.match(rendered, /Skills/);
5210
5530
  assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
5211
5531
  assert.match(rendered, /Assign models and effort/);
5212
- assert.match(rendered, /Browse captured changes/);
5532
+ assert.match(rendered, /Browse changes/);
5213
5533
  assert.match(rendered, /Gentle AI status/);
5214
5534
  assert.match(rendered, /Refresh skill registry/);
5215
5535
  assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
5216
5536
  assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
5217
5537
  assert.doesNotMatch(rendered, /skill:foo/);
5218
- const changesLine = lines.find((line) => line.includes("Browse captured changes"));
5219
- assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse captured changes");
5538
+ const changesLine = lines.find((line) => line.includes("Browse changes"));
5539
+ assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse changes");
5220
5540
  ui.overlayView!.handleInput("\x1b");
5221
5541
  await opened;
5222
5542
  });
@@ -5633,7 +5953,10 @@ test("after a profile switch the panel stops presenting the old profile's provid
5633
5953
  ui.closeOverlay?.();
5634
5954
  await opened;
5635
5955
 
5636
- writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: {} }, "solo");
5956
+ // An open session changes profile only through its own Enter (gentle-shell#1064
5957
+ // slice 3b-ii): a new global default would leave its frozen routing in place.
5958
+ bindSessionProfile("shell-session", "solo", {});
5959
+ t.after(() => resetSessionProfileBindingsForTesting());
5637
5960
  const reopened = commands.get("gentle:usage")!.handler("", ctx);
5638
5961
  await settle();
5639
5962
  assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope");
@@ -5641,6 +5964,61 @@ test("after a profile switch the panel stops presenting the old profile's provid
5641
5964
  await reopened;
5642
5965
  });
5643
5966
 
5967
+ test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => {
5968
+ const home = mkdtempSync(join(tmpdir(), "shell-frozen-"));
5969
+ const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-"));
5970
+ t.after(() => {
5971
+ rmSync(home, { recursive: true, force: true });
5972
+ rmSync(commonDir, { recursive: true, force: true });
5973
+ });
5974
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
5975
+ writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team");
5976
+ const { pi, handlers, commands } = fakePi();
5977
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
5978
+ const { ctx, ui } = fakeContext({ token: JWT });
5979
+ await fire(handlers, "session_start", ctx);
5980
+ assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start");
5981
+ writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo");
5982
+ writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
5983
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
5984
+ await settle();
5985
+ assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route");
5986
+ ui.closeOverlay?.();
5987
+ await opened;
5988
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void };
5989
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
5990
+ const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
5991
+ try {
5992
+ const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n");
5993
+ assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile");
5994
+ assert.doesNotMatch(status, /solo/);
5995
+ } finally {
5996
+ component.dispose();
5997
+ }
5998
+ });
5999
+
6000
+ test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => {
6001
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-"));
6002
+ t.after(() => rmSync(root, { recursive: true, force: true }));
6003
+ const commonDir = join(root, "git");
6004
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
6005
+ writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } }));
6006
+ const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }));
6007
+ pin("other");
6008
+ const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
6009
+ frozen.bind(root, () => ({ root, commonDir }), "session-frozen");
6010
+ assert.equal(frozen(), "other (local)");
6011
+ const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" });
6012
+ follow.bind(root, () => ({ root, commonDir }), "session-follow");
6013
+ assert.equal(follow(), "other (local)");
6014
+ rmSync(join(commonDir, "gentle-ai", "profile-pin.json"));
6015
+ assert.equal(frozen.refresh(), false, "the frozen label does not move");
6016
+ assert.equal(frozen(), "other (local)");
6017
+ assert.equal(follow.refresh(), true);
6018
+ assert.equal(follow(), "team", "follow falls to the global active profile");
6019
+ assert.equal(readFrozenInheritedProfile("session-follow"), undefined);
6020
+ });
6021
+
5644
6022
  function openPanelLines(ui: FakeUi): string[] {
5645
6023
  return ui.overlayView!.render(90).map(stripAnsi);
5646
6024
  }
@@ -5832,3 +6210,136 @@ test("an older overlapping refresh cannot mark a provider failed after a newer o
5832
6210
  ui.closeOverlay?.();
5833
6211
  await opened;
5834
6212
  });
6213
+
6214
+ test("active profile reader prefers a session binding, labels it (session), and falls back when it clears", (t) => {
6215
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-session-"));
6216
+ t.after(() => rmSync(root, { recursive: true, force: true }));
6217
+ const path = join(root, "profiles.json");
6218
+ writeFileSync(path, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, work: {} } }));
6219
+ resetSessionProfileBindingsForTesting();
6220
+ bindSessionProfile("session-1", "work", { worker: { model: "zai/glm-4.7" } });
6221
+ const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
6222
+ assert.equal(read(), "team", "an unbound reader still reads the global active profile");
6223
+ read.bind(root, () => undefined, "session-1");
6224
+ assert.equal(read(), "work (session)", "a bound session outranks the global active profile");
6225
+ assert.equal(read(), "work (session)", "the session label is stable across reads");
6226
+ clearSessionProfileBinding("session-1");
6227
+ assert.equal(read.refresh(), true, "refresh detects the cleared binding");
6228
+ assert.equal(read(), "team", "the read falls back once the binding clears");
6229
+ read.reset();
6230
+ assert.equal(read(), "team", "reset keeps the global read working");
6231
+ resetSessionProfileBindingsForTesting();
6232
+ });
6233
+
6234
+ test("active profile reader keeps the pin label when a different session is bound", (t) => {
6235
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-session-pin-"));
6236
+ t.after(() => rmSync(root, { recursive: true, force: true }));
6237
+ writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, work: {} } }));
6238
+ resetSessionProfileBindingsForTesting();
6239
+ bindSessionProfile("session-other", "work", { worker: { model: "zai/glm-4.7" } });
6240
+ const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
6241
+ read.bind(root, () => ({ root, commonDir: root }), "session-1");
6242
+ assert.equal(read(), "team", "no pin file and no binding for this session: the global active profile governs");
6243
+ clearSessionProfileBinding("session-other");
6244
+ resetSessionProfileBindingsForTesting();
6245
+ });
6246
+
6247
+ test("an explicit session binding routes the Usage scope ahead of the pin and the global layers", async (t) => {
6248
+ const home = mkdtempSync(join(tmpdir(), "shell-usage-session-"));
6249
+ const commonDir = mkdtempSync(join(tmpdir(), "shell-usage-session-git-"));
6250
+ t.after(() => {
6251
+ rmSync(home, { recursive: true, force: true });
6252
+ rmSync(commonDir, { recursive: true, force: true });
6253
+ });
6254
+ mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
6255
+ // Every shared layer points at openai-codex (the pin wins over the global
6256
+ // active profile), while the session binds nan explicitly: the Usage panel
6257
+ // must refresh the binding's provider, not the layers underneath it.
6258
+ writeProfilesStore(home, {
6259
+ team: { reviewer: { model: "openai-codex/gpt-5.5" } },
6260
+ solo: { reviewer: { model: "openai-codex/gpt-5.5" } },
6261
+ bound: { reviewer: { model: "nan/glm5.3" } },
6262
+ }, "team");
6263
+ writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
6264
+ resetSessionProfileBindingsForTesting();
6265
+ bindSessionProfile("shell-session", "bound", { reviewer: { model: "nan/glm5.3" } });
6266
+ try {
6267
+ const { pi, commands } = fakePi();
6268
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
6269
+ const { ctx, ui } = fakeContext({ token: JWT });
6270
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
6271
+ await settle();
6272
+ const lines = openPanelLines(ui).join("\n");
6273
+ assert.ok(lines.split("\n").some((line) => /^│ nan ·/.test(line)), "the Usage panel refreshes the session binding's nan provider");
6274
+ assert.ok(!lines.split("\n").some((line) => /^│ openai-codex ·/.test(line)), "the pinned codex route stays out of the bound session's Usage scope");
6275
+ ui.closeOverlay?.();
6276
+ await opened;
6277
+ } finally {
6278
+ resetSessionProfileBindingsForTesting();
6279
+ }
6280
+ });
6281
+
6282
+ // S9: the config home each of these reads resolves, per case. The OS home is
6283
+ // a temporary folder in every case, so no case can reach the real one.
6284
+ // "unset" and "empty" are exactly as before: an empty GENTLE_PI_CONFIG_HOME
6285
+ // names the current folder. "bundled" stands in for this package running from
6286
+ // a bundled install (testing overlay) whose prefix the launcher passed; the
6287
+ // user's own GENTLE_PI_CONFIG_HOME is then not this install's.
6288
+ type ConfigHomeCase = "unset" | "empty" | "bundled";
6289
+ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
6290
+ const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
6291
+ const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
6292
+ const cwd = process.cwd();
6293
+ process.env.HOME = join(base, "home");
6294
+ process.env.USERPROFILE = join(base, "home");
6295
+ delete process.env.GENTLE_PI_CONFIG_HOME;
6296
+ delete process.env.GENTLE_SHELL_PREFIX;
6297
+ t.after(() => {
6298
+ process.chdir(cwd);
6299
+ setBundledInstallForTesting(undefined);
6300
+ for (const [name, value] of Object.entries(saved)) {
6301
+ if (value === undefined) delete process.env[name];
6302
+ else process.env[name] = value;
6303
+ }
6304
+ rmSync(base, { recursive: true, force: true });
6305
+ });
6306
+ const userConfig = join(base, "user-config");
6307
+ if (kind === "unset") return { env: {}, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
6308
+ if (kind === "empty") {
6309
+ mkdirSync(join(base, "cwd"));
6310
+ process.chdir(join(base, "cwd"));
6311
+ return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "cwd"), userConfig };
6312
+ }
6313
+ const prefix = join(base, "prefix");
6314
+ mkdirSync(prefix);
6315
+ setBundledInstallForTesting({ root: prefix });
6316
+ return { env: { GENTLE_SHELL_PREFIX: prefix, GENTLE_PI_CONFIG_HOME: userConfig }, dir: join(prefix, "config", "gentle-ai"), userConfig };
6317
+ }
6318
+
6319
+ function profileStore(dir: string, active: string, names: string[]) {
6320
+ mkdirSync(dir, { recursive: true });
6321
+ writeFileSync(join(dir, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: Object.fromEntries(names.map((name) => [name, {}])) }));
6322
+ }
6323
+
6324
+ for (const kind of ["unset", "empty", "bundled"] as const) {
6325
+ test(`the profile reader reads the global store from the ${kind} config home`, (t) => {
6326
+ const { env, dir, userConfig } = configHomeCase(t, kind);
6327
+ // The user's own store names another profile: only the bundled case must ignore it.
6328
+ profileStore(userConfig, "team", ["team"]);
6329
+ profileStore(dir, "other", ["team", "other"]);
6330
+ assert.equal(createActiveProfileReader(env)(), "other");
6331
+ });
6332
+
6333
+ test(`the bound profile reader resolves pins against the ${kind} config home`, (t) => {
6334
+ const { env, dir, userConfig } = configHomeCase(t, kind);
6335
+ profileStore(userConfig, "team", ["team"]);
6336
+ profileStore(dir, "team", ["team", "other"]);
6337
+ const repo = join(dir, "..", `repo-${kind}`);
6338
+ mkdirSync(join(repo, ".pi", "gentle-ai"), { recursive: true });
6339
+ writeFileSync(join(repo, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "other" }));
6340
+ const read = createActiveProfileReader(env);
6341
+ read.bind(repo, () => ({ root: repo, commonDir: join(repo, ".git-common") }));
6342
+ read.refresh();
6343
+ assert.equal(read(), "other (repo)", "the pinned profile exists only in that config home's store");
6344
+ });
6345
+ }