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
@@ -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,102 @@ 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("alt+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 shortcuts never take a key Pi binds by default, so startup reports no shortcut conflict", async () => {
124
+ // Pi keeps its default table in dist/core/keybindings.js, next to the package entry.
125
+ const { KEYBINDINGS } = await import(new URL("core/keybindings.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href) as
126
+ { KEYBINDINGS: Record<string, { defaultKeys?: string | string[] }> };
127
+ assert.ok("tui.altScreen.previousPrompt" in KEYBINDINGS, "the table includes Pi's built-in prompt navigation");
128
+ const owners = new Map<string, string[]>();
129
+ for (const [id, binding] of Object.entries(KEYBINDINGS)) {
130
+ for (const key of ([] as string[]).concat(binding.defaultKeys ?? [])) owners.set(key, [...(owners.get(key) ?? []), id]);
131
+ }
132
+ const { pi, shortcuts } = fakePi();
133
+ gentleTodo(pi, {});
134
+ assert.deepEqual([...shortcuts.keys()].sort(), ["alt+shift+down", "alt+shift+up", "ctrl+shift+t"]);
135
+ for (const key of shortcuts.keys()) assert.equal(owners.get(key), undefined, `${key} is a Pi default for ${owners.get(key)?.join(", ")}`);
136
+ });
137
+
138
+ test("Todo keyboard scrolling targets the visible card after a cached sidebar breakpoint round-trip", async (t) => {
139
+ const node = Symbol.for("@earendil-works/pi-tui/layout-node");
140
+ for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
141
+ setCardStyle(style);
142
+ const { pi, tools, fire, shortcuts } = fakePi();
143
+ gentleTodo(pi, {});
144
+ const { ctx, widgets } = fakeContext();
145
+ await fire("session_start", ctx);
146
+ await tools.get("todo")!.execute("write", { action: "write", tasks: [{ title: Array.from({ length: 100 }, (_, i) => `TITLE${i}`).join(" "), status: "in_progress" }] }, undefined, undefined, ctx);
147
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
148
+ const root = { render: () => [], invalidate() {}, [node]: () => ({ type: "vstack", entries: [] }) };
149
+ const tui = { mode: "fullscreen", terminal: { rows: 30, columns: 160 }, layoutRoot: root, requestRender() {} };
150
+ const theme = { ...plainTheme, bold: (text: string) => text, bg: (_color: string, text: string) => `\x1b[48;5;22m${text}\x1b[49m` };
151
+ const bottom = widgets.get("gentle-todo")!(tui, theme);
152
+ const dispose = installSidebar(tui as unknown as TUI, theme);
153
+ try {
154
+ const railLines = () => {
155
+ const layout = root[node]() as unknown as { entries: { component: Component }[] };
156
+ return layout.entries[1]!.component.render(50).map(stripAnsi);
157
+ };
158
+ railLines();
159
+ tui.terminal.columns = 40;
160
+ root[node]();
161
+ bottom.render(40);
162
+ tui.terminal.columns = 160;
163
+ const before = railLines();
164
+ await shortcuts.get("alt+shift+down")!.handler(ctx);
165
+ const after = railLines();
166
+ assert.notDeepEqual(after, before, "DOWN changes the visible cached rail, not the hidden bottom widget");
167
+ assert.match(after.join("\n"), /↑↓ 4–/);
168
+ await shortcuts.get("alt+shift+up")!.handler(ctx);
169
+ assert.deepEqual(railLines(), before, "UP restores the expanded viewport");
170
+ } finally {
171
+ dispose();
172
+ }
173
+ }
174
+ t.after(() => setCardStyle(CARD_STYLE.NEON));
175
+ });
176
+
79
177
  test("todoEnabled and todoCollapseKey read their environment flags", () => {
80
178
  assert.equal(todoEnabled({}), true);
81
179
  assert.equal(todoEnabled({ GENTLE_PI_TODO: "0" }), false);
@@ -199,35 +297,204 @@ test("in the float style the Todos header sits on row 1 below the top padding, a
199
297
  assert.match(stripAnsi(component.render(70)[1]!), /^ ▎ ❀ Todos ▸ Expand 0 of 2 /);
200
298
  });
201
299
 
300
+ 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.";
301
+
302
+ function toolTurn(toolName = "read") {
303
+ return { message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: toolName, arguments: {} }] }, toolResults: [] };
304
+ }
305
+
306
+ type ContextMessage = { role: string; content: unknown; customType?: string; display?: boolean; timestamp?: number };
307
+
308
+ async function contextMessages(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage[] | undefined> {
309
+ const messages = [{ role: "user", content: "Keep working", timestamp: 1 }];
310
+ const result = await fire("context", ctx, { messages }) as { messages: ContextMessage[] } | undefined;
311
+ assert.deepEqual(messages, [{ role: "user", content: "Keep working", timestamp: 1 }], "request-local injection must not mutate the input transcript");
312
+ if (result === undefined) return undefined;
313
+ assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 }, "the transcript stays first; todo messages trail it");
314
+ return result.messages;
315
+ }
316
+
317
+ /** The request-local reminder, if the next model request carries one. */
318
+ async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
319
+ return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-reminder");
320
+ }
321
+
322
+ /** The request-local current-list message, if the next model request carries one (#1662). */
323
+ async function contextState(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
324
+ return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-state");
325
+ }
326
+
327
+ async function workTurns(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext, count: number) {
328
+ for (let index = 0; index < count; index++) {
329
+ assert.equal(await fire("turn_end", ctx, toolTurn()), undefined, "a reminder must never request continuation or persist entries");
330
+ }
331
+ }
332
+
333
+ test("runtime reminder arrives after four tool-use turns, only once, without changing todo state", async () => {
334
+ const { pi, tools, fire } = fakePi();
335
+ gentleTodo(pi, {});
336
+ const { ctx } = fakeContext([], false);
337
+ await fire("session_start", ctx);
338
+ const tool = tools.get("todo")!;
339
+ const written = await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
340
+ await fire("turn_end", ctx, toolTurn("todo"));
341
+ await workTurns(fire, ctx, 3);
342
+ assert.equal(await contextReminder(fire, ctx), undefined);
343
+ await workTurns(fire, ctx, 1);
344
+ const messages = await contextMessages(fire, ctx);
345
+ assert.ok(messages, "the next model request must carry a reminder within the same run");
346
+ assert.equal(messages.length, 3, "transcript, current list, then the reminder");
347
+ assert.equal(messages[1]!.customType, "gentle-todo-state");
348
+ const reminder = messages[2]!;
349
+ assert.deepEqual(reminder, { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: reminder.timestamp });
350
+ const converted = convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
351
+ assert.deepEqual(converted[2], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: reminder.timestamp }, "Pi forwards the reminder as provider-compatible text");
352
+ await workTurns(fire, ctx, 4);
353
+ assert.equal(await contextReminder(fire, ctx), undefined, "no repeated nagging without a todo write");
354
+ const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
355
+ assert.deepEqual(listed.details.gentleTodo, written.details.gentleTodo, "a nudge does not update tasks, freshness or IDs");
356
+ });
357
+
358
+ test("a successful todo update resets and rearms the runtime reminder", async () => {
359
+ const { pi, tools, fire } = fakePi();
360
+ gentleTodo(pi, {});
361
+ const { ctx } = fakeContext();
362
+ await fire("session_start", ctx);
363
+ const tool = tools.get("todo")!;
364
+ await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
365
+ await fire("turn_end", ctx, toolTurn("todo"));
366
+ await workTurns(fire, ctx, 4);
367
+ assert.ok(await contextReminder(fire, ctx));
368
+ await tool.execute("update", { action: "update", id: 1, status: "in_progress" }, undefined, undefined, ctx);
369
+ await fire("turn_end", ctx, toolTurn("todo"));
370
+ await workTurns(fire, ctx, 3);
371
+ assert.equal(await contextReminder(fire, ctx), undefined);
372
+ await workTurns(fire, ctx, 1);
373
+ assert.ok(await contextReminder(fire, ctx));
374
+ });
375
+
376
+ test("list and rejected todo writes do not reset or rearm runtime reminders", async () => {
377
+ const { pi, tools, fire } = fakePi();
378
+ gentleTodo(pi, {});
379
+ const { ctx } = fakeContext();
380
+ await fire("session_start", ctx);
381
+ const tool = tools.get("todo")!;
382
+ const written = await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
383
+ await fire("turn_end", ctx, toolTurn("todo"));
384
+ await workTurns(fire, ctx, 2);
385
+ const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
386
+ assert.deepEqual(listed.details.gentleTodo, written.details.gentleTodo);
387
+ await fire("turn_end", ctx, toolTurn("todo"));
388
+ const rejected = await tool.execute("bad", { action: "update", id: 999, status: "done" }, undefined, undefined, ctx);
389
+ assert.equal(rejected.content[0].text, "Error: no task #999");
390
+ assert.equal(rejected.details.error, "no task #999");
391
+ assert.deepEqual(rejected.details.gentleTodo, written.details.gentleTodo);
392
+ await fire("turn_end", ctx, toolTurn("todo"));
393
+ assert.ok(await contextReminder(fire, ctx));
394
+ await tool.execute("list-again", { action: "list" }, undefined, undefined, ctx);
395
+ await tool.execute("bad-again", { action: "update", id: 999, status: "done" }, undefined, undefined, ctx);
396
+ await workTurns(fire, ctx, 4);
397
+ assert.equal(await contextReminder(fire, ctx), undefined);
398
+ });
399
+
400
+ test("runtime reminders skip empty, blocked-only and finished plans and non-tool turns", async () => {
401
+ for (const tasks of [[], [{ title: "Wait", status: "blocked", note: "waiting for approval" }], [{ title: "Done", status: "done" }, { title: "Obsolete", status: "dropped" }]]) {
402
+ const { pi, tools, fire } = fakePi();
403
+ gentleTodo(pi, {});
404
+ const { ctx } = fakeContext();
405
+ await fire("session_start", ctx);
406
+ await tools.get("todo")!.execute("write", { action: "write", tasks }, undefined, undefined, ctx);
407
+ await fire("turn_end", ctx, toolTurn("todo"));
408
+ await workTurns(fire, ctx, 8);
409
+ assert.equal(await contextReminder(fire, ctx), undefined);
410
+ }
411
+ const { pi, tools, fire } = fakePi();
412
+ gentleTodo(pi, {});
413
+ const { ctx } = fakeContext();
414
+ await fire("session_start", ctx);
415
+ await tools.get("todo")!.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, ctx);
416
+ await fire("turn_end", ctx, toolTurn("todo"));
417
+ for (let index = 0; index < 8; index++) await fire("turn_end", ctx, { message: { role: "assistant", content: [{ type: "text", text: "Thinking" }] }, toolResults: [] });
418
+ assert.equal(await contextReminder(fire, ctx), undefined);
419
+ await workTurns(fire, ctx, 4);
420
+ await tools.get("todo")!.execute("clear", { action: "clear" }, undefined, undefined, ctx);
421
+ assert.equal(await contextReminder(fire, ctx), undefined, "clearing the plan invalidates a pending reminder");
422
+ });
423
+
424
+ test("runtime reminder tracking is isolated by session and reset on session load", async () => {
425
+ const { pi, tools, fire } = fakePi();
426
+ gentleTodo(pi, {});
427
+ const first = fakeContext();
428
+ const second = fakeContext();
429
+ second.ctx.sessionManager = { ...second.ctx.sessionManager, getSessionId: () => "s2" } as ExtensionContext["sessionManager"];
430
+ await fire("session_start", first.ctx);
431
+ await fire("session_start", second.ctx);
432
+ const tool = tools.get("todo")!;
433
+ const written = await tool.execute("write", { action: "write", tasks: [{ title: "Implement" }] }, undefined, undefined, first.ctx);
434
+ await fire("turn_end", first.ctx, toolTurn("todo"));
435
+ await workTurns(fire, first.ctx, 4);
436
+ await workTurns(fire, second.ctx, 4);
437
+ assert.equal(await contextReminder(fire, second.ctx), undefined, "another session cannot inherit the plan or reminder");
438
+ assert.ok(await contextReminder(fire, first.ctx));
439
+ await fire("session_shutdown", first.ctx);
440
+ const reloaded = fakeContext([{ type: "message", message: { role: "toolResult", toolName: "todo", details: written.details } }]);
441
+ await fire("session_start", reloaded.ctx);
442
+ assert.equal(await contextReminder(fire, reloaded.ctx), undefined, "reloaded plans start with a fresh reminder counter");
443
+ await workTurns(fire, reloaded.ctx, 4);
444
+ assert.ok(await contextReminder(fire, reloaded.ctx), "reload does not inherit a consumed reminder");
445
+ });
446
+
202
447
  function promptEvent(): { systemPrompt: string; systemPromptOptions: { appendSystemPrompt: string } } {
203
448
  return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
204
449
  }
