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
@@ -1,10 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test, { after, before } from "node:test";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import type { Component } from "@earendil-works/pi-tui";
3
+ import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Component, TUI } from "@earendil-works/pi-tui";
5
5
  import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
6
6
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
7
  import { stripAnsi } from "../lib/terminal-theme.ts";
8
+ import { sidebarState } from "../lib/shell-sidebar.ts";
9
+ import { installSidebar } from "../lib/shell-sidebar-layout.ts";
8
10
 
9
11
  // The card style defaults to float; these assertions pin the outlined (neon)
10
12
  // panels unless a test switches the style itself.
@@ -76,6 +78,87 @@ function fakeContext(branch: unknown[] = [], hasUI = true) {
76
78
  return { ctx, widgets, widget, widgetComponent };
77
79
  }
78
80
 
81
+ test("long Todo widgets stay bounded, scroll, resize and keep their collapse control in mobile and sidebar", async (t) => {
82
+ for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
83
+ setCardStyle(style);
84
+ const { pi, tools, fire, shortcuts } = fakePi();
85
+ gentleTodo(pi, {});
86
+ const { ctx, widgets } = fakeContext();
87
+ await fire("session_start", ctx);
88
+ const title = "long active title ".repeat(150);
89
+ const note = "description ".repeat(150);
90
+ const result = await tools.get("todo")!.execute("write", { action: "write", tasks: [{ title, note, status: "in_progress" }, { title: "LAST TASK" }] }, undefined, undefined, ctx);
91
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
92
+ const tui = { requestRender() {}, terminal: { rows: 30, columns: 40 } };
93
+ const theme = { ...plainTheme, bg: (_color: string, text: string) => `\x1b[48;5;22m${text}\x1b[49m` };
94
+ const bottom = widgets.get("gentle-todo")!(tui, theme);
95
+ const rail = sidebarState(tui as never).parts.get("todo")!;
96
+ for (const component of [bottom, rail]) {
97
+ const before = component.render(40).map(stripAnsi);
98
+ assert.ok(before.length <= 10, "card uses at most one third of terminal height");
99
+ const header = style === CARD_STYLE.FLOAT ? 1 : 0;
100
+ assert.match(before[header]!, /Todos ▾/);
101
+ assert.equal(component.handleMouse?.({ type: "wheel", button: "none", wheelDelta: 10000, x: 5, y: header + 2, screenX: 5, screenY: 5, width: 40, height: before.length, shift: false, alt: false, ctrl: false })?.handled, true);
102
+ const after = component.render(40).map(stripAnsi);
103
+ assert.match(after[header]!, /Todos ▾/);
104
+ assert.match(after.join("\n"), /LAST TASK/, "all details remain reachable by scrolling");
105
+ assert.notDeepEqual(after, before);
106
+ }
107
+ tui.terminal.rows = 24;
108
+ assert.ok(bottom.render(40).length <= 8, "height responds to resize");
109
+ await shortcuts.get("ctrl+shift+t")!.handler(ctx);
110
+ const collapsed = bottom.render(40).map(stripAnsi);
111
+ assert.ok(collapsed.length <= (style === CARD_STYLE.FLOAT ? 6 : 4));
112
+ assert.match(collapsed.join("\n"), /long active title/);
113
+ assert.match(collapsed.join("\n"), /…/);
114
+ assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].title, title.trim());
115
+ assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].note, note.trim());
116
+ await shortcuts.get("ctrl+shift+t")!.handler(ctx);
117
+ await shortcuts.get("ctrl+shift+down")!.handler(ctx);
118
+ assert.notDeepEqual(bottom.render(40), collapsed, "keyboard scrolling is available without mouse");
119
+ }
120
+ t.after(() => setCardStyle(CARD_STYLE.NEON));
121
+ });
122
+
123
+ test("Todo keyboard scrolling targets the visible card after a cached sidebar breakpoint round-trip", async (t) => {
124
+ const node = Symbol.for("@earendil-works/pi-tui/layout-node");
125
+ for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
126
+ setCardStyle(style);
127
+ const { pi, tools, fire, shortcuts } = fakePi();
128
+ gentleTodo(pi, {});
129
+ const { ctx, widgets } = fakeContext();
130
+ await fire("session_start", ctx);
131
+ await tools.get("todo")!.execute("write", { action: "write", tasks: [{ title: Array.from({ length: 100 }, (_, i) => `TITLE${i}`).join(" "), status: "in_progress" }] }, undefined, undefined, ctx);
132
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
133
+ const root = { render: () => [], invalidate() {}, [node]: () => ({ type: "vstack", entries: [] }) };
134
+ const tui = { mode: "fullscreen", terminal: { rows: 30, columns: 160 }, layoutRoot: root, requestRender() {} };
135
+ const theme = { ...plainTheme, bold: (text: string) => text, bg: (_color: string, text: string) => `\x1b[48;5;22m${text}\x1b[49m` };
136
+ const bottom = widgets.get("gentle-todo")!(tui, theme);
137
+ const dispose = installSidebar(tui as unknown as TUI, theme);
138
+ try {
139
+ const railLines = () => {
140
+ const layout = root[node]() as unknown as { entries: { component: Component }[] };
141
+ return layout.entries[1]!.component.render(50).map(stripAnsi);
142
+ };
143
+ railLines();
144
+ tui.terminal.columns = 40;
145
+ root[node]();
146
+ bottom.render(40);
147
+ tui.terminal.columns = 160;
148
+ const before = railLines();
149
+ await shortcuts.get("ctrl+shift+down")!.handler(ctx);
150
+ const after = railLines();
151
+ assert.notDeepEqual(after, before, "DOWN changes the visible cached rail, not the hidden bottom widget");
152
+ assert.match(after.join("\n"), /↑↓ 4–/);
153
+ await shortcuts.get("ctrl+shift+up")!.handler(ctx);
154
+ assert.deepEqual(railLines(), before, "UP restores the expanded viewport");
155
+ } finally {
156
+ dispose();
157
+ }
158
+ }
159
+ t.after(() => setCardStyle(CARD_STYLE.NEON));
160
+ });
161
+
79
162
  test("todoEnabled and todoCollapseKey read their environment flags", () => {
80
163
  assert.equal(todoEnabled({}), true);
81
164
  assert.equal(todoEnabled({ GENTLE_PI_TODO: "0" }), false);
@@ -199,6 +282,138 @@ test("in the float style the Todos header sits on row 1 below the top padding, a
199
282
  assert.match(stripAnsi(component.render(70)[1]!), /^ ▎ ❀ Todos ▸ Expand 0 of 2 /);
200
283
  });
201
284
 
285
+ const REMINDER = "The todo plan has not been reviewed during several tool-use turns. Check whether it still matches the work performed and update tasks where needed. Do not mark work done without verification.";
286
+
287
+ function toolTurn(toolName = "read") {
288
+ return { message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: toolName, arguments: {} }] }, toolResults: [] };
289
+ }
290
+
291
+ async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext) {
292
+ const messages = [{ role: "user", content: "Keep working", timestamp: 1 }];
293
+ const result = await fire("context", ctx, { messages }) as { messages: Array<{ role: string; content: unknown }> } | undefined;
294
+ assert.deepEqual(messages, [{ role: "user", content: "Keep working", timestamp: 1 }], "request-local injection must not mutate the input transcript");
295
+ return result;
296
+ }
297
+
298
+ async function workTurns(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext, count: number) {
299
+ for (let index = 0; index < count; index++) {
300
+ assert.equal(await fire("turn_end", ctx, toolTurn()), undefined, "a reminder must never request continuation or persist entries");
301
+ }
302
+ }
303
+
304
+ test("runtime reminder arrives after four tool-use turns, only once, without changing todo state", async () => {
305
+ const { pi, tools, fire } = fakePi();
306
+ gentleTodo(pi, {});
307
+ const { ctx } = fakeContext([], false);
308
+ await fire("session_start", ctx);
309
+ const tool = tools.get("todo")!;
310
+ const written = await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
311
+ await fire("turn_end", ctx, toolTurn("todo"));
312
+ await workTurns(fire, ctx, 3);
313
+ assert.equal(await contextReminder(fire, ctx), undefined);
314
+ await workTurns(fire, ctx, 1);
315
+ const result = await contextReminder(fire, ctx);
316
+ assert.ok(result, "the next model request must carry a reminder within the same run");
317
+ assert.equal(result.messages.length, 2);
318
+ assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 });
319
+ assert.deepEqual(result.messages[1], { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: (result.messages[1] as { timestamp?: number }).timestamp });
320
+ const converted = convertToLlm(result.messages as Parameters<typeof convertToLlm>[0]);
321
+ assert.deepEqual(converted[1], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: (result.messages[1] as { timestamp?: number }).timestamp }, "Pi forwards the reminder as provider-compatible text");
322
+ await workTurns(fire, ctx, 4);
323
+ assert.equal(await contextReminder(fire, ctx), undefined, "no repeated nagging without a todo write");
324
+ const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
325
+ assert.deepEqual(listed.details.gentleTodo, written.details.gentleTodo, "a nudge does not update tasks, freshness or IDs");
326
+ });
327
+
328
+ test("a successful todo update resets and rearms the runtime reminder", async () => {
329
+ const { pi, tools, fire } = fakePi();
330
+ gentleTodo(pi, {});
331
+ const { ctx } = fakeContext();
332
+ await fire("session_start", ctx);
333
+ const tool = tools.get("todo")!;
334
+ await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
335
+ await fire("turn_end", ctx, toolTurn("todo"));
336
+ await workTurns(fire, ctx, 4);
337
+ assert.ok(await contextReminder(fire, ctx));
338
+ await tool.execute("update", { action: "update", id: 1, status: "in_progress" }, undefined, undefined, ctx);
339
+ await fire("turn_end", ctx, toolTurn("todo"));
340
+ await workTurns(fire, ctx, 3);
341
+ assert.equal(await contextReminder(fire, ctx), undefined);
342
+ await workTurns(fire, ctx, 1);
343
+ assert.ok(await contextReminder(fire, ctx));
344
+ });
345
+
346
+ test("list and rejected todo writes do not reset or rearm runtime reminders", async () => {
347
+ const { pi, tools, fire } = fakePi();
348
+ gentleTodo(pi, {});
349
+ const { ctx } = fakeContext();
350
+ await fire("session_start", ctx);
351
+ const tool = tools.get("todo")!;
352
+ const written = await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
353
+ await fire("turn_end", ctx, toolTurn("todo"));
354
+ await workTurns(fire, ctx, 2);
355
+ const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
356
+ assert.deepEqual(listed.details.gentleTodo, written.details.gentleTodo);
357
+ await fire("turn_end", ctx, toolTurn("todo"));
358
+ const rejected = await tool.execute("bad", { action: "update", id: 999, status: "done" }, undefined, undefined, ctx);
359
+ assert.equal(rejected.content[0].text, "Error: no task #999");
360
+ assert.equal(rejected.details.error, "no task #999");
361
+ assert.deepEqual(rejected.details.gentleTodo, written.details.gentleTodo);
362
+ await fire("turn_end", ctx, toolTurn("todo"));
363
+ assert.ok(await contextReminder(fire, ctx));
364
+ await tool.execute("list-again", { action: "list" }, undefined, undefined, ctx);
365
+ await tool.execute("bad-again", { action: "update", id: 999, status: "done" }, undefined, undefined, ctx);
366
+ await workTurns(fire, ctx, 4);
367
+ assert.equal(await contextReminder(fire, ctx), undefined);
368
+ });
369
+
370
+ test("runtime reminders skip empty, blocked-only and finished plans and non-tool turns", async () => {
371
+ for (const tasks of [[], [{ title: "Wait", status: "blocked", note: "waiting for approval" }], [{ title: "Done", status: "done" }, { title: "Obsolete", status: "dropped" }]]) {
372
+ const { pi, tools, fire } = fakePi();
373
+ gentleTodo(pi, {});
374
+ const { ctx } = fakeContext();
375
+ await fire("session_start", ctx);
376
+ await tools.get("todo")!.execute("write", { action: "write", tasks }, undefined, undefined, ctx);
377
+ await fire("turn_end", ctx, toolTurn("todo"));
378
+ await workTurns(fire, ctx, 8);
379
+ assert.equal(await contextReminder(fire, ctx), undefined);
380
+ }
381
+ const { pi, tools, fire } = fakePi();
382
+ gentleTodo(pi, {});
383
+ const { ctx } = fakeContext();
384
+ await fire("session_start", ctx);
385
+ await tools.get("todo")!.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
386
+ await fire("turn_end", ctx, toolTurn("todo"));
387
+ for (let index = 0; index < 8; index++) await fire("turn_end", ctx, { message: { role: "assistant", content: [{ type: "text", text: "Thinking" }] }, toolResults: [] });
388
+ assert.equal(await contextReminder(fire, ctx), undefined);
389
+ await workTurns(fire, ctx, 4);
390
+ await tools.get("todo")!.execute("clear", { action: "clear" }, undefined, undefined, ctx);
391
+ assert.equal(await contextReminder(fire, ctx), undefined, "clearing the plan invalidates a pending reminder");
392
+ });
393
+
394
+ test("runtime reminder tracking is isolated by session and reset on session load", async () => {
395
+ const { pi, tools, fire } = fakePi();
396
+ gentleTodo(pi, {});
397
+ const first = fakeContext();
398
+ const second = fakeContext();
399
+ second.ctx.sessionManager = { ...second.ctx.sessionManager, getSessionId: () => "s2" } as ExtensionContext["sessionManager"];
400
+ await fire("session_start", first.ctx);
401
+ await fire("session_start", second.ctx);
402
+ const tool = tools.get("todo")!;
403
+ const written = await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, first.ctx);
404
+ await fire("turn_end", first.ctx, toolTurn("todo"));
405
+ await workTurns(fire, first.ctx, 4);
406
+ await workTurns(fire, second.ctx, 4);
407
+ assert.equal(await contextReminder(fire, second.ctx), undefined, "another session cannot inherit the plan or reminder");
408
+ assert.ok(await contextReminder(fire, first.ctx));
409
+ await fire("session_shutdown", first.ctx);
410
+ const reloaded = fakeContext([{ type: "message", message: { role: "toolResult", toolName: "todo", details: written.details } }]);
411
+ await fire("session_start", reloaded.ctx);
412
+ assert.equal(await contextReminder(fire, reloaded.ctx), undefined, "reloaded plans start with a fresh reminder counter");
413
+ await workTurns(fire, reloaded.ctx, 4);
414
+ assert.ok(await contextReminder(fire, reloaded.ctx), "reload does not inherit a consumed reminder");
415
+ });
416
+
202
417
  function promptEvent(): { systemPrompt: string; systemPromptOptions: { appendSystemPrompt: string } } {
203
418
  return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
204
419
  }
