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
@@ -0,0 +1,223 @@
1
+ // Narrow update ownership proof. Deliberately independent of Shell upgrade/uninstall.
2
+ import { posix, win32 } from 'node:path';
3
+ import { bundledUpdatePlan } from '../runtime/gentle-shell-bundled.mjs';
4
+
5
+ const PI = '@earendil-works/pi-coding-agent';
6
+ const SHELL = 'gentle-pi';
7
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ const succeeded = result => result?.code === 0 && !result.timedOut && result.signal == null;
9
+ const refusal = detail => new Error(`gentle-shell update: ownership ${detail}; update Pi with the package manager or source checkout that supplies the selected runtime. No installation changed.`);
10
+ const LINKED = /^(?:file|link|workspace):/i;
11
+ const CLI_TAIL = /[\\/]dist[\\/]bundle[\\/]cli\.js$/i;
12
+ // A quoted cli.js target inside a package-manager shim: pnpm's POSIX shim
13
+ // (`"$basedir/../global/.../cli.js"`), its `.CMD`/npm's `.cmd` shim
14
+ // (`"%~dp0\\...\\cli.js"`, `"%dp0%\\...\\cli.js"`) or an absolute path.
15
+ const SHIM_TARGET = /"((?:\$basedir[\\/]|%~?dp0%?[\\/])?[^"]*?[\\/]dist[\\/]bundle[\\/]cli\.js)"/g;
16
+
17
+ export function managedUpdatePlan(args) {
18
+ // The legacy extension-only alias must never trigger self-update.
19
+ if (args.includes('--extensions-only')) return {self:false, piArgs:args};
20
+ return bundledUpdatePlan(args);
21
+ }
22
+
23
+ function pathsFor(platform) {
24
+ const path = platform === 'win32' ? win32 : posix;
25
+ const key = value => platform === 'win32' ? path.normalize(value).toLowerCase() : path.normalize(value);
26
+ return {path, key};
27
+ }
28
+
29
+ function canonicalWith(adapters, path) {
30
+ return async value => {
31
+ if (typeof value !== 'string' || !path.isAbsolute(value)) throw refusal('has no absolute package path');
32
+ const result = await adapters.fs.realpath(value);
33
+ if (typeof result !== 'string' || !path.isAbsolute(result)) throw refusal('has an invalid canonical path');
34
+ return result;
35
+ };
36
+ }
37
+
38
+ /** The cli.js a selected PATH or GENTLE_SHELL_PI executable runs: itself when
39
+ * it is the entry (npm's bin symlink resolves there), otherwise the single
40
+ * target its shim names, relative to the shim's directory. Anything else
41
+ * cannot be matched against a listed package and is refused.
42
+ */
43
+ async function selectedCliOf(runtime, {adapters, path, canonical}) {
44
+ if (runtime.kind === 'bundled') return canonical(runtime.args[0]);
45
+ const file = await canonical(runtime.args.length ? runtime.args[0] : runtime.command);
46
+ if (CLI_TAIL.test(file)) return file;
47
+ let text;
48
+ try { text = String(await adapters.fs.readFile(file, 'utf8')); } catch { throw refusal('cannot read the selected executable'); }
49
+ const targets = new Set();
50
+ for (const [, quoted] of text.matchAll(SHIM_TARGET)) {
51
+ const relative = /^(?:\$basedir|%~?dp0%?)[\\/]/.exec(quoted);
52
+ const target = relative ? path.join(path.dirname(file), quoted.slice(relative[0].length)) : quoted;
53
+ if (!path.isAbsolute(target)) throw refusal('selected shim names a relative target');
54
+ targets.add(path.normalize(target));
55
+ }
56
+ if (targets.size !== 1) throw refusal(targets.size === 0 ? 'selected executable is not a Pi entry or shim' : 'selected shim names several targets');
57
+ return canonical([...targets][0]);
58
+ }
59
+
60
+ async function manifestOf(adapters, path, root) {
61
+ return JSON.parse(await adapters.fs.readFile(path.join(root, 'package.json'), 'utf8'));
62
+ }
63
+
64
+ /** The Pi package a canonical Shell root runs as its adjacent peer, as
65
+ * { root, manifest, cli }, or null when none resolves. pnpm's linked store
66
+ * places the peer beside the Shell package (`<...>/node_modules/<PI>`), npm
67
+ * nests it (`<shell>/node_modules/<PI>`); the global group's own
68
+ * `node_modules` is the last place a peer link can live.
69
+ */
70
+ async function adjacentPeer({shellRoot, groupRoot, adapters, path, key, canonical}) {
71
+ const places = [path.join(shellRoot, 'node_modules', PI), path.join(path.dirname(shellRoot), PI)];
72
+ if (groupRoot) places.push(path.join(groupRoot, PI));
73
+ for (const place of places) {
74
+ let root;
75
+ try { root = await canonical(place); } catch { continue; }
76
+ const manifest = await manifestOf(adapters, path, root);
77
+ if (manifest.name !== PI || manifest.bin?.pi !== 'dist/bundle/cli.js') throw refusal('adjacent Pi identity differs');
78
+ const cli = await canonical(path.join(root, manifest.bin.pi));
79
+ // The canonical package root must supply its own CLI file, not a link elsewhere.
80
+ if (key(cli) !== key(path.join(root, manifest.bin.pi))) throw refusal('adjacent Pi package root does not supply its CLI');
81
+ return {root, manifest, cli};
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // Package managers run from a neutral directory: inside a project, pnpm
87
+ // honors that project's `packageManager` pin and may download another pnpm
88
+ // before answering, and the global installation does not depend on the cwd.
89
+ async function globalListing(name, invocation, adapters, cwd) {
90
+ const argv = name === 'pnpm' ? ['list', '-g', '--depth', '0', '--json'] : ['ls', '-g', '--depth=0', '--json', '--long'];
91
+ const result = await adapters.run(invocation.command, [...invocation.prefix, ...argv], {deadlineMs:30_000, cwd});
92
+ if (!succeeded(result)) throw refusal('listing failed');
93
+ const json = JSON.parse(result.stdout);
94
+ const groups = name === 'pnpm' ? json : [json];
95
+ if (!Array.isArray(groups) || groups.length === 0) throw refusal('listing is malformed');
96
+ return groups;
97
+ }
98
+
99
+ async function invocationFor(name, adapters, platform) {
100
+ const invocation = adapters.invocation ? await adapters.invocation(name) : await adapters.which(name).then(command => command ? {command, prefix:[]} : null);
101
+ if (!invocation) return null;
102
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(invocation.command)) throw refusal(`cannot run ${name} without its shell shim`);
103
+ return invocation;
104
+ }
105
+
106
+ /** Every listed top-level Pi or Shell package of one manager, with the
107
+ * canonical global group it links from and that group's member names.
108
+ */
109
+ async function listedEntries(groups, {adapters, path, key, canonical}) {
110
+ const entries = [];
111
+ const members = new Map();
112
+ for (const group of groups) {
113
+ if (!object(group) || typeof group.path !== 'string' || !object(group.dependencies)) throw refusal('listing is malformed');
114
+ for (const [pkg, dependency] of Object.entries(group.dependencies)) {
115
+ if (!object(dependency) || !/^(?:@[^/\\]+\/)?[^/\\]+$/.test(pkg)) throw refusal('listing is malformed');
116
+ // pnpm's JSON may aggregate several global groups. The dependency's
117
+ // listed link location, not the aggregate group.path or canonical store
118
+ // package root, identifies which old group `add -g` would replace.
119
+ const listed = dependency.path;
120
+ if (typeof listed !== 'string' || !path.isAbsolute(listed)) throw refusal('listed package path is missing');
121
+ const modules = pkg.startsWith('@') ? path.dirname(path.dirname(listed)) : path.dirname(listed);
122
+ if (path.basename(modules) !== 'node_modules' || key(path.join(modules, pkg)) !== key(listed)) throw refusal('listed top-level group is unknown');
123
+ const groupRoot = await canonical(modules);
124
+ const set = members.get(key(groupRoot)) ?? new Set();
125
+ set.add(pkg);
126
+ members.set(key(groupRoot), set);
127
+ if (pkg === PI || pkg === SHELL) entries.push({pkg, dependency, groupRoot});
128
+ }
129
+ }
130
+ return {entries, members};
131
+ }
132
+
133
+ /** A single listed package must supply the exact selected executable.
134
+ * pnpm v11 lists separate global groups whose packages resolve into a linked
135
+ * store outside PNPM_HOME. Neither containment nor a package name proves
136
+ * ownership. An adjacent runtime is owned by the Shell group that resolved
137
+ * it: updating a separate global Pi never changes what that Shell runs.
138
+ */
139
+ export async function resolveUpdateOwner({runtime, packageRoot, platform = process.platform, adapters, cwd}) {
140
+ const {path, key} = pathsFor(platform);
141
+ const canonical = canonicalWith(adapters, path);
142
+ const selected = await selectedCliOf(runtime, {adapters, path, canonical});
143
+ const shellRoot = await canonical(packageRoot);
144
+ const adjacent = runtime.kind === 'bundled';
145
+ const candidates = [];
146
+ for (const name of ['pnpm', 'npm']) {
147
+ const invocation = await invocationFor(name, adapters, platform);
148
+ if (!invocation) continue;
149
+ const groups = await globalListing(name, invocation, adapters, cwd);
150
+ const {entries, members} = await listedEntries(groups, {adapters, path, key, canonical});
151
+ for (const {pkg, dependency, groupRoot} of entries) {
152
+ if ((pkg === SHELL) !== adjacent) continue;
153
+ if (dependency.link === true || [dependency.resolved, dependency.version].some(value => typeof value === 'string' && LINKED.test(value))) throw refusal('listed supplier is a source or linked package');
154
+ const root = await canonical(dependency.path);
155
+ const manifest = await manifestOf(adapters, path, root);
156
+ if (manifest.name !== pkg) throw refusal('listed package identity differs');
157
+ let cli, spec;
158
+ if (pkg === PI) {
159
+ if (manifest.bin?.pi !== 'dist/bundle/cli.js') throw refusal('listed Pi bin is unsupported');
160
+ cli = await canonical(path.join(root, manifest.bin.pi));
161
+ if (key(cli) !== key(path.join(root, manifest.bin.pi))) throw refusal('listed Pi package root does not supply its CLI');
162
+ spec = `${PI}@latest`;
163
+ } else {
164
+ // Only the Shell package this launcher runs from can own its peer.
165
+ if (key(root) !== key(shellRoot)) continue;
166
+ const peer = await adjacentPeer({shellRoot: root, groupRoot, adapters, path, key, canonical});
167
+ if (!peer) continue;
168
+ cli = peer.cli;
169
+ // Reinstalling the same Shell version re-resolves its peer; `update`
170
+ // never upgrades Shell itself (that is `gentle-shell upgrade`).
171
+ if (typeof manifest.version !== 'string') throw refusal('listed Shell version is missing');
172
+ spec = `${SHELL}@${manifest.version}`;
173
+ }
174
+ if (key(cli) !== key(selected)) continue;
175
+ if (name === 'pnpm' && members.get(key(groupRoot)).size !== 1) throw refusal('selected supplier shares its global install group');
176
+ candidates.push({name, ...invocation, package:pkg, spec});
177
+ }
178
+ }
179
+ if (candidates.length !== 1) throw refusal(candidates.length > 1 ? 'is ambiguous' : 'could not be verified from global listings');
180
+ return candidates[0];
181
+ }
182
+
183
+ /** The Pi version the owner's package now supplies, read back from a fresh
184
+ * listing after the install; null when it cannot be read.
185
+ */
186
+ async function suppliedPiVersion(owner, {platform, adapters, cwd}) {
187
+ const {path, key} = pathsFor(platform);
188
+ const canonical = canonicalWith(adapters, path);
189
+ try {
190
+ const groups = await globalListing(owner.name, owner, adapters, cwd);
191
+ const {entries} = await listedEntries(groups, {adapters, path, key, canonical});
192
+ for (const {pkg, dependency, groupRoot} of entries) {
193
+ if (pkg !== owner.package) continue;
194
+ const root = await canonical(dependency.path);
195
+ if (pkg === PI) return (await manifestOf(adapters, path, root)).version ?? null;
196
+ if ((await manifestOf(adapters, path, root)).name !== SHELL) continue;
197
+ const peer = await adjacentPeer({shellRoot: root, groupRoot, adapters, path, key, canonical});
198
+ if (peer) return peer.manifest.version ?? null;
199
+ }
200
+ } catch {
201
+ return null;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ export async function updateSelectedPi(input) {
207
+ let owner;
208
+ try { owner = await resolveUpdateOwner(input); }
209
+ catch (error) { throw error.message?.includes('ownership') ? error : refusal('could not be resolved'); }
210
+ // Exactly one approved package per command; never create a comma group or
211
+ // reinstall arbitrary dependencies merely because they share a store.
212
+ const args = owner.name === 'pnpm' ? ['add', '-g', owner.spec, ...(owner.package === SHELL ? ['--allow-build=gentle-pi'] : [])] : ['install', '-g', owner.spec, ...(owner.package === PI ? ['--ignore-scripts'] : [])];
213
+ const result = await input.adapters.run(owner.command, [...owner.prefix, ...args], {deadlineMs:20 * 60_000, cwd: input.cwd});
214
+ if (!succeeded(result)) throw new Error(`gentle-shell update: ${owner.name} could not install ${owner.spec}${result?.stderrTail ? `\n${result.stderrTail}` : ''}`);
215
+ const after = await suppliedPiVersion(owner, input);
216
+ const before = input.currentVersion;
217
+ if (after === null) input.out?.(`${owner.name} installed ${owner.spec}. The Pi version it now supplies could not be read back; run 'gentle-shell --version' to check it.`);
218
+ else if (before !== undefined && after === before) {
219
+ input.out?.(`${owner.name} installed ${owner.spec}, and the selected Pi is still ${after}.`);
220
+ if (owner.name === 'pnpm') input.out?.('pnpm installs only releases older than its minimumReleaseAge setting (pnpm 11 defaults to 4320 minutes); a newer Pi release is picked up once it is old enough.');
221
+ } else input.out?.(`${owner.name} installed ${owner.spec}; the selected Pi is now ${after}${before !== undefined ? ` (was ${before})` : ''}.`);
222
+ return 0;
223
+ }
@@ -0,0 +1,205 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ const config = JSON.parse(process.argv[2]);
9
+ const piVersion = JSON.parse(readFileSync(resolve(config.piRoot, "package.json"), "utf8")).version;
10
+ const tuiVersion = JSON.parse(readFileSync(resolve(config.piRoot, "node_modules/@earendil-works/pi-tui/package.json"), "utf8")).version;
11
+ assert.equal(piVersion, "1.1.0", "private frame API audited only on Pi 1.1.0");
12
+ assert.equal(tuiVersion, piVersion, "host TUI package must match");
13
+ const hostImport = (path) => import(pathToFileURL(resolve(config.piRoot, "dist", path)).href);
14
+ const { createJiti } = await hostImport("core/extensions/jiti-loader.js");
15
+ const { VIRTUAL_MODULES } = await hostImport("core/extensions/virtual-modules.js");
16
+ const pi = VIRTUAL_MODULES["@earendil-works/pi-coding-agent"];
17
+ const { Container, TuiAltScreen } = VIRTUAL_MODULES["@earendil-works/pi-tui"];
18
+ const nativeTheme = await hostImport("modes/interactive/theme/theme.js");
19
+ const { KeybindingsManager } = await hostImport("core/keybindings.js");
20
+ const { ToolExecutionComponent } = await hostImport("modes/interactive/components/tool-execution.js");
21
+ const { UserMessageComponent } = await hostImport("modes/interactive/components/user-message.js");
22
+ const { AssistantMessageComponent } = await hostImport("modes/interactive/components/assistant-message.js");
23
+ const { createChatViewport } = await hostImport("modes/interactive/chat-viewport.js");
24
+ const loader = createJiti(import.meta.url, { moduleCache: false, fsCache: false, virtualModules: VIRTUAL_MODULES, tryNative: false });
25
+ const source = (path) => loader.import(resolve(config.root, path));
26
+ const shell = await source("extensions/gentle-shell.ts");
27
+ const quiet = await source("extensions/quiet-tools.ts");
28
+ const bar = await source("lib/shell-bar.ts");
29
+ const sidebar = await source("lib/shell-sidebar.ts");
30
+ const layout = await source("lib/shell-sidebar-layout.ts");
31
+ const cards = await source("lib/shell-card.ts");
32
+ assert.equal(Object.getPrototypeOf(shell.GentlePromptEditor.prototype), pi.CustomEditor.prototype, "editor must extend this exact host class");
33
+ const memory = () => Object.fromEntries(Object.entries(process.memoryUsage()).map(([key, bytes]) => [key, bytes / 1024 / 1024]));
34
+ const digest = (lines) => createHash("sha256").update(lines.join("\n")).digest("hex");
35
+ const usage = { input: 100, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 120, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.001 } };
36
+
37
+ function fixture(spec, live = false) {
38
+ pi.initTheme("dark");
39
+ cards.setCardStyle(spec.style);
40
+ const counts = { paints: 0, projections: 0, entries: 0, requests: 0, pulses: 0, bytes: 0 };
41
+ const theme = { fg: (...args) => nativeTheme.theme.fg(...args), bg: (...args) => nativeTheme.theme.bg(...args), bold: (text) => nativeTheme.theme.bold(text) };
42
+ const toolTheme = { ...theme, fg: (...args) => { counts.paints++; return theme.fg(...args); }, bg: (...args) => { counts.paints++; return theme.bg(...args); } };
43
+ const geometry = { columns: spec.width, rows: 32 };
44
+ let inputHandler;
45
+ const terminal = {
46
+ start(onInput) { inputHandler = onInput; }, stop() {}, async drainInput() {}, write(text) { counts.bytes += Buffer.byteLength(text); },
47
+ get columns() { return geometry.columns; }, get rows() { return geometry.rows; },
48
+ moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {},
49
+ setTitle() {}, setProgress() {}, kittyProtocolActive: false, getBufferedInput() { return ""; }, hasPendingInput() { return false; },
50
+ };
51
+ const tui = new TuiAltScreen(terminal, false);
52
+ const manager = pi.SessionManager.inMemory("/performance", { id: "performance-session" });
53
+ const getEntries = manager.getEntries.bind(manager);
54
+ manager.getEntries = () => { counts.entries++; return getEntries(); };
55
+ const model = { id: "synthetic-model", provider: "synthetic", reasoning: true, contextWindow: 200000 };
56
+ const ctx = { model, sessionManager: manager, modelRegistry: { isUsingOAuth: () => false },
57
+ getContextUsage() { counts.projections++; return pi.AgentSession.prototype.getContextUsage.call({ sessionManager: manager, _limitsModel: () => model }); } };
58
+ const api = { getThinkingLevel: () => "high" };
59
+ const footerData = { getGitBranch: () => "performance", getExtensionStatuses: () => new Map(), onBranchChange: () => () => {} };
60
+ let clock = 0;
61
+ const editor = new shell.GentlePromptEditor(tui, nativeTheme.getEditorTheme(), new KeybindingsManager(), {
62
+ ...theme, pending: () => false, now: () => live ? Date.now() : clock, doubleEscCancelEnabled: () => false,
63
+ dispatchQueuedText() {}, requestRender() { counts.requests++; if (live) tui.requestRender(); },
64
+ });
65
+ let pulse = () => {};
66
+ if (live) editor.setWorking(true);
67
+ else {
68
+ // Capture the actual quality interval callback, not a reimplementation.
69
+ const original = globalThis.setInterval;
70
+ try { globalThis.setInterval = (fn, ms) => { assert.equal(ms, 80); pulse = () => { counts.pulses++; fn(); }; return { unref() {} }; }; editor.setWorking(true); }
71
+ finally { globalThis.setInterval = original; }
72
+ }
73
+ const document = new Container();
74
+ const renderer = quiet.createQuietToolRenderer("read");
75
+ const definition = { ...renderer, renderCall: (args, _theme, context) => renderer.renderCall(args, toolTheme, context),
76
+ renderResult: (result, options, _theme, context) => renderer.renderResult(result, options, toolTheme, context) };
77
+ let toolCount = 0;
78
+ let latest;
79
+ const append = () => {
80
+ const id = `tool-${toolCount++}`;
81
+ const args = { path: `src/file-${id}.ts` };
82
+ const text = `Inspect **${id}** with Unicode 広い and ANSI-safe content.`;
83
+ manager.appendMessage({ role: "user", content: text, timestamp: 1 });
84
+ const assistant = { role: "assistant", content: [{ type: "text", text: "Checking `source` and a **settled** paragraph." }, { type: "toolCall", id, name: "read", arguments: args }],
85
+ api: "openai-responses", provider: "synthetic", model: model.id, stopReason: "toolUse", timestamp: 1, usage };
86
+ manager.appendMessage(assistant);
87
+ const result = { content: [{ type: "text", text: `Result ${id}\nconst answer = 42;\n${"long wrapped output ".repeat(12)}` }], details: {}, isError: false };
88
+ manager.appendMessage({ ...result, role: "toolResult", toolCallId: id, toolName: "read", timestamp: 1 });
89
+ document.addChild(new UserMessageComponent(text));
90
+ document.addChild(new AssistantMessageComponent(assistant));
91
+ latest = new ToolExecutionComponent("read", id, args, {}, definition, tui, "/performance");
92
+ latest.setArgsComplete(); latest.updateResult(result); document.addChild(latest);
93
+ };
94
+ for (let index = 0; index < spec.tools; index++) append();
95
+ const footer = new Container();
96
+ const modelForFrame = () => shell.buildShellBarModel(api, ctx, footerData);
97
+ const bottom = shell.createShellBarComponent(api, ctx, tui, theme, footerData);
98
+ footer.addChild(sidebar.sidebarPart(tui, "footer", bottom, {
99
+ digest: () => JSON.stringify(modelForFrame()), render: (width) => bar.renderShellSidebarBar(modelForFrame(), theme, width), invalidate() {},
100
+ }));
101
+ const disposeHeader = sidebar.sidebarHeader(tui, { digest: () => JSON.stringify(modelForFrame()),
102
+ render: (width) => bar.renderShellHeaderChrome(bar.buildShellHeaderModel(modelForFrame()), theme, width).rows, invalidate() {} });
103
+ const viewport = createChatViewport({ document, pendingMessages: new Container(), status: new Container(), widgetsAbove: new Container(),
104
+ editor, widgetsBelow: new Container(), footer, scrollbar: "hidden" });
105
+ tui.setLayoutRoot(viewport.root); tui.setFocus(editor);
106
+ let uninstall = layout.installSidebar(tui, theme);
107
+ tui.start();
108
+ const render = () => {
109
+ tui.doRender();
110
+ assert.equal(sidebar.sidebarState(tui).active, geometry.columns >= layout.SIDEBAR_BREAKPOINT, "sidebar must really own wide frames");
111
+ assert.equal(cards.cardStyle(), spec.style);
112
+ return [...tui.previousScreen];
113
+ };
114
+ return { tui, editor, manager, counts, render, input: (data) => inputHandler(data),
115
+ advance() { clock += 80; pulse(); },
116
+ apply(scenario, index) {
117
+ if (scenario === "scroll") { if (index % 2) viewport.transcript.scrollToEnd(); else viewport.transcript.scrollTo(0, { disableFollow: true }); }
118
+ if (scenario === "typing") { const text = editor.getText(); editor.handleInput("x"); assert.equal(editor.getText(), text + "x"); }
119
+ if (scenario === "stream") latest.updateResult({ content: [{ type: "text", text: `stream ${index} ${"partial ".repeat(index + 1)}` }], details: {} }, true);
120
+ if (scenario === "append") append();
121
+ if (scenario === "resize") { const previous = geometry.columns; geometry.columns = index % 2 ? spec.width : Math.max(40, spec.width - 60); assert.notEqual(geometry.columns, previous); }
122
+ if (scenario === "theme") {
123
+ const previous = theme.bg("toolSuccessBg", "");
124
+ pi.initTheme(index % 2 ? "dark" : "light");
125
+ assert.notEqual(theme.bg("toolSuccessBg", ""), previous);
126
+ document.invalidate(); editor.invalidate();
127
+ uninstall(); uninstall = layout.installSidebar(tui, { ...theme });
128
+ }
129
+ },
130
+ close() { editor.dispose(); disposeHeader(); uninstall(); bottom.dispose(); tui.stop(); },
131
+ };
132
+ }
133
+
134
+ function controlled(spec) {
135
+ globalThis.gc();
136
+ const idleMemory = memory();
137
+ const buildStart = performance.now();
138
+ const scene = fixture(spec);
139
+ const buildMs = performance.now() - buildStart;
140
+ assert.equal(scene.counts.projections, 0, "first frame must not already have rendered");
141
+ const coldStart = performance.now();
142
+ const coldLines = scene.render();
143
+ const coldMs = performance.now() - coldStart;
144
+ const coldScreen = digest(coldLines);
145
+ for (let index = 0; index < config.warmup; index++) { scene.advance(); scene.render(); }
146
+ globalThis.gc();
147
+ const warmMemory = memory();
148
+ const initial = { ...scene.counts };
149
+ const result = { spec, buildMs, coldMs, coldScreen, idleMemory, warmMemory, wallMs: [], cpuMs: [], screens: [] };
150
+ const samples = ["resize", "theme"].includes(spec.scenario) ? Math.min(8, config.samples) : config.samples;
151
+ try {
152
+ for (let index = 0; index < samples; index++) {
153
+ const cpu = process.cpuUsage();
154
+ const start = performance.now();
155
+ scene.advance(); scene.apply(spec.scenario, index);
156
+ const screen = scene.render();
157
+ result.wallMs.push(performance.now() - start);
158
+ const used = process.cpuUsage(cpu);
159
+ result.cpuMs.push((used.user + used.system) / 1000);
160
+ result.screens.push(digest(screen));
161
+ }
162
+ result.counters = Object.fromEntries(Object.entries(scene.counts).map(([key, value]) => [key, value - initial[key]]));
163
+ assert.equal(result.counters.pulses, samples, "every controlled frame advances the real quality pulse");
164
+ } finally { scene.close(); }
165
+ return result;
166
+ }
167
+
168
+ async function live(spec) {
169
+ const scene = fixture(spec, true);
170
+ for (let index = 0; index < config.warmup; index++) scene.render();
171
+ scene.editor.setWorking(false); scene.editor.setWorking(true); // Align real pulse onset after warmup.
172
+ const original = scene.tui.doRender.bind(scene.tui);
173
+ const result = { spec, durationMs: config.liveMs, frameMs: [], inputToFrameMs: [], inputTimerDriftMs: [] };
174
+ const started = performance.now();
175
+ const initial = { ...scene.counts };
176
+ let pendingInput;
177
+ let expected = started + 100;
178
+ scene.tui.doRender = () => {
179
+ const start = performance.now(); original();
180
+ result.frameMs.push(performance.now() - start);
181
+ if (pendingInput !== undefined) { result.inputToFrameMs.push(performance.now() - pendingInput); pendingInput = undefined; }
182
+ };
183
+ const input = setInterval(() => {
184
+ result.inputTimerDriftMs.push(performance.now() - expected); expected += 100;
185
+ pendingInput ??= performance.now(); scene.input("x");
186
+ }, 100);
187
+ try { await new Promise((resolve) => setTimeout(resolve, config.liveMs)); }
188
+ finally { clearInterval(input); scene.close(); }
189
+ result.actualDurationMs = performance.now() - started;
190
+ result.counters = Object.fromEntries(Object.entries(scene.counts).map(([key, value]) => [key, value - initial[key]]));
191
+ return result;
192
+ }
193
+
194
+ const cases = [];
195
+ for (const tools of config.counts) for (const width of config.widths) for (const style of config.styles) {
196
+ for (const scenario of ["pulse", "scroll", "typing", "stream", "append", "resize", "theme"]) {
197
+ const result = controlled({ tools, width, style, scenario });
198
+ // WeakRef keep-alive lasts until the job boundary; collect only after it.
199
+ await new Promise((resolve) => setImmediate(resolve)); globalThis.gc(); result.closedMemory = memory(); cases.push(result);
200
+ }
201
+ }
202
+ const liveRuns = config.liveMs ? [await live({ tools: Math.max(...config.counts), width: Math.max(...config.widths), style: config.styles[0], scenario: "real-quality-timers-and-typing" })] : [];
203
+ const metadata = { piVersion, tuiVersion,
204
+ editorIdentityVerified: true, animation: "quality/80ms", terminal: "in-memory ANSI sink; no emulator", scheduler: "controlled quality callback; separate real-timer run" };
205
+ console.log(JSON.stringify({ metadata, cases, live: liveRuns }));
@@ -0,0 +1,115 @@
1
+ // Diagnostic comparison, not a speed gate. Run each source in a fresh process.
2
+ import assert from "node:assert/strict";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { closeSync, ftruncateSync, openSync, readFileSync, readdirSync, realpathSync, writeSync } from "node:fs";
6
+ import { cpus, loadavg } from "node:os";
7
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export function percentile(values, fraction) {
11
+ if (!values.length) return null;
12
+ const sorted = [...values].sort((a, b) => a - b);
13
+ const position = (sorted.length - 1) * fraction;
14
+ const low = Math.floor(position);
15
+ return sorted[low] + (sorted[Math.ceil(position)] - sorted[low]) * (position - low);
16
+ }
17
+ export function summarize(values) {
18
+ return { count: values.length, median: percentile(values, 0.5), p95: percentile(values, 0.95), max: values.length ? Math.max(...values) : null };
19
+ }
20
+ export function compare(before, after) {
21
+ assert.deepEqual(after.metadata, before.metadata, "host and scheduler must match");
22
+ assert.equal(before.cases.length, after.cases.length);
23
+ return before.cases.map((original, index) => {
24
+ const candidate = after.cases[index];
25
+ assert.deepEqual(candidate.spec, original.spec);
26
+ assert.equal(candidate.coldScreen, original.coldScreen, "cold visible output differs");
27
+ assert.deepEqual(candidate.screens, original.screens, `visible output differs: ${JSON.stringify(original.spec)}`);
28
+ return { spec: original.spec, before: summarize(original.wallMs), after: summarize(candidate.wallMs),
29
+ speedup: percentile(original.wallMs, 0.5) / percentile(candidate.wallMs, 0.5) };
30
+ });
31
+ }
32
+ function hashFiles(root, paths) {
33
+ const hash = createHash("sha256");
34
+ for (const path of paths.sort()) hash.update(path + "\0").update(readFileSync(resolve(root, path))).update("\0");
35
+ return hash.digest("hex");
36
+ }
37
+ function fingerprint(root) {
38
+ const paths = execFileSync("git", ["-C", root, "ls-files", "-z"], { encoding: "utf8" }).split("\0").filter(Boolean);
39
+ return { root, head: execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(), sourceSha256: hashFiles(root, paths) };
40
+ }
41
+ function hostFingerprint(root) {
42
+ const packages = [root, ...["pi-tui", "pi-ai", "pi-agent-core", "jiti"].map((name) => resolve(root, "node_modules", name === "jiti" ? name : `@earendil-works/${name}`))];
43
+ return packages.map((path) => {
44
+ const packageRoot = realpathSync(path);
45
+ const files = readdirSync(packageRoot, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => relative(packageRoot, resolve(entry.parentPath, entry.name)));
46
+ return { root: packageRoot, package: JSON.parse(readFileSync(resolve(path, "package.json"), "utf8")).version, sha256: hashFiles(packageRoot, files) };
47
+ });
48
+ }
49
+ export function outputDescriptor(output, protectedRoots) {
50
+ const canonical = resolve(realpathSync(dirname(resolve(output))), basename(output));
51
+ for (const root of protectedRoots) {
52
+ const path = relative(realpathSync(root), canonical);
53
+ assert.ok(path && (isAbsolute(path) || path === ".." || path.startsWith(".." + sep)), "output must be outside source, harness and host roots");
54
+ }
55
+ return openSync(canonical, "wx", 0o600);
56
+ }
57
+ async function main() {
58
+ const options = Object.fromEntries(process.argv.slice(2).map((arg) => {
59
+ assert.match(arg, /^--[a-z-]+=.+$/, "use --name=value arguments");
60
+ const split = arg.indexOf("="); return [arg.slice(2, split), arg.slice(split + 1)];
61
+ }));
62
+ for (const key of ["baseline-root", "candidate-root", "pi-root", "output"]) assert.ok(options[key], `--${key} is required`);
63
+ const roots = { before: resolve(options["baseline-root"]), after: resolve(options["candidate-root"]) };
64
+ const initial = Object.fromEntries(Object.entries(roots).map(([key, root]) => [key, fingerprint(root)]));
65
+ assert.equal(initial.before.head, initial.after.head, "compare worktrees based on the same commit");
66
+ const numeric = (name, fallback, min) => { const value = Number(options[name] ?? fallback); assert.ok(Number.isInteger(value) && value >= min, `invalid --${name}`); return value; };
67
+ const list = (name, fallback) => (options[name] ?? fallback).split(",").map(Number);
68
+ const config = { piRoot: resolve(options["pi-root"]), counts: list("counts", "100,1000,5000"), widths: list("widths", "100,160"),
69
+ styles: (options.styles ?? "float,neon").split(","), samples: numeric("samples", 50, 2), warmup: 5, liveMs: numeric("live-ms", 6000, 0) };
70
+ assert.ok([...config.counts, ...config.widths].every((value) => Number.isInteger(value) && value > 0));
71
+ assert.ok(config.styles.length <= 2 && config.styles.every((style) => ["float", "neon"].includes(style)));
72
+ assert.ok(config.widths.every((width) => width > 40), "resize scenarios need widths above 40");
73
+ const repetitions = numeric("repetitions", 4, 1);
74
+ assert.ok(repetitions <= 20 && config.samples <= 2000 && config.liveMs <= 60000);
75
+ assert.ok(config.counts.length <= 4 && config.widths.length <= 4 && Math.max(...config.counts) <= 20000 && Math.max(...config.widths) <= 300);
76
+ const report = { schema: "gentle-shell.render-performance/v1", started: new Date().toISOString(), node: process.version,
77
+ cpu: cpus()[0]?.model, loadAtStart: loadavg(), sources: initial, config, runs: [], comparisons: [] };
78
+ const worker = fileURLToPath(new URL("render-cache-performance-worker.mjs", import.meta.url));
79
+ const instrumentationHash = () => createHash("sha256").update(readFileSync(fileURLToPath(import.meta.url))).update(readFileSync(worker)).digest("hex");
80
+ report.instrumentationSha256 = instrumentationHash();
81
+ report.host = hostFingerprint(config.piRoot);
82
+ const fd = outputDescriptor(options.output, [...Object.values(roots), config.piRoot, ...report.host.map((entry) => entry.root), resolve(dirname(fileURLToPath(import.meta.url)), "..")]);
83
+ const persist = () => {
84
+ const data = Buffer.from(JSON.stringify(report, null, 2) + "\n");
85
+ ftruncateSync(fd, 0);
86
+ let offset = 0;
87
+ while (offset < data.length) {
88
+ const written = writeSync(fd, data, offset, data.length - offset, offset);
89
+ assert.ok(written > 0); offset += written;
90
+ }
91
+ };
92
+ try {
93
+ for (let repetition = 0; repetition < repetitions; repetition++) {
94
+ const pair = {};
95
+ for (const variant of repetition % 2 ? ["after", "before"] : ["before", "after"]) {
96
+ console.log(`replica ${repetition + 1}/${repetitions}: ${variant}`);
97
+ const output = execFileSync(process.execPath, ["--expose-gc", "--experimental-strip-types", worker,
98
+ JSON.stringify({ ...config, root: roots[variant], repetition })], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 1_200_000 });
99
+ pair[variant] = JSON.parse(output);
100
+ report.runs.push({ repetition, variant, ...pair[variant] });
101
+ persist();
102
+ }
103
+ report.comparisons.push({ repetition, cases: compare(pair.before, pair.after) });
104
+ persist();
105
+ }
106
+ for (const [key, root] of Object.entries(roots)) assert.deepEqual(fingerprint(root), initial[key], "source changed during measurement");
107
+ assert.equal(instrumentationHash(), report.instrumentationSha256, "instrumentation changed during measurement");
108
+ assert.deepEqual(hostFingerprint(config.piRoot), report.host, "host runtime changed during measurement");
109
+ report.finished = new Date().toISOString();
110
+ report.loadAtEnd = loadavg();
111
+ persist();
112
+ } finally { closeSync(fd); }
113
+ console.log(`PASS: ${report.comparisons[0].cases.length} equivalent cases × ${repetitions} paired replicas; raw report: ${resolve(options.output)}`);
114
+ }
115
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -10,7 +10,9 @@
10
10
  // POSIX and Windows CI.
11
11
 
12
12
  import { spawn } from "node:child_process";
13
- import { readFileSync, realpathSync } from "node:fs";
13
+ import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
14
16
  import { fileURLToPath } from "node:url";
15
17
 
16
18
  export const DEFAULT_STAGES = Object.freeze([
@@ -20,14 +22,26 @@ export const DEFAULT_STAGES = Object.freeze([
20
22
  ]);
21
23
 
22
24
  export async function runStage(stage) {
23
- return await new Promise((resolve) => {
24
- const child = spawn(stage.command, { shell: true, stdio: "inherit" });
25
- child.on("close", (code) => resolve({ name: stage.name, code: code ?? 1 }));
26
- child.on("error", (error) => {
27
- console.error(`\n[${stage.name}] spawn failed: ${error.message}`);
28
- resolve({ name: stage.name, code: 1 });
25
+ // Unit fixtures must not inherit the terminal's Git overrides, child-session
26
+ // identity, or personal guardrails. Other stages retain their existing env.
27
+ const configHome = stage.name === "unit-tests" ? mkdtempSync(join(tmpdir(), "gentle-pi-unit-config-")) : undefined;
28
+ const env = configHome ? Object.fromEntries(Object.entries(process.env).filter(([key]) => {
29
+ const normalizedKey = key.toUpperCase();
30
+ return !normalizedKey.startsWith("GIT_") && !normalizedKey.startsWith("GENTLE_PI_AGENTS_") && normalizedKey !== "GENTLE_PI_CONFIG_HOME";
31
+ })) : process.env;
32
+ if (configHome) env.GENTLE_PI_CONFIG_HOME = configHome;
33
+ try {
34
+ return await new Promise((resolve) => {
35
+ const child = spawn(stage.command, { shell: true, stdio: "inherit", env });
36
+ child.on("close", (code) => resolve({ name: stage.name, code: code ?? 1 }));
37
+ child.on("error", (error) => {
38
+ console.error(`\n[${stage.name}] spawn failed: ${error.message}`);
39
+ resolve({ name: stage.name, code: 1 });
40
+ });
29
41
  });
30
- });
42
+ } finally {
43
+ if (configHome) rmSync(configHome, { recursive: true, force: true });
44
+ }
31
45
  }
32
46
 
33
47
  export async function runTestSuite(stages = DEFAULT_STAGES, { runStageImpl = runStage, write = (line) => console.log(line) } = {}) {