205
450
 
206
- test("every turn carries the open tasks in appendSystemPrompt (never a returned systemPrompt) and the card goes stale after two silent turns", async () => {
451
+ test("the system prompt carries only a byte-stable todo block; the current list travels as a request-local message (#1662)", async () => {
207
452
  const { pi, tools, fire } = fakePi();
208
453
  gentleTodo(pi, {});
209
454
  const { ctx, widget } = fakeContext();
210
455
  await fire("session_start", ctx);
211
- await fire("before_agent_start", ctx, promptEvent());
456
+ const emptyEvent = promptEvent();
457
+ assert.equal(await fire("before_agent_start", ctx, emptyEvent), undefined, "the handler must not return a replacement systemPrompt");
458
+ assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
459
+ assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /mark a task in_progress before starting/);
460
+ assert.equal(await contextState(fire, ctx), undefined, "an empty list sends no state message");
461
+
212
462
  await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
213
463
  await fire("tool_execution_end", ctx, { toolName: "todo" });
214
-
215
464
  const withTasksEvent = promptEvent();
216
- const withTasksResult = await fire("before_agent_start", ctx, withTasksEvent);
217
- assert.equal(withTasksResult, undefined, "the handler must not return a replacement systemPrompt");
218
- assert.match(withTasksEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
219
- assert.match(withTasksEvent.systemPromptOptions.appendSystemPrompt, /1\. \[pending\] Fix the bug/);
465
+ await fire("before_agent_start", ctx, withTasksEvent);
466
+ assert.equal(withTasksEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "writing a list must not change the system prompt");
467
+ assert.doesNotMatch(withTasksEvent.systemPromptOptions.appendSystemPrompt, /Fix the bug/, "task content never enters the system prompt");
468
+ const state = await contextState(fire, ctx);
469
+ assert.ok(state, "open tasks arrive as a trailing request-local message");
470
+ assert.equal(state.role, "custom");
471
+ assert.equal(state.display, false);
472
+ assert.match(String(state.content), /1\. \[pending\] Fix the bug/);
220
473
  assert.doesNotMatch(widget()![0], /stale/);
221
474
 
222
- const staleEvent = promptEvent();
223
- await fire("before_agent_start", ctx, staleEvent);
224
- assert.match(staleEvent.systemPromptOptions.appendSystemPrompt, /stale: 2 turns without an update/);
225
- assert.match(widget()![0], /ctrl\+shift\+t collapse/);
226
- assert.match(widget()![1], /stale · 2 turns/);
475
+ // Silent turns: the UI card goes stale, the system prompt stays frozen (#1367).
476
+ for (const expected of ["stale · 2 turns", "stale · 3 turns"]) {
477
+ const silent = promptEvent();
478
+ await fire("before_agent_start", ctx, silent);
479
+ assert.equal(silent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt);
480
+ assert.match(widget()![1], new RegExp(expected));
481
+ }
227
482
 
483
+ // Status, note, title and ordering changes reach the model through the message only.
228
484
  await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
229
485
  await fire("tool_execution_end", ctx, { toolName: "todo" });
486
+ const updatedEvent = promptEvent();
487
+ await fire("before_agent_start", ctx, updatedEvent);
488
+ assert.equal(updatedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "a status change must not change the system prompt");
489
+ assert.match(String((await contextState(fire, ctx))!.content), /1\. \[in_progress\] Fix the bug — on it/);
230
490
  assert.doesNotMatch(widget()![0], /stale/);
491
+
492
+ await tools.get("todo")!.execute("c3", { action: "update", id: 1, status: "done" }, undefined, undefined, ctx);
493
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
494
+ assert.equal(await contextState(fire, ctx), undefined, "a finished list sends no state message");
495
+ const finishedEvent = promptEvent();
496
+ await fire("before_agent_start", ctx, finishedEvent);
497
+ assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
231
498
  });
232
499
 
233
500
  test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
@@ -309,3 +576,39 @@ test("session_start drops a list that was already finished, so a reload never sh
309
576
  const prompt = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string } | undefined;
310
577
  assert.equal(prompt, undefined, "and nothing is injected into the prompt");
311
578
  });