@@ -309,3 +524,39 @@ test("session_start drops a list that was already finished, so a reload never sh
309
524
  const prompt = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string } | undefined;
310
525
  assert.equal(prompt, undefined, "and nothing is injected into the prompt");
311
526
  });
527
+
528
+ test("the todo tool offers blocked and dropped in its schema and guidelines", () => {
529
+ const { pi, tools } = fakePi();
530
+ gentleTodo(pi, {});
531
+ const tool = tools.get("todo") as unknown as { parameters: { properties: Record<string, { enum?: string[]; items?: { properties: Record<string, { enum?: string[]; description?: string }> } }> }; promptGuidelines: string[] };
532
+ const statuses = ["pending", "in_progress", "blocked", "done", "dropped"];
533
+ assert.deepEqual(tool.parameters.properties.status.enum, statuses);
534
+ assert.deepEqual(tool.parameters.properties.tasks.items!.properties.status.enum, statuses);
535
+ assert.match(tool.parameters.properties.tasks.items!.properties.note.description!, /required for blocked/);
536
+ assert.ok(tool.promptGuidelines.some((line) => /blocked with a note naming what it waits for/.test(line)));
537
+ assert.ok(tool.promptGuidelines.some((line) => /dropped/.test(line) && /obsolete/.test(line)));
538
+ });
539
+
540
+ test("a list left with only dropped tasks open finishes and clears; a blocked-only list stays in the prompt without going stale", async () => {
541
+ const { pi, tools, fire } = fakePi();
542
+ gentleTodo(pi, {});
543
+ const { ctx, widget } = fakeContext();
544
+ await fire("session_start", ctx);
545
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Old plan", status: "dropped" }] }, undefined, undefined, ctx);
546
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
547
+ await fire("agent_end", ctx);
548
+ assert.match(widget()![0], /Todos ▾ Collapse · 1 of 1/);
549
+ await fire("before_agent_start", ctx, promptEvent());
550
+ assert.equal(widget(), undefined, "a done-and-dropped list clears at the next turn");
551
+
552
+ await tools.get("todo")!.execute("c2", { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Deploy", status: "blocked", note: "waiting for an admin" }] }, undefined, undefined, ctx);
553
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
554
+ for (let turn = 0; turn < 4; turn++) {
555
+ await fire("agent_end", ctx);
556
+ const event = promptEvent();
557
+ await fire("before_agent_start", ctx, event);
558
+ assert.match(event.systemPromptOptions.appendSystemPrompt, /2\. \[blocked\] Deploy — waiting for an admin/);
559
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /stale/);
560
+ }
561
+ assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
562
+ });
@@ -0,0 +1,243 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import childProcess from "node:child_process";
4
+ import fs from "node:fs";
5
+ import { syncBuiltinESMExports } from "node:module";
6
+ import { visibleWidth } from "@earendil-works/pi-tui";
7
+ import { activitySummary, ActivityPublisher, createActivityColumns, metadataArgs, metadataTransport } from "../lib/herdr-activity.ts";
8
+
9
+ const tasks = [{ title: "Revisar integración Herdr", status: "in_progress" }];
10
+ const project = (title: string, columns = 30) => activitySummary([{ title, status: "in_progress" }], columns);
11
+
12
+ test("summary contains only the icon and sanitized active Todo title", () => {
13
+ assert.equal(activitySummary(tasks, 30), "◐ Revisar integración Herdr");
14
+ assert.equal(project("\x1b[31munsafe\x1b[0m\n\u202e title"), "◐ unsafe title");
15
+ assert.equal(project("\x1b]8;;https://private\x07safe\x1b]8;;\x07"), "◐ safe");
16
+ assert.equal(project("\x1b_private\x1b\\safe"), "◐ safe");
17
+ });
18
+
19
+ test("absent, completed, malformed or sanitized-empty active titles clear the summary", () => {
20
+ for (const entries of [null, {}, [], [{ title: "pending", status: "pending" }],
21
+ [{ title: "done", status: "completed" }], [{ title: 42, status: "in_progress" }],
22
+ [{ title: "\x1b[31m\x1b[0m\n\u202e", status: "in_progress" }], [null]]) {
23
+ assert.equal(activitySummary(entries), null);
24
+ }
25
+ });
26
+
27
+ test("two rows preserve words and all fitting text; only the last row truncates", () => {
28
+ assert.equal(project("one two three four", 12), "◐ one two\n three four");
29
+ const long = project("one two three four five six", 12)!;
30
+ assert.equal(long, "◐ one two\n three…");
31
+ assert.equal(project("abcdefghijklmnop", 10), "◐ abcdefgh\n ijklmnop");
32
+ assert.equal(project("abcdefghijk", 3), "◐ a\n …");
33
+ assert.equal(project("abc", 2), "◐…");
34
+ });
35
+
36
+ test("Unicode uses display cells and keeps accents, CJK and emoji graphemes intact", () => {
37
+ for (const title of ["café a\u0301 mañana", "漢字漢字漢字漢字", "👩🏽‍💻 👨‍👩‍👧‍👦 😀", "🇪🇸🇯🇵🇫🇷"]) {
38
+ const text = project(title, 10)!;
39
+ assert.ok(text.split("\n").length <= 2);
40
+ for (const row of text.split("\n")) assert.ok(visibleWidth(row) <= 10, row);
41
+ assert.equal(text.replace(/^◐ /, "").replace("\n ", "").replace(/ /g, ""), title.replace(/ /g, ""));
42
+ }
43
+ assert.equal(project("👩🏽‍💻".repeat(10), 8), "◐ 👩🏽‍💻👩🏽‍💻👩🏽‍💻\n 👩🏽‍💻👩🏽‍💻…");
44
+ });
45
+
46
+ test("tokens fit 80 Unicode scalars before wrapping, including prefixes and ellipsis", () => {
47
+ assert.equal(project("x".repeat(100), 105), `◐ ${"x".repeat(78)}\n ${"x".repeat(22)}`);
48
+ assert.equal(project("😀".repeat(50), 105), `◐ ${"😀".repeat(50)}`, "count scalars, not UTF-16 units");
49
+ assert.equal(project("x".repeat(156), 512), `◐ ${"x".repeat(78)}\n ${"x".repeat(78)}`);
50
+ assert.equal(project("x".repeat(157), 512), `◐ ${"x".repeat(78)}\n ${"x".repeat(77)}…`);
51
+ assert.equal(project("😀".repeat(56) + "x".repeat(26), 200),
52
+ `◐ ${"😀".repeat(56)}${"x".repeat(22)}\n xxxx`, "private LF is not part of the 256-byte token budget");
53
+ const combining = "a\u0301\u0302".repeat(40);
54
+ assert.equal(project(combining, 105), `◐ ${"a\u0301\u0302".repeat(26)}\n ${"a\u0301\u0302".repeat(14)}`);
55
+ for (const title of ["x".repeat(200), combining.repeat(2), "😀".repeat(100),
56
+ "a" + "\u0301".repeat(80), "👩🏽‍💻".repeat(100)]) {
57
+ const text = project(title, 105)!;
58
+ assert.ok(text.endsWith("…"), "actual capacity overflow needs ellipsis");
59
+ assert.ok(text.split("\n").length <= 2);
60
+ for (const row of text.split("\n")) {
61
+ assert.ok(Array.from(row).length <= 80, "each raw token includes its prefix and ellipsis");
62
+ assert.ok(visibleWidth(row) <= 105);
63
+ }
64
+ assert.ok(Buffer.byteLength(text.replace("\n", "")) <= 256);
65
+ }
66
+ });
67
+
68
+ test("word preference yields to a hard break only to avoid unnecessary ellipsis", () => {
69
+ assert.equal(project("a bcdefghijkl", 10), "◐ a bcdefg\n hijkl");
70
+ assert.equal(project("a 漢字漢字漢", 10), "◐ a 漢字漢\n 字漢");
71
+ assert.equal(project("one two three four", 12), "◐ one two\n three four");
72
+ assert.equal(project(`a ${"b".repeat(100)}`, 80), `◐ a ${"b".repeat(76)}\n ${"b".repeat(24)}`);
73
+ const combined = project(`a ${"😀".repeat(70)}`, 105)!;
74
+ assert.ok(combined.endsWith("…"), "hard breaks cannot bypass the shared byte budget");
75
+ assert.ok(Buffer.byteLength(combined.replace("\n", "")) <= 256);
76
+ for (const row of combined.split("\n")) assert.ok(Array.from(row).length <= 80);
77
+ });
78
+
79
+ test("sanitization removes Arabic Letter Mark without removing emoji ZWJ", () => {
80
+ assert.equal(project("abc\u061c123"), "◐ abc 123");
81
+ assert.equal(project("👩🏽‍💻\u061c ok"), "◐ 👩🏽‍💻 ok");
82
+ });
83
+
84
+ test("UTF-8 budget covers both tokens including prefixes and ellipsis", () => {
85
+ for (const title of ["😀".repeat(500), "👩🏽‍💻".repeat(500), "a" + "\u0301".repeat(10000), "x".repeat(10000)]) {
86
+ const text = project(title, 200)!;
87
+ assert.ok(Buffer.byteLength(text.replace("\n", "")) <= 256);
88
+ assert.ok(text.endsWith("…"));
89
+ assert.doesNotMatch(text, /[\x00-\x09\x0b-\x1f\x7f\u202e]/);
90
+ assert.ok(text.split("\n").length <= 2);
91
+ }
92
+ });
93
+
94
+ test("publisher serializes latest-only updates, clear, refresh and transport failures", async () => {
95
+ const calls: Array<{ summary: string | null; seq: number }> = [];
96
+ const queue: Array<() => void> = [];
97
+ let release: (() => void) | undefined;
98
+ const publisher = new ActivityPublisher(async (summary, seq) => {
99
+ calls.push({ summary, seq });
100
+ if (calls.length === 1) await new Promise<void>((resolve) => { release = resolve; });
101
+ if (summary === "fail") throw new Error("offline");
102
+ }, (fn) => { queue.push(fn); return () => {}; });
103
+ publisher.update("old"); publisher.update("first"); queue.shift()!();
104
+ publisher.update("second"); publisher.update("latest");
105
+ release!(); await new Promise(setImmediate);
106
+ queue.shift()!(); await new Promise(setImmediate);
107
+ assert.deepEqual(calls.map((call) => call.summary), ["first", "latest"]);
108
+ publisher.update("latest"); assert.equal(queue.length, 0);
109
+ publisher.update("latest", true); queue.shift()!(); await new Promise(setImmediate);
110
+ publisher.update("fail"); queue.shift()!(); await new Promise(setImmediate);
111
+ publisher.update(null); await new Promise(setImmediate);
112
+ assert.equal(calls.at(-1)?.summary, null);
113
+ assert.ok(calls.every((call, index) => index === 0 || call.seq > calls[index - 1]!.seq));
114
+ publisher.close(); publisher.update("ignored"); assert.equal(queue.length, 0);
115
+ await new Promise(setImmediate);
116
+ });
117
+
118
+ test("a non-forced clear supersedes active work in flight after a previous clear", async () => {
119
+ const calls: Array<string | null> = [];
120
+ const queue: Array<() => void> = [];
121
+ let release!: () => void;
122
+ const publisher = new ActivityPublisher(async (summary) => {
123
+ calls.push(summary);
124
+ if (summary === "active") await new Promise<void>((resolve) => { release = resolve; });
125
+ }, (fn) => { queue.push(fn); return () => {}; });
126
+ publisher.update(null); await new Promise(setImmediate);
127
+ publisher.update("active"); queue.shift()!();
128
+ publisher.update(null); publisher.update(null);
129
+ release(); await new Promise(setImmediate);
130
+ assert.equal(queue.length, 1, "one latest clear must be queued, not deduplicated against stale last");
131
+ queue.shift()!(); await new Promise(setImmediate);
132
+ assert.deepEqual(calls, [null, "active", null], "clear happens before close or TTL expiry");
133
+ publisher.close(); await new Promise(setImmediate);
134
+ });
135
+
136
+ test("shutdown supersedes queued activity even while a send is in flight", async () => {
137
+ const calls: Array<string | null> = [];
138
+ const queue: Array<() => void> = [];
139
+ let release!: () => void;
140
+ const publisher = new ActivityPublisher(async (summary) => {
141
+ calls.push(summary);
142
+ if (calls.length === 1) await new Promise<void>((resolve) => { release = resolve; });
143
+ }, (fn) => { queue.push(fn); return () => {}; });
144
+ publisher.update("active"); queue.shift()!(); publisher.update("stale");
145
+ publisher.close(); publisher.update("ignored"); release();
146
+ await new Promise(setImmediate); queue.shift()!(); await new Promise(setImmediate);
147
+ assert.deepEqual(calls, ["active", null]);
148
+ });
149
+
150
+ test("CLI transport uses only a bounded shell-free stub and tolerates offline failure", async (t) => {
151
+ const calls: unknown[][] = [];
152
+ const stub = t.mock.method(childProcess, "execFile", (...args: unknown[]) => {
153
+ calls.push(args);
154
+ (args[3] as (error: Error) => void)(new Error("offline"));
155
+ });
156
+ syncBuiltinESMExports();
157
+ try {
158
+ const env = { HERDR_ENV: "1", HERDR_SOCKET_PATH: "/fake", HERDR_PANE_ID: "fake-pane" };
159
+ await metadataTransport(env)("◐ long\n title", 7);
160
+ assert.equal(calls[0]?.[0], "herdr");
161
+ assert.deepEqual(calls[0]?.[1], metadataArgs("fake-pane", "◐ long\n title", 7));
162
+ assert.deepEqual(calls[0]?.[2], { env, timeout: 1500, maxBuffer: 4096, windowsHide: true });
163
+ } finally { stub.mock.restore(); syncBuiltinESMExports(); }
164
+ });
165
+
166
+ test("CLI transport honors an explicit Herdr binary path without shell interpretation", async (t) => {
167
+ const calls: unknown[][] = [];
168
+ const stub = t.mock.method(childProcess, "execFile", (...args: unknown[]) => {
169
+ calls.push(args);
170
+ (args[3] as (error: Error | null) => void)(null);
171
+ });
172
+ syncBuiltinESMExports();
173
+ try {
174
+ const env = { HERDR_BIN_PATH: "/fake/custom herdr/bin/herdr", HERDR_PANE_ID: "pane", PATH: "/fake/bin" };
175
+ await metadataTransport(env)("◐ task", 8);
176
+ assert.equal(calls[0]?.[0], env.HERDR_BIN_PATH);
177
+ assert.deepEqual(calls[0]?.[1], metadataArgs("pane", "◐ task", 8));
178
+ assert.deepEqual(calls[0]?.[2], { env, timeout: 1500, maxBuffer: 4096, windowsHide: true });
179
+ } finally { stub.mock.restore(); syncBuiltinESMExports(); }
180
+ });
181
+
182
+ test("one metadata report sets or clears both tokens without changing semantic state", () => {
183
+ const base = ["pane", "report-metadata", "pane", "--source", "gentle:activity", "--agent", "pi", "--seq", "42", "--ttl-ms", "30000"];
184
+ assert.deepEqual(metadataArgs("pane", null, 42), [...base, "--clear-token", "summary", "--clear-token", "summary2"]);
185
+ assert.deepEqual(metadataArgs("pane", "◐ long\n title", 42), [...base, "--token", "summary=◐ long", "--token", "summary2= title"]);
186
+ assert.deepEqual(metadataArgs("pane", "◐ short", 42), [...base, "--token", "summary=◐ short", "--clear-token", "summary2"]);
187
+ });
188
+
189
+ test("geometry opens nonblocking and closes non-files without reading", (t) => {
190
+ let flags: unknown;
191
+ const stubs = [
192
+ t.mock.method(fs, "openSync", (_path: string, mode: unknown) => { flags = mode; return 9; }),
193
+ t.mock.method(fs, "fstatSync", () => ({ size: 0, isFile: () => false })),
194
+ t.mock.method(fs, "readSync", () => { throw new Error("must not read a non-file"); }),
195
+ t.mock.method(fs, "closeSync", () => {}),
196
+ ];
197
+ syncBuiltinESMExports();
198
+ try {
199
+ assert.equal(createActivityColumns("/fake/herdr.sock")(), 24);
200
+ assert.equal(stubs[2]!.mock.callCount(), 0);
201
+ assert.deepEqual(stubs[3]!.mock.calls[0]!.arguments, [9]);
202
+ assert.equal(stubs[3]!.mock.callCount(), 1);
203
+ assert.equal(typeof flags, "number", "open must use numeric nonblocking flags");
204
+ assert.equal(flags, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
205
+ } finally { for (const stub of stubs) stub.mock.restore(); syncBuiltinESMExports(); }
206
+ });
207
+
208
+ test("geometry is cached, local/named, bounded and fail-safe without reading user data", (t) => {
209
+ let now = 0;
210
+ let body = '{"sidebar_width":35}';
211
+ let grew = false;
212
+ const paths: string[] = [];
213
+ const stubs = [
214
+ t.mock.method(fs, "openSync", (path: string) => { paths.push(path); return 9; }),
215
+ t.mock.method(fs, "fstatSync", () => ({ size: grew ? 1 : Buffer.byteLength(body), isFile: () => true })),
216
+ t.mock.method(fs, "readSync", (_fd: number, buffer: Buffer) => {
217
+ assert.equal(buffer.length, 262145);
218
+ return grew ? buffer.length : buffer.write(body);
219
+ }),
220
+ t.mock.method(fs, "closeSync", () => {}),
221
+ ];
222
+ syncBuiltinESMExports();
223
+ try {
224
+ const columns = createActivityColumns("/fake/named/herdr.sock", () => now);
225
+ assert.equal(columns(), 30); body = '{"sidebar_width":25}';
226
+ assert.equal(columns(), 30); assert.equal(paths.length, 1);
227
+ now = 5000; assert.equal(columns(), 20);
228
+ assert.deepEqual(paths, ["/fake/named/session.json", "/fake/named/session.json"]);
229
+ for (const invalid of ["{", "null", '{"sidebar_width":"35"}', '{"sidebar_width":-1}',
230
+ '{"sidebar_width":35.5}', '{"sidebar_width":9999}', "x".repeat(262145)]) {
231
+ body = invalid; now += 5000; assert.equal(columns(), 24);
232
+ }
233
+ assert.equal(stubs[2]!.mock.callCount(), paths.length - 1, "oversized fstat must skip read");
234
+ grew = true; // Simulate a snapshot growing after fstat.
235
+ now += 5000; assert.equal(columns(), 24);
236
+ assert.equal(stubs[3]!.mock.callCount(), paths.length, "every opened descriptor closes");
237
+ stubs[0]!.mock.mockImplementation(() => { throw new Error("missing"); });
238
+ now += 5000; assert.equal(columns(), 24);
239
+ const before = paths.length;
240
+ assert.equal(createActivityColumns("relative.sock")(), 24);
241
+ assert.equal(paths.length, before, "relative paths must not read arbitrary snapshots");
242
+ } finally { for (const stub of stubs) stub.mock.restore(); syncBuiltinESMExports(); }
243
+ });
@@ -0,0 +1,81 @@
1
+ import { after, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ // S9: in a bundled install the store, its legacy files and the transcript
8
+ // scan follow the agent home Pi runs with (PI_CODING_AGENT_DIR). Everywhere
9
+ // else they stay under ~/.pi/agent, where existing histories live.
10
+
11
+ // The OS home is a temporary folder before the extension is imported, so even
12
+ // a resolver that ignores PI_CODING_AGENT_DIR (or reads the OS home at import)
13
+ // can never write into the real home.
14
+ const osHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-os-home-"));
15
+ const previousHome = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
16
+ process.env.HOME = osHome;
17
+ process.env.USERPROFILE = osHome;
18
+ const { default: promptHistoryExtension } = await import("../extensions/history/index.ts");
19
+ const { setBundledInstallForTesting } = await import("../lib/gentle-shell-bundled.ts");
20
+ after(() => {
21
+ for (const [name, value] of Object.entries(previousHome)) {
22
+ if (value === undefined) delete process.env[name];
23
+ else process.env[name] = value;
24
+ }
25
+ fs.rmSync(osHome, { recursive: true, force: true });
26
+ });
27
+
28
+ type Handler = (...args: unknown[]) => unknown;
29
+
30
+ function load(env: NodeJS.ProcessEnv) {
31
+ const handlers = new Map<string, Handler[]>();
32
+ const pi = {
33
+ on: (name: string, handler: Handler) => {
34
+ handlers.set(name, [...(handlers.get(name) ?? []), handler]);
35
+ },
36
+ registerShortcut: () => {},
37
+ registerCommand: () => {},
38
+ };
39
+ promptHistoryExtension(pi as never, {
40
+ env: { GENTLE_PI_HISTORY_CAPTURE: "1", ...env },
41
+ cwd: "/pi-history-test/project-agent-home",
42
+ instanceId: "inst-agent-home",
43
+ now: () => 1700000000000,
44
+ gentlePiConfigHome: fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-agent-home-config-")),
45
+ });
46
+ return async (prompt: string) => {
47
+ await new Promise((resolve) => setImmediate(resolve));
48
+ for (const handler of handlers.get("before_agent_start") ?? []) await handler({ prompt }, {});
49
+ };
50
+ }
51
+
52
+ function holds(store: string, prompt: string): boolean {
53
+ if (!fs.existsSync(store)) return false;
54
+ return fs.readdirSync(store, { recursive: true }).map(String)
55
+ .map((file) => path.join(store, file))
56
+ .filter((file) => fs.statSync(file).isFile())
57
+ .some((file) => fs.readFileSync(file, "utf8").includes(prompt));
58
+ }
59
+
60
+ test("outside a bundled install prompt history stays under ~/.pi/agent, whatever agent home Pi runs with", async (t) => {
61
+ const agentHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-agent-home-"));
62
+ t.after(() => fs.rmSync(agentHome, { recursive: true, force: true }));
63
+ // The isolated launcher's home, and a prefix variable this package is not installed in.
64
+ await load({ PI_CODING_AGENT_DIR: agentHome, GENTLE_SHELL_PREFIX: agentHome })("isolated home prompt");
65
+ assert.ok(holds(path.join(osHome, ".pi", "agent", "history"), "isolated home prompt"));
66
+ assert.equal(fs.existsSync(path.join(agentHome, "history")), false);
67
+ });
68
+
69
+ test("in a bundled install prompt history lives under the agent home Pi runs with", async (t) => {
70
+ const agentHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-agent-home-"));
71
+ const prefix = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-prefix-")));
72
+ // The testing overlay stands in for this package running from that prefix.
73
+ setBundledInstallForTesting({ root: prefix });
74
+ t.after(() => {
75
+ setBundledInstallForTesting(undefined);
76
+ for (const dir of [agentHome, prefix]) fs.rmSync(dir, { recursive: true, force: true });
77
+ });
78
+ await load({ PI_CODING_AGENT_DIR: agentHome, GENTLE_SHELL_PREFIX: prefix })("bundled prompt");
79
+ assert.ok(holds(path.join(agentHome, "history"), "bundled prompt"));
80
+ assert.equal(holds(path.join(osHome, ".pi", "agent", "history"), "bundled prompt"), false);
81
+ });