gentle-pi 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (398) hide show
  1. package/README.md +122 -8
  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 +703 -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/bundled-install.md +397 -0
  28. package/docs/delegated-verification.md +3 -5
  29. package/docs/gentle-agents-activity.md +513 -0
  30. package/docs/gentle-shell-uninstall.md +136 -0
  31. package/docs/gentle-shell.md +81 -31
  32. package/docs/install-wizard.md +2040 -0
  33. package/docs/native-authority-architecture.md +1 -1
  34. package/docs/permission-modes.md +82 -0
  35. package/docs/prompt-history.md +1 -1
  36. package/docs/readme-reference.md +267 -40
  37. package/docs/render-cache-performance.md +28 -0
  38. package/docs/sound-notifications-proposal.md +191 -0
  39. package/docs/sound-notifications.md +114 -0
  40. package/docs/yolo-mode.md +9 -9
  41. package/extensions/child-context.ts +11 -2
  42. package/extensions/child-safety.ts +4 -2
  43. package/extensions/codegraph-tools.ts +18 -5
  44. package/extensions/gentle-agents.ts +688 -88
  45. package/extensions/gentle-ai.ts +886 -238
  46. package/extensions/gentle-herdr-activity.ts +81 -0
  47. package/extensions/gentle-notifications.ts +104 -0
  48. package/extensions/gentle-shell.ts +265 -69
  49. package/extensions/gentle-todo.ts +108 -13
  50. package/extensions/history/index.ts +11 -6
  51. package/extensions/pi-pretty.ts +39 -3
  52. package/extensions/quiet-tools.ts +91 -21
  53. package/extensions/skill-registry.ts +175 -46
  54. package/extensions/startup-banner.ts +11 -2
  55. package/lib/agent-home.ts +10 -1
  56. package/lib/agent-model-resolution.ts +80 -0
  57. package/lib/agent-profiles.ts +13 -3
  58. package/lib/agents-completion-delivery.ts +11 -3
  59. package/lib/agents-messaging.ts +1 -1
  60. package/lib/agents-protocol.ts +40 -1
  61. package/lib/agents-runner.ts +90 -15
  62. package/lib/auto-permission-classifier.ts +280 -0
  63. package/lib/auto-permission-runtime.ts +268 -0
  64. package/lib/background-jobs-tools.ts +202 -0
  65. package/lib/background-jobs.ts +253 -0
  66. package/lib/background-monitor.ts +154 -0
  67. package/lib/bounded-writer-admission.ts +83 -10
  68. package/lib/builtin-codemode-optout.ts +166 -0
  69. package/lib/card-content-policy.ts +85 -0
  70. package/lib/child-package-injection.ts +62 -0
  71. package/lib/codemode-renderer.ts +14 -4
  72. package/lib/command-palette-catalog.ts +3 -2
  73. package/lib/context-threshold.ts +26 -0
  74. package/lib/gentle-ai-binary.ts +109 -4
  75. package/lib/gentle-shell-bundled.ts +517 -0
  76. package/lib/gentle-shell-launcher.ts +74 -23
  77. package/lib/gentle-shell-startup-flow.ts +54 -0
  78. package/lib/gentle-shell-startup-update.ts +92 -0
  79. package/lib/gentle-shell-uninstall.ts +840 -0
  80. package/lib/gentle-shell-update-panel.ts +231 -0
  81. package/lib/herdr-activity.ts +183 -0
  82. package/lib/inprocess-reviewer.ts +3 -0
  83. package/lib/jobs-sidebar-state.ts +20 -0
  84. package/lib/jobs-view.ts +161 -0
  85. package/lib/model-price-ratio.ts +83 -0
  86. package/lib/nan-provider.ts +33 -11
  87. package/lib/native-review-cli.ts +194 -73
  88. package/lib/notification-audio-native.ts +162 -0
  89. package/lib/notification-audio-windows.ts +399 -0
  90. package/lib/notification-audio.ts +332 -0
  91. package/lib/notification-customize.ts +241 -0
  92. package/lib/notification-events.ts +113 -0
  93. package/lib/notification-policy.ts +148 -0
  94. package/lib/notification-pulse-client.ts +367 -0
  95. package/lib/notification-pulse-protocol.ts +305 -0
  96. package/lib/notification-pulse-stream.ts +213 -0
  97. package/lib/notification-pulse-worker.ts +66 -0
  98. package/lib/notification-scheduler.ts +173 -0
  99. package/lib/notification-service.ts +202 -0
  100. package/lib/notification-ui.ts +87 -0
  101. package/lib/odd-phase.ts +1 -1
  102. package/lib/orchestrator-alias.ts +20 -0
  103. package/lib/orchestrator-catalog.ts +79 -0
  104. package/lib/orchestrator-consultation.ts +61 -0
  105. package/lib/orchestrator-discovery.ts +49 -0
  106. package/lib/orchestrator-helper-consent.ts +122 -0
  107. package/lib/orchestrator-helper.ts +167 -0
  108. package/lib/orchestrator-message-card.ts +105 -0
  109. package/lib/orchestrator-presence.ts +115 -14
  110. package/lib/orchestrator-scope.ts +80 -0
  111. package/lib/orchestrator-state.ts +107 -0
  112. package/lib/orchestrator-tool-card.ts +280 -0
  113. package/lib/orchestrator-work-search.ts +218 -0
  114. package/lib/orchestrator-work.ts +79 -0
  115. package/lib/permission-mode-policy.ts +59 -0
  116. package/lib/review-candidate-view-owner.ts +8 -6
  117. package/lib/review-candidate-view.ts +3 -2
  118. package/lib/review-compact-contract.ts +2 -2
  119. package/lib/review-host-relay.ts +7 -2
  120. package/lib/review-integration-v2.ts +100 -22
  121. package/lib/review-publication-gate.ts +1 -1
  122. package/lib/review-risk-assessment.ts +77 -51
  123. package/lib/review-sidebar-state.ts +2 -1
  124. package/lib/review-transaction.ts +72 -3
  125. package/lib/runtime-metrics-children.ts +1 -1
  126. package/lib/runtime-metrics-native.ts +3 -2
  127. package/lib/selection-engine.ts +18 -6
  128. package/lib/session-change-capture.ts +5 -4
  129. package/lib/session-changes.ts +27 -22
  130. package/lib/session-profile-binding.ts +83 -0
  131. package/lib/session-profile-freeze.ts +266 -0
  132. package/lib/session-worktree-registry.ts +74 -8
  133. package/lib/shell-bar.ts +41 -11
  134. package/lib/shell-changes-view.ts +40 -13
  135. package/lib/shell-changes.ts +86 -71
  136. package/lib/shell-sidebar-layout.ts +5 -1
  137. package/lib/shell-todo.ts +62 -19
  138. package/lib/shell-usage.ts +4 -3
  139. package/lib/status-timing-diagnostics.ts +201 -0
  140. package/lib/telemetry-trigger.ts +3 -1
  141. package/lib/vim-editor-adapter.ts +7 -3
  142. package/lib/visual-customization-policy.ts +20 -9
  143. package/lib/visual-customize-view.ts +171 -20
  144. package/lib/writer-surfaces.ts +162 -0
  145. package/lib/yolo-session-policy.ts +198 -121
  146. package/package.json +1 -1
  147. package/runtime/child-package-injection.mjs +63 -0
  148. package/runtime/gentle-ai-binary.mjs +109 -4
  149. package/runtime/gentle-shell-bundled.mjs +518 -0
  150. package/runtime/gentle-shell-launcher.mjs +73 -22
  151. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  152. package/runtime/gentle-shell-startup-update.mjs +93 -0
  153. package/runtime/gentle-shell-uninstall.mjs +841 -0
  154. package/runtime/gentle-shell-update-panel.mjs +232 -0
  155. package/runtime/native-review-cli.mjs +191 -70
  156. package/runtime/review-integration-v2.mjs +100 -22
  157. package/runtime/review-risk-assessment.mjs +77 -51
  158. package/runtime/status-timing-diagnostics.mjs +202 -0
  159. package/runtime/telemetry-trigger.mjs +3 -1
  160. package/scripts/bootstrap.cmd +255 -0
  161. package/scripts/bootstrap.sh +168 -0
  162. package/scripts/build-distribution.mjs +157 -0
  163. package/scripts/build-installer-bundles.mjs +136 -0
  164. package/scripts/build-runtime-modules.mjs +7 -0
  165. package/scripts/bundled-install.mjs +1308 -0
  166. package/scripts/bundled-wizard.mjs +399 -0
  167. package/scripts/check-types.mjs +4 -6
  168. package/scripts/gentle-ai-installer.mjs +79 -32
  169. package/scripts/install-gentle-ai.mjs +48 -1
  170. package/scripts/install-tui-mode-setting.mjs +10 -1
  171. package/scripts/install-wizard-preview.mjs +154 -0
  172. package/scripts/installer-downloads.mjs +648 -0
  173. package/scripts/installer-preflight.mjs +387 -0
  174. package/scripts/installer-probes.mjs +564 -0
  175. package/scripts/installer-runner.mjs +1102 -0
  176. package/scripts/installer-server.mjs +881 -0
  177. package/scripts/installer-windows-artifacts.json +15 -0
  178. package/scripts/installer-windows.mjs +752 -0
  179. package/scripts/main-channel.mjs +452 -0
  180. package/scripts/npm-pack-result.mjs +31 -0
  181. package/scripts/render-cache-performance-worker.mjs +205 -0
  182. package/scripts/render-cache-performance.mjs +115 -0
  183. package/scripts/run-test-suite.mjs +22 -8
  184. package/scripts/test-installer-acceptance.sh +161 -0
  185. package/scripts/test-packed-runner.mjs +23 -5
  186. package/scripts/types-baseline.json +2 -92
  187. package/scripts/verify-package-files.mjs +82 -18
  188. package/skills/gentle-ai/SKILL.md +12 -11
  189. package/tests/agent-home.test.ts +56 -3
  190. package/tests/agent-model-resolution.test.ts +113 -0
  191. package/tests/agent-profiles.test.ts +12 -0
  192. package/tests/agent-risk-escalation.test.ts +123 -0
  193. package/tests/agents-completion-delivery.test.ts +17 -0
  194. package/tests/agents-protocol.test.ts +57 -0
  195. package/tests/agents-queries.test.ts +9 -9
  196. package/tests/agents-runner-process.test.ts +31 -0
  197. package/tests/agents-runner.test.ts +89 -4
  198. package/tests/auto-mode-invariants.test.ts +258 -0
  199. package/tests/auto-mode-sdk.test.ts +57 -0
  200. package/tests/auto-mode.test.ts +49 -0
  201. package/tests/auto-permission-classifier.test.ts +283 -0
  202. package/tests/auto-permission-runtime.test.ts +196 -0
  203. package/tests/background-cache-warming.test.ts +2 -1
  204. package/tests/background-jobs.test.ts +227 -0
  205. package/tests/background-monitor.test.ts +195 -0
  206. package/tests/background-subagents-default-mode.test.ts +28 -5
  207. package/tests/bounded-stop-contract.test.ts +85 -0
  208. package/tests/bounded-writer-admission.test.ts +52 -1
  209. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  210. package/tests/bridge-wake-rendering.test.ts +56 -0
  211. package/tests/build-distribution.test.ts +269 -0
  212. package/tests/builtin-codemode-optout.test.ts +242 -0
  213. package/tests/bundled-install.test.ts +1319 -0
  214. package/tests/bundled-wizard.test.ts +505 -0
  215. package/tests/card-content-policy.test.ts +68 -0
  216. package/tests/card-style-policy.test.ts +34 -2
  217. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  218. package/tests/child-package-entrypoints.test.ts +65 -0
  219. package/tests/child-package-injection.test.ts +74 -0
  220. package/tests/child-safety.test.ts +12 -0
  221. package/tests/child-web-package.test.ts +224 -0
  222. package/tests/codegraph-tools.test.ts +35 -0
  223. package/tests/codemode-rendering.test.ts +107 -2
  224. package/tests/command-palette.test.ts +5 -5
  225. package/tests/context-threshold.test.ts +31 -0
  226. package/tests/delegate-for-reason-contract.test.ts +327 -0
  227. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  228. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  229. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  230. package/tests/engram-safety.test.ts +288 -0
  231. package/tests/generic-agent-tools.test.ts +16 -1
  232. package/tests/gentle-agents.test.ts +1722 -59
  233. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  234. package/tests/gentle-ai-binary.test.ts +19 -2
  235. package/tests/gentle-ai-child-guards.test.ts +202 -0
  236. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  237. package/tests/gentle-ai-installer.test.ts +548 -58
  238. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  239. package/tests/gentle-ai-renderer.test.ts +6 -6
  240. package/tests/gentle-ai.test.ts +999 -31
  241. package/tests/gentle-herdr-activity.test.ts +100 -0
  242. package/tests/gentle-notifications.test.ts +427 -0
  243. package/tests/gentle-shell-bin.test.ts +249 -5
  244. package/tests/gentle-shell-bundled.test.ts +1002 -0
  245. package/tests/gentle-shell-launcher.test.ts +234 -13
  246. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  247. package/tests/gentle-shell-startup-update.test.ts +85 -0
  248. package/tests/gentle-shell-uninstall.test.ts +978 -0
  249. package/tests/gentle-shell-update-panel.test.ts +158 -0
  250. package/tests/gentle-shell.test.ts +541 -96
  251. package/tests/gentle-todo.test.ts +253 -2
  252. package/tests/herdr-activity.test.ts +243 -0
  253. package/tests/history-agent-home.test.ts +81 -0
  254. package/tests/history-child-guard.test.ts +70 -0
  255. package/tests/inprocess-reviewer.test.ts +20 -0
  256. package/tests/install-gentle-ai-failure.test.ts +69 -0
  257. package/tests/install-tui-mode-setting.test.ts +15 -0
  258. package/tests/install-wizard.test.ts +1223 -0
  259. package/tests/installer-bundles.test.ts +122 -0
  260. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  261. package/tests/installer-preflight.test.ts +750 -0
  262. package/tests/installer-probes.test.ts +1106 -0
  263. package/tests/installer-runner.test.ts +2158 -0
  264. package/tests/installer-server.test.ts +1567 -0
  265. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  266. package/tests/jobs-view.test.ts +160 -0
  267. package/tests/main-channel-upgrade.test.ts +389 -0
  268. package/tests/main-channel.test.ts +344 -0
  269. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  270. package/tests/model-price-ratio.test.ts +116 -0
  271. package/tests/model-profiles-preservation.test.ts +153 -0
  272. package/tests/model-routing-package.test.ts +10 -0
  273. package/tests/nan-provider-runtime.test.ts +156 -0
  274. package/tests/nan-provider.test.ts +28 -18
  275. package/tests/native-binary-gate.test.ts +2 -0
  276. package/tests/native-review-capability-contract.test.ts +7 -1
  277. package/tests/native-review-cli.test.ts +236 -1
  278. package/tests/native-review-consent.test.ts +2 -2
  279. package/tests/native-review-parity-runtime.test.ts +1 -1
  280. package/tests/native-review-parity.test.ts +12 -48
  281. package/tests/native-risk-evidence-parity.test.ts +21 -0
  282. package/tests/no-self-pause-contract.test.ts +87 -0
  283. package/tests/notification-audio-native.test.ts +281 -0
  284. package/tests/notification-audio-windows.test.ts +581 -0
  285. package/tests/notification-audio.test.ts +548 -0
  286. package/tests/notification-customize.test.ts +655 -0
  287. package/tests/notification-events.test.ts +115 -0
  288. package/tests/notification-policy.test.ts +201 -0
  289. package/tests/notification-pulse-client.test.ts +356 -0
  290. package/tests/notification-pulse-protocol.test.ts +213 -0
  291. package/tests/notification-pulse-stream.test.ts +320 -0
  292. package/tests/notification-scheduler.test.ts +258 -0
  293. package/tests/notification-ui.test.ts +157 -0
  294. package/tests/npm-pack-result.test.ts +41 -0
  295. package/tests/odd-integration.test.ts +14 -5
  296. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  297. package/tests/odd-routing-contract.test.ts +80 -23
  298. package/tests/orchestrator-budget.test.ts +29 -6
  299. package/tests/orchestrator-catalog.test.ts +146 -0
  300. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  301. package/tests/orchestrator-consultation.test.ts +111 -0
  302. package/tests/orchestrator-discovery.test.ts +241 -0
  303. package/tests/orchestrator-helper-consent.test.ts +171 -0
  304. package/tests/orchestrator-helper.test.ts +297 -0
  305. package/tests/orchestrator-message-card.test.ts +190 -0
  306. package/tests/orchestrator-presence.test.ts +23 -0
  307. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  308. package/tests/orchestrator-scope.test.ts +107 -0
  309. package/tests/orchestrator-state.test.ts +114 -0
  310. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  311. package/tests/orchestrator-tool-card.test.ts +398 -0
  312. package/tests/orchestrator-work-search.test.ts +318 -0
  313. package/tests/orchestrator-work.test.ts +147 -0
  314. package/tests/package-manifest.test.ts +101 -17
  315. package/tests/packed-runner-owned-path.test.ts +35 -1
  316. package/tests/parallel-reason-contract.test.ts +21 -0
  317. package/tests/parallel-writer-admission.test.ts +210 -0
  318. package/tests/permission-mode-policy.test.ts +194 -0
  319. package/tests/persona-single-channel.test.ts +4 -12
  320. package/tests/pi-pretty.test.ts +56 -0
  321. package/tests/provider-defect-handoff.test.ts +4 -3
  322. package/tests/quiet-tool-rendering.test.ts +160 -3
  323. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  324. package/tests/render-cache-performance.test.ts +48 -0
  325. package/tests/review-agent-end-preflight.test.ts +62 -10
  326. package/tests/review-candidate-view.test.ts +101 -12
  327. package/tests/review-contract-prompt.test.ts +33 -4
  328. package/tests/review-controller-native-recovery.test.ts +327 -17
  329. package/tests/review-controller-native-routing.test.ts +255 -1
  330. package/tests/review-controller-workspace-root.test.ts +43 -2
  331. package/tests/review-controller.test.ts +2 -2
  332. package/tests/review-correction-lifecycle.test.ts +2 -1
  333. package/tests/review-gate.test.ts +7 -4
  334. package/tests/review-graph-schema.test.ts +2 -2
  335. package/tests/review-host-relay-routing.test.ts +5 -3
  336. package/tests/review-host-relay.test.ts +49 -2
  337. package/tests/review-integration-v2-forward.test.ts +21 -1
  338. package/tests/review-json-arguments.test.ts +167 -0
  339. package/tests/review-last-event-closure.test.ts +157 -8
  340. package/tests/review-ledger-contract.test.ts +5 -4
  341. package/tests/review-policy-judgment-day.test.ts +1 -1
  342. package/tests/review-policy-ordinary.test.ts +2 -2
  343. package/tests/review-relay-transport-agent.test.ts +66 -1
  344. package/tests/review-risk-assessment.test.ts +109 -33
  345. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  346. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  347. package/tests/review-test-fixtures.ts +7 -1
  348. package/tests/review-transaction.test.ts +18 -1
  349. package/tests/run-test-suite.test.ts +83 -5
  350. package/tests/runtime-harness.mjs +42 -15
  351. package/tests/runtime-metrics-extension.test.ts +1 -1
  352. package/tests/runtime-metrics-native.test.ts +31 -1
  353. package/tests/selection-engine.test.ts +30 -0
  354. package/tests/session-change-capture.test.ts +10 -10
  355. package/tests/session-changes-shell.test.ts +242 -48
  356. package/tests/session-changes.test.ts +17 -24
  357. package/tests/session-profile-binding.test.ts +163 -0
  358. package/tests/session-profile-freeze.test.ts +381 -0
  359. package/tests/session-subject-routing-contract.test.ts +34 -0
  360. package/tests/session-worktree-registry.test.ts +57 -2
  361. package/tests/shell-bar.test.ts +40 -0
  362. package/tests/shell-changes-view.test.ts +85 -34
  363. package/tests/shell-changes.test.ts +61 -62
  364. package/tests/shell-render-cache-runtime.test.ts +69 -0
  365. package/tests/shell-render-cache.test.ts +129 -0
  366. package/tests/shell-sidebar-layout.test.ts +27 -2
  367. package/tests/shell-todo.test.ts +120 -2
  368. package/tests/shell-usage-view.test.ts +1 -1
  369. package/tests/shell-usage.test.ts +35 -0
  370. package/tests/short-check-routing-contract.test.ts +49 -0
  371. package/tests/skill-registry-watcher.test.ts +119 -0
  372. package/tests/skill-registry.test.ts +311 -5
  373. package/tests/startup-banner.test.ts +117 -3
  374. package/tests/startup-home-repo.test.ts +170 -0
  375. package/tests/status-timing-diagnostics.test.ts +283 -0
  376. package/tests/support/orchestrator-modules.ts +19 -0
  377. package/tests/task-size-routing-contract.test.ts +241 -0
  378. package/tests/telemetry-trigger.test.ts +27 -1
  379. package/tests/test-strength-contract.test.ts +124 -0
  380. package/tests/verify-agent-contract.test.ts +90 -0
  381. package/tests/verify-first-launch-contract.test.ts +139 -0
  382. package/tests/verify-package-files.test.ts +56 -0
  383. package/tests/vim-editor-adapter.test.ts +7 -7
  384. package/tests/visual-customization-policy.test.ts +32 -3
  385. package/tests/visual-customize-view.test.ts +288 -10
  386. package/tests/writer-edit-surface-scope.test.ts +59 -2
  387. package/tests/writer-surfaces.test.ts +199 -0
  388. package/tests/yolo-customize.test.ts +18 -3
  389. package/tests/yolo-mode-runtime.test.ts +37 -10
  390. package/tests/yolo-mode.test.ts +32 -4
  391. package/tests/yolo-session-policy.test.ts +1 -1
  392. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  393. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  394. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  395. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  396. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  397. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  398. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -3,7 +3,7 @@ import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type EditorCom