579
+
580
+ test("the todo tool offers blocked and dropped in its schema and guidelines", () => {
581
+ const { pi, tools } = fakePi();
582
+ gentleTodo(pi, {});
583
+ const tool = tools.get("todo") as unknown as { parameters: { properties: Record<string, { enum?: string[]; items?: { properties: Record<string, { enum?: string[]; description?: string }> } }> }; promptGuidelines: string[] };
584
+ const statuses = ["pending", "in_progress", "blocked", "done", "dropped"];
585
+ assert.deepEqual(tool.parameters.properties.status.enum, statuses);
586
+ assert.deepEqual(tool.parameters.properties.tasks.items!.properties.status.enum, statuses);
587
+ assert.match(tool.parameters.properties.tasks.items!.properties.note.description!, /required for blocked/);
588
+ assert.ok(tool.promptGuidelines.some((line) => /blocked with a note naming what it waits for/.test(line)));
589
+ assert.ok(tool.promptGuidelines.some((line) => /dropped/.test(line) && /obsolete/.test(line)));
590
+ });
591
+
592
+ test("a list left with only dropped tasks open finishes and clears; a blocked-only list stays in the prompt without going stale", async () => {
593
+ const { pi, tools, fire } = fakePi();
594
+ gentleTodo(pi, {});
595
+ const { ctx, widget } = fakeContext();
596
+ await fire("session_start", ctx);
597
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A", status: "done" }, { title: "Old plan", status: "dropped" }] }, undefined, undefined, ctx);
598
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
599
+ await fire("agent_end", ctx);
600
+ assert.match(widget()![0], /Todos ▾ Collapse · 1 of 1/);
601
+ await fire("before_agent_start", ctx, promptEvent());
602
+ assert.equal(widget(), undefined, "a done-and-dropped list clears at the next turn");
603
+
604
+ 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);
605
+ await fire("tool_execution_end", ctx, { toolName: "todo" });
606
+ for (let turn = 0; turn < 4; turn++) {
607
+ await fire("agent_end", ctx);
608
+ const event = promptEvent();
609
+ await fire("before_agent_start", ctx, event);
610
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Deploy/);
611
+ assert.match(String((await contextState(fire, ctx))!.content), /2\. \[blocked\] Deploy — waiting for an admin/);
612
+ }
613
+ assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
614
+ });
@@ -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
+ });