3
3
 
4
4
  /**
5
5
  * Native text-selection engine for GentlePromptEditor: shift+home / shift+end
6
- * selection, alt+a select-all, and replace-on-key (backspace / delete /
6
+ * selection, alt+e / alt+a select-all, and replace-on-key (backspace / delete /
7
7
  * printable character) with reverse-video highlight and a bottom-rule hint.
8
8
  * Ported from @exopro/pi-select-del so the petal prompt owns the feature
9
9
  * natively — no factory composition, no cross-extension focus handoff.
@@ -12,7 +12,10 @@ import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type EditorCom
12
12
  * - shift+home — anchor at the cursor, move to line start (held presses at
13
13
  * the edge keep the selection; only a zero-width span collapses)
14
14
  * - shift+end — anchor at the cursor, move to line end
15
- * - alt+a — select all
15
+ * - alt+e — select all; alt+a too when no extension shortcut claims it
16
+ * (Gentle Agents owns alt+a by default, gentle-shell#1565)
17
+ * - a registered extension shortcut always wins over these selection chords,
18
+ * matching CustomEditor's own precedence
16
19
  * - backspace / delete / printable character or bracketed paste over an
17
20
  * active selection — replace it in one atomic edit (undo restores text AND cursor)
18
21
  * - any other key — collapse the selection first, then behave natively
@@ -275,8 +278,10 @@ export class SelectionEngine {
275
278
  * Selection dispatch in front of the native chain. Selection and replace
276
279
  * keys are handled here; everything else collapses the anchor first, then
277
280
  * behaves natively. Degraded hosts keep pure native key handling.
281
+ * `shortcut` probes registered extension shortcuts before a selection chord
282
+ * is consumed; it returns true when a shortcut handled the key.
278
283
  */
279
- handleInput(data: string, native: (data: string) => void): void {
284
+ handleInput(data: string, native: (data: string) => void, shortcut?: (data: string) => boolean): void {
280
285
  if (this.degraded) {
281
286
  native(data);
282
287
  return;
@@ -314,12 +319,13 @@ export class SelectionEngine {
314
319
  this.pasteBufferedBytes = 0;
315
320
  this.pasteTail = "";
316
321
  this.replaceSelectionWithPaste(frame, native);
317
- if (remaining) this.handleInput(remaining, native);
322
+ if (remaining) this.handleInput(remaining, native, shortcut);
318
323
  return;
319
324
  }
320
325
  // Kitty flag 2 release byte strings still match their own key, so
321
326
  // releases must be dropped before any matchesKey.
322
327
  if (isKeyRelease(data)) return;
328
+ if (this.isSelectionChord(data) && shortcut?.(data)) return;
323
329
  if (matchesKey(data, "shift+home")) {
324
330
  this.selectToLineEdge(false);
325
331
  return;
@@ -328,7 +334,7 @@ export class SelectionEngine {
328
334
  this.selectToLineEdge(true);
329
335
  return;
330
336
  }
331
- if (matchesKey(data, "alt+a")) {
337
+ if (matchesKey(data, "alt+e") || matchesKey(data, "alt+a")) {
332
338
  this.selectAll();
333
339
  return;
334
340
  }
@@ -424,7 +430,13 @@ export class SelectionEngine {
424
430
  this.internals.tui.requestRender();
425
431
  }
426
432
 
427
- /** Select the entire editor text (alt+a). Cursor moves to the end of the last line. */
433
+ /** Chords the engine itself consumes; extension shortcuts are probed first. */
434
+ private isSelectionChord(data: string): boolean {
435
+ return matchesKey(data, "shift+home") || matchesKey(data, "shift+end")
436
+ || matchesKey(data, "alt+e") || matchesKey(data, "alt+a");
437
+ }
438
+
439
+ /** Select the entire editor text (alt+e / alt+a). Cursor moves to the end of the last line. */
428
440
  private selectAll(): void {
429
441
  const lines = this.s.lines;
430
442
  const lastLine = Math.max(0, lines.length - 1);
@@ -5,7 +5,7 @@ import { dirname, relative, resolve } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
7
7
  import { SessionChanges, readChangeSnapshot, sameSnapshot, textSnapshot, isSessionChangeEvidence,
8
- SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT, SESSION_CHANGE_RELAY, type SessionChangeEvidence, type ChangeSnapshot } from "./session-changes.ts";
8
+ SESSION_CHANGE_ENTRY, SESSION_CHANGE_RELAY, type SessionChangeEvidence, type ChangeSnapshot } from "./session-changes.ts";
9
9
 
10
10
  const foreignPublishers = new WeakMap<ExtensionAPI, (sessionId: string, evidence: SessionChangeEvidence) => void>();
11
11
  /** Internal authenticated handoff from a successful child tool callback, never a model message or public event. */
@@ -17,7 +17,10 @@ interface Pending { sessionId: string; inputPath: string; path: string; root: st
17
17
  const normalized = (text: string) => text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
18
18
  const unknown = (): ChangeSnapshot => ({ kind: "unavailable", reason: "Tool snapshots could not be verified; diff unavailable." });
19
19
 
20
- /** Observe explicit write/edit outcomes, never infer ownership from Git status or shell text. */
20
+ /**
21
+ * Observe explicit write/edit outcomes, never infer ownership from Git status or shell text.
22
+ * The evidence feeds session statistics and names the roots /gentle:changes reads from Git.
23
+ */
21
24
  export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, resolver: WorktreeResolver = resolveSessionWorktree): void {
22
25
  const child = env.GENTLE_PI_AGENTS_CHILD === "1";
23
26
  const pending = new Map<string, Pending>();
@@ -26,8 +29,6 @@ export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.Proces
26
29
  const publish = (evidence: SessionChangeEvidence) => {
27
30
  if (!current || !store || current.sessionManager.getSessionId() !== store.sessionId) return;
28
31
  if (store.record(evidence)) pi.appendEntry(SESSION_CHANGE_ENTRY, { sessionId: store.sessionId, evidence });
29
- const notice = store.takeNotice();
30
- pi.events.emit(SESSION_CHANGE_EVENT, { sessionId: store.sessionId, ...(notice ? { notice } : {}) });
31
32
  };
32
33
  foreignPublishers.set(pi, (sessionId, evidence) => {
33
34
  if (!child && current?.sessionManager.getSessionId() === sessionId && isSessionChangeEvidence(evidence)) publish(evidence);
@@ -1,12 +1,14 @@
1
1
  import { constants } from "node:fs";
2
- import { createHash } from "node:crypto";
3
2
  import { open } from "node:fs/promises";
4
3
  import { isAbsolute } from "node:path";
5
4
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
6
5
  import { changesModel, type ChangedFile, type WorktreeChanges } from "./shell-changes.ts";
6
+ import { SESSION_WORKTREE_ENTRY } from "./session-worktree-registry.ts";
7
7
 
8
+ // Captured write/edit evidence of a session and its subagents. It feeds
9
+ // session statistics and tells /gentle:changes which roots the session
10
+ // touched; the files Changes lists always come from Git.
8
11
  export const SESSION_CHANGE_ENTRY = "gentle-pi.session-change/v1";
9
- export const SESSION_CHANGE_EVENT = "gentle-pi:session-change";
10
12
  export const SESSION_CHANGE_RELAY = "gentle-pi:child-session-change";
11
13
  export const MAX_CHANGE_BYTES = 64 * 1024;
12
14
  const MAX_RECORDS = 256;
@@ -65,13 +67,29 @@ export function isSessionChangeEvidence(value: unknown): value is SessionChangeE
65
67
  export const sameSnapshot = (a: ChangeSnapshot, b: ChangeSnapshot): boolean =>
66
68
  a.kind === b.kind && (a.kind === "absent" || (a.kind === "text" && b.kind === "text" && a.text === b.text));
67
69
  const snapshotText = (value: ChangeSnapshot) => value.kind === "text" ? value.text : "";
70
+ const shown = (state: FileState) => Boolean(state.unavailable) || !sameSnapshot(state.before, state.after);
71
+
72
+ /**
73
+ * Roots this session works in: its registered worktrees (own root, explicit and
74
+ * subagent registrations) and the roots of captured evidence (subagents in an
75
+ * independent repository). Only roots, never files.
76
+ */
77
+ export function sessionChangeRoots(sessionId: string, entries: readonly Entry[]): string[] {
78
+ const roots = new Set<string>();
79
+ for (const entry of entries) {
80
+ if (entry.type !== "custom" || !entry.data || typeof entry.data !== "object") continue;
81
+ const data = entry.data as { sessionId?: unknown; root?: unknown; evidence?: unknown };
82
+ if (data.sessionId !== sessionId) continue;
83
+ if (entry.customType === SESSION_WORKTREE_ENTRY && typeof data.root === "string" && isAbsolute(data.root)) roots.add(data.root);
84
+ if (entry.customType === SESSION_CHANGE_ENTRY && isSessionChangeEvidence(data.evidence)) roots.add(data.evidence.root);
85
+ }
86
+ return [...roots];
87
+ }
68
88
 
69
89
  export class SessionChanges {
70
90
  private readonly seen = new Set<string>();
71
91
  private readonly files = new Map<string, Map<string, FileState>>();
72
92
  private bytes = 0;
73
- private announced = false;
74
- notice: string | undefined;
75
93
  readonly sessionId: string;
76
94
  private cached: WorktreeChanges[] | undefined;
77
95
  constructor(sessionId: string, entries: readonly Entry[] = []) { this.sessionId = sessionId; this.restore(entries); }
@@ -86,10 +104,8 @@ export class SessionChanges {
86
104
  record(evidence: SessionChangeEvidence): boolean {
87
105
  if (!isSessionChangeEvidence(evidence) || this.seen.has(evidence.id)) return false;
88
106
  const bytes = Buffer.byteLength(JSON.stringify(evidence));
89
- if (this.seen.size >= MAX_RECORDS || this.bytes + bytes > MAX_SESSION_BYTES) {
90
- this.notice = "Session change capture limit reached; additional changes are not displayed.";
91
- return false;
92
- }
107
+ // Bounds session-file growth; Changes itself lists from Git without a cap.
108
+ if (this.seen.size >= MAX_RECORDS || this.bytes + bytes > MAX_SESSION_BYTES) return false;
93
109
  this.seen.add(evidence.id);
94
110
  this.bytes += bytes;
95
111
  if (sameSnapshot(evidence.before, evidence.after)) return false;
@@ -109,13 +125,13 @@ export class SessionChanges {
109
125
  }
110
126
  private changed(root: string): ChangedFile[] {
111
127
  return [...(this.files.get(root) ?? [])].flatMap(([path, state]) => {
112
- if (!state.unavailable && sameSnapshot(state.before, state.after)) return [];
128
+ if (!shown(state)) return [];
113
129
  const patch = this.patch(path, state);
114
130
  const patchLines = patch.split("\n");
115
131
  const firstHunk = patchLines.findIndex(line => line.startsWith("@@"));
116
132
  const lines = firstHunk < 0 ? [] : patchLines.slice(firstHunk + 1);
117
133
  return [{
118
- path, diffRevision: createHash("sha256").update(patch).digest("hex"), status: state.before.kind === "absent" ? "added" as const : state.after.kind === "absent" ? "deleted" as const : "modified" as const,
134
+ path, status: state.before.kind === "absent" ? "added" as const : state.after.kind === "absent" ? "deleted" as const : "modified" as const,
119
135
  added: state.unavailable ? 0 : lines.filter(line => line.startsWith("+")).length,
120
136
  deleted: state.unavailable ? 0 : lines.filter(line => line.startsWith("-")).length,
121
137
  ...(state.unavailable ? { countsUnavailable: state.unavailable } : {}),
@@ -131,17 +147,6 @@ export class SessionChanges {
131
147
  }
132
148
  get model() {
133
149
  const trees = this.worktrees;
134
- return { ...changesModel(trees.flatMap(tree => tree.model.files.map(file => ({ ...file, path: trees.length === 1 ? file.path : tree.root + "/" + file.path })))), ...(this.notice ? { notice: this.notice } : {}) };
135
- }
136
- /** Return the capture-limit notice once, for a single user notification. The `notice` field stays set. */
137
- takeNotice(): string | undefined {
138
- if (!this.notice || this.announced) return undefined;
139
- this.announced = true;
140
- return this.notice;
141
- }
142
- async refresh() { return this.model; }
143
- loadDiff(root: string, file: ChangedFile): string {
144
- const state = this.files.get(root)?.get(file.path);
145
- return state ? this.patch(file.path, state) : "No captured agent diff for this file.";
150
+ return changesModel(trees.flatMap(tree => tree.model.files.map(file => ({ ...file, path: trees.length === 1 ? file.path : tree.root + "/" + file.path }))));
146
151
  }
147
152
  }
@@ -0,0 +1,83 @@
1
+ // Session-bound active profiles (gentle-shell#1064, slice 1).
2
+ //
3
+ // One parent Pi session may bind one saved profile for its own launches without
4
+ // touching any shared state: no clone pin (`p`), no repository declaration (`P`),
5
+ // no global routing, no materialized stores, no agent frontmatter, no Pi default
6
+ // settings. The binding is an in-process store keyed by the parent session id, so
7
+ // concurrent Pi processes are isolated by construction and a resumed session
8
+ // starts from the shared layers (`p → P → global`) until it binds again.
9
+ // Persistence across resume and reload semantics are deliberate follow-up
10
+ // slices; this module never writes a file.
11
+ //
12
+ // Pi loads every extension entrypoint with its own Jiti instance and
13
+ // `moduleCache: false` (loader.js `loadExtensionModule`), so a module-local Map
14
+ // would be re-evaluated per entrypoint: the panel writes one copy while the
15
+ // launch, usage, and status readers hold their own empty copies
16
+ // (gentle-shell#1558). The store therefore lives on `globalThis` behind a
17
+ // `Symbol.for` key, which every evaluation of this module in one process
18
+ // resolves to the same symbol and therefore the same shared Map.
19
+ import type { AgentModelConfig } from "./model-routing-authority.ts";
20
+
21
+ /** A session's bound profile: its saved name plus the routing snapshot taken at bind time. */
22
+ export interface SessionProfileBinding {
23
+ readonly name: string;
24
+ readonly modelProfiles: AgentModelConfig;
25
+ }
26
+
27
+ const bindingsRegistryKey = Symbol.for("gentle-pi:session-profile-bindings");
28
+
29
+ /** The one process-wide binding store, shared across every extension entrypoint that imports this module. */
30
+ function bindingsBySession(): Map<string, SessionProfileBinding> {
31
+ const holder = globalThis as { [bindingsRegistryKey]?: Map<string, SessionProfileBinding> };
32
+ (holder[bindingsRegistryKey] ??= new Map());
33
+ return holder[bindingsRegistryKey]!;
34
+ }
35
+
36
+ /** Copy one routing snapshot level deep — the layer a defensive copy needs, so a binding never aliases its source or its readers. */
37
+ function cloneProfiles(profiles: AgentModelConfig): AgentModelConfig {
38
+ return Object.fromEntries(Object.entries(profiles).map(([agent, entry]) => [agent, { ...entry }]));
39
+ }
40
+
41
+ /**
42
+ * Bind (or rebind) one parent session to a profile. The routing snapshot is
43
+ * copied, so later edits to the caller's object or to the saved profile
44
+ * definition never mutate a session that already bound it; refreshing a bound
45
+ * session requires an explicit re-selection.
46
+ */
47
+ export function bindSessionProfile(sessionId: string, name: string, modelProfiles: AgentModelConfig): void {
48
+ bindingsBySession().set(sessionId, { name, modelProfiles: cloneProfiles(modelProfiles) });
49
+ }
50
+
51
+ /**
52
+ * Read a session's binding as a private copy. Returns `undefined` for unknown,
53
+ * cleared, or undefined session ids: an unbound session keeps resolving the
54
+ * shared layers exactly as before.
55
+ */
56
+ export function readSessionProfileBinding(sessionId: string | undefined): SessionProfileBinding | undefined {
57
+ if (sessionId === undefined) return undefined;
58
+ const binding = bindingsBySession().get(sessionId);
59
+ return binding === undefined ? undefined : { name: binding.name, modelProfiles: cloneProfiles(binding.modelProfiles) };
60
+ }
61
+
62
+ /** Remove one session's binding without touching any other session's. */
63
+ export function clearSessionProfileBinding(sessionId: string): void {
64
+ bindingsBySession().delete(sessionId);
65
+ }
66
+
67
+ /** Testing-only reset of every binding. */
68
+ export function resetSessionProfileBindingsForTesting(): void {
69
+ bindingsBySession().clear();
70
+ }
71
+
72
+ /**
73
+ * The launch-time precedence for wholesale routing replacement: a session
74
+ * binding outranks a winning pin, a pin alone still replaces, and neither
75
+ * leaves today's routing untouched. Named so the precedence rule a launch and
76
+ * the status display agree on lives in exactly one place.
77
+ */
78
+ export function sessionOrPinModelProfiles(
79
+ session: AgentModelConfig | undefined,
80
+ pin: AgentModelConfig | undefined,
81
+ ): AgentModelConfig | undefined {
82
+ return session ?? pin;
83
+ }
@@ -0,0 +1,266 @@
1
+ // Inherited profile freeze (gentle-shell#1064 slice 3b-ii).
2
+ //
3
+ // A parent session that never pressed Enter in `/gentle:profiles` resolves the
4
+ // shared defaults `p → P → G` (local pin, repository declaration, global
5
+ // `active` in profiles.json) ONCE, at startup, and keeps that snapshot with its
6
+ // origin. Changing a default later only affects new sessions, exactly like Pi's
7
+ // "set as default" for the orchestrator model; an open session changes profile
8
+ // only through its own Enter. Each launch re-resolves the defaults to compare
9
+ // and shows one drift notice per distinct change.
10
+ //
11
+ // Precedence, the single rule every consumer reads through
12
+ // `resolveSessionProfile`:
13
+ //
14
+ // explicit Enter binding → frozen inherited snapshot → live p → P → G
15
+ //
16
+ // The live resolution applies only in `follow` mode (GENTLE_PI_PROFILE_FOLLOW=1,
17
+ // for CI and headless runs), in subagent children (their routing comes from the
18
+ // parent through --model), and when no session id is available. In `follow`
19
+ // mode the orchestrator still comes from the startup profile while children
20
+ // follow later defaults: an accepted, documented limitation.
21
+ //
22
+ // The frozen state is in-process only, like the explicit binding of slice 2: it
23
+ // lives on `globalThis` behind a `Symbol.for` key because Pi evaluates this
24
+ // module once per extension entrypoint. A resumed session (`--resume`,
25
+ // `--continue`, `--session`) therefore freezes again from the current defaults,
26
+ // and its drift memory starts empty. Persisting the freeze as a
27
+ // `gentle-pi.session-profile/v1` entry with origin `local | repo | global` is
28
+ // the integration point with slice 3b-i.
29
+ import { createHash } from "node:crypto";
30
+ import { profileRoleEntries, profilesFilePath, readProfilesFileResult } from "./agent-profiles.ts";
31
+ import { readProfilePinStatus, resolveProfilePin, resolveUnversionedProjectProfile } from "./agent-profile-pin.ts";
32
+ import type { AgentModelConfig } from "./model-routing-authority.ts";
33
+ import { readSessionProfileBinding } from "./session-profile-binding.ts";
34
+ import type { WorktreeIdentity, WorktreeResolver } from "./session-worktree-registry.ts";
35
+
36
+ /** Opt-in that keeps the live `p → P → G` resolution instead of freezing it at startup. */
37
+ export const PROFILE_FOLLOW_ENV = "GENTLE_PI_PROFILE_FOLLOW";
38
+
39
+ /** Which shared default supplied an inherited profile. */
40
+ export type InheritedProfileOrigin = "local" | "repo" | "global";
41
+
42
+ /** A profile resolved from the shared defaults: its name, its origin, and its role routing. */
43
+ export interface InheritedProfile {
44
+ readonly name: string;
45
+ readonly origin: InheritedProfileOrigin;
46
+ readonly modelProfiles: AgentModelConfig;
47
+ }
48
+
49
+ /** A frozen session: `profile` is `undefined` when no layer had a profile at startup. */
50
+ export interface FrozenInheritedProfile {
51
+ readonly profile: InheritedProfile | undefined;
52
+ }
53
+
54
+ /**
55
+ * The profile a session uses. `inherited` is the startup snapshot (with or
56
+ * without a profile); `follow` and `live` carry the current defaults.
57
+ */
58
+ export type SessionProfileResolution =
59
+ | { kind: "explicit"; name: string; modelProfiles: AgentModelConfig }
60
+ | { kind: "inherited"; profile: InheritedProfile | undefined }
61
+ | { kind: "follow"; profile: InheritedProfile | undefined }
62
+ | { kind: "live"; profile: InheritedProfile | undefined };
63
+
64
+ export interface InheritedProfileOptions {
65
+ cwd: string;
66
+ configHome: string;
67
+ resolveWorktree?: WorktreeResolver;
68
+ }
69
+
70
+ export interface SessionProfileOptions extends InheritedProfileOptions {
71
+ sessionId: string | undefined;
72
+ env?: NodeJS.ProcessEnv;
73
+ }
74
+
75
+ interface FreezeRegistry {
76
+ frozen: Map<string, FrozenInheritedProfile>;
77
+ // Drift fingerprints already reported, per session.
78
+ notified: Map<string, Set<string>>;
79
+ }
80
+
81
+ const freezeRegistryKey = Symbol.for("gentle-pi:session-profile-freeze");
82
+
83
+ function registry(): FreezeRegistry {
84
+ const holder = globalThis as { [freezeRegistryKey]?: FreezeRegistry };
85
+ (holder[freezeRegistryKey] ??= { frozen: new Map(), notified: new Map() });
86
+ return holder[freezeRegistryKey]!;
87
+ }
88
+
89
+ function cloneProfiles(profiles: AgentModelConfig): AgentModelConfig {
90
+ return Object.fromEntries(Object.entries(profiles).map(([agent, entry]) => [agent, { ...entry }]));
91
+ }
92
+
93
+ function cloneProfile(profile: InheritedProfile | undefined): InheritedProfile | undefined {
94
+ return profile === undefined ? undefined : { name: profile.name, origin: profile.origin, modelProfiles: cloneProfiles(profile.modelProfiles) };
95
+ }
96
+
97
+ function roleRouting(config: AgentModelConfig): AgentModelConfig {
98
+ return Object.fromEntries(profileRoleEntries(config).map(([agent, entry]) => [agent, { ...entry }]));
99
+ }
100
+
101
+ export function profileFollowEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
102
+ return env[PROFILE_FOLLOW_ENV] === "1";
103
+ }
104
+
105
+ function isChildSession(env: NodeJS.ProcessEnv): boolean {
106
+ return env.GENTLE_PI_AGENTS_CHILD === "1";
107
+ }
108
+
109
+ /**
110
+ * The shared defaults `p → P → G` for one directory, with the layer that won.
111
+ * Pin layers keep `resolveProfilePin` semantics; outside a Git worktree the
112
+ * repository declaration is read as admission does for non-Git projects; the
113
+ * global layer is the `active` profile of profiles.json. The orchestrator key
114
+ * is never part of the routing. `undefined` means no layer has a profile.
115
+ */
116
+ export function resolveInheritedProfile(options: InheritedProfileOptions): InheritedProfile | undefined {
117
+ // One worktree lookup serves both the pin layers and the non-Git check.
118
+ let resolved = false;
119
+ let identity: WorktreeIdentity | undefined;
120
+ const resolveWorktree: WorktreeResolver | undefined = options.resolveWorktree === undefined ? undefined : (cwd, base) => {
121
+ if (!resolved) {
122
+ resolved = true;
123
+ identity = options.resolveWorktree!(cwd, base);
124
+ }
125
+ return identity;
126
+ };
127
+ const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree });
128
+ if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
129
+ if (readProfilePinStatus(options.cwd, resolveWorktree) === undefined) {
130
+ const declaration = resolveUnversionedProjectProfile(options.cwd, options.configHome);
131
+ if (declaration !== undefined) return { name: declaration.profile, origin: "repo", modelProfiles: declaration.modelProfiles };
132
+ }
133
+ const store = readProfilesFileResult(profilesFilePath(options.configHome));
134
+ if (store.status !== "valid" || store.file.active === undefined) return undefined;
135
+ const active = store.file.active;
136
+ if (!Object.prototype.hasOwnProperty.call(store.file.profiles, active)) return undefined;
137
+ return { name: active, origin: "global", modelProfiles: roleRouting(store.file.profiles[active]!) };
138
+ }
139
+
140
+ /** A copy of one session's frozen snapshot, or `undefined` when it never froze. */
141
+ export function readFrozenInheritedProfile(sessionId: string | undefined): FrozenInheritedProfile | undefined {
142
+ if (sessionId === undefined) return undefined;
143
+ const frozen = registry().frozen.get(sessionId);
144
+ return frozen === undefined ? undefined : { profile: cloneProfile(frozen.profile) };
145
+ }
146
+
147
+ function freezes(options: SessionProfileOptions): boolean {
148
+ const env = options.env ?? process.env;
149
+ return options.sessionId !== undefined && options.sessionId.length > 0 && !isChildSession(env) && !profileFollowEnabled(env);
150
+ }
151
+
152
+ // Only called after `freezes(options)`, which guarantees a session id.
153
+ function frozenOrFreeze(options: SessionProfileOptions): FrozenInheritedProfile {
154
+ const store = registry().frozen;
155
+ const sessionId = options.sessionId!;
156
+ let frozen = store.get(sessionId);
157
+ if (frozen === undefined) {
158
+ frozen = { profile: cloneProfile(resolveInheritedProfile(options)) };
159
+ store.set(sessionId, frozen);
160
+ }
161
+ return { profile: cloneProfile(frozen.profile) };
162
+ }
163
+
164
+ /**
165
+ * Freeze a parent session's inherited profile at startup. Idempotent: the
166
+ * first snapshot wins, so a session keeps it across `/reload` in the same
167
+ * process. Children and `follow` sessions never freeze.
168
+ */
169
+ export function freezeSessionProfileAtStartup(options: SessionProfileOptions): void {
170
+ if (freezes(options)) frozenOrFreeze(options);
171
+ }
172
+
173
+ /**
174
+ * The single precedence rule for launch, status and Usage. A parent session
175
+ * that has not frozen yet freezes here, so the snapshot never depends on which
176
+ * extension's `session_start` ran first.
177
+ */
178
+ export function resolveSessionProfile(options: SessionProfileOptions): SessionProfileResolution {
179
+ const explicit = readSessionProfileBinding(options.sessionId);
180
+ if (explicit !== undefined) return { kind: "explicit", name: explicit.name, modelProfiles: explicit.modelProfiles };
181
+ if (freezes(options)) return { kind: "inherited", profile: frozenOrFreeze(options).profile };
182
+ const env = options.env ?? process.env;
183
+ const profile = resolveInheritedProfile(options);
184
+ return isChildSession(env) || !profileFollowEnabled(env) ? { kind: "live", profile } : { kind: "follow", profile };
185
+ }
186
+
187
+ /** The routing that replaces subagent model profiles, or `undefined` for today's routing. */
188
+ export function sessionProfileModelProfiles(resolution: SessionProfileResolution): AgentModelConfig | undefined {
189
+ return resolution.kind === "explicit" ? resolution.modelProfiles : resolution.profile?.modelProfiles;
190
+ }
191
+
192
+ /**
193
+ * The routing one launch target uses under a resolution read once per request.
194
+ * Explicit and frozen profiles belong to the session, so they apply wherever
195
+ * the launch goes, a foreign repository included; a `follow` or `live`
196
+ * resolution reads the defaults of the target itself. A session frozen without
197
+ * a profile has nothing to carry into a foreign repository, so that
198
+ * repository keeps its own pin layers (local pin, then declaration), as
199
+ * before the freeze; the global active profile is not the repository's own
200
+ * default and does not apply there either.
201
+ */
202
+ export function sessionProfileRoutingAt(
203
+ resolution: SessionProfileResolution,
204
+ target: InheritedProfileOptions & { foreignRepository?: boolean },
205
+ ): AgentModelConfig | undefined {
206
+ if (resolution.kind === "explicit") return resolution.modelProfiles;
207
+ if (resolution.kind === "inherited") {
208
+ if (resolution.profile !== undefined || target.foreignRepository !== true) return resolution.profile?.modelProfiles;
209
+ return resolveProfilePin(target)?.modelProfiles;
210
+ }
211
+ return resolveInheritedProfile(target)?.modelProfiles;
212
+ }
213
+
214
+ /** The status label, spelled as before: `name (session)`, `name (local)`, `name (repo)`, or the bare global name. */
215
+ export function sessionProfileLabel(resolution: SessionProfileResolution): string | undefined {
216
+ if (resolution.kind === "explicit") return `${resolution.name} (session)`;
217
+ const profile = resolution.profile;
218
+ if (profile === undefined) return undefined;
219
+ return profile.origin === "global" ? profile.name : `${profile.name} (${profile.origin})`;
220
+ }
221
+
222
+ function stableJson(value: unknown): string {
223
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
224
+ if (value !== null && typeof value === "object") {
225
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
226
+ }
227
+ return JSON.stringify(value) ?? "null";
228
+ }
229
+
230
+ // The origin does not route anything: the same profile reached through another
231
+ // layer is not drift.
232
+ function fingerprint(profile: InheritedProfile | undefined): string {
233
+ if (profile === undefined) return "none";
234
+ return createHash("sha256").update(stableJson({ name: profile.name, modelProfiles: profile.modelProfiles })).digest("hex");
235
+ }
236
+
237
+ function describe(profile: InheritedProfile | undefined): string {
238
+ return profile === undefined ? "no profile" : `"${profile.name}" (${profile.origin})`;
239
+ }
240
+
241
+ /**
242
+ * Compare a frozen inherited session with the current defaults. Returns the
243
+ * one-line notice the first time a given current default differs from the
244
+ * snapshot, and `undefined` otherwise: the same change is never reported
245
+ * twice in one session. Explicit bindings, `follow` sessions and children are
246
+ * never compared. The comparison never adopts the new default.
247
+ */
248
+ export function inheritedProfileDriftNotice(options: SessionProfileOptions): string | undefined {
249
+ const resolution = resolveSessionProfile(options);
250
+ if (resolution.kind !== "inherited") return undefined;
251
+ const current = resolveInheritedProfile(options);
252
+ const key = fingerprint(current);
253
+ if (key === fingerprint(resolution.profile)) return undefined;
254
+ const notified = registry().notified;
255
+ const seen = notified.get(options.sessionId!) ?? new Set<string>();
256
+ if (seen.has(key)) return undefined;
257
+ seen.add(key);
258
+ notified.set(options.sessionId!, seen);
259
+ return `el Gentleman: the default profile changed to ${describe(current)}, this session keeps ${describe(resolution.profile)}. Press Enter on a profile in /gentle:profiles to adopt it.`;
260
+ }
261
+
262
+ /** Testing-only reset of every frozen snapshot and drift memory. */
263
+ export function resetFrozenInheritedProfilesForTesting(): void {
264
+ registry().frozen.clear();
265
+ registry().notified.clear();
266
+ }
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { realpathSync, statSync } from "node:fs";
2
+ import { lstatSync, realpathSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
 
6
6
  export const SESSION_WORKTREE_ENTRY = "gentle-pi.session-worktree/v1";
7
7
  export const SESSION_WORKTREE_CHANGED = "gentle-pi:session-worktree-changed";
@@ -23,12 +23,16 @@ export function worktreeGitEnvironment(env: NodeJS.ProcessEnv = process.env): No
23
23
  }
24
24
 
25
25
  // Match Pi's ordinary path spelling; Git, not the argument, establishes identity.
26
+ function canonicalDirectory(path: string, cwd: string): string {
27
+ let spelling = path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
28
+ if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
29
+ const canonical = realpathSync(resolve(cwd, spelling));
30
+ return statSync(canonical).isDirectory() ? canonical : dirname(canonical);
31
+ }
32
+
26
33
  export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: typeof execFileSync = execFileSync): WorktreeIdentity | undefined {
27
34
  try {
28
- let spelling = path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
29
- if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
30
- const canonical = realpathSync(resolve(cwd, spelling));
31
- const directory = statSync(canonical).isDirectory() ? canonical : dirname(canonical);
35
+ const directory = canonicalDirectory(path, cwd);
32
36
  // Ambient Git routing must not redirect a path into another repository.
33
37
  const env = worktreeGitEnvironment();
34
38
  const git = (arg: string) => String(run("git", ["--no-optional-locks", "-C", directory, "rev-parse", "--path-format=absolute", arg], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true, env })).replace(/\r?\n$/, "");
@@ -38,7 +42,69 @@ export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: ty
38
42
  }
39
43
  }
40
44
 
41
- export const resolveSessionWorktree: WorktreeResolver = resolveSessionWorktreeWithGit;
45
+ // Filesystem identity of everything Git used to answer: no `.git` between the
46
+ // directory and its root, and the same root, `.git` entry and common dir.
47
+ // Undefined when any of it cannot be proven, so the answer is not memoized.
48
+ function worktreeFingerprint(directory: string, identity: WorktreeIdentity): string | undefined {
49
+ const same = (a: string, b: string) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
50
+ try {
51
+ for (let current = directory; !same(current, identity.root); current = dirname(current)) {
52
+ if (dirname(current) === current) return undefined;
53
+ try { lstatSync(join(current, ".git")); return undefined; }
54
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined; }
55
+ }
56
+ const marker = lstatSync(join(identity.root, ".git"));
57
+ const parts = [statSync(identity.root), marker, statSync(identity.commonDir)].map(entry => `${entry.dev}:${entry.ino}`);
58
+ // A linked worktree's `.git` file can be rewritten in place to another clone.
59
+ if (marker.isFile()) parts.push(`${marker.size}:${marker.mtimeMs}`);
60
+ return parts.join("|");
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ export interface WorktreeMemoOptions { ttlMs?: number; now?: () => number }
67
+ interface MemoEntry { identity: WorktreeIdentity; directory: string; fingerprint: string; expiresAt: number }
68
+ const memoInvalidators = new Set<() => void>();
69
+
70
+ // Startup asks the same (cwd, cwd) question from several extensions; each
71
+ // answer costs two synchronous Git children. Only proven repositories are
72
+ // memoized, and every hit re-checks their filesystem identity, so removal,
73
+ // replacement, a nested `.git` and bootstrap (never cached) are seen at once.
74
+ // The TTL bounds what stat cannot see, such as Git config edits.
75
+ export function memoizeWorktreeResolver(resolver: WorktreeResolver, { ttlMs = 5000, now = Date.now }: WorktreeMemoOptions = {}): WorktreeResolver {
76
+ const memo = new Map<string, MemoEntry>();
77
+ memoInvalidators.add(() => memo.clear());
78
+ return (path, cwd) => {
79
+ const key = `${path}\u0000${cwd}`;
80
+ const cached = memo.get(key);
81
+ if (cached && cached.expiresAt > now()) {
82
+ try {
83
+ const directory = canonicalDirectory(path, cwd);
84
+ if (directory === cached.directory && worktreeFingerprint(directory, cached.identity) === cached.fingerprint) return { ...cached.identity };
85
+ } catch { /* Fall through to a fresh resolution. */ }
86
+ }
87
+ memo.delete(key);
88
+ const identity = resolver(path, cwd);
89
+ if (!identity) return undefined;
90
+ try {
91
+ const directory = canonicalDirectory(path, cwd);
92
+ const fingerprint = worktreeFingerprint(directory, identity);
93
+ if (fingerprint !== undefined) {
94
+ if (memo.size >= 256) memo.clear();
95
+ memo.set(key, { identity: { ...identity }, directory, fingerprint, expiresAt: now() + ttlMs });
96
+ }
97
+ } catch { /* Unprovable identity is returned but never memoized. */ }
98
+ return identity;
99
+ };
100
+ }
101
+
102
+ /** Drops every memoized worktree identity; the next lookup asks Git again. */
103
+ export function clearSessionWorktreeMemo(): void {
104
+ for (const invalidate of memoInvalidators) invalidate();
105
+ }
106
+
107
+ export const resolveSessionWorktree: WorktreeResolver = memoizeWorktreeResolver(resolveSessionWorktreeWithGit);
42
108
 
43
109
  export function toolWorktreePath(name: string, input: Record<string, unknown>): string | undefined {
44
110
  if (!["read", "write", "edit", "grep", "find", "ls"].includes(name)) return undefined;
@@ -82,7 +148,7 @@ export class SessionWorktreeRegistry {
82
148
  if (!this.isCurrent()) throw new Error("Cannot register a worktree for an inactive session.");
83
149
  const identity = this.refreshIdentity();
84
150
  const target = this.resolver(path, this.cwd);
85
- if (!target || !identity || target.commonDir !== identity.commonDir) throw new Error("Select an existing worktree in the same Git clone as this session.");
151
+ if (!target || !identity || target.commonDir !== identity.commonDir) throw new Error("Select an existing worktree in the same Git clone as this session. To work in an independent Git repository, delegate with subagent_run with repository_root instead; it asks for your consent first.");
86
152
  return target.root;
87
153
  }
88
154