@zerwiz/ymir 0.1.4 → 0.1.7

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 (865) hide show
  1. package/.agents/README.md +291 -0
  2. package/.agents/agents/bragi-marketer.md +111 -0
  3. package/.agents/agents/brokk.md +352 -0
  4. package/.agents/agents/forseti-reviewer.md +93 -0
  5. package/.agents/agents/frigg-consent.md +77 -0
  6. package/.agents/agents/groa-update.md +77 -0
  7. package/.agents/agents/hnoss-designer.md +69 -0
  8. package/.agents/agents/huginn-researcher.md +99 -0
  9. package/.agents/agents/jord-projects.md +78 -0
  10. package/.agents/agents/kvasir-scout.md +91 -0
  11. package/.agents/agents/mimir-planner.md +92 -0
  12. package/.agents/agents/muninn-stow.md +77 -0
  13. package/.agents/agents/saga-bearings.md +78 -0
  14. package/.agents/agents/sindri-developer.md +114 -0
  15. package/.agents/agents/snotra-documenter.md +105 -0
  16. package/.agents/agents/syn-recovery.md +78 -0
  17. package/.agents/agents/tyr-check.md +78 -0
  18. package/.agents/agents/urdh-hold.md +77 -0
  19. package/.agents/agents/volundr-smith.md +80 -0
  20. package/.agents/agents/vor-diagnostics.md +77 -0
  21. package/.agents/assets/agents/naming.md +50 -0
  22. package/.agents/assets/agents/registry.md +93 -0
  23. package/.agents/assets/agents/runtime.md +36 -0
  24. package/.agents/assets/agents/toon-tasks-cli.md +42 -0
  25. package/.agents/assets/templates/company_entity.template.md +37 -0
  26. package/.agents/backend/README.md +74 -0
  27. package/.agents/backend/backends/cmux.sh +649 -0
  28. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  29. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  30. package/.agents/backend/backends/herdr.sh +3386 -0
  31. package/.agents/backend/backends/orca.sh +290 -0
  32. package/.agents/backend/backends/tmux.sh +357 -0
  33. package/.agents/backend/backends/zellij.sh +700 -0
  34. package/.agents/backend/fm-afk-launch.sh +654 -0
  35. package/.agents/backend/fm-afk-return.sh +241 -0
  36. package/.agents/backend/fm-afk-start.sh +172 -0
  37. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  38. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  39. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  40. package/.agents/backend/fm-backend.sh +981 -0
  41. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  42. package/.agents/backend/fm-backlog-receive.sh +187 -0
  43. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  44. package/.agents/backend/fm-bearings-board.sh +204 -0
  45. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  46. package/.agents/backend/fm-bootstrap.sh +1568 -0
  47. package/.agents/backend/fm-branch-outcome.sh +203 -0
  48. package/.agents/backend/fm-branch-prompt.sh +101 -0
  49. package/.agents/backend/fm-brief.sh +452 -0
  50. package/.agents/backend/fm-busy-event.sh +234 -0
  51. package/.agents/backend/fm-busy-lib.sh +986 -0
  52. package/.agents/backend/fm-captain-hold.sh +1001 -0
  53. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  54. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  55. package/.agents/backend/fm-check-lib.sh +74 -0
  56. package/.agents/backend/fm-check-register.sh +42 -0
  57. package/.agents/backend/fm-check-unregister.sh +52 -0
  58. package/.agents/backend/fm-classify-lib.sh +1770 -0
  59. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  60. package/.agents/backend/fm-composer-lib.sh +1416 -0
  61. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  62. package/.agents/backend/fm-config-push.sh +254 -0
  63. package/.agents/backend/fm-control-lib.sh +251 -0
  64. package/.agents/backend/fm-control.sh +878 -0
  65. package/.agents/backend/fm-crew-state.sh +625 -0
  66. package/.agents/backend/fm-cursor-lib.sh +243 -0
  67. package/.agents/backend/fm-decision-hold.sh +232 -0
  68. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  69. package/.agents/backend/fm-dod-lib.sh +67 -0
  70. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  71. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  72. package/.agents/backend/fm-extension.mjs +2577 -0
  73. package/.agents/backend/fm-extension.sh +16 -0
  74. package/.agents/backend/fm-ff-lib.sh +420 -0
  75. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  76. package/.agents/backend/fm-fleet-sync.sh +458 -0
  77. package/.agents/backend/fm-fleet-view.sh +96 -0
  78. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  79. package/.agents/backend/fm-guard.sh +242 -0
  80. package/.agents/backend/fm-harness.sh +195 -0
  81. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  82. package/.agents/backend/fm-herdr-lab.sh +346 -0
  83. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  84. package/.agents/backend/fm-home-seed.sh +984 -0
  85. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  86. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  87. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  88. package/.agents/backend/fm-inbox.sh +399 -0
  89. package/.agents/backend/fm-install-actionlint.sh +84 -0
  90. package/.agents/backend/fm-install-herdr.sh +96 -0
  91. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  92. package/.agents/backend/fm-install-treehouse.sh +95 -0
  93. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  94. package/.agents/backend/fm-lease-lib.sh +218 -0
  95. package/.agents/backend/fm-lease.sh +189 -0
  96. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  97. package/.agents/backend/fm-lint-workflows.sh +137 -0
  98. package/.agents/backend/fm-lint.sh +591 -0
  99. package/.agents/backend/fm-lock-lib.sh +104 -0
  100. package/.agents/backend/fm-lock.sh +107 -0
  101. package/.agents/backend/fm-marker-lib.sh +12 -0
  102. package/.agents/backend/fm-merge-local.sh +74 -0
  103. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  104. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  105. package/.agents/backend/fm-on.sh +125 -0
  106. package/.agents/backend/fm-operational-input.sh +253 -0
  107. package/.agents/backend/fm-peek.sh +46 -0
  108. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  109. package/.agents/backend/fm-pr-check.sh +135 -0
  110. package/.agents/backend/fm-pr-lib.sh +1016 -0
  111. package/.agents/backend/fm-pr-merge.sh +707 -0
  112. package/.agents/backend/fm-pr-poll.sh +110 -0
  113. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  114. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  115. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  116. package/.agents/backend/fm-procevent-lib.sh +892 -0
  117. package/.agents/backend/fm-procevent-quota.sh +290 -0
  118. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  119. package/.agents/backend/fm-procevent-when.sh +504 -0
  120. package/.agents/backend/fm-procevent.sh +1466 -0
  121. package/.agents/backend/fm-project-mode.sh +92 -0
  122. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  123. package/.agents/backend/fm-promote.sh +282 -0
  124. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  125. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  126. package/.agents/backend/fm-public-followup.sh +1330 -0
  127. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  128. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  129. package/.agents/backend/fm-quota-choose.sh +384 -0
  130. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  131. package/.agents/backend/fm-remote-doctor.sh +800 -0
  132. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  133. package/.agents/backend/fm-remote-file.sh +234 -0
  134. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  135. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  136. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  137. package/.agents/backend/fm-remote-inherit.sh +179 -0
  138. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  139. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  140. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  141. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  142. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  143. package/.agents/backend/fm-review-diff.sh +158 -0
  144. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  145. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  146. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  147. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  148. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  149. package/.agents/backend/fm-secondmate-report.sh +88 -0
  150. package/.agents/backend/fm-send.sh +1059 -0
  151. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  152. package/.agents/backend/fm-session-start.sh +970 -0
  153. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  154. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  155. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  156. package/.agents/backend/fm-spawn.sh +3153 -0
  157. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  158. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  159. package/.agents/backend/fm-startup-network.sh +642 -0
  160. package/.agents/backend/fm-stow-cascade.sh +251 -0
  161. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  162. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  163. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  164. package/.agents/backend/fm-supervision-lib.sh +90 -0
  165. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  166. package/.agents/backend/fm-tangle-lib.sh +53 -0
  167. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  168. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  169. package/.agents/backend/fm-teardown.sh +2922 -0
  170. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  171. package/.agents/backend/fm-test-run.sh +2191 -0
  172. package/.agents/backend/fm-timeout-lib.sh +141 -0
  173. package/.agents/backend/fm-timing-lib.sh +179 -0
  174. package/.agents/backend/fm-tmux-lib.sh +291 -0
  175. package/.agents/backend/fm-tool-update-check.sh +898 -0
  176. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  177. package/.agents/backend/fm-transition-lib.sh +103 -0
  178. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  179. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  180. package/.agents/backend/fm-turnend-guard.sh +432 -0
  181. package/.agents/backend/fm-update.sh +110 -0
  182. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  183. package/.agents/backend/fm-voice-client.py +1373 -0
  184. package/.agents/backend/fm-voice-relay.py +1256 -0
  185. package/.agents/backend/fm-wake-drain.sh +592 -0
  186. package/.agents/backend/fm-wake-grant.sh +127 -0
  187. package/.agents/backend/fm-wake-lib.sh +1849 -0
  188. package/.agents/backend/fm-watch-arm.sh +602 -0
  189. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  190. package/.agents/backend/fm-watch.sh +1962 -0
  191. package/.agents/backend/fm-x-dismiss.sh +116 -0
  192. package/.agents/backend/fm-x-followup.sh +286 -0
  193. package/.agents/backend/fm-x-lib.sh +1000 -0
  194. package/.agents/backend/fm-x-link.sh +234 -0
  195. package/.agents/backend/fm-x-poll.sh +185 -0
  196. package/.agents/backend/fm-x-reply.sh +385 -0
  197. package/.agents/backend/fm_voice_frame.py +166 -0
  198. package/.agents/backend/fm_voice_records.py +574 -0
  199. package/.agents/backend/model-bridge.py +360 -0
  200. package/.agents/backend/opencode-go-bridge.py +30 -0
  201. package/.agents/bus/README.md +12 -0
  202. package/.agents/bus/messages.json +1 -0
  203. package/.agents/config/agents.machine.example.yaml +16 -0
  204. package/.agents/config/agents.yaml.example +72 -0
  205. package/.agents/config/crew-dispatch.json +22 -0
  206. package/.agents/config/cron.yaml +11 -0
  207. package/.agents/config/eindri-dispatch.json +67 -0
  208. package/.agents/config/eindri-harness +1 -0
  209. package/.agents/config/startup-memory-budget +1 -0
  210. package/.agents/filebrowser/.filebrowser.json +6 -0
  211. package/.agents/filebrowser/README.md +11 -0
  212. package/.agents/gateway/README.md +12 -0
  213. package/.agents/github/README.md +15 -0
  214. package/.agents/github/webhooks/.gitkeep +0 -0
  215. package/.agents/github/workflows/.gitkeep +0 -0
  216. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  217. package/.agents/harness/opencode/plugins/package.json +4 -0
  218. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  219. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  220. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  221. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  222. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  223. package/.agents/memory/README.md +46 -0
  224. package/.agents/memory/runes_audit.md +5 -0
  225. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  226. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  227. package/.agents/migrations/0003-private-data-separation.sh +121 -0
  228. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  229. package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
  230. package/.agents/sandbox/Dockerfile.utgard +19 -0
  231. package/.agents/sandbox/README.md +14 -0
  232. package/.agents/sandbox/utgard.config.json +14 -0
  233. package/.agents/skills/README.md +59 -0
  234. package/.agents/skills/bragi-marketing/README.md +3 -0
  235. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  236. package/.agents/skills/eindri-homes/README.md +3 -0
  237. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  238. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  239. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  240. package/.agents/skills/frigg-consent/README.md +3 -0
  241. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  242. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  243. package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
  244. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  245. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  246. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  247. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  248. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
  276. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  277. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  278. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  279. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
  280. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  281. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  283. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  284. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  285. package/.agents/skills/groa-update/SKILL.md +90 -0
  286. package/.agents/skills/hamr-adapters/README.md +3 -0
  287. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  288. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  289. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  290. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  291. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  292. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  293. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  294. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  295. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  296. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  297. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  298. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  299. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  300. package/.agents/skills/herdr-panes/README.md +3 -0
  301. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  302. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  303. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  304. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  305. package/.agents/skills/hnoss-design/README.md +3 -0
  306. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  307. package/.agents/skills/hvild-afk/README.md +3 -0
  308. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  309. package/.agents/skills/jord-projects/README.md +3 -0
  310. package/.agents/skills/jord-projects/SKILL.md +92 -0
  311. package/.agents/skills/lifecycle/SKILL.md +56 -0
  312. package/.agents/skills/lifecycle/smoke_test.sh +57 -0
  313. package/.agents/skills/lifecycle/start.sh +21 -0
  314. package/.agents/skills/lifecycle/status.sh +35 -0
  315. package/.agents/skills/lifecycle/stop.sh +16 -0
  316. package/.agents/skills/muninn-stow/README.md +3 -0
  317. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  318. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  319. package/.agents/skills/nornir-schedule/README.md +3 -0
  320. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  321. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  322. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  323. package/.agents/skills/nsr-compliance/README.md +3 -0
  324. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  325. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  326. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  327. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  328. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  329. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  431. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  432. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  433. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  434. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  435. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  453. package/.agents/skills/pr-ops/README.md +3 -0
  454. package/.agents/skills/pr-ops/SKILL.md +109 -0
  455. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  456. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  457. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  458. package/.agents/skills/saga-bearings/README.md +3 -0
  459. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  460. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  461. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  462. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  463. package/.agents/skills/syn-recovery/README.md +3 -0
  464. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  465. package/.agents/skills/tyr-check/README.md +3 -0
  466. package/.agents/skills/tyr-check/SKILL.md +218 -0
  467. package/.agents/skills/urdh-hold/README.md +3 -0
  468. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  469. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  470. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  471. package/.agents/skills/vor-diagnostics/README.md +3 -0
  472. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  473. package/.agents/skills/ymir-host/README.md +3 -0
  474. package/.agents/skills/ymir-host/SKILL.md +35 -0
  475. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  476. package/.agents/skills/ymir-host/assets/install.md +69 -0
  477. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  478. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  479. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  480. package/.agents/skills/ymir-host/assets/update.md +64 -0
  481. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  482. package/.agents/tests/cmux-test-safety.sh +51 -0
  483. package/.agents/tests/fixtures.sh +291 -0
  484. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  485. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  486. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  487. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  488. package/.agents/tests/fm-afk-return.test.sh +283 -0
  489. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  490. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  491. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  492. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  493. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  494. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  495. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  496. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  497. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  498. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  499. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  500. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  501. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  502. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  503. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  504. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  505. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  506. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  507. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  508. package/.agents/tests/fm-backend.test.sh +1142 -0
  509. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  510. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  511. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  512. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  513. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  514. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  515. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  516. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  517. package/.agents/tests/fm-brief.test.sh +785 -0
  518. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  519. package/.agents/tests/fm-busy-state.test.sh +465 -0
  520. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  521. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  522. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  523. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  524. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  525. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  526. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  527. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  528. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  529. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  530. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  531. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  532. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  533. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  534. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  535. package/.agents/tests/fm-control.test.sh +910 -0
  536. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  537. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  538. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  539. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  540. package/.agents/tests/fm-daemon.test.sh +2740 -0
  541. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  542. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  543. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  544. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  545. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  546. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  547. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  548. package/.agents/tests/fm-gotmp.test.sh +230 -0
  549. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  550. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  551. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  552. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  553. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  554. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  555. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  556. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  557. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  558. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  559. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  560. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  561. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  562. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  563. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  564. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  565. package/.agents/tests/fm-lint.test.sh +1023 -0
  566. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  567. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  568. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  569. package/.agents/tests/fm-on.test.sh +507 -0
  570. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  571. package/.agents/tests/fm-operational-input.test.sh +160 -0
  572. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  573. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  574. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  575. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  576. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  577. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  578. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  579. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  580. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  581. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  582. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  583. package/.agents/tests/fm-procevent.test.sh +1745 -0
  584. package/.agents/tests/fm-project-origin.test.sh +110 -0
  585. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  586. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  587. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  588. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  589. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  590. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  591. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  592. package/.agents/tests/fm-remote-job.test.sh +768 -0
  593. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  594. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  595. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  596. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  597. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  598. package/.agents/tests/fm-review-diff.test.sh +176 -0
  599. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  600. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  601. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  602. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  603. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  604. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  605. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  606. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  607. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  608. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  609. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  610. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  611. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  612. package/.agents/tests/fm-send-settle.test.sh +147 -0
  613. package/.agents/tests/fm-send-strict.test.sh +241 -0
  614. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  615. package/.agents/tests/fm-session-start.test.sh +2515 -0
  616. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  617. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  618. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  619. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  620. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  621. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  622. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  623. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  624. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  625. package/.agents/tests/fm-startup-network.test.sh +712 -0
  626. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  627. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  628. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  629. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  630. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  631. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  632. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  633. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  634. package/.agents/tests/fm-teardown.test.sh +2665 -0
  635. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  636. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  637. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  638. package/.agents/tests/fm-test-run.test.sh +1196 -0
  639. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  640. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  641. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  642. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  643. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  644. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  645. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  646. package/.agents/tests/fm-update.test.sh +304 -0
  647. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  648. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  649. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  650. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  651. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  652. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  653. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  654. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  655. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  656. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  657. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  658. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  659. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  660. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  661. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  662. package/.agents/tests/herdr-test-safety.sh +42 -0
  663. package/.agents/tests/lib.sh +315 -0
  664. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  665. package/.agents/tests/secondmate-helpers.sh +193 -0
  666. package/.agents/tests/smoke.test.sh +93 -0
  667. package/.agents/tests/wake-helpers.sh +338 -0
  668. package/.agents/tests/zellij-test-safety.sh +60 -0
  669. package/.agents/tools/README.md +10 -0
  670. package/.agents/tools/bin/tasks-cli +267 -0
  671. package/.agents/tools/tasks-cli.ts +216 -0
  672. package/AGENTS.md +498 -0
  673. package/CHANGELOG.md +1913 -0
  674. package/LICENSE +1 -1
  675. package/NOTICE +63 -0
  676. package/README.md +114 -60
  677. package/RULES/01-domains.md +51 -0
  678. package/RULES/02-agents.md +78 -0
  679. package/RULES/03-houses.md +61 -0
  680. package/RULES/04-hoard.md +97 -0
  681. package/RULES/05-platforms.md +67 -0
  682. package/RULES/06-append-only.md +108 -0
  683. package/RULES/07-config.md +101 -0
  684. package/RULES/08-delivery-gate.md +75 -0
  685. package/RULES/09-electron.md +58 -0
  686. package/RULES/README.md +22 -0
  687. package/bin/README.md +107 -0
  688. package/bin/a2a-mcp.sh +133 -0
  689. package/bin/a2a-serve.py +103 -0
  690. package/bin/a2a-serve.sh +8 -0
  691. package/bin/a2a-talk.sh +50 -0
  692. package/bin/a2abridge-ensure.sh +108 -0
  693. package/bin/agent-run.sh +43 -0
  694. package/bin/agents-config.sh +256 -0
  695. package/bin/apodex-smoke-test.sh +105 -0
  696. package/bin/bifrost-bridge.sh +131 -0
  697. package/bin/bifrost-ingress.sh +77 -0
  698. package/bin/bootstrap-macos.sh +112 -0
  699. package/bin/bootstrap-windows.ps1 +112 -0
  700. package/bin/branch-guard.sh +64 -0
  701. package/bin/brokk +73 -0
  702. package/bin/brokk-classify-lib.sh +1770 -0
  703. package/bin/brokk-lease-lib.sh +222 -0
  704. package/bin/brokk-lease.sh +193 -0
  705. package/bin/brokk-lint.sh +65 -0
  706. package/bin/brokk-send.sh +51 -0
  707. package/bin/brokk-update.sh +9 -0
  708. package/bin/brokk-wake-grant.sh +127 -0
  709. package/bin/brokk-wake-lib.sh +1849 -0
  710. package/bin/changelog-assemble.sh +154 -0
  711. package/bin/changelog-guard.sh +197 -0
  712. package/bin/crash-sense.sh +173 -0
  713. package/bin/design-check.sh +80 -0
  714. package/bin/design-icon.sh +153 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +225 -0
  718. package/bin/eindri-acclaim.sh +47 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seat.sh +83 -0
  722. package/bin/eindri-seen.sh +54 -0
  723. package/bin/eindri-send.sh +15 -0
  724. package/bin/eindri-start.sh +98 -0
  725. package/bin/eindri-watch.sh +57 -0
  726. package/bin/einherjar-spawn.sh +576 -0
  727. package/bin/eir-doctor.sh +158 -0
  728. package/bin/erindi-brief.sh +271 -0
  729. package/bin/feature-inventory.sh +73 -0
  730. package/bin/fleet-apply.sh +81 -0
  731. package/bin/github-deploy.sh +94 -0
  732. package/bin/gjallarhorn-expose.sh +136 -0
  733. package/bin/gjallarhorn-notify.sh +71 -0
  734. package/bin/gjallarhorn-purge.sh +67 -0
  735. package/bin/gjallarhorn-tunnel.sh +85 -0
  736. package/bin/gleipnir-lock-lib.sh +284 -0
  737. package/bin/groa-update.sh +130 -0
  738. package/bin/hall-snapshot.sh +111 -0
  739. package/bin/hamr-harness.sh +246 -0
  740. package/bin/heimdall-ssh-keys.sh +374 -0
  741. package/bin/herdr-agents.py +44 -0
  742. package/bin/herdr-ensure.sh +104 -0
  743. package/bin/herdr-run.sh +452 -0
  744. package/bin/hermes-ensure.sh +88 -0
  745. package/bin/hlidskjalf-agents.sh +172 -0
  746. package/bin/hlidskjalf-usage.sh +196 -0
  747. package/bin/hoard-guard.sh +135 -0
  748. package/bin/hoard-lib.sh +105 -0
  749. package/bin/hodd.sh +102 -0
  750. package/bin/host-sense.sh +199 -0
  751. package/bin/huginn-research-worker.sh +121 -0
  752. package/bin/local-model-lock.sh +70 -0
  753. package/bin/mimir-bridge.py +259 -0
  754. package/bin/mimir-bridge.sh +100 -0
  755. package/bin/mimir-ingest.sh +131 -0
  756. package/bin/mimir-reflect.sh +27 -0
  757. package/bin/mimir.sh +113 -0
  758. package/bin/mjollnir-webhook.sh +72 -0
  759. package/bin/mjollnir.sh +104 -0
  760. package/bin/model-hardware.sh +96 -0
  761. package/bin/model-resolve.sh +160 -0
  762. package/bin/models-detect.sh +178 -0
  763. package/bin/nornir-cron-start.sh +162 -0
  764. package/bin/nornir-job-daily-briefing.sh +191 -0
  765. package/bin/nornir-job-git-sync.sh +152 -0
  766. package/bin/nornir-job-hall-snapshot.sh +28 -0
  767. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  768. package/bin/nornir-job-nsr-compliance.sh +54 -0
  769. package/bin/nornir-job-observer.sh +166 -0
  770. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  771. package/bin/npm-publish.sh +136 -0
  772. package/bin/omarchy-hook-install.sh +60 -0
  773. package/bin/omarchy-install.sh +161 -0
  774. package/bin/omarchy-plugins.sh +187 -0
  775. package/bin/omarchy-sense.sh +162 -0
  776. package/bin/perm-guard.sh +30 -0
  777. package/bin/pi-agent.sh +68 -0
  778. package/bin/pi-ensure.sh +77 -0
  779. package/bin/pi-local.sh +37 -0
  780. package/bin/pi-model-agnostic.sh +71 -0
  781. package/bin/pi-seat.sh +63 -0
  782. package/bin/prereq-ensure.sh +166 -0
  783. package/bin/private-guard.sh +58 -0
  784. package/bin/project-git.sh +69 -0
  785. package/bin/public-guard.sh +71 -0
  786. package/bin/ratatoskr.sh +49 -0
  787. package/bin/realm-lib.sh +32 -0
  788. package/bin/repo-scrub.sh +62 -0
  789. package/bin/rodd-operational-input.sh +250 -0
  790. package/bin/runes-append.sh +198 -0
  791. package/bin/saga-session-start.sh +172 -0
  792. package/bin/saga-sessionstart-run.sh +79 -0
  793. package/bin/saga-wake-drain.sh +41 -0
  794. package/bin/secret-guard.sh +63 -0
  795. package/bin/sessrumnir-ensure.sh +100 -0
  796. package/bin/sessrumnir-sync.sh +237 -0
  797. package/bin/sessrumnir.sh +104 -0
  798. package/bin/skillopt-setup.sh +76 -0
  799. package/bin/skuld-branch-outcome.sh +203 -0
  800. package/bin/skuld-branch-prompt.sh +101 -0
  801. package/bin/smidja-bootstrap.sh +94 -0
  802. package/bin/smidja-observe.sh +86 -0
  803. package/bin/syn-arm-pretool-check.sh +57 -0
  804. package/bin/syn-asset-pretool-check.sh +89 -0
  805. package/bin/syn-cd-pretool-check.sh +28 -0
  806. package/bin/syn-guard-pretool-check.sh +83 -0
  807. package/bin/syn-spawn-pretool-check.sh +58 -0
  808. package/bin/syn-turnend-guard.sh +36 -0
  809. package/bin/syn-watch-arm.sh +99 -0
  810. package/bin/tailscale-sync.sh +203 -0
  811. package/bin/telegram-bot.sh +82 -0
  812. package/bin/toolchain.sh +74 -0
  813. package/bin/ui-truth-check.sh +75 -0
  814. package/bin/utgard.sh +111 -0
  815. package/bin/valhalla.sh +115 -0
  816. package/bin/valknut-load.sh +298 -0
  817. package/bin/vor-crew-state.sh +124 -0
  818. package/bin/wedge-notify.sh +60 -0
  819. package/bin/workspace-provision.sh +80 -0
  820. package/bin/workspace-rag.sh +163 -0
  821. package/bin/wyrd-db.sh +47 -0
  822. package/bin/yggdrasil.sh +152 -0
  823. package/bin/ymir-edit.sh +145 -0
  824. package/bin/ymir-install.sh +806 -0
  825. package/bin/ymir-invite.sh +93 -0
  826. package/bin/ymir-isolation.sh +88 -0
  827. package/bin/ymir-migrate.sh +61 -0
  828. package/bin/ymir-plan.sh +356 -0
  829. package/bin/ymir-platform.sh +348 -0
  830. package/bin/ymir-say.sh +110 -0
  831. package/bin/ymir-setup-auth.sh +119 -0
  832. package/bin/ymir-validate.sh +193 -0
  833. package/bin/ymir.js +74 -16
  834. package/docs/Architecture.md +339 -0
  835. package/docs/design.md +351 -0
  836. package/docs/installations/README.md +63 -0
  837. package/docs/installations/ymir-home.md +93 -0
  838. package/docs/integration.md +105 -0
  839. package/docs/lore.md +1060 -0
  840. package/docs/research/agent-harnesses.md +290 -0
  841. package/docs/runbooks/README.md +19 -0
  842. package/docs/runbooks/a2a.md +91 -0
  843. package/docs/runbooks/agent-permissions.md +99 -0
  844. package/docs/runbooks/agents.md +57 -0
  845. package/docs/runbooks/models.md +69 -0
  846. package/docs/runbooks/realm-onboarding.md +60 -0
  847. package/docs/runbooks/secrets-and-hoard.md +60 -0
  848. package/docs/runbooks/updates-and-migrations.md +110 -0
  849. package/docs/runbooks/worktrees-isolation.md +72 -0
  850. package/docs/session-start.md +105 -0
  851. package/docs/skillopt-integration.md +321 -0
  852. package/docs/sync-system.md +66 -0
  853. package/docs/workspaces.md +104 -0
  854. package/docs/ymir-rut.md +260 -0
  855. package/package.json +54 -14
  856. package/packaging/build.sh +74 -0
  857. package/packaging/macos/Ymir Installer.command +43 -0
  858. package/packaging/windows/ymir-setup.nsi +74 -0
  859. package/scripts/README.md +22 -0
  860. package/scripts/electron.sh +269 -0
  861. package/scripts/lower.sh +34 -0
  862. package/scripts/raise.sh +42 -0
  863. package/scripts/start.sh +217 -0
  864. package/scripts/stop.sh +113 -0
  865. package/install.sh +0 -35
@@ -0,0 +1,3979 @@
1
+ #!/usr/bin/env bash
2
+ # Focused rendering, lifecycle, persistence, and interactive TUI checks for /calm.
3
+ set -u
4
+
5
+ # shellcheck source=tests/lib.sh
6
+ . "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
7
+
8
+ TMP_ROOT=$(fm_test_tmproot fm-calm-pi-extension)
9
+ EXT="$ROOT/.pi/extensions/fm-calm.ts"
10
+ ASSISTANT_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts"
11
+ OPERATIONAL_USER_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
12
+ VISIBILITY="$ROOT/.pi/extensions/lib/fm-calm-visibility.ts"
13
+ WORKING_SHIP="$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts"
14
+ WATCH_EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts"
15
+ OPERATIONAL_INPUT="$ROOT/bin/fm-operational-input.sh"
16
+ PI_OPERATIONAL_INPUT="$ROOT/.pi/extensions/lib/fm-operational-input.ts"
17
+ PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"}
18
+ TMUX_SOCKET="fm-calm-$$"
19
+ TMUX_SESSION="fm-calm-e2e"
20
+ # Verified against Pi 0.81.1 and 0.82.0 (docs/calm-mode-feasibility.md). This is
21
+ # known-good evidence, not a support ceiling: the fixtures below run against whatever
22
+ # Pi is actually installed, and record_pi_version_evidence never rejects a newer
23
+ # version. The tracked presentation adapters probe the exact API they patch (see
24
+ # .pi/extensions/fm-calm.ts) instead of relying on version inference, so a version
25
+ # string is evidence for the record, not a gate.
26
+ record_pi_version_evidence() {
27
+ local version=$1 context=$2
28
+ [ -n "$version" ] || fail "$context could not determine the installed Pi version"
29
+ }
30
+
31
+ cleanup() {
32
+ if command -v tmux >/dev/null 2>&1; then
33
+ tmux -L "$TMUX_SOCKET" kill-server 2>/dev/null || true
34
+ fi
35
+ fm_test_cleanup
36
+ }
37
+ trap cleanup EXIT
38
+
39
+ wait_for_text() {
40
+ local file=$1 text=$2 i=0
41
+ while [ "$i" -lt 120 ]; do
42
+ # Include recent scrollback: expanding a long restored transcript can move
43
+ # the asserted tool output above the current viewport while the footer and
44
+ # editor remain visible.
45
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true
46
+ grep -Fq "$text" "$file" 2>/dev/null && return 0
47
+ sleep 0.05
48
+ i=$((i + 1))
49
+ done
50
+ return 1
51
+ }
52
+
53
+ find_chrome() {
54
+ local candidate
55
+ if [ -n "${FM_CHROME_BIN:-}" ] && [ -x "$FM_CHROME_BIN" ]; then
56
+ printf '%s\n' "$FM_CHROME_BIN"
57
+ return 0
58
+ fi
59
+ for candidate in \
60
+ google-chrome \
61
+ google-chrome-stable \
62
+ chromium \
63
+ chromium-browser \
64
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
65
+ do
66
+ if command -v "$candidate" >/dev/null 2>&1; then
67
+ command -v "$candidate"
68
+ return 0
69
+ fi
70
+ done
71
+ return 1
72
+ }
73
+
74
+ test_home_resolution() {
75
+ local fixture out status version
76
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
77
+ echo "skip: node or npm not found for Pi calm home-resolution test"
78
+ return 0
79
+ fi
80
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
81
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
82
+ return 0
83
+ fi
84
+ version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version")
85
+ record_pi_version_evidence "$version" "Pi calm compatibility assumptions"
86
+
87
+ fixture="$TMP_ROOT/home-resolution"
88
+ mkdir -p \
89
+ "$fixture/project/.pi/extensions/lib" \
90
+ "$fixture/project/node_modules/@earendil-works" \
91
+ "$fixture/override" \
92
+ "$fixture/launch-cwd"
93
+ cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts"
94
+ cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
95
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
96
+ cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts"
97
+ cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts"
98
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts"
99
+ ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent"
100
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui"
101
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox"
102
+ printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json"
103
+
104
+ out=$(cd "$fixture/launch-cwd" && \
105
+ EXT="$fixture/project/.pi/extensions/fm-calm.ts" \
106
+ OVERRIDE_HOME="$fixture/override" \
107
+ EXTENSION_HOME="$fixture/project" \
108
+ node --input-type=module 2>&1 <<'JS'
109
+ import { existsSync, readFileSync } from "node:fs";
110
+ import { pathToFileURL } from "node:url";
111
+
112
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?home=${Date.now()}`);
113
+
114
+ function registerCalm() {
115
+ const handlers = new Map();
116
+ let calmCommand;
117
+ const pi = {
118
+ events: {
119
+ emit() {},
120
+ on() {},
121
+ },
122
+ on(event, handler) {
123
+ handlers.set(event, handler);
124
+ },
125
+ registerCommand(name, command) {
126
+ if (name === "calm") calmCommand = command;
127
+ },
128
+ registerEntryRenderer() {},
129
+ registerTool() {},
130
+ getAllTools() {
131
+ return [];
132
+ },
133
+ };
134
+ extension.default(pi);
135
+ if (!calmCommand || !handlers.has("session_start")) {
136
+ throw new Error("Calm extension did not register its command and session handler");
137
+ }
138
+ return { calmCommand, sessionStart: handlers.get("session_start") };
139
+ }
140
+
141
+ const context = {
142
+ ui: {
143
+ getEditorText() {
144
+ return "";
145
+ },
146
+ getToolsExpanded() {
147
+ return false;
148
+ },
149
+ onTerminalInput() {
150
+ return () => {};
151
+ },
152
+ setHiddenThinkingLabel() {},
153
+ setStatus() {},
154
+ setToolsExpanded() {},
155
+ setWorkingVisible() {},
156
+ notify() {},
157
+ },
158
+ };
159
+
160
+ delete process.env.FM_HOME;
161
+ delete process.env.FM_CONFIG_OVERRIDE;
162
+ process.env.FM_ROOT_OVERRIDE = process.env.OVERRIDE_HOME;
163
+ let calm = registerCalm();
164
+ calm.sessionStart({ reason: "startup" }, context);
165
+ await calm.calmCommand.handler("", context);
166
+ if (readFileSync(`${process.env.OVERRIDE_HOME}/config/calm`, "utf8") !== "on\n") {
167
+ throw new Error("Calm ignored FM_ROOT_OVERRIDE when FM_HOME was unset");
168
+ }
169
+
170
+ delete process.env.FM_ROOT_OVERRIDE;
171
+ calm = registerCalm();
172
+ calm.sessionStart({ reason: "startup" }, context);
173
+ await calm.calmCommand.handler("", context);
174
+ if (readFileSync(`${process.env.EXTENSION_HOME}/config/calm`, "utf8") !== "on\n") {
175
+ throw new Error("Calm did not derive the Firstmate home from its extension path");
176
+ }
177
+ if (existsSync(`${process.cwd()}/config/calm`)) {
178
+ throw new Error("Calm wrote its preference under Pi's launch directory");
179
+ }
180
+ JS
181
+ )
182
+ status=$?
183
+ [ "$status" -eq 0 ] || fail "Pi calm home resolution failed: $out"
184
+ [ -z "$out" ] || fail "Pi calm home-resolution test printed output: $out"
185
+ pass "Pi calm resolves its persistent home independently of Pi's launch directory"
186
+ }
187
+
188
+ test_pi_compat_no_upper_bound() {
189
+ local version
190
+ for version in 0.83.0 0.90.0 1.0.0 2.3.4 0.82.1 10.20.30; do
191
+ record_pi_version_evidence "$version" "synthetic newer Pi" \
192
+ || fail "record_pi_version_evidence rejected Pi $version solely for being newer than 0.82.0"
193
+ done
194
+ if (record_pi_version_evidence "" "malformed Pi version probe") 2>/dev/null; then
195
+ fail "record_pi_version_evidence accepted a missing/malformed Pi version"
196
+ fi
197
+ pass "Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version"
198
+ }
199
+
200
+ test_pi_compat_degraded_adapter() {
201
+ local fixture out status
202
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
203
+ echo "skip: node or npm not found for Pi calm degraded-adapter test"
204
+ return 0
205
+ fi
206
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
207
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
208
+ return 0
209
+ fi
210
+
211
+ fixture="$TMP_ROOT/degraded-adapter"
212
+ mkdir -p \
213
+ "$fixture/project/.pi/extensions/lib" \
214
+ "$fixture/project/node_modules/@earendil-works"
215
+ cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts"
216
+ cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
217
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
218
+ cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts"
219
+ cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts"
220
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts"
221
+ ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent"
222
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui"
223
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox"
224
+ printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json"
225
+
226
+ out=$(cd "$fixture/project" && \
227
+ EXT="$fixture/project/.pi/extensions/fm-calm.ts" \
228
+ PI_PACKAGE_DIR="$PI_PACKAGE_DIR" \
229
+ node --input-type=module 2>&1 <<'JS'
230
+ import { pathToFileURL } from "node:url";
231
+
232
+ const packageRoot = process.env.PI_PACKAGE_DIR;
233
+ const { AssistantMessageComponent } = await import(
234
+ pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href
235
+ );
236
+ const originalUpdateContent = AssistantMessageComponent.prototype.updateContent;
237
+ if (typeof originalUpdateContent !== "function") {
238
+ throw new Error(
239
+ "fixture precondition failed: installed Pi lacks AssistantMessageComponent.prototype.updateContent",
240
+ );
241
+ }
242
+ delete AssistantMessageComponent.prototype.updateContent;
243
+
244
+ const diagnostics = [];
245
+ const originalConsoleError = console.error;
246
+ console.error = (...args) => diagnostics.push(args.join(" "));
247
+
248
+ let calmCommand;
249
+ const handlers = new Map();
250
+ const pi = {
251
+ events: { emit() {}, on() {} },
252
+ on(event, handler) {
253
+ handlers.set(event, handler);
254
+ },
255
+ registerCommand(name, command) {
256
+ if (name === "calm") calmCommand = command;
257
+ },
258
+ registerEntryRenderer() {},
259
+ registerTool() {},
260
+ };
261
+
262
+ let threw = false;
263
+ try {
264
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?degraded=${Date.now()}`);
265
+ extension.default(pi);
266
+ } catch {
267
+ threw = true;
268
+ }
269
+ console.error = originalConsoleError;
270
+
271
+ if (threw) {
272
+ throw new Error(
273
+ "a missing presentation API crashed the whole Calm extension instead of degrading just that adapter",
274
+ );
275
+ }
276
+ if (!calmCommand || !handlers.has("session_start")) {
277
+ throw new Error(
278
+ "Calm command/session lifecycle did not register when only one presentation adapter was unavailable",
279
+ );
280
+ }
281
+ if (typeof AssistantMessageComponent.prototype.updateContent !== "undefined") {
282
+ throw new Error(
283
+ "the degraded adapter path patched updateContent anyway despite the missing API, which would claim false success",
284
+ );
285
+ }
286
+ const sawClearSkipReason = diagnostics.some(
287
+ (line) => line.includes("collapsed-thinking") && /unavailable|skip/i.test(line),
288
+ );
289
+ if (!sawClearSkipReason) {
290
+ throw new Error(
291
+ `missing a clear skip reason for the degraded collapsed-thinking adapter; saw: ${JSON.stringify(diagnostics)}`,
292
+ );
293
+ }
294
+
295
+ AssistantMessageComponent.prototype.updateContent = originalUpdateContent;
296
+ JS
297
+ )
298
+ status=$?
299
+ [ "$status" -eq 0 ] || fail "Pi calm degraded-adapter path failed: $out"
300
+ [ -z "$out" ] || fail "Pi calm degraded-adapter test printed output: $out"
301
+ pass "a missing collapsed-thinking presentation API degrades only that Calm adapter with a clear skip reason, while the rest of Calm still registers"
302
+ }
303
+
304
+ test_pi_compat_missing_adapter_exports() {
305
+ local fixture out status
306
+ if ! command -v node >/dev/null 2>&1; then
307
+ echo "skip: node not found for Pi calm missing-adapter-export test"
308
+ return 0
309
+ fi
310
+
311
+ fixture="$TMP_ROOT/missing-adapter-exports"
312
+ mkdir -p \
313
+ "$fixture/project/.pi/extensions/lib" \
314
+ "$fixture/project/node_modules/@earendil-works/pi-coding-agent"
315
+ cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
316
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
317
+ cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts"
318
+ cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts"
319
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts"
320
+ printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json"
321
+ printf '%s\n' \
322
+ '{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}' \
323
+ >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/package.json"
324
+ printf '%s\n' \
325
+ 'export function getMarkdownTheme() { return {}; }' \
326
+ 'export class UserMessageComponent {}' \
327
+ >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/index.js"
328
+
329
+ out=$(cd "$fixture/project" && node --input-type=module 2>&1 <<'JS'
330
+ const assistant = await import("./.pi/extensions/lib/fm-calm-assistant-layout.ts");
331
+ const operational = await import("./.pi/extensions/lib/fm-calm-operational-user-layout.ts");
332
+
333
+ for (const [name, install, expected] of [
334
+ ["collapsed-thinking", assistant.installCalmAssistantLayout, "AssistantMessageComponent"],
335
+ ["operational-user-row", operational.installCalmOperationalUserLayout, "InteractiveMode"],
336
+ ]) {
337
+ let reason;
338
+ try {
339
+ install();
340
+ } catch (error) {
341
+ reason = error instanceof Error ? error.message : String(error);
342
+ }
343
+ if (!reason?.includes(expected)) {
344
+ throw new Error(
345
+ `${name} adapter did not load and report its missing runtime export: ${String(reason)}`,
346
+ );
347
+ }
348
+ }
349
+ JS
350
+ )
351
+ status=$?
352
+ [ "$status" -eq 0 ] || fail "Pi calm missing-adapter-export path failed: $out"
353
+ [ -z "$out" ] || fail "Pi calm missing-adapter-export test printed output: $out"
354
+ pass "missing Pi presentation class exports reach the independent adapter degradation path"
355
+ }
356
+
357
+ test_builtin_gate_load_time() {
358
+ local fixture out output_file status
359
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
360
+ echo "skip: node or npm not found for Pi calm gate test"
361
+ return 0
362
+ fi
363
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
364
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
365
+ return 0
366
+ fi
367
+
368
+ fixture="$TMP_ROOT/gate-load-time"
369
+ mkdir -p \
370
+ "$fixture/project/.pi/extensions/lib" \
371
+ "$fixture/project/node_modules/@earendil-works" \
372
+ "$fixture/home-off/config" \
373
+ "$fixture/home-on/config"
374
+ cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts"
375
+ cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
376
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
377
+ cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts"
378
+ cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts"
379
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts"
380
+ ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent"
381
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui"
382
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox"
383
+ printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json"
384
+ printf '%s\n' on >"$fixture/home-on/config/calm"
385
+
386
+ output_file="$fixture/node-output"
387
+ (cd "$fixture/project" && \
388
+ EXT="$fixture/project/.pi/extensions/fm-calm.ts" \
389
+ HOME_OFF="$fixture/home-off" \
390
+ HOME_ON="$fixture/home-on" \
391
+ node --input-type=module) >"$output_file" 2>&1 <<'JS'
392
+ import { pathToFileURL } from "node:url";
393
+
394
+ function fakePi() {
395
+ const tools = [];
396
+ const handlers = new Map();
397
+ const pi = {
398
+ events: { emit() {}, on() {} },
399
+ on(event, handler) {
400
+ handlers.set(event, handler);
401
+ },
402
+ registerCommand() {},
403
+ registerEntryRenderer() {},
404
+ registerTool(tool) {
405
+ tools.push(tool);
406
+ },
407
+ getAllTools() {
408
+ return tools.map((tool) => ({ name: tool.name, sourceInfo: { source: "extension", path: "self" } }));
409
+ },
410
+ };
411
+ return { pi, tools, handlers };
412
+ }
413
+
414
+ // Calm-off (config/calm absent for this home): load-time registration must be
415
+ // entirely skipped, so a non-Calm user contests nothing.
416
+ process.env.FM_HOME = process.env.HOME_OFF;
417
+ const offRun = fakePi();
418
+ const extensionOff = await import(`${pathToFileURL(process.env.EXT).href}?gate-off=${Date.now()}`);
419
+ extensionOff.default(offRun.pi);
420
+ if (offRun.tools.length !== 0) {
421
+ throw new Error(`Calm registered ${offRun.tools.length} built-ins while config/calm was absent: ${offRun.tools.map((t) => t.name).join(",")}`);
422
+ }
423
+
424
+ // Calm-on (config/calm="on" for this home): registration must happen synchronously,
425
+ // during this same smidja call, exactly the timing /reload's pre-session_start
426
+ // transcript render depends on - not deferred to session_start or later.
427
+ process.env.FM_HOME = process.env.HOME_ON;
428
+ const onRun = fakePi();
429
+ const extensionOn = await import(`${pathToFileURL(process.env.EXT).href}?gate-on=${Date.now()}`);
430
+ extensionOn.default(onRun.pi);
431
+ const names = onRun.tools.map((t) => t.name).sort();
432
+ const expected = ["bash", "edit", "find", "grep", "ls", "read", "write"];
433
+ if (JSON.stringify(names) !== JSON.stringify(expected)) {
434
+ throw new Error(`Calm registered ${JSON.stringify(names)} synchronously at load with config/calm=on, expected ${JSON.stringify(expected)}`);
435
+ }
436
+ JS
437
+ status=$?
438
+ out=$(cat "$output_file")
439
+ [ "$status" -eq 0 ] || fail "Pi calm gate-at-load-time path failed: $out"
440
+ [ -z "$out" ] || fail "Pi calm gate-at-load-time test printed output: $out"
441
+ pass "Calm registers none of its 7 built-in tool wrappers at load while config/calm is off, and all 7 synchronously at load while config/calm is on"
442
+ }
443
+
444
+ test_calm_activation_collision_and_regression_bound() {
445
+ local fixture out output_file status
446
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
447
+ echo "skip: node or npm not found for Pi calm activation test"
448
+ return 0
449
+ fi
450
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
451
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
452
+ return 0
453
+ fi
454
+
455
+ fixture="$TMP_ROOT/activation-collision"
456
+ mkdir -p \
457
+ "$fixture/project/.pi/extensions/lib" \
458
+ "$fixture/project/node_modules/@earendil-works" \
459
+ "$fixture/home/config"
460
+ cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts"
461
+ cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
462
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
463
+ cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts"
464
+ cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts"
465
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts"
466
+ ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent"
467
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui"
468
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox"
469
+ printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json"
470
+ printf '%s\n' 'export default function () {}' >"$fixture/project/foreign-bash-extension.ts"
471
+
472
+ output_file="$fixture/node-output"
473
+ (cd "$fixture/project" && \
474
+ EXT="$fixture/project/.pi/extensions/fm-calm.ts" \
475
+ FOREIGN_EXT="$fixture/project/foreign-bash-extension.ts" \
476
+ FM_HOME="$fixture/home" \
477
+ PI_PACKAGE_DIR="$PI_PACKAGE_DIR" \
478
+ node --input-type=module) >"$output_file" 2>&1 <<'JS'
479
+ import { fileURLToPath, pathToFileURL } from "node:url";
480
+
481
+ const packageRoot = process.env.PI_PACKAGE_DIR;
482
+ const { ToolExecutionComponent } = await import(
483
+ pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href
484
+ );
485
+ const { initTheme } = await import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href);
486
+ const { setCapabilities } = await import(
487
+ pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href
488
+ );
489
+ initTheme("dark");
490
+ setCapabilities({ images: null, trueColor: true, hyperlinks: false });
491
+
492
+ // Reproduces the collision: a different, earlier-loaded extension already owns
493
+ // "bash" by the time Calm's first activation runs, exactly as Pi's real
494
+ // ExtensionRunner resolves same-name pi.registerTool() calls (first-registered-
495
+ // extension-per-name wins, verified in the installed Pi package's
496
+ // ExtensionRunner.getAllRegisteredTools).
497
+ const foreignPath = fileURLToPath(pathToFileURL(process.env.FOREIGN_EXT).href);
498
+ const FOREIGN_MARKER = "FOREIGN_BASH_EXECUTED";
499
+ const foreignBash = {
500
+ name: "bash",
501
+ label: "Foreign bash",
502
+ description: "A different extension's own bash override, e.g. an approval gate.",
503
+ parameters: { type: "object", properties: {} },
504
+ async execute() {
505
+ return { content: [{ type: "text", text: FOREIGN_MARKER }], details: {}, isError: false };
506
+ },
507
+ };
508
+
509
+ const registry = new Map([["bash", { tool: foreignBash, ownerPath: foreignPath }]]);
510
+ const notifications = [];
511
+ const diagnostics = [];
512
+ const originalConsoleError = console.error;
513
+ console.error = (...args) => diagnostics.push(args.join(" "));
514
+
515
+ const handlers = new Map();
516
+ let calmCommand;
517
+ const extPath = fileURLToPath(pathToFileURL(process.env.EXT).href);
518
+ const pi = {
519
+ events: { emit() {}, on() {} },
520
+ on(event, handler) {
521
+ handlers.set(event, handler);
522
+ },
523
+ registerCommand(name, command) {
524
+ if (name === "calm") calmCommand = command;
525
+ },
526
+ registerEntryRenderer() {},
527
+ // Mirrors Pi's own arbitration: first registrant for a name keeps it, silently.
528
+ registerTool(tool) {
529
+ if (!registry.has(tool.name)) {
530
+ registry.set(tool.name, { tool, ownerPath: extPath });
531
+ }
532
+ },
533
+ getAllTools() {
534
+ return Array.from(registry.entries()).map(([name, { ownerPath }]) => ({
535
+ name,
536
+ sourceInfo: { source: "extension", path: ownerPath },
537
+ }));
538
+ },
539
+ };
540
+
541
+ let threw = false;
542
+ try {
543
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?activation=${Date.now()}`);
544
+ extension.default(pi);
545
+ } catch {
546
+ threw = true;
547
+ }
548
+ if (threw) throw new Error("Calm's own smidja threw while config/calm was absent and another extension already owned bash");
549
+ if (registry.size !== 1) {
550
+ throw new Error(`Calm registered built-ins at load time despite config/calm being absent: ${JSON.stringify(Array.from(registry.keys()))}`);
551
+ }
552
+ if (!calmCommand || !handlers.has("session_start")) {
553
+ throw new Error("Calm did not finish registering its command and session handler");
554
+ }
555
+
556
+ // A row constructed before Calm's first-ever activation this session: this is the
557
+ // captain-accepted, documented bound on the gate-at-load fix (see fm-calm.ts's file
558
+ // header and docs/calm.md) - Pi gives no way to re-point an already-constructed
559
+ // ToolExecutionComponent at a definition registered later, so this row can never
560
+ // retroactively collapse. Lock that in explicitly rather than let it regress further.
561
+ const renderUi = { requestRender() {} };
562
+ const preToggleReadArgs = { path: "sample.txt" };
563
+ const preToggleRead = new ToolExecutionComponent(
564
+ "read",
565
+ "pre-toggle-read",
566
+ preToggleReadArgs,
567
+ { showImages: false },
568
+ registry.get("read")?.tool,
569
+ renderUi,
570
+ process.cwd(),
571
+ );
572
+ preToggleRead.markExecutionStarted();
573
+ preToggleRead.setArgsComplete();
574
+ preToggleRead.updateResult({ content: [{ type: "text", text: "PRE_TOGGLE_READ_OUTPUT" }], details: {}, isError: false });
575
+ const preToggleRenderedBefore = preToggleRead.render(100);
576
+ if (preToggleRenderedBefore.length === 0) {
577
+ throw new Error("a tool row rendered as hidden before Calm was ever activated");
578
+ }
579
+
580
+ const ctx = {
581
+ ui: {
582
+ getEditorText: () => "",
583
+ getToolsExpanded: () => false,
584
+ onTerminalInput: () => () => {},
585
+ setHiddenThinkingLabel() {},
586
+ setStatus() {},
587
+ setToolsExpanded() {},
588
+ setWorkingVisible() {},
589
+ notify(message, type) {
590
+ notifications.push({ message, type });
591
+ },
592
+ },
593
+ };
594
+ console.error = (...args) => diagnostics.push(args.join(" "));
595
+ await calmCommand.handler("", ctx);
596
+ console.error = originalConsoleError;
597
+
598
+ const bashEntry = registry.get("bash");
599
+ if (bashEntry.tool !== foreignBash) {
600
+ throw new Error("Calm replaced the foreign extension's bash registration instead of leaving it alone");
601
+ }
602
+ const bashResult = await bashEntry.tool.execute();
603
+ if (bashResult.content[0]?.text !== FOREIGN_MARKER) {
604
+ throw new Error("the foreign extension's bash tool no longer executes its own real behavior");
605
+ }
606
+ for (const name of ["read", "edit", "write", "grep", "find", "ls"]) {
607
+ const entry = registry.get(name);
608
+ if (!entry || entry.ownerPath !== extPath) {
609
+ throw new Error(`Calm failed to claim the uncontested built-in "${name}" on first activation`);
610
+ }
611
+ }
612
+
613
+ // Part C: a single, prominent, user-facing warning naming the contested tool, not
614
+ // merely a console diagnostic.
615
+ if (notifications.length !== 1) {
616
+ throw new Error(`expected exactly one contested-tool notification, saw ${JSON.stringify(notifications)}`);
617
+ }
618
+ if (notifications[0].type !== "warning") {
619
+ throw new Error(`contested-tool notification was not type "warning": ${JSON.stringify(notifications[0])}`);
620
+ }
621
+ if (!notifications[0].message.includes("bash") || !notifications[0].message.toLowerCase().includes("calm")) {
622
+ throw new Error(`contested-tool notification did not name the tool clearly: ${JSON.stringify(notifications[0])}`);
623
+ }
624
+ const sawBashDiagnostic = diagnostics.some((line) => line.includes("bash"));
625
+ if (!sawBashDiagnostic) {
626
+ throw new Error(`expected a console diagnostic naming the skipped built-in too; saw: ${JSON.stringify(diagnostics)}`);
627
+ }
628
+
629
+ // The documented bound itself: still non-empty after Calm is now active, because it
630
+ // was constructed before Calm ever claimed anything.
631
+ if (preToggleRead.render(100).length === 0) {
632
+ throw new Error("a pre-activation tool row retroactively hid after Calm turned on; the documented bound regressed");
633
+ }
634
+
635
+ // A row for the same tool constructed after activation behaves normally: it does hide.
636
+ const postToggleRead = new ToolExecutionComponent(
637
+ "read",
638
+ "post-toggle-read",
639
+ preToggleReadArgs,
640
+ { showImages: false },
641
+ registry.get("read")?.tool,
642
+ renderUi,
643
+ process.cwd(),
644
+ );
645
+ postToggleRead.markExecutionStarted();
646
+ postToggleRead.setArgsComplete();
647
+ postToggleRead.updateResult({ content: [{ type: "text", text: "POST_TOGGLE_READ_OUTPUT" }], details: {}, isError: false });
648
+ if (postToggleRead.render(100).length !== 0) {
649
+ throw new Error("a tool row constructed after Calm's activation did not hide");
650
+ }
651
+ JS
652
+ status=$?
653
+ out=$(cat "$output_file")
654
+ [ "$status" -eq 0 ] || fail "Pi calm activation/collision/regression-bound path failed: $out"
655
+ [ -z "$out" ] || fail "Pi calm activation/collision/regression-bound test printed output: $out"
656
+ pass "Calm's first same-session /calm activation claims every uncontested built-in, leaves a foreign bash tool fully intact and callable, warns prominently and logs the contested name, and only rows constructed before that activation - the documented bound - fail to retroactively collapse"
657
+ }
658
+
659
+ test_rendering_and_session_lifecycle() {
660
+ local fixture out output_file status version
661
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
662
+ echo "skip: node or npm not found for Pi calm renderer test"
663
+ return 0
664
+ fi
665
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
666
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
667
+ return 0
668
+ fi
669
+ version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version")
670
+ record_pi_version_evidence "$version" "Pi calm compatibility assumptions"
671
+
672
+ fixture="$TMP_ROOT/renderer"
673
+ mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works"
674
+ cp "$EXT" "$fixture/fm-calm.ts"
675
+ cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts"
676
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts"
677
+ cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts"
678
+ cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts"
679
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/lib/fm-operational-input.ts"
680
+ cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$fixture/lib/fm-branch-dispatch.ts"
681
+ cp "$WATCH_EXT" "$fixture/fm-primary-pi-watch.ts"
682
+ ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent"
683
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
684
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox"
685
+ printf '%s\n' '{"type":"module"}' >"$fixture/package.json"
686
+ cat >"$fixture/operational-input-probe.sh" <<'SH'
687
+ #!/usr/bin/env bash
688
+ printf '%s\n' "${1-}" >>"$FM_OPERATIONAL_INPUT_CALLS"
689
+ exec "$FM_OPERATIONAL_INPUT_OWNER" "$@"
690
+ SH
691
+ chmod +x "$fixture/operational-input-probe.sh"
692
+
693
+ output_file="$fixture/node-output"
694
+ (cd "$fixture" && EXT="$fixture/fm-calm.ts" WATCH_EXT="$fixture/fm-primary-pi-watch.ts" FM_HOME="$fixture/home" FM_OPERATIONAL_INPUT_SCRIPT="$fixture/operational-input-probe.sh" FM_OPERATIONAL_INPUT_OWNER="$OPERATIONAL_INPUT" FM_OPERATIONAL_INPUT_CALLS="$fixture/operational-input-calls" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module) >"$output_file" 2>&1 <<'JS'
695
+ import { readFileSync, writeFileSync } from "node:fs";
696
+ import { fileURLToPath, pathToFileURL } from "node:url";
697
+
698
+ // fm-calm.ts derives its own identity the same way (fileURLToPath(import.meta.url)),
699
+ // which normalizes away irregularities like a symlinked TMPDIR (macOS /tmp, /var);
700
+ // comparing against the raw env var would spuriously read this fixture's own
701
+ // registration as foreign.
702
+ const extPath = fileURLToPath(pathToFileURL(process.env.EXT).href);
703
+
704
+ const packageRoot = process.env.PI_PACKAGE_DIR;
705
+ const [{ AssistantMessageComponent }, { CustomEntryComponent }, { ToolExecutionComponent }, { UserMessageComponent }, { InteractiveMode }, { initTheme, theme }, { Text, getKeybindings, setCapabilities }, { createToolHtmlRenderer }] = await Promise.all([
706
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href),
707
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/custom-entry.js`).href),
708
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href),
709
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/user-message.js`).href),
710
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/interactive-mode.js`).href),
711
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href),
712
+ import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href),
713
+ import(pathToFileURL(`${packageRoot}/dist/core/export-html/tool-renderer.js`).href),
714
+ ]);
715
+ initTheme("dark");
716
+ setCapabilities({ images: null, trueColor: true, hyperlinks: false });
717
+
718
+ const tools = [];
719
+ const handlers = new Map();
720
+ const entryRenderers = new Map();
721
+ const eventListeners = new Map();
722
+ let calmCommand;
723
+ const pi = {
724
+ events: {
725
+ emit(name, data) {
726
+ for (const listener of eventListeners.get(name) ?? []) listener(data);
727
+ },
728
+ on(name, listener) {
729
+ const listeners = eventListeners.get(name) ?? [];
730
+ listeners.push(listener);
731
+ eventListeners.set(name, listeners);
732
+ },
733
+ },
734
+ on(event, handler) {
735
+ const eventHandlers = handlers.get(event) ?? [];
736
+ eventHandlers.push(handler);
737
+ handlers.set(event, eventHandlers);
738
+ },
739
+ registerCommand(name, command) {
740
+ if (name === "calm") calmCommand = command;
741
+ },
742
+ registerEntryRenderer(customType, renderer) {
743
+ entryRenderers.set(customType, renderer);
744
+ },
745
+ registerTool(tool) {
746
+ const existingIndex = tools.findIndex((existing) => existing.name === tool.name);
747
+ if (existingIndex === -1) tools.push(tool);
748
+ else tools[existingIndex] = tool;
749
+ },
750
+ getAllTools() {
751
+ // Only Calm itself has registered anything in this fixture, so every entry
752
+ // reports Calm's own extension path; the dedicated collision fixture below is
753
+ // what exercises a foreign extension already owning a name.
754
+ return tools.map((tool) => ({
755
+ name: tool.name,
756
+ sourceInfo: { source: "extension", path: extPath },
757
+ }));
758
+ },
759
+ };
760
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?test=${Date.now()}`);
761
+ extension.default(pi);
762
+ const visibility = await import(`${pathToFileURL(`${process.cwd()}/lib/fm-calm-visibility.ts`).href}?policy=${Date.now()}`);
763
+ const operationalInput = await import(`${pathToFileURL(`${process.cwd()}/lib/fm-operational-input.ts`).href}?input=${Date.now()}`);
764
+
765
+ // Registration is gated on config/calm at load (see fm-calm.ts's file header); this
766
+ // fixture has no config/calm file, so nothing is registered yet. Every render-
767
+ // equivalence assertion below needs the wrapped definitions the way a user who kept
768
+ // Calm on across a previous session would already have them, so force that here via
769
+ // the same /calm command path a real activation uses, then round-trip back off so the
770
+ // rest of this fixture's own off/on toggle sequence still observes its usual starting
771
+ // state. This does not touch the calm-off/toggle-on assertions further down: those
772
+ // exercise activateBuiltInsIfNeeded's own contested-name skip and warning through the
773
+ // dedicated fixture below, not this one.
774
+ const earlyActivationUi = {
775
+ getEditorText: () => "",
776
+ getToolsExpanded: () => false,
777
+ onTerminalInput: () => () => {},
778
+ setHiddenThinkingLabel() {},
779
+ setStatus() {},
780
+ setToolsExpanded() {},
781
+ setWorkingVisible() {},
782
+ notify() {},
783
+ };
784
+ await calmCommand.handler("", { ui: earlyActivationUi });
785
+ await calmCommand.handler("", { ui: earlyActivationUi });
786
+
787
+ const names = tools.map((tool) => tool.name);
788
+ const expectedNames = ["read", "bash", "edit", "write", "grep", "find", "ls"];
789
+ if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
790
+ throw new Error(`unexpected wrapped built-ins: ${names.join(",")}`);
791
+ }
792
+ if (!calmCommand || !handlers.has("session_start")) {
793
+ throw new Error("calm command or session lifecycle handler was not registered");
794
+ }
795
+ if (handlers.has("input")) {
796
+ throw new Error("Calm registered a semantic input interceptor");
797
+ }
798
+ if (
799
+ calmCommand.description !==
800
+ "Toggle Firstmate's supported conversation-only transcript presentation."
801
+ ) {
802
+ throw new Error(`unexpected calm command description: ${calmCommand.description}`);
803
+ }
804
+
805
+ for (const itemClass of visibility.CALM_TRANSCRIPT_CLASSES) {
806
+ const visible = visibility.calmTranscriptClassIsVisible(itemClass);
807
+ const expected =
808
+ itemClass === "genuine-user-prompt" ||
809
+ itemClass === "genuine-agent-response" ||
810
+ itemClass === "working-status";
811
+ if (visible !== expected) {
812
+ throw new Error(`Calm allowlist classified ${itemClass} as visible=${visible}`);
813
+ }
814
+ }
815
+ const watcherBody =
816
+ "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status\n\n" +
817
+ "Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.";
818
+ const watcherMessage = operationalInput.encodeFirstmateOperationalInput("watcher", watcherBody);
819
+ const legacyAwayMessage = "\u2063Supervisor escalate (legacy presentation compatibility)";
820
+ const operationalHistory = [];
821
+ const operationalChat = {
822
+ children: [new Text("VISIBLE_PREDECESSOR", 0, 0)],
823
+ addChild(component) {
824
+ this.children.push(component);
825
+ },
826
+ };
827
+ const operationalMode = {
828
+ chatContainer: operationalChat,
829
+ editor: { addToHistory: (value) => operationalHistory.push(value) },
830
+ // Pi builds user rows with the registered markdown transformers from 0.83 onward and
831
+ // without them before that; the stub answers both shapes with the empty list Pi and
832
+ // Firstmate both use today.
833
+ getMarkdownTransformers: () => [],
834
+ getMarkdownThemeWithSettings: () => undefined,
835
+ getUserMessageText: (message) => typeof message.content === "string"
836
+ ? message.content
837
+ : message.content.filter((item) => item.type === "text").map((item) => item.text).join(""),
838
+ outputPad: 1,
839
+ };
840
+ const callsBeforePlainReplay = readFileSync(process.env.FM_OPERATIONAL_INPUT_CALLS, "utf8");
841
+ const plainReplayChat = {
842
+ children: [],
843
+ addChild(component) {
844
+ this.children.push(component);
845
+ },
846
+ };
847
+ for (let index = 0; index < 50; index += 1) {
848
+ InteractiveMode.prototype.addMessageToChat.call(
849
+ { ...operationalMode, chatContainer: plainReplayChat },
850
+ { role: "user", content: `ORDINARY_REPLAY_${index}` },
851
+ );
852
+ }
853
+ if (readFileSync(process.env.FM_OPERATIONAL_INPUT_CALLS, "utf8") !== callsBeforePlainReplay) {
854
+ throw new Error("ordinary replay rows invoked operational subprocess classification");
855
+ }
856
+ InteractiveMode.prototype.addMessageToChat.call(
857
+ operationalMode,
858
+ { role: "user", content: [{ type: "text", text: watcherMessage }] },
859
+ { populateHistory: true },
860
+ );
861
+ InteractiveMode.prototype.addMessageToChat.call(
862
+ operationalMode,
863
+ { role: "user", content: legacyAwayMessage },
864
+ );
865
+ const operationalComponent = operationalChat.children[1];
866
+ const legacyOperationalComponent = operationalChat.children[2];
867
+ const stockOperationalComponent = new UserMessageComponent(watcherMessage, undefined, 1);
868
+ const expectedCalmOffOperationalRows = ["", ...stockOperationalComponent.render(100)];
869
+ if (JSON.stringify(operationalComponent.render(100)) !== JSON.stringify(expectedCalmOffOperationalRows)) {
870
+ throw new Error("Calm-off operational user rendering changed from Pi stock rows");
871
+ }
872
+ if (operationalHistory.length !== 1 || operationalHistory[0] !== watcherMessage) {
873
+ throw new Error("operational user presentation changed Pi input history behavior");
874
+ }
875
+
876
+ writeFileSync("sample.txt", "alpha\n");
877
+ const cases = [
878
+ ["read", { path: "sample.txt" }, { content: [{ type: "text", text: "alpha" }], details: {}, isError: false }],
879
+ ["bash", { command: "printf 'CALM_RENDER_OUTPUT\\n'" }, { content: [{ type: "text", text: "CALM_RENDER_OUTPUT" }], details: {}, isError: false }],
880
+ ["edit", { path: "sample.txt", edits: [{ oldText: "alpha", newText: "beta" }] }, { content: [{ type: "text", text: "Successfully replaced 1 block(s) in sample.txt." }], details: { diff: "-alpha\n+beta", patch: "", firstChangedLine: 1 }, isError: false }],
881
+ ["write", { path: "sample.txt", content: "beta\n" }, { content: [{ type: "text", text: "Successfully wrote 5 bytes to sample.txt" }], details: undefined, isError: false }],
882
+ ["grep", { pattern: "alpha", path: "." }, { content: [{ type: "text", text: "sample.txt:1:alpha" }], details: {}, isError: false }],
883
+ ["find", { pattern: "*.txt", path: "." }, { content: [{ type: "text", text: "sample.txt" }], details: {}, isError: false }],
884
+ ["ls", { path: "." }, { content: [{ type: "text", text: "sample.txt" }], details: {}, isError: false }],
885
+ ];
886
+ const renderUi = { requestRender() {} };
887
+ const rows = [];
888
+ for (const [name, args, result] of cases) {
889
+ const wrapped = tools.find((tool) => tool.name === name);
890
+ const baseline = new ToolExecutionComponent(name, `baseline-${name}`, args, { showImages: false }, undefined, renderUi, process.cwd());
891
+ const actual = new ToolExecutionComponent(name, `wrapped-${name}`, args, { showImages: false }, wrapped, renderUi, process.cwd());
892
+ for (const row of [baseline, actual]) {
893
+ row.markExecutionStarted();
894
+ row.setArgsComplete();
895
+ row.updateResult(result);
896
+ }
897
+ const collapsedExpected = baseline.render(100);
898
+ const collapsedActual = actual.render(100);
899
+ if (JSON.stringify(collapsedActual) !== JSON.stringify(collapsedExpected)) {
900
+ throw new Error(`${name} collapsed rendering changed while calm mode was off`);
901
+ }
902
+ baseline.setExpanded(true);
903
+ actual.setExpanded(true);
904
+ const expandedExpected = baseline.render(100);
905
+ const expandedActual = actual.render(100);
906
+ if (JSON.stringify(expandedActual) !== JSON.stringify(expandedExpected)) {
907
+ throw new Error(`${name} expanded rendering changed while calm mode was off`);
908
+ }
909
+ rows.push({ name, baseline, actual });
910
+ }
911
+
912
+ const watchPi = {
913
+ ...pi,
914
+ appendEntry() {},
915
+ sendMessage() {},
916
+ registerCommand() {},
917
+ registerEntryRenderer() {},
918
+ };
919
+ const watchExtension = await import(`${pathToFileURL(process.env.WATCH_EXT).href}?test=${Date.now()}`);
920
+ watchExtension.default(watchPi);
921
+ const watchTool = tools.find((tool) => tool.name === "fm_watch_arm_pi");
922
+ if (!watchTool) throw new Error("Firstmate watcher extension did not register fm_watch_arm_pi");
923
+ const stockWatchTool = { ...watchTool };
924
+ delete stockWatchTool.renderCall;
925
+ delete stockWatchTool.renderResult;
926
+ delete stockWatchTool.renderShell;
927
+ const watchArgs = {};
928
+ const watchResult = {
929
+ content: [{ type: "text", text: "watcher: started Pi extension arm child 1" }],
930
+ details: { ok: true, message: "watcher: started Pi extension arm child 1" },
931
+ isError: false,
932
+ };
933
+ const watchBaseline = new ToolExecutionComponent(
934
+ "fm_watch_arm_pi",
935
+ "watch-baseline",
936
+ watchArgs,
937
+ { showImages: false },
938
+ stockWatchTool,
939
+ renderUi,
940
+ process.cwd(),
941
+ );
942
+ const watchActual = new ToolExecutionComponent(
943
+ "fm_watch_arm_pi",
944
+ "watch-actual",
945
+ watchArgs,
946
+ { showImages: false },
947
+ watchTool,
948
+ renderUi,
949
+ process.cwd(),
950
+ );
951
+ for (const row of [watchBaseline, watchActual]) {
952
+ row.markExecutionStarted();
953
+ row.setArgsComplete();
954
+ row.updateResult(watchResult);
955
+ }
956
+ if (JSON.stringify(watchActual.render(100)) !== JSON.stringify(watchBaseline.render(100))) {
957
+ throw new Error("Firstmate watcher tool changed stock rendering while Calm was off");
958
+ }
959
+
960
+ const customDefinition = {
961
+ name: "third_party_tool",
962
+ label: "Third party tool",
963
+ description: "Custom-tool boundary probe",
964
+ parameters: { type: "object", properties: {} },
965
+ renderShell: "self",
966
+ async execute() {
967
+ return { content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {} };
968
+ },
969
+ renderCall() {
970
+ return new Text("CUSTOM_CALL", 0, 0);
971
+ },
972
+ renderResult() {
973
+ return new Text("CUSTOM_RESULT", 0, 0);
974
+ },
975
+ };
976
+ const customRow = new ToolExecutionComponent(
977
+ "third_party_tool",
978
+ "custom-row",
979
+ {},
980
+ { showImages: false },
981
+ customDefinition,
982
+ renderUi,
983
+ process.cwd(),
984
+ );
985
+ customRow.markExecutionStarted();
986
+ customRow.setArgsComplete();
987
+ customRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false });
988
+
989
+ setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
990
+ const imageRow = new ToolExecutionComponent(
991
+ "read",
992
+ "read-image-row",
993
+ { path: "pixel.png" },
994
+ { showImages: true },
995
+ tools.find((tool) => tool.name === "read"),
996
+ renderUi,
997
+ process.cwd(),
998
+ );
999
+ imageRow.markExecutionStarted();
1000
+ imageRow.setArgsComplete();
1001
+ imageRow.updateResult({
1002
+ content: [
1003
+ {
1004
+ type: "image",
1005
+ data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
1006
+ mimeType: "image/png",
1007
+ },
1008
+ ],
1009
+ details: {},
1010
+ isError: false,
1011
+ });
1012
+ imageRow.setExpanded(true);
1013
+ const imageVisibleBefore = imageRow.render(100);
1014
+ if (!imageVisibleBefore.join("\n").includes("\x1b]1337;File=")) {
1015
+ throw new Error("image-capable Pi fixture did not render the built-in read image boundary");
1016
+ }
1017
+
1018
+ const assistantBase = {
1019
+ role: "assistant",
1020
+ api: "calm-render-test",
1021
+ provider: "calm-render-test",
1022
+ model: "deterministic",
1023
+ usage: {
1024
+ input: 0,
1025
+ output: 0,
1026
+ cacheRead: 0,
1027
+ cacheWrite: 0,
1028
+ totalTokens: 0,
1029
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1030
+ },
1031
+ stopReason: "stop",
1032
+ timestamp: 1,
1033
+ };
1034
+ const assistantTextOnly = new AssistantMessageComponent({
1035
+ ...assistantBase,
1036
+ content: [{ type: "text", text: "VISIBLE_ASSISTANT_TEXT" }],
1037
+ }, true);
1038
+ const assistantThinkingText = new AssistantMessageComponent({
1039
+ ...assistantBase,
1040
+ content: [
1041
+ { type: "thinking", thinking: "HIDDEN_FINAL_THINKING" },
1042
+ { type: "text", text: "VISIBLE_ASSISTANT_TEXT" },
1043
+ ],
1044
+ }, true);
1045
+ const assistantThinkingTool = new AssistantMessageComponent({
1046
+ ...assistantBase,
1047
+ content: [
1048
+ { type: "thinking", thinking: "HIDDEN_TOOL_THINKING" },
1049
+ { type: "toolCall", id: "assistant-layout-tool", name: "read", arguments: { path: "sample.txt" } },
1050
+ ],
1051
+ stopReason: "toolUse",
1052
+ }, true);
1053
+ if (!assistantThinkingText.render(100).join("\n").includes("Thinking...")) {
1054
+ throw new Error("stock collapsed-thinking fixture did not render before Calm was active");
1055
+ }
1056
+
1057
+ const assistantComponents = [assistantTextOnly, assistantThinkingText, assistantThinkingTool];
1058
+ let expanded = true;
1059
+ let editorText = "";
1060
+ let terminalInputHandler;
1061
+ let workingVisible;
1062
+ let hiddenThinkingLabel = "unset";
1063
+ const statuses = new Map();
1064
+ const sessionEntries = [{ type: "message", message: { role: "toolResult", content: "kept" } }];
1065
+ const entriesBefore = JSON.stringify(sessionEntries);
1066
+ const commandContext = {
1067
+ sessionManager: { getEntries: () => sessionEntries },
1068
+ ui: {
1069
+ getEditorText: () => editorText,
1070
+ getToolsExpanded: () => expanded,
1071
+ onTerminalInput(handler) {
1072
+ terminalInputHandler = handler;
1073
+ return () => {
1074
+ if (terminalInputHandler === handler) terminalInputHandler = undefined;
1075
+ };
1076
+ },
1077
+ setHiddenThinkingLabel(value) {
1078
+ hiddenThinkingLabel = value;
1079
+ for (const component of assistantComponents) {
1080
+ component.setHiddenThinkingLabel(value ?? "Thinking...");
1081
+ }
1082
+ },
1083
+ setStatus(key, value) {
1084
+ statuses.set(key, value);
1085
+ },
1086
+ setToolsExpanded(value) {
1087
+ expanded = value;
1088
+ for (const row of rows) row.actual.setExpanded(value);
1089
+ watchActual.setExpanded(value);
1090
+ customRow.setExpanded(value);
1091
+ imageRow.setExpanded(value);
1092
+ },
1093
+ setWorkingVisible(value) {
1094
+ workingVisible = value;
1095
+ },
1096
+ },
1097
+ };
1098
+
1099
+ await handlers.get("session_start")[0]({ reason: "startup" }, commandContext);
1100
+ if (workingVisible !== true || hiddenThinkingLabel !== undefined) {
1101
+ throw new Error("session start did not restore Pi's stock working and thinking presentation");
1102
+ }
1103
+ const presentationRenderer = entryRenderers.get("firstmate-synthetic-input-presentation");
1104
+ if (!presentationRenderer) throw new Error("legacy synthetic presentation renderer was not registered");
1105
+ const presentationEntry = {
1106
+ customType: "firstmate-synthetic-input-presentation",
1107
+ data: { content: watcherMessage, kind: "watcher" },
1108
+ };
1109
+ const presentationComponent = new CustomEntryComponent(presentationEntry, presentationRenderer);
1110
+ presentationComponent.setExpanded(expanded);
1111
+ if (
1112
+ !presentationComponent.hasContent() ||
1113
+ !presentationComponent.render(100).join("\n").includes("FIRSTMATE WATCHER WAKE")
1114
+ ) {
1115
+ throw new Error("Calm-off legacy synthetic presentation did not use a stock user-message row");
1116
+ }
1117
+
1118
+ await calmCommand.handler("", commandContext);
1119
+ if (expanded !== true || workingVisible !== true || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) {
1120
+ throw new Error("Calm did not preserve working visibility or apply its thinking and footer presentation controls");
1121
+ }
1122
+ if (readFileSync(`${process.env.FM_HOME}/config/calm`, "utf8") !== "on\n") {
1123
+ throw new Error("Calm did not persist the active choice in the effective Firstmate home");
1124
+ }
1125
+ presentationComponent.setExpanded(!expanded);
1126
+ if (presentationComponent.hasContent() || presentationComponent.render(100).length !== 0) {
1127
+ throw new Error("Calm left a synthetic Firstmate presentation row or spacer visible");
1128
+ }
1129
+ if (operationalComponent.render(100).length !== 0) {
1130
+ throw new Error("Calm left a current operational user row or its leading spacer visible");
1131
+ }
1132
+ if (legacyOperationalComponent.render(100).length !== 0) {
1133
+ throw new Error("Calm left the supported bare-marker legacy user row visible");
1134
+ }
1135
+ const operationalNearMisses = [
1136
+ {
1137
+ content: `Captain quote: ${watcherMessage}`,
1138
+ visible: "Captain quote:",
1139
+ },
1140
+ {
1141
+ content: "FIRSTMATE_OP: v1 watcher: ASCII_ONLY_CAPTAIN_MESSAGE",
1142
+ visible: "ASCII_ONLY_CAPTAIN_MESSAGE",
1143
+ },
1144
+ {
1145
+ content: `Ordinary captain text before ${watcherMessage}`,
1146
+ visible: "Ordinary captain text before",
1147
+ },
1148
+ {
1149
+ content: "\u2063ordinary captain text after an unrelated separator",
1150
+ visible: "ordinary captain text after an unrelated separator",
1151
+ },
1152
+ {
1153
+ content: "\u2063FIRSTMATE_OP: legacy untyped captain message",
1154
+ visible: "legacy untyped captain message",
1155
+ },
1156
+ {
1157
+ content: "Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.",
1158
+ visible: "before executing any other instructions",
1159
+ },
1160
+ {
1161
+ content:
1162
+ "FIRSTMATE WATCHER WAKE: captain-authored legacy-shaped message\n\n" +
1163
+ "Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.",
1164
+ visible: "captain-authored legacy-shaped message",
1165
+ },
1166
+ {
1167
+ content:
1168
+ "TURN WOULD END BLIND - supervision is off. The watcher cycle is missing, failed, or unhealthy. " +
1169
+ "Follow the harness recovery instruction below before ending the turn.\n\n" +
1170
+ "captain-authored legacy-shaped message",
1171
+ visible: "captain-authored legacy-shaped message",
1172
+ },
1173
+ {
1174
+ content: [
1175
+ { type: "text", text: watcherMessage },
1176
+ { type: "image", data: "ignored-by-text-renderer", mimeType: "image/png" },
1177
+ ],
1178
+ visible: "FIRSTMATE WATCHER WAKE",
1179
+ },
1180
+ ];
1181
+ for (const nearMiss of operationalNearMisses) {
1182
+ const chat = {
1183
+ children: [new Text("VISIBLE_PREDECESSOR", 0, 0)],
1184
+ addChild(component) {
1185
+ this.children.push(component);
1186
+ },
1187
+ };
1188
+ InteractiveMode.prototype.addMessageToChat.call(
1189
+ { ...operationalMode, chatContainer: chat },
1190
+ { role: "user", content: nearMiss.content },
1191
+ );
1192
+ const rendered = chat.children.flatMap((component) => component.render(180)).join("\n");
1193
+ if (!rendered.includes(nearMiss.visible)) {
1194
+ throw new Error(`Calm hid an operational near miss: ${nearMiss.visible}`);
1195
+ }
1196
+ }
1197
+ for (const { name, actual } of rows) {
1198
+ if (actual.render(100).length !== 0) {
1199
+ throw new Error(`${name} was not hidden before export rendering`);
1200
+ }
1201
+ }
1202
+ async function assertStockHtmlRendering(command, submitData) {
1203
+ editorText = command;
1204
+ terminalInputHandler(submitData);
1205
+ const htmlRenderer = createToolHtmlRenderer({
1206
+ getToolDefinition: (name) => tools.find((tool) => tool.name === name),
1207
+ theme,
1208
+ cwd: process.cwd(),
1209
+ });
1210
+ const exportCases = [
1211
+ ...cases.filter(([toolName]) => toolName === "grep" || toolName === "find"),
1212
+ ["fm_watch_arm_pi", watchArgs, watchResult],
1213
+ ];
1214
+ for (const [name, args, result] of exportCases) {
1215
+ const toolCallId = `${command}-${name}`;
1216
+ const callHtml = htmlRenderer.renderCall(toolCallId, name, args);
1217
+ const resultHtml = htmlRenderer.renderResult(
1218
+ toolCallId,
1219
+ name,
1220
+ result.content,
1221
+ result.details,
1222
+ result.isError,
1223
+ );
1224
+ if (!callHtml || !resultHtml?.expanded) {
1225
+ throw new Error(`${name} disappeared from ${command} HTML while calm mode was on`);
1226
+ }
1227
+ }
1228
+ editorText = "";
1229
+ await new Promise((resolve) => setTimeout(resolve, 0));
1230
+ }
1231
+
1232
+ await assertStockHtmlRendering("/export calm.html", "\r");
1233
+ getKeybindings().setUserBindings({ "tui.input.submit": "alt+s" });
1234
+ editorText = "/export remapped.html";
1235
+ terminalInputHandler("\r");
1236
+ const unmatchedRenderer = createToolHtmlRenderer({
1237
+ getToolDefinition: (name) => tools.find((tool) => tool.name === name),
1238
+ theme,
1239
+ cwd: process.cwd(),
1240
+ });
1241
+ if (unmatchedRenderer.renderCall("unmatched-submit", "grep", { pattern: "alpha", path: "." })) {
1242
+ throw new Error("ordinary non-submit input activated HTML export rendering");
1243
+ }
1244
+ editorText = "";
1245
+ await assertStockHtmlRendering("/share", "\x1bs");
1246
+ for (const { name, actual } of rows) {
1247
+ const rendered = actual.render(100);
1248
+ if (rendered.length !== 0) {
1249
+ throw new Error(`${name} left residual tool rows while calm mode was on: ${JSON.stringify(rendered)}`);
1250
+ }
1251
+ }
1252
+ const calmImageOutput = imageRow.render(100).join("\n");
1253
+ if (!calmImageOutput.includes("\x1b]1337;File=")) {
1254
+ throw new Error("calm mode hid the disclosed built-in read image boundary");
1255
+ }
1256
+ if (calmImageOutput.includes("pixel.png")) {
1257
+ throw new Error("calm mode left the built-in read call shell beside the disclosed image output");
1258
+ }
1259
+ if (!customRow.render(100).join("\n").includes("CUSTOM_CALL")) {
1260
+ throw new Error("calm mode incorrectly claimed or applied generic custom-tool coverage");
1261
+ }
1262
+ if (watchActual.render(100).length !== 0) {
1263
+ throw new Error("Calm left the fm_watch_arm_pi call/result shell visible");
1264
+ }
1265
+ if (assistantThinkingTool.render(100).length !== 0) {
1266
+ throw new Error("Calm-hidden thinking beside a tool call retained vertical height");
1267
+ }
1268
+ if (JSON.stringify(assistantThinkingText.render(100)) !== JSON.stringify(assistantTextOnly.render(100))) {
1269
+ throw new Error("Calm-hidden thinking changed final assistant row geometry");
1270
+ }
1271
+ assistantThinkingTool.setHideThinkingBlock(false);
1272
+ if (!assistantThinkingTool.render(100).join("\n").includes("HIDDEN_TOOL_THINKING")) {
1273
+ throw new Error("expanding thinking did not restore the original reasoning content");
1274
+ }
1275
+ assistantThinkingTool.setHideThinkingBlock(true);
1276
+ if (assistantThinkingTool.render(100).length !== 0) {
1277
+ throw new Error("collapsing thinking again restored residual Calm rows");
1278
+ }
1279
+ if (JSON.stringify(sessionEntries) !== entriesBefore) {
1280
+ throw new Error("calm mode changed session entries or model context");
1281
+ }
1282
+
1283
+ for (const { baseline } of rows) baseline.setExpanded(expanded);
1284
+ await calmCommand.handler("", commandContext);
1285
+ presentationComponent.setExpanded(expanded);
1286
+ if (
1287
+ !presentationComponent.hasContent() ||
1288
+ !presentationComponent.render(100).join("\n").includes("FIRSTMATE WATCHER WAKE")
1289
+ ) {
1290
+ throw new Error("turning Calm off did not restore a legacy synthetic presentation row");
1291
+ }
1292
+ if (JSON.stringify(operationalComponent.render(100)) !== JSON.stringify(expectedCalmOffOperationalRows)) {
1293
+ throw new Error("turning Calm off did not restore byte-identical operational user rows and spacing");
1294
+ }
1295
+ if (!legacyOperationalComponent.render(100).join("\n").includes("legacy presentation compatibility")) {
1296
+ throw new Error("turning Calm off did not restore the supported legacy operational row");
1297
+ }
1298
+ for (const { name, baseline, actual } of rows) {
1299
+ if (JSON.stringify(actual.render(100)) !== JSON.stringify(baseline.render(100))) {
1300
+ throw new Error(`${name} did not restore the expanded standard renderer`);
1301
+ }
1302
+ }
1303
+ if (JSON.stringify(imageRow.render(100)) !== JSON.stringify(imageVisibleBefore)) {
1304
+ throw new Error("built-in read image row did not restore its ordinary call shell and image output");
1305
+ }
1306
+ if (JSON.stringify(watchActual.render(100)) !== JSON.stringify(watchBaseline.render(100))) {
1307
+ throw new Error("fm_watch_arm_pi did not restore its stock call/result shell");
1308
+ }
1309
+ if (workingVisible !== true || hiddenThinkingLabel !== undefined || statuses.get("firstmate-calm") !== undefined) {
1310
+ throw new Error("turning Calm off did not restore stock presentation controls");
1311
+ }
1312
+ if (!assistantThinkingTool.render(100).join("\n").includes("Thinking...")) {
1313
+ throw new Error("turning Calm off did not restore the collapsed thinking label");
1314
+ }
1315
+ if (readFileSync(`${process.env.FM_HOME}/config/calm`, "utf8") !== "off\n") {
1316
+ throw new Error("Calm did not persist the inactive choice in the effective Firstmate home");
1317
+ }
1318
+ presentationComponent.setExpanded(expanded);
1319
+ if (
1320
+ !presentationComponent.hasContent() ||
1321
+ !presentationComponent.render(100).join("\n").includes("FIRSTMATE WATCHER WAKE")
1322
+ ) {
1323
+ throw new Error("turning Calm off did not restore synthetic user-row presentation");
1324
+ }
1325
+
1326
+ await calmCommand.handler("", commandContext);
1327
+ for (const reason of ["startup", "new", "resume", "fork", "reload"]) {
1328
+ await handlers.get("session_start")[0]({ reason }, commandContext);
1329
+ for (const row of rows) row.actual.setExpanded(expanded);
1330
+ for (const { name, actual } of rows) {
1331
+ if (actual.render(100).length !== 0) {
1332
+ throw new Error(`${reason} session did not retain the active Calm choice for ${name}`);
1333
+ }
1334
+ }
1335
+ if (workingVisible !== true || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) {
1336
+ throw new Error(`${reason} session did not retain gapless Calm presentation with native working visibility`);
1337
+ }
1338
+ }
1339
+ await calmCommand.handler("", commandContext);
1340
+
1341
+ const readWrapper = tools.find((tool) => tool.name === "read");
1342
+ const { createReadToolDefinition } = await import(pathToFileURL(`${packageRoot}/dist/index.js`).href);
1343
+ const originalRead = createReadToolDefinition(process.cwd());
1344
+ const executeContext = { cwd: process.cwd() };
1345
+ const [originalResult, wrappedResult] = await Promise.all([
1346
+ originalRead.execute("original-read", { path: "sample.txt" }, undefined, undefined, executeContext),
1347
+ readWrapper.execute("wrapped-read", { path: "sample.txt" }, undefined, undefined, executeContext),
1348
+ ]);
1349
+ if (JSON.stringify(wrappedResult) !== JSON.stringify(originalResult)) {
1350
+ throw new Error("calm wrapper changed built-in read execution or result data");
1351
+ }
1352
+ JS
1353
+ status=$?
1354
+ out=$(cat "$output_file")
1355
+ [ "$status" -eq 0 ] || fail "Pi calm renderer and lifecycle contract failed: $out"
1356
+ [ -z "$out" ] || fail "Pi calm renderer test printed output: $out"
1357
+ pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts"
1358
+ }
1359
+
1360
+ test_calm_mid_turn_working_notes() {
1361
+ local fixture out output_file status version
1362
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
1363
+ echo "skip: node or npm not found for Pi calm mid-turn renderer test"
1364
+ return 0
1365
+ fi
1366
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
1367
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
1368
+ return 0
1369
+ fi
1370
+ version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version")
1371
+ record_pi_version_evidence "$version" "Pi calm mid-turn presentation"
1372
+
1373
+ fixture="$TMP_ROOT/calm-mid-turn"
1374
+ mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works"
1375
+ cp "$EXT" "$fixture/fm-calm.ts"
1376
+ cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts"
1377
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts"
1378
+ cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts"
1379
+ cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts"
1380
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/lib/fm-operational-input.ts"
1381
+ ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent"
1382
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
1383
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox"
1384
+ printf '%s\n' '{"type":"module"}' >"$fixture/package.json"
1385
+
1386
+ output_file="$fixture/node-output"
1387
+ (cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module) >"$output_file" 2>&1 <<'JS'
1388
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1389
+ import { pathToFileURL } from "node:url";
1390
+
1391
+ const packageRoot = process.env.PI_PACKAGE_DIR;
1392
+ const [{ AssistantMessageComponent }, { initTheme }, { setCapabilities }] = await Promise.all([
1393
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href),
1394
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href),
1395
+ import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href),
1396
+ ]);
1397
+ initTheme("dark");
1398
+ setCapabilities({ images: null, trueColor: true, hyperlinks: false });
1399
+
1400
+ // Both extension instances below resolve their own relative "./lib/..." specifiers to
1401
+ // the same module URLs, so they share one live visibility policy exactly the way a
1402
+ // single Pi process does.
1403
+ const visibility = await import(pathToFileURL(`${process.cwd()}/lib/fm-calm-visibility.ts`).href);
1404
+ const calmPreferencePath = `${process.env.FM_HOME}/config/calm`;
1405
+ const components = [];
1406
+ const ui = {
1407
+ getEditorText: () => "",
1408
+ getToolsExpanded: () => false,
1409
+ onTerminalInput: () => () => {},
1410
+ setHiddenThinkingLabel(value) {
1411
+ // Pi's own fan-out: every mounted assistant row re-runs its layout.
1412
+ for (const component of components) component.setHiddenThinkingLabel(value ?? "Thinking...");
1413
+ },
1414
+ setStatus() {},
1415
+ setToolsExpanded() {},
1416
+ setWorkingVisible() {},
1417
+ notify() {},
1418
+ };
1419
+ const context = { ui };
1420
+
1421
+ async function loadCalmExtension() {
1422
+ const registeredTools = [];
1423
+ let sessionStart;
1424
+ let calmCommand;
1425
+ const pi = {
1426
+ events: { emit() {}, on() {} },
1427
+ on(event, handler) {
1428
+ if (event === "session_start") sessionStart = handler;
1429
+ },
1430
+ registerCommand(name, command) {
1431
+ if (name === "calm") calmCommand = command;
1432
+ },
1433
+ registerEntryRenderer() {},
1434
+ registerTool(tool) {
1435
+ registeredTools.push(tool.name);
1436
+ },
1437
+ getAllTools() {
1438
+ return [];
1439
+ },
1440
+ };
1441
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?instance=${Date.now()}-${Math.random()}`);
1442
+ extension.default(pi);
1443
+ if (!calmCommand || !sessionStart) {
1444
+ throw new Error("Calm extension did not register its command and session handler");
1445
+ }
1446
+ return { calmCommand, sessionStart, registeredTools };
1447
+ }
1448
+
1449
+ const assistantBase = {
1450
+ role: "assistant",
1451
+ api: "calm-mid-turn-test",
1452
+ provider: "calm-mid-turn-test",
1453
+ model: "deterministic",
1454
+ usage: {
1455
+ input: 0,
1456
+ output: 0,
1457
+ cacheRead: 0,
1458
+ cacheWrite: 0,
1459
+ totalTokens: 0,
1460
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1461
+ },
1462
+ timestamp: 1,
1463
+ };
1464
+ const toolCall = { type: "toolCall", id: "calm-mid-turn-tool", name: "read", arguments: { path: "sample.txt" } };
1465
+ const messages = {
1466
+ // The reported incident: narration emitted in the same assistant message as a tool call.
1467
+ midTurn: {
1468
+ ...assistantBase,
1469
+ stopReason: "toolUse",
1470
+ content: [{ type: "text", text: "MIDTURN_WORKING_NOTE" }, toolCall],
1471
+ },
1472
+ // The genuine reply that ends a response, which Calm never hides.
1473
+ finalReply: {
1474
+ ...assistantBase,
1475
+ stopReason: "stop",
1476
+ content: [{ type: "text", text: "FINAL_REPLY_TEXT" }],
1477
+ },
1478
+ // Still streaming: finality is unknown, and hiding here would stop a real reply.
1479
+ streaming: {
1480
+ ...assistantBase,
1481
+ stopReason: "pending",
1482
+ content: [{ type: "text", text: "STREAMING_NOTE_TEXT" }],
1483
+ },
1484
+ // Truncated with tool calls is mid-turn; Pi's own truncation notice stays.
1485
+ truncatedMidTurn: {
1486
+ ...assistantBase,
1487
+ stopReason: "length",
1488
+ content: [{ type: "text", text: "TRUNCATED_MIDTURN_NOTE" }, toolCall],
1489
+ },
1490
+ // Truncated without tool calls ended the response.
1491
+ truncatedFinal: {
1492
+ ...assistantBase,
1493
+ stopReason: "length",
1494
+ content: [{ type: "text", text: "TRUNCATED_FINAL_TEXT" }],
1495
+ },
1496
+ };
1497
+ const messagesBefore = JSON.stringify(messages);
1498
+ const rows = {};
1499
+ for (const [name, message] of Object.entries(messages)) {
1500
+ rows[name] = new AssistantMessageComponent(message, true);
1501
+ components.push(rows[name]);
1502
+ }
1503
+ const rendered = (name) => rows[name].render(100);
1504
+ const renderedText = (name) => rendered(name).join("\n");
1505
+ const snapshot = () => {
1506
+ const shot = {};
1507
+ for (const name of Object.keys(rows)) shot[name] = JSON.stringify(rendered(name));
1508
+ return shot;
1509
+ };
1510
+ const requireVisible = (name, needle, context) => {
1511
+ if (rendered(name).length === 0 || !renderedText(name).includes(needle)) {
1512
+ throw new Error(`${context}: ${name} lost ${needle}`);
1513
+ }
1514
+ };
1515
+ const requireHidden = (name, needle, context) => {
1516
+ if (renderedText(name).includes(needle)) {
1517
+ throw new Error(`${context}: ${name} still rendered ${needle}`);
1518
+ }
1519
+ };
1520
+
1521
+ let calm = await loadCalmExtension();
1522
+ if (calm.registeredTools.length !== 0) {
1523
+ throw new Error("Calm claimed built-in tools with no persisted preference");
1524
+ }
1525
+ await calm.sessionStart({ reason: "startup" }, context);
1526
+ const stockRows = snapshot();
1527
+ for (const name of Object.keys(rows)) {
1528
+ if (rendered(name).length === 0) throw new Error(`Calm-off rendering hid ${name}`);
1529
+ }
1530
+ requireVisible("midTurn", "MIDTURN_WORKING_NOTE", "Calm off");
1531
+
1532
+ await calm.calmCommand.handler("", context);
1533
+ if (readFileSync(calmPreferencePath, "utf8") !== "on\n") {
1534
+ throw new Error("plain /calm from off did not persist on");
1535
+ }
1536
+ if (rendered("midTurn").length !== 0) {
1537
+ throw new Error(`Calm on left mid-turn working-note rows: ${JSON.stringify(rendered("midTurn"))}`);
1538
+ }
1539
+ requireHidden("truncatedMidTurn", "TRUNCATED_MIDTURN_NOTE", "Calm on");
1540
+ // Pi owns the wording of its truncation notice; Calm must leave that row's own notice
1541
+ // standing rather than collapsing an incomplete response to nothing.
1542
+ if (rendered("truncatedMidTurn").length === 0) {
1543
+ throw new Error("Calm on removed Pi's own truncation notice with the working note");
1544
+ }
1545
+ requireVisible("streaming", "STREAMING_NOTE_TEXT", "Calm on");
1546
+ requireVisible("truncatedFinal", "TRUNCATED_FINAL_TEXT", "Calm on");
1547
+ requireVisible("finalReply", "FINAL_REPLY_TEXT", "Calm on");
1548
+ if (JSON.stringify(rendered("finalReply")) !== stockRows.finalReply) {
1549
+ throw new Error("Calm on changed the genuine final reply row");
1550
+ }
1551
+ if (JSON.stringify(messages) !== messagesBefore) {
1552
+ throw new Error("Calm on mutated the assistant messages instead of a presentation copy");
1553
+ }
1554
+
1555
+ // The removed third level: /calm parses no argument, so every invocation is the plain
1556
+ // on/off toggle and no third literal is ever persisted.
1557
+ await calm.calmCommand.handler("max", context);
1558
+ if (readFileSync(calmPreferencePath, "utf8") !== "off\n") {
1559
+ throw new Error("/calm max was still read as a level instead of the plain toggle");
1560
+ }
1561
+ requireVisible("midTurn", "MIDTURN_WORKING_NOTE", "Calm off after /calm max");
1562
+ const restoredRows = snapshot();
1563
+ for (const name of Object.keys(rows)) {
1564
+ if (restoredRows[name] !== stockRows[name]) {
1565
+ throw new Error(`turning Calm off did not restore byte-identical ${name} rendering`);
1566
+ }
1567
+ }
1568
+ await calm.calmCommand.handler(" MaX ", context);
1569
+ if (readFileSync(calmPreferencePath, "utf8") !== "on\n" || rendered("midTurn").length !== 0) {
1570
+ throw new Error("a spaced, mixed-case argument did not fall through to the plain toggle");
1571
+ }
1572
+ await calm.calmCommand.handler("unrecognized", context);
1573
+ if (readFileSync(calmPreferencePath, "utf8") !== "off\n") {
1574
+ throw new Error("an unrecognized /calm argument did not fall back to the plain toggle");
1575
+ }
1576
+
1577
+ // Restart from each persisted value, including the legacy "max" a home upgraded from
1578
+ // the removed third level still carries: every one restores ordinary Calm, never off.
1579
+ for (const persisted of ["on\n", "max\n", "max"]) {
1580
+ writeFileSync(calmPreferencePath, persisted, "utf8");
1581
+ // Scramble the live state the way a fresh process starts, then let a newly loaded
1582
+ // extension restore from the persisted file alone.
1583
+ visibility.setCalmPresentation(false);
1584
+ ui.setHiddenThinkingLabel(undefined);
1585
+ requireVisible("midTurn", "MIDTURN_WORKING_NOTE", "scrambled live state");
1586
+ calm = await loadCalmExtension();
1587
+ if (calm.registeredTools.length !== 7) {
1588
+ throw new Error(
1589
+ `a session restored from ${JSON.stringify(persisted)} claimed ${calm.registeredTools.length} built-in tools instead of 7`,
1590
+ );
1591
+ }
1592
+ for (const reason of ["startup", "resume", "new", "fork", "reload"]) {
1593
+ await calm.sessionStart({ reason }, context);
1594
+ if (rendered("midTurn").length !== 0) {
1595
+ throw new Error(
1596
+ `a ${reason} session restored from ${JSON.stringify(persisted)} did not hide mid-turn working notes`,
1597
+ );
1598
+ }
1599
+ requireVisible("finalReply", "FINAL_REPLY_TEXT", `${reason} session`);
1600
+ }
1601
+ // A session restored as on toggles to off; one that had wrongly dropped to off would
1602
+ // persist "on" here instead.
1603
+ await calm.calmCommand.handler("", context);
1604
+ if (readFileSync(calmPreferencePath, "utf8") !== "off\n") {
1605
+ throw new Error(`${JSON.stringify(persisted)} did not restore as ordinary Calm on`);
1606
+ }
1607
+ requireVisible("midTurn", "MIDTURN_WORKING_NOTE", "Calm toggled off after restore");
1608
+ }
1609
+ if (!existsSync(calmPreferencePath)) {
1610
+ throw new Error("Calm stopped persisting its preference file");
1611
+ }
1612
+ JS
1613
+ status=$?
1614
+ out=$(cat "$output_file")
1615
+ [ "$status" -eq 0 ] || fail "Pi calm mid-turn contract failed: $out"
1616
+ [ -z "$out" ] || fail "Pi calm mid-turn test printed output: $out"
1617
+ pass "Pi calm on collapses mid-turn assistant working notes to zero height while Calm off keeps them, leaves streaming, truncated-final, and genuine final replies untouched, never mutates the messages, ignores every /calm argument, and restores a legacy persisted max as ordinary Calm on"
1618
+ }
1619
+
1620
+ test_operational_followup_turn_e2e() {
1621
+ local project home config sessions version label case_name calm_state expected_notifications session_file pane i captain_line handled_line geometry_gap exact_session
1622
+ if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then
1623
+ echo "skip: pi or tmux not found for Pi operational follow-up E2E"
1624
+ return 0
1625
+ fi
1626
+ version=$(pi --version 2>/dev/null || true)
1627
+ record_pi_version_evidence "$version" "Pi operational follow-up E2E"
1628
+
1629
+ project="$TMP_ROOT/followup-project"
1630
+ home="$TMP_ROOT/followup-home"
1631
+ config="$TMP_ROOT/followup-config"
1632
+ sessions="$TMP_ROOT/followup-sessions"
1633
+ mkdir -p "$project/.pi/extensions/lib" "$home/config" "$config" "$sessions"
1634
+ fm_git_init_commit "$project"
1635
+ cp "$EXT" "$project/.pi/extensions/fm-calm.ts"
1636
+ cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
1637
+ cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
1638
+ cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts"
1639
+ cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts"
1640
+ cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts"
1641
+ printf '%s\n' '{"followUpMode":"all"}' >"$config/settings.json"
1642
+
1643
+ cat >"$project/followup-e2e.ts" <<'TS'
1644
+ import {
1645
+ type AssistantMessage,
1646
+ createAssistantMessageEventStream,
1647
+ } from "@earendil-works/pi-ai";
1648
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1649
+ import { encodeFirstmateOperationalInput } from "./.pi/extensions/lib/fm-operational-input.ts";
1650
+
1651
+ let phase: "idle" | "captain" | "monitor" = "idle";
1652
+ let label = "";
1653
+ let adjacent = false;
1654
+ let latestInputRole: "user" | "custom" | undefined;
1655
+
1656
+ const EXACT_WATCHER_INPUT =
1657
+ "\u2063FIRSTMATE_OP: v1 watcher: FIRSTMATE WATCHER WAKE: signal: /home/fixture/github/kunchenguid/firstmate/state/oss-triage-t4.status\n\n" +
1658
+ "Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.";
1659
+
1660
+ function monitorInput(suffix: "ONE" | "TWO"): string {
1661
+ if (label === "exact_watcher" && suffix === "ONE") return EXACT_WATCHER_INPUT;
1662
+ if (label === "legacy_away" && suffix === "ONE") {
1663
+ return "\u2063Supervisor escalate (LEGACY_AWAY_E2E)";
1664
+ }
1665
+ return encodeFirstmateOperationalInput("watcher", `MONITOR_${label}_${suffix}`);
1666
+ }
1667
+
1668
+ function contentText(content: unknown): string {
1669
+ if (typeof content === "string") return content;
1670
+ if (!Array.isArray(content)) return "";
1671
+ return content
1672
+ .filter((item): item is { type: "text"; text: string } =>
1673
+ typeof item === "object" && item !== null &&
1674
+ (item as { type?: unknown }).type === "text" &&
1675
+ typeof (item as { text?: unknown }).text === "string")
1676
+ .map((item) => item.text)
1677
+ .join("\n");
1678
+ }
1679
+
1680
+ export default function (pi: ExtensionAPI): void {
1681
+ pi.on("message_start", (event) => {
1682
+ if (event.message.role === "user" || event.message.role === "custom") {
1683
+ latestInputRole = event.message.role;
1684
+ }
1685
+ if (event.message.role !== "assistant" || phase !== "captain") return;
1686
+ phase = "monitor";
1687
+ pi.sendUserMessage(monitorInput("ONE"), { deliverAs: "followUp" });
1688
+ if (adjacent) {
1689
+ pi.sendUserMessage(monitorInput("TWO"), { deliverAs: "followUp" });
1690
+ }
1691
+ });
1692
+
1693
+ pi.registerProvider("followup-e2e", {
1694
+ baseUrl: "http://127.0.0.1/unused",
1695
+ apiKey: "test-only",
1696
+ api: "followup-e2e-api",
1697
+ models: [{
1698
+ id: "deterministic",
1699
+ name: "Deterministic operational follow-up regression",
1700
+ reasoning: false,
1701
+ input: ["text"],
1702
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1703
+ contextWindow: 4096,
1704
+ maxTokens: 128,
1705
+ }],
1706
+ streamSimple(model, context) {
1707
+ const stream = createAssistantMessageEventStream();
1708
+ const allUserText = context.messages
1709
+ .filter((message) => message.role === "user")
1710
+ .map((message) => contentText(message.content))
1711
+ .join("\n");
1712
+ const responseText = latestInputRole === "custom"
1713
+ ? `CAPTAIN_ANSWER_${label}`
1714
+ : allUserText.includes(monitorInput("ONE"))
1715
+ ? adjacent && allUserText.includes(monitorInput("TWO"))
1716
+ ? `MONITOR_HANDLED_${label}_ONE_TWO`
1717
+ : `MONITOR_HANDLED_${label}_ONE`
1718
+ : `CAPTAIN_ANSWER_${label}`;
1719
+ const output: AssistantMessage = {
1720
+ role: "assistant",
1721
+ content: [],
1722
+ api: model.api,
1723
+ provider: model.provider,
1724
+ model: model.id,
1725
+ usage: {
1726
+ input: 0,
1727
+ output: 0,
1728
+ cacheRead: 0,
1729
+ cacheWrite: 0,
1730
+ totalTokens: 0,
1731
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1732
+ },
1733
+ stopReason: "stop",
1734
+ timestamp: Date.now(),
1735
+ };
1736
+ queueMicrotask(() => {
1737
+ stream.push({ type: "start", partial: output });
1738
+ const block = { type: "text" as const, text: responseText };
1739
+ output.content.push(block);
1740
+ stream.push({ type: "text_start", contentIndex: 0, partial: output });
1741
+ stream.push({ type: "text_delta", contentIndex: 0, delta: responseText, partial: output });
1742
+ stream.push({ type: "text_end", contentIndex: 0, content: responseText, partial: output });
1743
+ stream.push({ type: "done", reason: "stop", message: output });
1744
+ stream.end();
1745
+ });
1746
+ return stream;
1747
+ },
1748
+ });
1749
+
1750
+ pi.registerCommand("followup-e2e", {
1751
+ description: "Run one captain prompt followed by typed monitoring input.",
1752
+ handler: async (args, ctx) => {
1753
+ const [nextLabel, shape] = args.trim().split(/\s+/);
1754
+ if (!nextLabel) throw new Error("missing follow-up E2E label");
1755
+ const model = ctx.modelRegistry.find("followup-e2e", "deterministic");
1756
+ if (!model || !(await pi.setModel(model))) throw new Error("follow-up E2E model unavailable");
1757
+ label = nextLabel;
1758
+ adjacent = shape === "adjacent";
1759
+ phase = "captain";
1760
+ pi.sendUserMessage(`CAPTAIN_PROMPT_${label}`);
1761
+ },
1762
+ });
1763
+ }
1764
+ TS
1765
+
1766
+ run_followup_case() {
1767
+ case_name=$1
1768
+ calm_state=$2
1769
+ label=$3
1770
+ expected_notifications=$4
1771
+ local session_arg=${5:-}
1772
+ local shape=${6:-single}
1773
+ local extensions
1774
+
1775
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
1776
+ if [ "$calm_state" = absent ]; then
1777
+ rm -f "$home/config/calm"
1778
+ extensions='-e ./followup-e2e.ts'
1779
+ elif [ "$calm_state" = default ]; then
1780
+ rm -f "$home/config/calm"
1781
+ extensions='-e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts'
1782
+ else
1783
+ printf '%s\n' "$calm_state" >"$home/config/calm"
1784
+ extensions='-e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts'
1785
+ fi
1786
+ if [ -z "$session_arg" ]; then
1787
+ session_arg="--session-dir '$sessions/$label'"
1788
+ mkdir -p "$sessions/$label"
1789
+ else
1790
+ session_arg="--session '$session_arg'"
1791
+ fi
1792
+
1793
+ tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \
1794
+ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions $extensions $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20"
1795
+ i=0
1796
+ while [ "$i" -lt 120 ]; do
1797
+ pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true)
1798
+ printf '%s\n' "$pane" | grep -Fq 'followup-e2e.ts' && break
1799
+ sleep 0.05
1800
+ i=$((i + 1))
1801
+ done
1802
+ printf '%s\n' "$pane" | grep -Fq 'followup-e2e.ts' \
1803
+ || fail "Pi follow-up $case_name case ($label) did not reach the ready composer"
1804
+
1805
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/followup-e2e $label $shape"
1806
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
1807
+ i=0
1808
+ while [ "$i" -lt 240 ]; do
1809
+ session_file=$(find "$sessions" -type f -name '*.jsonl' -exec grep -l "CAPTAIN_PROMPT_$label" {} + 2>/dev/null | head -1 || true)
1810
+ if [ -n "$session_file" ] && grep -Fq "MONITOR_HANDLED_${label}_ONE" "$session_file"; then
1811
+ break
1812
+ fi
1813
+ sleep 0.05
1814
+ i=$((i + 1))
1815
+ done
1816
+ if [ -z "$session_file" ] || ! grep -Fq "MONITOR_HANDLED_${label}_ONE" "$session_file"; then
1817
+ fail "Pi follow-up $label case did not process the monitoring notification"
1818
+ fi
1819
+
1820
+ pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true)
1821
+ [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \
1822
+ || fail "Pi follow-up $label case rendered a duplicate captain answer"
1823
+ assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt"
1824
+ assert_contains "$pane" "MONITOR_HANDLED_${label}_ONE" "Pi follow-up $label case did not render the intended processing result"
1825
+ if [ "$calm_state" = on ]; then
1826
+ assert_not_contains "$pane" "MONITOR_${label}_ONE" "Pi follow-up $label case rendered a Calm-hidden operational user row"
1827
+ if [ "$label" = exact_watcher ]; then
1828
+ assert_not_contains "$pane" "FIRSTMATE WATCHER WAKE: signal: /home/fixture/github/kunchenguid/firstmate/state/oss-triage-t4.status" \
1829
+ "Pi exact watcher case rendered the Calm-hidden authoritative payload"
1830
+ assert_not_contains "$pane" "Run bin/fm-wake-drain.sh first and handle the queued wake." \
1831
+ "Pi exact watcher case rendered the Calm-hidden drain instruction"
1832
+ elif [ "$label" = legacy_away ]; then
1833
+ assert_not_contains "$pane" "LEGACY_AWAY_E2E" \
1834
+ "Pi legacy-away case rendered the narrowly supported Calm-hidden input"
1835
+ fi
1836
+ if [ "$expected_notifications" -eq 2 ]; then
1837
+ assert_not_contains "$pane" "MONITOR_${label}_TWO" "Pi follow-up $label case rendered the adjacent Calm-hidden operational row"
1838
+ fi
1839
+ captain_line=$(printf '%s\n' "$pane" | grep -Fn "CAPTAIN_ANSWER_$label" | tail -1 | cut -d: -f1)
1840
+ handled_line=$(printf '%s\n' "$pane" | grep -Fn "MONITOR_HANDLED_${label}_ONE" | tail -1 | cut -d: -f1)
1841
+ geometry_gap=$((handled_line - captain_line))
1842
+ [ "$geometry_gap" -eq 2 ] \
1843
+ || fail "Pi follow-up $label case consumed $geometry_gap rows between neighboring assistant text instead of the two-row visible-only geometry"
1844
+ else
1845
+ assert_contains "$pane" "MONITOR_${label}_ONE" "Pi follow-up $label case lost the Calm-off operational user row"
1846
+ if [ "$expected_notifications" -eq 2 ]; then
1847
+ assert_contains "$pane" "MONITOR_${label}_TWO" "Pi follow-up $label case lost the adjacent Calm-off operational user row"
1848
+ fi
1849
+ fi
1850
+
1851
+ node - "$session_file" "$label" "$expected_notifications" <<'JS' \
1852
+ || fail "Pi follow-up $label persisted the wrong turn or input semantics"
1853
+ const fs = require("node:fs");
1854
+ const [file, label, expectedRaw] = process.argv.slice(2);
1855
+ const expected = Number(expectedRaw);
1856
+ const entries = fs.readFileSync(file, "utf8").trim().split("\n").map(JSON.parse);
1857
+ const text = (content) => typeof content === "string"
1858
+ ? content
1859
+ : (content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
1860
+ const captainPrompt = `CAPTAIN_PROMPT_${label}`;
1861
+ const captainAnswer = `CAPTAIN_ANSWER_${label}`;
1862
+ const handled = expected === 2
1863
+ ? `MONITOR_HANDLED_${label}_ONE_TWO`
1864
+ : `MONITOR_HANDLED_${label}_ONE`;
1865
+ const expectedOperationalTexts = Array.from({ length: expected }, (_, index) => {
1866
+ const suffix = index === 0 ? "ONE" : "TWO";
1867
+ return label === "exact_watcher" && suffix === "ONE"
1868
+ ? "\u2063FIRSTMATE_OP: v1 watcher: FIRSTMATE WATCHER WAKE: signal: /home/fixture/github/kunchenguid/firstmate/state/oss-triage-t4.status\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned."
1869
+ : label === "legacy_away" && suffix === "ONE"
1870
+ ? "\u2063Supervisor escalate (LEGACY_AWAY_E2E)"
1871
+ : `\u2063FIRSTMATE_OP: v1 watcher: MONITOR_${label}_${suffix}`;
1872
+ });
1873
+ const matching = entries.filter((entry) => {
1874
+ const entryText = entry.type === "message"
1875
+ ? text(entry.message.content)
1876
+ : entry.type === "custom_message"
1877
+ ? text(entry.content)
1878
+ : "";
1879
+ return entryText.includes(label) || expectedOperationalTexts.includes(entryText);
1880
+ });
1881
+ const userEntries = matching.filter((entry) => entry.type === "message" && entry.message.role === "user");
1882
+ const customEntries = matching.filter((entry) => entry.type === "custom_message");
1883
+ const assistantEntries = matching.filter((entry) => entry.type === "message" && entry.message.role === "assistant");
1884
+ const assistantText = assistantEntries.map((entry) => text(entry.message.content));
1885
+ if (customEntries.length !== 0) throw new Error(`operational input was rerouted: ${JSON.stringify(customEntries)}`);
1886
+ if (userEntries.length !== expected + 1) throw new Error(`expected ${expected + 1} user inputs, found ${userEntries.length}`);
1887
+ if (text(userEntries[0].message.content) !== captainPrompt) throw new Error("genuine captain prompt changed");
1888
+ for (let index = 1; index <= expected; index += 1) {
1889
+ const exact = expectedOperationalTexts[index - 1];
1890
+ if (text(userEntries[index].message.content) !== exact) throw new Error(`operational origin changed: ${text(userEntries[index].message.content)}`);
1891
+ }
1892
+ if (assistantText.filter((value) => value === captainAnswer).length !== 1) {
1893
+ throw new Error(`captain answer count changed: ${JSON.stringify(assistantText)}`);
1894
+ }
1895
+ if (assistantText.filter((value) => value === handled).length !== 1) {
1896
+ throw new Error(`monitor processing count changed: ${JSON.stringify(assistantText)}`);
1897
+ }
1898
+ if (assistantEntries.length !== 2) throw new Error(`expected one captain and one processing turn, found ${assistantEntries.length}`);
1899
+ const positions = matching.map((entry) => entries.indexOf(entry));
1900
+ if (positions.some((position, index) => index > 0 && position <= positions[index - 1])) {
1901
+ throw new Error(`turn ordering changed: ${positions.join(",")}`);
1902
+ }
1903
+ JS
1904
+
1905
+ if [ "$calm_state" = on ] || [ "$calm_state" = off ]; then
1906
+ [ "$(cat "$home/config/calm")" = "$calm_state" ] \
1907
+ || fail "Pi follow-up $label case changed the persisted Calm choice"
1908
+ elif [ "$calm_state" = default ]; then
1909
+ [ ! -e "$home/config/calm" ] \
1910
+ || fail "Pi follow-up $label case persisted a default Calm choice without a toggle"
1911
+ fi
1912
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit'
1913
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
1914
+ sleep 0.2
1915
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
1916
+ }
1917
+
1918
+ replay_exact_case() {
1919
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
1920
+ printf '%s\n' on >"$home/config/calm"
1921
+ tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \
1922
+ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts --session '$exact_session'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20"
1923
+ i=0
1924
+ while [ "$i" -lt 120 ]; do
1925
+ pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true)
1926
+ printf '%s\n' "$pane" | grep -Fq 'MONITOR_HANDLED_exact_watcher_ONE' && break
1927
+ sleep 0.05
1928
+ i=$((i + 1))
1929
+ done
1930
+ assert_contains "$pane" "CAPTAIN_PROMPT_exact_watcher" "Pi restart lost the genuine captain prompt"
1931
+ assert_contains "$pane" "MONITOR_HANDLED_exact_watcher_ONE" "Pi restart lost the operational processing response"
1932
+ assert_not_contains "$pane" "FIRSTMATE WATCHER WAKE: signal: /home/fixture/github/kunchenguid/firstmate/state/oss-triage-t4.status" \
1933
+ "Pi restart replayed the Calm-hidden exact watcher row"
1934
+ captain_line=$(printf '%s\n' "$pane" | grep -Fn 'CAPTAIN_ANSWER_exact_watcher' | tail -1 | cut -d: -f1)
1935
+ handled_line=$(printf '%s\n' "$pane" | grep -Fn 'MONITOR_HANDLED_exact_watcher_ONE' | tail -1 | cut -d: -f1)
1936
+ geometry_gap=$((handled_line - captain_line))
1937
+ [ "$geometry_gap" -eq 2 ] \
1938
+ || fail "Pi restart replay consumed $geometry_gap rows between neighboring assistant text"
1939
+ node - "$exact_session" <<'JS' || fail "Pi restart replay changed exact watcher persistence"
1940
+ const fs = require("node:fs");
1941
+ const entries = fs.readFileSync(process.argv[2], "utf8").trim().split("\n").map(JSON.parse);
1942
+ const text = (content) => typeof content === "string"
1943
+ ? content
1944
+ : (content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("");
1945
+ const exact = "\u2063FIRSTMATE_OP: v1 watcher: FIRSTMATE WATCHER WAKE: signal: /home/fixture/github/kunchenguid/firstmate/state/oss-triage-t4.status\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.";
1946
+ const users = entries.filter((entry) => entry.type === "message" && entry.message.role === "user" && text(entry.message.content) === exact);
1947
+ const responses = entries.filter((entry) => entry.type === "message" && entry.message.role === "assistant" && text(entry.message.content) === "MONITOR_HANDLED_exact_watcher_ONE");
1948
+ if (users.length !== 1 || responses.length !== 1) {
1949
+ throw new Error(`restart changed exactly-once entries: users=${users.length} responses=${responses.length}`);
1950
+ }
1951
+ JS
1952
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit'
1953
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
1954
+ sleep 0.2
1955
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
1956
+ }
1957
+
1958
+ run_followup_case loaded-on on loaded_on 1
1959
+ run_followup_case exact-watcher on exact_watcher 1
1960
+ exact_session=$session_file
1961
+ replay_exact_case
1962
+ run_followup_case legacy-away on legacy_away 1
1963
+ run_followup_case loaded-off off loaded_off 1
1964
+ run_followup_case loaded-default default loaded_default 1
1965
+ run_followup_case extension-absent absent absent 1
1966
+ run_followup_case adjacent on adjacent 2 '' adjacent
1967
+ run_followup_case restart-before on restart_before 1
1968
+ local restart_session=$session_file
1969
+ run_followup_case restart-after on restart_after 1 "$restart_session"
1970
+ pass "Pi operational follow-up E2E processes exact user-role notifications once while Calm hides current and adjacent rows, Calm off and absent render them, and restart preserves semantics"
1971
+ }
1972
+
1973
+ test_hidden_block_geometry_e2e() {
1974
+ local project home config sessions session_file snapshot expanded_snapshot calm_off_snapshot restarted_snapshot
1975
+ local version skill_line final_line gap i
1976
+ if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then
1977
+ echo "skip: pi or tmux not found for Pi Calm hidden-block geometry E2E"
1978
+ return 0
1979
+ fi
1980
+ version=$(pi --version 2>/dev/null || true)
1981
+ record_pi_version_evidence "$version" "Pi Calm hidden-block geometry E2E"
1982
+
1983
+ project="$TMP_ROOT/geometry-project"
1984
+ home="$TMP_ROOT/geometry-home"
1985
+ config="$TMP_ROOT/geometry-config"
1986
+ sessions="$TMP_ROOT/geometry-sessions"
1987
+ snapshot="$TMP_ROOT/geometry-calm-on.txt"
1988
+ expanded_snapshot="$TMP_ROOT/geometry-expanded.txt"
1989
+ calm_off_snapshot="$TMP_ROOT/geometry-calm-off.txt"
1990
+ restarted_snapshot="$TMP_ROOT/geometry-restarted.txt"
1991
+ mkdir -p \
1992
+ "$project/.agents/skills/ahoy" \
1993
+ "$project/.pi/extensions/lib" \
1994
+ "$home/config" \
1995
+ "$config" \
1996
+ "$sessions"
1997
+ fm_git_init_commit "$project"
1998
+ cp "$EXT" "$project/.pi/extensions/fm-calm.ts"
1999
+ cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
2000
+ cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
2001
+ cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts"
2002
+ cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts"
2003
+ cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts"
2004
+ printf '%s\n' on >"$home/config/calm"
2005
+ printf '%s\n' '{"hideThinkingBlock":true,"terminal":{"clearOnShrink":false}}' >"$config/settings.json"
2006
+ printf '%s\n' 'tool result one' >"$project/probe-one.txt"
2007
+ printf '%s\n' 'tool result two' >"$project/probe-two.txt"
2008
+ cat >"$project/.agents/skills/ahoy/SKILL.md" <<'MD'
2009
+ ---
2010
+ name: ahoy
2011
+ description: Deterministic Calm hidden-block geometry probe.
2012
+ ---
2013
+
2014
+ # Ahoy
2015
+
2016
+ Read both probe files, then return the final response.
2017
+ MD
2018
+ cat >"$project/geometry-provider.ts" <<'TS'
2019
+ import {
2020
+ createFauxCore,
2021
+ fauxAssistantMessage,
2022
+ fauxThinking,
2023
+ fauxText,
2024
+ fauxToolCall,
2025
+ } from "@earendil-works/pi-ai";
2026
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2027
+
2028
+ export default function (pi: ExtensionAPI): void {
2029
+ const faux = createFauxCore({
2030
+ api: "calm-geometry-e2e-api",
2031
+ provider: "calm-geometry-e2e",
2032
+ models: [{
2033
+ id: "deterministic",
2034
+ name: "Calm hidden-block geometry E2E",
2035
+ reasoning: true,
2036
+ input: ["text"],
2037
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2038
+ contextWindow: 4096,
2039
+ maxTokens: 128,
2040
+ }],
2041
+ tokenSize: { min: 1, max: 1 },
2042
+ });
2043
+ faux.setResponses([
2044
+ fauxAssistantMessage([
2045
+ fauxThinking("CALM_GEOMETRY_THINKING_ONE"),
2046
+ fauxToolCall("read", { path: "probe-one.txt" }, { id: "calm_geometry_read_one" }),
2047
+ ], { stopReason: "toolUse" }),
2048
+ fauxAssistantMessage([
2049
+ fauxThinking("CALM_GEOMETRY_THINKING_TWO"),
2050
+ fauxToolCall("read", { path: "probe-two.txt" }, { id: "calm_geometry_read_two" }),
2051
+ ], { stopReason: "toolUse" }),
2052
+ fauxAssistantMessage([
2053
+ fauxThinking("CALM_GEOMETRY_FINAL_THINKING"),
2054
+ fauxText("CALM_GEOMETRY_FINAL\n\n- visible row one\n- visible row two"),
2055
+ ]),
2056
+ ]);
2057
+ pi.registerProvider("calm-geometry-e2e", {
2058
+ baseUrl: "http://127.0.0.1/unused",
2059
+ apiKey: "test-only",
2060
+ api: faux.api,
2061
+ models: faux.models,
2062
+ streamSimple: faux.streamSimple,
2063
+ });
2064
+ pi.registerCommand("calm-geometry-e2e", {
2065
+ description: "Select the deterministic Calm hidden-block geometry model.",
2066
+ handler: async (_args, ctx) => {
2067
+ const model = ctx.modelRegistry.find("calm-geometry-e2e", "deterministic");
2068
+ if (!model || !(await pi.setModel(model))) {
2069
+ throw new Error("Calm hidden-block geometry model unavailable");
2070
+ }
2071
+ },
2072
+ });
2073
+ }
2074
+ TS
2075
+
2076
+ start_geometry_pi() {
2077
+ local session_arg=$1
2078
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
2079
+ tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 100 -y 44 \
2080
+ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' PI_OFFLINE=1 pi --approve --no-context-files --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./geometry-provider.ts $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20"
2081
+ }
2082
+
2083
+ capture_geometry_viewport() {
2084
+ local file=$1
2085
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$file" 2>/dev/null
2086
+ }
2087
+
2088
+ wait_for_geometry_text() {
2089
+ local file=$1 text=$2 attempt=0
2090
+ while [ "$attempt" -lt 120 ]; do
2091
+ capture_geometry_viewport "$file" || true
2092
+ grep -Fq "$text" "$file" 2>/dev/null && return 0
2093
+ sleep 0.05
2094
+ attempt=$((attempt + 1))
2095
+ done
2096
+ return 1
2097
+ }
2098
+
2099
+ wait_for_geometry_transition() {
2100
+ local file=$1 transient_text=$2 final_text=$3 attempt=0 saw_transient=0
2101
+ while [ "$attempt" -lt 600 ]; do
2102
+ capture_geometry_viewport "$file" || true
2103
+ if grep -Fq "$transient_text" "$file" 2>/dev/null; then
2104
+ saw_transient=1
2105
+ elif [ "$saw_transient" -eq 1 ] && grep -Fq "$final_text" "$file" 2>/dev/null; then
2106
+ return 0
2107
+ fi
2108
+ sleep 0.01
2109
+ attempt=$((attempt + 1))
2110
+ done
2111
+ return 1
2112
+ }
2113
+
2114
+ assert_geometry_gap() {
2115
+ local file=$1 label=$2
2116
+ skill_line=$(grep -n -m1 '\[skill\] ahoy' "$file" | cut -d: -f1)
2117
+ final_line=$(grep -n -m1 'CALM_GEOMETRY_FINAL' "$file" | cut -d: -f1)
2118
+ [ -n "$skill_line" ] && [ -n "$final_line" ] \
2119
+ || fail "$label did not render the collapsed skill row and final assistant response"
2120
+ gap=$((final_line - skill_line - 1))
2121
+ [ "$gap" -eq 2 ] \
2122
+ || fail "$label left $gap rows between the collapsed skill row and final response instead of the two standard visible-row separators"
2123
+ }
2124
+
2125
+ start_geometry_pi "--session-dir '$sessions'"
2126
+ wait_for_geometry_text "$snapshot" "geometry-provider.ts" \
2127
+ || fail "Pi Calm hidden-block geometry E2E did not reach the ready composer"
2128
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm-geometry-e2e'
2129
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2130
+ sleep 0.1
2131
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/skill:ahoy'
2132
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2133
+ wait_for_geometry_text "$snapshot" "visible row two" \
2134
+ || fail "Pi Calm hidden-block geometry E2E did not complete the /skill:ahoy turn"
2135
+ i=0
2136
+ while [ "$i" -lt 120 ]; do
2137
+ capture_geometry_viewport "$snapshot"
2138
+ tail -12 "$snapshot" | grep -Fq "Working..." || break
2139
+ sleep 0.05
2140
+ i=$((i + 1))
2141
+ done
2142
+ assert_contains "$(cat "$snapshot")" "[skill] ahoy" "Calm hid the collapsed skill header"
2143
+ assert_contains "$(cat "$snapshot")" "CALM_GEOMETRY_FINAL" "Calm hid the final assistant response"
2144
+ assert_not_contains "$(cat "$snapshot")" "Thinking..." "Calm left a collapsed thinking label visible"
2145
+ assert_not_contains "$(cat "$snapshot")" "probe-one.txt" "Calm left a tool-call row visible"
2146
+ assert_not_contains "$(cat "$snapshot")" "tool result one" "Calm left a tool-result row visible"
2147
+ assert_geometry_gap "$snapshot" "completed native Calm /skill:ahoy turn"
2148
+
2149
+ session_file=$(find "$sessions" -type f -name '*.jsonl' -exec grep -l 'CALM_GEOMETRY_FINAL' {} + 2>/dev/null | head -1 || true)
2150
+ [ -n "$session_file" ] || fail "Pi Calm hidden-block geometry E2E did not persist its session"
2151
+ grep -Fq 'CALM_GEOMETRY_THINKING_ONE' "$session_file" \
2152
+ || fail "Calm removed hidden thinking from persisted history"
2153
+ grep -Fq 'tool result one' "$session_file" \
2154
+ || fail "Calm removed hidden tool results from persisted history"
2155
+
2156
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/reload'
2157
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2158
+ wait_for_geometry_transition \
2159
+ "$snapshot" \
2160
+ "Reloading keybindings, extensions, skills, prompts, themes, and context files..." \
2161
+ "CALM_GEOMETRY_FINAL" \
2162
+ || fail "Pi Calm hidden-block geometry E2E did not complete the /reload viewport transition"
2163
+ assert_geometry_gap "$snapshot" "reloaded native Calm transcript"
2164
+
2165
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-t
2166
+ wait_for_geometry_text "$expanded_snapshot" "CALM_GEOMETRY_THINKING_ONE" \
2167
+ || fail "thinking expansion did not restore Calm-hidden reasoning"
2168
+ assert_not_contains "$(cat "$expanded_snapshot")" "probe-one.txt" "thinking expansion restored Calm-hidden tool rows"
2169
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-t
2170
+ i=0
2171
+ while [ "$i" -lt 120 ]; do
2172
+ capture_geometry_viewport "$snapshot"
2173
+ grep -Fq "CALM_GEOMETRY_THINKING_ONE" "$snapshot" || break
2174
+ sleep 0.05
2175
+ i=$((i + 1))
2176
+ done
2177
+ assert_not_contains "$(cat "$snapshot")" "CALM_GEOMETRY_THINKING_ONE" "collapsing thinking restored hidden-row output"
2178
+ assert_geometry_gap "$snapshot" "re-collapsed native Calm transcript"
2179
+
2180
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm'
2181
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2182
+ wait_for_geometry_text "$calm_off_snapshot" "probe-one.txt" \
2183
+ || fail "turning Calm off did not restore the tool-call row"
2184
+ assert_contains "$(cat "$calm_off_snapshot")" "Thinking..." "turning Calm off did not restore collapsed thinking labels"
2185
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm'
2186
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2187
+ i=0
2188
+ while [ "$i" -lt 120 ]; do
2189
+ capture_geometry_viewport "$snapshot"
2190
+ if ! grep -Fq "probe-one.txt" "$snapshot" && ! grep -Fq "Thinking..." "$snapshot"; then
2191
+ break
2192
+ fi
2193
+ sleep 0.05
2194
+ i=$((i + 1))
2195
+ done
2196
+ assert_geometry_gap "$snapshot" "Calm redraw of existing transcript"
2197
+
2198
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit'
2199
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2200
+ sleep 0.2
2201
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
2202
+ start_geometry_pi "--session '$session_file'"
2203
+ wait_for_geometry_text "$restarted_snapshot" "visible row two" \
2204
+ || fail "Pi did not restore the Calm hidden-block geometry session"
2205
+ assert_not_contains "$(cat "$restarted_snapshot")" "Thinking..." "restart restored a collapsed thinking label under Calm"
2206
+ assert_not_contains "$(cat "$restarted_snapshot")" "probe-one.txt" "restart restored a tool-call row under Calm"
2207
+ assert_geometry_gap "$restarted_snapshot" "restarted native Calm transcript"
2208
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit'
2209
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter
2210
+ sleep 0.2
2211
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
2212
+ pass "Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering"
2213
+ }
2214
+
2215
+ test_working_ship_geometry_and_lifecycle() {
2216
+ local fixture out status version
2217
+ if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
2218
+ echo "skip: node or npm not found for Pi Calm working-ship test"
2219
+ return 0
2220
+ fi
2221
+ if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then
2222
+ echo "skip: installed @earendil-works/pi-coding-agent package not found"
2223
+ return 0
2224
+ fi
2225
+ version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version")
2226
+ record_pi_version_evidence "$version" "Pi Calm working-ship assumptions"
2227
+
2228
+ fixture="$TMP_ROOT/working-ship"
2229
+ mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works"
2230
+ cp "$EXT" "$fixture/fm-calm.ts"
2231
+ cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts"
2232
+ cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts"
2233
+ cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts"
2234
+ cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts"
2235
+ cp "$PI_OPERATIONAL_INPUT" "$fixture/lib/fm-operational-input.ts"
2236
+ ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent"
2237
+ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
2238
+ ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox"
2239
+ printf '%s\n' '{"type":"module"}' >"$fixture/package.json"
2240
+
2241
+ out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS'
2242
+ import { pathToFileURL } from "node:url";
2243
+
2244
+ const packageRoot = process.env.PI_PACKAGE_DIR;
2245
+ const [{ initTheme, theme }, { visibleWidth, setCapabilities }] = await Promise.all([
2246
+ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href),
2247
+ import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href),
2248
+ ]);
2249
+ initTheme("dark");
2250
+ setCapabilities({ images: null, trueColor: true, hyperlinks: false });
2251
+
2252
+ const ship = await import(
2253
+ `${pathToFileURL(`${process.cwd()}/lib/fm-calm-working-ship.ts`).href}?ship=${Date.now()}`
2254
+ );
2255
+ const {
2256
+ CALM_WORKING_SHIP_WIDGET_KEY,
2257
+ CALM_WORKING_SHIP_TICK_MS,
2258
+ CALM_WORKING_SHIP_TICKS_PER_MOVE,
2259
+ createCalmWorkingShipAnimation,
2260
+ createCalmWorkingShipWidget,
2261
+ } = ship;
2262
+
2263
+ const ESC = "\u001b";
2264
+ const BLUE = `${ESC}[34m`;
2265
+ const YELLOW = `${ESC}[33m`;
2266
+ const RESET = `${ESC}[39m`;
2267
+ const strip = (text) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "");
2268
+ const check = (condition, message) => {
2269
+ if (!condition) throw new Error(message);
2270
+ };
2271
+ const sailOf = (frame) => {
2272
+ const row = strip(frame[0]);
2273
+ if (row.includes("<|")) return "<|";
2274
+ if (row.includes("|>")) return "|>";
2275
+ return "none";
2276
+ };
2277
+
2278
+ // --- Calm cadence: the boat is materially slower than the water ------------------
2279
+ {
2280
+ // The pre-revision boat moved one column every 140ms. The revised boat must be
2281
+ // plainly slower in real use while the water keeps rippling between its steps.
2282
+ const msPerColumn = CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE;
2283
+ check(msPerColumn >= 700, `boat cadence ${msPerColumn}ms per column is not materially slower`);
2284
+ check(
2285
+ CALM_WORKING_SHIP_TICKS_PER_MOVE >= 2,
2286
+ "the water cadence is not independent of and faster than the boat cadence",
2287
+ );
2288
+ check(
2289
+ CALM_WORKING_SHIP_TICK_MS < msPerColumn,
2290
+ "the water does not animate faster than the boat moves",
2291
+ );
2292
+ }
2293
+
2294
+ // --- Water phases loop independently while the boat stays put --------------------
2295
+ {
2296
+ const width = 40;
2297
+ const animation = createCalmWorkingShipAnimation();
2298
+ animation.render(width);
2299
+ const startPosition = animation.position();
2300
+ const waterRows = new Set();
2301
+ const phases = new Set();
2302
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE - 1; step += 1) {
2303
+ animation.tick();
2304
+ check(
2305
+ animation.position() === startPosition,
2306
+ `the boat moved on tick ${step + 1} instead of waiting for its own cadence`,
2307
+ );
2308
+ waterRows.add(strip(animation.render(width)[1]));
2309
+ phases.add(animation.waterPhase());
2310
+ }
2311
+ check(waterRows.size > 1, "the water did not animate while the boat was stationary");
2312
+ check(phases.size > 1, "the water phase did not advance between boat movements");
2313
+ // The boat then moves on its own cadence tick.
2314
+ animation.tick();
2315
+ check(
2316
+ animation.position() !== startPosition,
2317
+ "the boat never moved on its own cadence tick",
2318
+ );
2319
+ // Water motion alone must not change the hull column.
2320
+ const beforeHull = strip(animation.render(width)[1]).indexOf("\\__/");
2321
+ animation.tick();
2322
+ const afterHull = strip(animation.render(width)[1]).indexOf("\\__/");
2323
+ check(beforeHull === afterHull, "advancing only the water appeared to move the boat");
2324
+ }
2325
+
2326
+ // --- Water phases are bounded, fixed-cell, and never change geometry -------------
2327
+ {
2328
+ const width = 30;
2329
+ const animation = createCalmWorkingShipAnimation();
2330
+ const seenPhases = new Set();
2331
+ for (let step = 0; step < 64; step += 1) {
2332
+ const frame = animation.render(width);
2333
+ seenPhases.add(animation.waterPhase());
2334
+ check(frame.length === 2, `water phase ${animation.waterPhase()} changed the row count`);
2335
+ check(
2336
+ visibleWidth(frame[1]) === width,
2337
+ `water phase ${animation.waterPhase()} changed the visible width`,
2338
+ );
2339
+ animation.tick();
2340
+ }
2341
+ check(seenPhases.size > 1 && seenPhases.size <= 8, `water phase set is not bounded: ${seenPhases.size}`);
2342
+ }
2343
+
2344
+ // --- Standard ANSI colors, with resets that prevent bleed ------------------------
2345
+ {
2346
+ const width = 24;
2347
+ const animation = createCalmWorkingShipAnimation();
2348
+ for (let step = 0; step < 12; step += 1) {
2349
+ const [sailRow, waterRow] = animation.render(width);
2350
+
2351
+ // Standard codes only: no bright variants, no 256-color, no RGB.
2352
+ for (const row of [sailRow, waterRow]) {
2353
+ const codes = row.match(new RegExp(`${ESC}\\[[0-9;]*m`, "g")) ?? [];
2354
+ for (const code of codes) {
2355
+ check(
2356
+ code === BLUE || code === YELLOW || code === RESET,
2357
+ `non-standard ANSI escape ${JSON.stringify(code)} in ${JSON.stringify(row)}`,
2358
+ );
2359
+ }
2360
+ check(codes.length > 0, "a rendered row carried no color at all");
2361
+ // Every colored run is closed, so nothing bleeds into padding or later frames.
2362
+ check(
2363
+ codes.filter((c) => c !== RESET).length === codes.filter((c) => c === RESET).length,
2364
+ `unbalanced color/reset pairs in ${JSON.stringify(row)}`,
2365
+ );
2366
+ check(codes[codes.length - 1] === RESET, `row does not end color-reset: ${JSON.stringify(row)}`);
2367
+ }
2368
+
2369
+ // Sail-row padding must be plain spaces outside any color run.
2370
+ const leading = sailRow.slice(0, sailRow.indexOf(ESC));
2371
+ check(/^ *$/.test(leading), `sail row padding was colored: ${JSON.stringify(leading)}`);
2372
+
2373
+ // The complete boat is yellow; every water cell is blue.
2374
+ for (const piece of [`${YELLOW}<|${RESET}`, `${YELLOW}|>${RESET}`]) {
2375
+ if (sailRow.includes(piece.slice(0, -RESET.length))) {
2376
+ check(sailRow.includes(piece), `sail was not a closed yellow run: ${JSON.stringify(sailRow)}`);
2377
+ }
2378
+ }
2379
+ check(
2380
+ waterRow.includes(`${YELLOW}\\__/${RESET}`),
2381
+ `hull was not a closed yellow run: ${JSON.stringify(waterRow)}`,
2382
+ );
2383
+ for (const run of waterRow.split(YELLOW)) {
2384
+ const blueRuns = run.split(BLUE).slice(1);
2385
+ for (const blueRun of blueRuns) {
2386
+ const cells = blueRun.slice(0, blueRun.indexOf(RESET));
2387
+ check(cells.length > 0, "an empty blue run emitted a bare color escape");
2388
+ check(
2389
+ /^[~-]+$/.test(cells),
2390
+ `blue run contained a non-water cell: ${JSON.stringify(cells)}`,
2391
+ );
2392
+ }
2393
+ }
2394
+ animation.tick();
2395
+ }
2396
+ }
2397
+
2398
+ // --- ANSI-stripped visible width is exact at every width and phase ---------------
2399
+ for (let width = 1; width <= 120; width += 1) {
2400
+ const animation = createCalmWorkingShipAnimation();
2401
+ animation.render(width);
2402
+ for (let step = 0; step <= width + 8; step += 1) {
2403
+ const frame = animation.render(width);
2404
+ const expectedRows = width >= 4 ? 2 : 1;
2405
+ check(frame.length === expectedRows, `width ${width} rendered ${frame.length} rows`);
2406
+ for (const line of frame) {
2407
+ check(
2408
+ visibleWidth(line) <= width,
2409
+ `width ${width} rendered a ${visibleWidth(line)}-cell line and would wrap`,
2410
+ );
2411
+ check(
2412
+ visibleWidth(line) === strip(line).length,
2413
+ `width ${width} let ANSI bytes affect the measured geometry`,
2414
+ );
2415
+ }
2416
+ // The water row always fills the complete usable width.
2417
+ const waterRow = frame[frame.length - 1];
2418
+ check(
2419
+ visibleWidth(waterRow) === width,
2420
+ `width ${width} water row was ${visibleWidth(waterRow)} cells instead of full width`,
2421
+ );
2422
+ animation.tick();
2423
+ }
2424
+ }
2425
+
2426
+ // --- Directional sail and exact bounce, including tiny spans ---------------------
2427
+ for (const width of [40, 16, 8, 6, 5, 4, 3, 2]) {
2428
+ const animation = createCalmWorkingShipAnimation();
2429
+ animation.render(width);
2430
+ const span = width >= 4 ? width - 4 : Math.max(0, width - 2);
2431
+ const frames = [];
2432
+ for (let step = 0; step < span * CALM_WORKING_SHIP_TICKS_PER_MOVE * 3 + 16; step += 1) {
2433
+ const frame = animation.render(width);
2434
+ frames.push({ position: animation.position(), sail: sailOf(frame) });
2435
+ animation.tick();
2436
+ }
2437
+ for (const frame of frames) {
2438
+ check(
2439
+ frame.position >= 0 && frame.position <= span,
2440
+ `width ${width} left the track at column ${frame.position}`,
2441
+ );
2442
+ }
2443
+ if (width >= 2) {
2444
+ // Every frame must already show the heading it is about to travel, so no frame
2445
+ // at or after a reversal shows the old sail.
2446
+ for (let index = 1; index < frames.length; index += 1) {
2447
+ const previous = frames[index - 1];
2448
+ const current = frames[index];
2449
+ if (current.position > previous.position) {
2450
+ check(
2451
+ previous.sail === "<|",
2452
+ `width ${width} moved right showing ${previous.sail} at column ${previous.position}`,
2453
+ );
2454
+ }
2455
+ if (current.position < previous.position) {
2456
+ check(
2457
+ previous.sail === "|>",
2458
+ `width ${width} moved left showing ${previous.sail} at column ${previous.position}`,
2459
+ );
2460
+ }
2461
+ }
2462
+ }
2463
+ if (span > 0) {
2464
+ const sails = new Set(frames.map((frame) => frame.sail));
2465
+ check(sails.has("<|") && sails.has("|>"), `width ${width} never showed both headings`);
2466
+ const positions = frames.map((frame) => frame.position);
2467
+ check(Math.min(...positions) === 0, `width ${width} never reached the left edge`);
2468
+ check(Math.max(...positions) === span, `width ${width} never reached the right edge`);
2469
+ // Both reversals must be covered.
2470
+ let rightToLeft = false;
2471
+ let leftToRight = false;
2472
+ for (let index = 1; index < frames.length; index += 1) {
2473
+ if (frames[index - 1].sail === "<|" && frames[index].sail === "|>") rightToLeft = true;
2474
+ if (frames[index - 1].sail === "|>" && frames[index].sail === "<|") leftToRight = true;
2475
+ }
2476
+ check(rightToLeft, `width ${width} never reversed from right to left`);
2477
+ check(leftToRight, `width ${width} never reversed from left to right`);
2478
+ }
2479
+ }
2480
+
2481
+ // --- Shrink and grow resize clamping ----------------------------------------------
2482
+ {
2483
+ const animation = createCalmWorkingShipAnimation();
2484
+ animation.render(80);
2485
+ while (animation.position() < 76) animation.tick();
2486
+ check(animation.position() === 76, `boat did not reach the wide right edge: ${animation.position()}`);
2487
+
2488
+ const shrunk = animation.render(20);
2489
+ check(animation.position() === 16, `shrink did not clamp the track immediately: ${animation.position()}`);
2490
+ check(visibleWidth(shrunk[1]) === 20, `shrunk water row was ${visibleWidth(shrunk[1])} cells instead of 20`);
2491
+ check(visibleWidth(shrunk[0]) <= 20, "shrunk sail row would wrap");
2492
+ check(sailOf(shrunk) === "|>", "the boat did not turn around after being clamped to the right edge");
2493
+
2494
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick();
2495
+ const afterShrink = animation.render(20);
2496
+ check(animation.position() < 16, "the boat stalled at the edge after a shrink");
2497
+ check(visibleWidth(afterShrink[1]) === 20, "motion after a shrink broke the water row width");
2498
+
2499
+ const grown = animation.render(60);
2500
+ check(visibleWidth(grown[1]) === 60, `grown water row was ${visibleWidth(grown[1])} cells`);
2501
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick();
2502
+ const afterGrow = animation.render(60);
2503
+ check(
2504
+ animation.position() >= 0 && animation.position() <= 56,
2505
+ `motion left the grown track: ${animation.position()}`,
2506
+ );
2507
+ check(visibleWidth(afterGrow[1]) === 60, "motion after a grow broke the water row width");
2508
+ }
2509
+
2510
+ // --- Deterministic narrow fallbacks ------------------------------------------------
2511
+ {
2512
+ const animation = createCalmWorkingShipAnimation();
2513
+ check(JSON.stringify(animation.render(0)) === "[]", "zero width rendered a line");
2514
+ for (const width of [1, 2, 3]) {
2515
+ const fallback = createCalmWorkingShipAnimation();
2516
+ for (let step = 0; step < 12; step += 1) {
2517
+ const frame = fallback.render(width);
2518
+ check(frame.length === 1, `width ${width} fallback was not a single row`);
2519
+ check(visibleWidth(frame[0]) === width, `width ${width} fallback was not exactly ${width} cells`);
2520
+ const bare = strip(frame[0]);
2521
+ if (width === 1) {
2522
+ check(/^[~-]$/.test(bare), `width 1 fallback was not a single water cell: ${bare}`);
2523
+ } else {
2524
+ check(
2525
+ bare.includes("<|") || bare.includes("|>"),
2526
+ `width ${width} fallback lost the sail: ${bare}`,
2527
+ );
2528
+ }
2529
+ fallback.tick();
2530
+ }
2531
+ }
2532
+ }
2533
+
2534
+ // --- Freeze/resume continuity on one shared animation instance ---------------------
2535
+ // Hiding the working presentation must freeze column and direction. The next widget
2536
+ // bound to the same animation resumes exactly there; hidden wall time must not jump.
2537
+ {
2538
+ const animation = createCalmWorkingShipAnimation();
2539
+ const tui = { requestRender() {} };
2540
+ animation.render(40);
2541
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE * 7; step += 1) animation.tick();
2542
+ animation.render(40);
2543
+ const frozenColumn = animation.position();
2544
+ const frozenDirection = animation.direction();
2545
+ const frozenPhase = animation.waterPhase();
2546
+ check(frozenColumn > 0, `continuity setup never left the left edge: ${frozenColumn}`);
2547
+
2548
+ const first = createCalmWorkingShipWidget(tui, animation);
2549
+ check(first.render(40) && animation.position() === frozenColumn, "binding a widget moved the frozen boat");
2550
+ first.dispose();
2551
+ // Dispose freezes; further wall time without ticks must not change logical state.
2552
+ check(animation.position() === frozenColumn, "dispose changed the frozen column");
2553
+ check(animation.direction() === frozenDirection, "dispose changed the frozen direction");
2554
+ check(animation.waterPhase() === frozenPhase, "dispose changed the frozen water phase");
2555
+
2556
+ const resumed = createCalmWorkingShipWidget(tui, animation);
2557
+ const firstFrame = resumed.render(40);
2558
+ check(
2559
+ animation.position() === frozenColumn && animation.direction() === frozenDirection,
2560
+ `resume first frame left frozen state: col=${animation.position()} dir=${animation.direction()}`,
2561
+ );
2562
+ check(sailOf(firstFrame) === (frozenDirection >= 0 ? "<|" : "|>"), "resume first frame lost sail heading");
2563
+ check(animation.waterPhase() === frozenPhase, "resume advanced water phase without a tick");
2564
+ // After resume, motion continues from the frozen state rather than restarting.
2565
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick();
2566
+ check(
2567
+ animation.position() === frozenColumn + frozenDirection,
2568
+ `post-resume motion did not continue from frozen column: ${animation.position()}`,
2569
+ );
2570
+ resumed.dispose();
2571
+
2572
+ // Hidden resize clamps without needing a live widget, and preserves a valid heading.
2573
+ animation.render(80);
2574
+ while (animation.position() < 76) animation.tick();
2575
+ animation.render(80);
2576
+ check(animation.position() === 76 && animation.direction() === -1, "endpoint setup failed before hidden resize");
2577
+ const beforeHiddenResize = { column: animation.position(), direction: animation.direction(), phase: animation.waterPhase() };
2578
+ animation.clampToWidth(20);
2579
+ check(animation.position() === 16, `hidden shrink did not clamp: ${animation.position()}`);
2580
+ check(animation.direction() === -1, "hidden shrink lost the leftward heading at the right edge");
2581
+ check(animation.waterPhase() === beforeHiddenResize.phase, "hidden clamp advanced water phase");
2582
+ // Growing while hidden must not invent motion either.
2583
+ animation.clampToWidth(60);
2584
+ check(animation.position() === 16, `hidden grow moved the boat: ${animation.position()}`);
2585
+ check(animation.direction() === -1, "hidden grow changed direction without cause");
2586
+
2587
+ // Endpoint and bounce continuity: pause immediately before, at, and after each edge.
2588
+ for (const scenario of [
2589
+ { label: "before-right", setup(anim) {
2590
+ anim.reset(); anim.render(12);
2591
+ while (anim.position() < 7) anim.tick();
2592
+ check(anim.position() === 7 && anim.direction() === 1, "before-right setup");
2593
+ }},
2594
+ { label: "at-right", setup(anim) {
2595
+ anim.reset(); anim.render(12);
2596
+ while (anim.position() < 8) anim.tick();
2597
+ check(anim.position() === 8 && anim.direction() === -1, "at-right setup");
2598
+ }},
2599
+ { label: "after-right", setup(anim) {
2600
+ anim.reset(); anim.render(12);
2601
+ while (anim.position() < 8) anim.tick();
2602
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) anim.tick();
2603
+ check(anim.position() === 7 && anim.direction() === -1, "after-right setup");
2604
+ }},
2605
+ { label: "before-left", setup(anim) {
2606
+ anim.reset(); anim.render(12);
2607
+ while (anim.position() < 8) anim.tick();
2608
+ while (!(anim.position() === 1 && anim.direction() === -1)) anim.tick();
2609
+ }},
2610
+ { label: "at-left", setup(anim) {
2611
+ anim.reset(); anim.render(12);
2612
+ while (anim.position() < 8) anim.tick();
2613
+ while (!(anim.position() === 0 && anim.direction() === 1)) anim.tick();
2614
+ }},
2615
+ { label: "after-left", setup(anim) {
2616
+ anim.reset(); anim.render(12);
2617
+ while (anim.position() < 8) anim.tick();
2618
+ while (!(anim.position() === 0 && anim.direction() === 1)) anim.tick();
2619
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) anim.tick();
2620
+ check(anim.position() === 1 && anim.direction() === 1, "after-left setup");
2621
+ }},
2622
+ ]) {
2623
+ const edge = createCalmWorkingShipAnimation();
2624
+ scenario.setup(edge);
2625
+ edge.render(12);
2626
+ const frozen = { column: edge.position(), direction: edge.direction(), phase: edge.waterPhase() };
2627
+ const paused = createCalmWorkingShipWidget(tui, edge);
2628
+ paused.dispose();
2629
+ const again = createCalmWorkingShipWidget(tui, edge);
2630
+ again.render(12);
2631
+ check(
2632
+ edge.position() === frozen.column && edge.direction() === frozen.direction && edge.waterPhase() === frozen.phase,
2633
+ `${scenario.label} resume changed frozen edge state`,
2634
+ );
2635
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) edge.tick();
2636
+ const expectedColumn = Math.min(8, Math.max(0, frozen.column + frozen.direction));
2637
+ let expectedDirection = frozen.direction;
2638
+ if (expectedColumn >= 8) expectedDirection = -1;
2639
+ else if (expectedColumn <= 0) expectedDirection = 1;
2640
+ check(
2641
+ edge.position() === expectedColumn && edge.direction() === expectedDirection,
2642
+ `${scenario.label} post-resume bounce drifted: col=${edge.position()} dir=${edge.direction()}`,
2643
+ );
2644
+ again.dispose();
2645
+ }
2646
+
2647
+ // reset() returns a genuine fresh-session initial state.
2648
+ animation.reset();
2649
+ check(
2650
+ animation.position() === 0 && animation.direction() === 1 && animation.waterPhase() === 0,
2651
+ "reset() did not restore the normal initial boat state",
2652
+ );
2653
+ animation.render(40);
2654
+ check(sailOf(animation.render(40)) === "<|", "reset() first frame was not the initial rightward sail");
2655
+
2656
+ // Two controller instances never share motion state.
2657
+ const left = createCalmWorkingShipAnimation();
2658
+ const right = createCalmWorkingShipAnimation();
2659
+ left.render(40);
2660
+ right.render(40);
2661
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE * 3; step += 1) left.tick();
2662
+ check(left.position() === 3 && right.position() === 0, "separate animations leaked motion state");
2663
+ }
2664
+
2665
+ {
2666
+ const realSetInterval = globalThis.setInterval;
2667
+ const realClearInterval = globalThis.clearInterval;
2668
+ const callbacks = [];
2669
+ const handles = new Set();
2670
+ globalThis.setInterval = (callback) => {
2671
+ callbacks.push(callback);
2672
+ const handle = { unref() {} };
2673
+ handles.add(handle);
2674
+ return handle;
2675
+ };
2676
+ globalThis.clearInterval = (handle) => {
2677
+ handles.delete(handle);
2678
+ };
2679
+
2680
+ try {
2681
+ const tui = { renderRequests: 0, requestRender() { this.renderRequests += 1; } };
2682
+ const animation = createCalmWorkingShipAnimation();
2683
+ const first = createCalmWorkingShipWidget(tui, animation);
2684
+ first.render(40);
2685
+ callbacks[callbacks.length - 1]();
2686
+ callbacks[callbacks.length - 1]();
2687
+ check(tui.renderRequests === 2, "unpainted timer ticks did not request renders");
2688
+ first.dispose();
2689
+ check(handles.size === 0, "disposing the unpainted widget left its timer scheduled");
2690
+ check(
2691
+ animation.position() === 0 && animation.direction() === 1 && animation.waterPhase() === 0,
2692
+ "dispose retained state from unpainted timer ticks",
2693
+ );
2694
+
2695
+ const resumed = createCalmWorkingShipWidget(tui, animation);
2696
+ resumed.render(40);
2697
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) {
2698
+ callbacks[callbacks.length - 1]();
2699
+ }
2700
+ resumed.render(40);
2701
+ check(animation.position() === 1, "unpainted ticks leaked into the resumed cadence");
2702
+ check(animation.waterPhase() === 0, "resumed cadence did not restore the rendered water phase");
2703
+ resumed.dispose();
2704
+
2705
+ const committed = createCalmWorkingShipAnimation();
2706
+ const progressing = createCalmWorkingShipWidget(tui, committed);
2707
+ progressing.render(40);
2708
+ callbacks[callbacks.length - 1]();
2709
+ progressing.render(40);
2710
+ const renderedPhase = committed.waterPhase();
2711
+ callbacks[callbacks.length - 1]();
2712
+ progressing.dispose();
2713
+ check(committed.position() === 0, "dispose changed the committed column after an unpainted tick");
2714
+ check(committed.waterPhase() === renderedPhase, "dispose changed the committed phase after an unpainted tick");
2715
+
2716
+ const committedResume = createCalmWorkingShipWidget(tui, committed);
2717
+ committedResume.render(40);
2718
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE - 2; step += 1) {
2719
+ callbacks[callbacks.length - 1]();
2720
+ }
2721
+ check(committed.position() === 0, "serviced render did not preserve the committed cadence");
2722
+ callbacks[callbacks.length - 1]();
2723
+ committedResume.render(40);
2724
+ check(committed.position() === 1, "serviced render did not commit progress for the next cadence");
2725
+ committedResume.dispose();
2726
+
2727
+ const boundaryCases = [
2728
+ [7, 1], [8, -1], [7, -1], [1, -1], [0, 1], [1, 1],
2729
+ ];
2730
+ for (const [targetPosition, targetDirection] of boundaryCases) {
2731
+ const edge = createCalmWorkingShipAnimation();
2732
+ edge.render(12);
2733
+ let reached = false;
2734
+ for (let step = 0; step < 160; step += 1) {
2735
+ if (edge.position() === targetPosition && edge.direction() === targetDirection) {
2736
+ edge.render(12);
2737
+ reached = true;
2738
+ break;
2739
+ }
2740
+ edge.tick();
2741
+ edge.render(12);
2742
+ }
2743
+ check(reached, `could not prepare bounce state ${targetPosition}/${targetDirection}`);
2744
+ const before = { position: edge.position(), direction: edge.direction(), phase: edge.waterPhase() };
2745
+ const paused = createCalmWorkingShipWidget(tui, edge);
2746
+ paused.render(12);
2747
+ for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) {
2748
+ callbacks[callbacks.length - 1]();
2749
+ }
2750
+ paused.dispose();
2751
+ check(
2752
+ edge.position() === before.position &&
2753
+ edge.direction() === before.direction &&
2754
+ edge.waterPhase() === before.phase,
2755
+ `unpainted bounce tick escaped ${targetPosition}/${targetDirection}`,
2756
+ );
2757
+ const resumedEdge = createCalmWorkingShipWidget(tui, edge);
2758
+ resumedEdge.render(12);
2759
+ check(
2760
+ edge.position() === before.position && edge.direction() === before.direction,
2761
+ `bounce state ${targetPosition}/${targetDirection} changed on resume`,
2762
+ );
2763
+ resumedEdge.dispose();
2764
+ }
2765
+ } finally {
2766
+ globalThis.setInterval = realSetInterval;
2767
+ globalThis.clearInterval = realClearInterval;
2768
+ }
2769
+ }
2770
+
2771
+ // --- Lifecycle through the Calm extension's registered handlers --------------------
2772
+ let liveTimers = 0;
2773
+ const realSetInterval = globalThis.setInterval;
2774
+ const realClearInterval = globalThis.clearInterval;
2775
+ globalThis.setInterval = (...args) => {
2776
+ liveTimers += 1;
2777
+ return realSetInterval(...args);
2778
+ };
2779
+ globalThis.clearInterval = (timer) => {
2780
+ if (timer !== undefined) liveTimers -= 1;
2781
+ return realClearInterval(timer);
2782
+ };
2783
+
2784
+ const sessionWrites = [];
2785
+ const handlers = new Map();
2786
+ let calmCommand;
2787
+ const pi = {
2788
+ events: { emit() {}, on() {} },
2789
+ on(event, handler) {
2790
+ const existing = handlers.get(event) ?? [];
2791
+ existing.push(handler);
2792
+ handlers.set(event, existing);
2793
+ },
2794
+ registerCommand(name, command) {
2795
+ if (name === "calm") calmCommand = command;
2796
+ },
2797
+ registerEntryRenderer() {},
2798
+ registerTool() {},
2799
+ getAllTools() {
2800
+ return [];
2801
+ },
2802
+ appendEntry: (...args) => sessionWrites.push(["appendEntry", ...args]),
2803
+ sendMessage: (...args) => sessionWrites.push(["sendMessage", ...args]),
2804
+ sendUserMessage: (...args) => sessionWrites.push(["sendUserMessage", ...args]),
2805
+ setSessionName: (...args) => sessionWrites.push(["setSessionName", ...args]),
2806
+ };
2807
+ const extension = await import(`${pathToFileURL(process.env.EXT).href}?ship=${Date.now()}`);
2808
+ extension.default(pi);
2809
+ check(!!calmCommand, "Calm command was not registered");
2810
+ for (const event of ["session_start", "agent_start", "agent_settled", "session_shutdown"]) {
2811
+ check(handlers.has(event), `Calm did not register a ${event} handler`);
2812
+ }
2813
+
2814
+ let renderRequests = 0;
2815
+ const tui = { requestRender: () => { renderRequests += 1; } };
2816
+ const ui = {
2817
+ workingVisible: [],
2818
+ visibilityCalls: 0,
2819
+ widgetOps: [],
2820
+ widgets: new Map(),
2821
+ setWorkingVisible(visible) {
2822
+ this.visibilityCalls += 1;
2823
+ this.workingVisible.push(visible);
2824
+ },
2825
+ // Mirrors Pi's documented widget contract: the previous component under a key is
2826
+ // disposed before a replacement is installed, and clearing disposes it too.
2827
+ setWidget(key, content, options) {
2828
+ const existing = this.widgets.get(key);
2829
+ if (existing?.dispose) existing.dispose();
2830
+ this.widgets.delete(key);
2831
+ this.widgetOps.push({
2832
+ key,
2833
+ action: content === undefined ? "clear" : "set",
2834
+ placement: options?.placement,
2835
+ });
2836
+ if (content === undefined) return;
2837
+ this.widgets.set(key, typeof content === "function" ? content(tui, theme) : content);
2838
+ },
2839
+ getEditorText: () => "",
2840
+ getToolsExpanded: () => false,
2841
+ onTerminalInput: () => () => {},
2842
+ setHiddenThinkingLabel() {},
2843
+ setStatus() {},
2844
+ setToolsExpanded() {},
2845
+ notify() {},
2846
+ theme,
2847
+ };
2848
+ const ctx = { ui };
2849
+ const fire = async (event, payload = {}) => {
2850
+ for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
2851
+ };
2852
+ const reset = () => {
2853
+ ui.workingVisible.length = 0;
2854
+ ui.widgetOps.length = 0;
2855
+ ui.visibilityCalls = 0;
2856
+ };
2857
+ const shipWidget = () => ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY);
2858
+
2859
+ // --- Calm off leaves Pi's stock working behavior completely untouched -------------
2860
+ await fire("session_start", { reason: "startup" });
2861
+ reset();
2862
+ for (const event of ["agent_start", "agent_settled", "session_shutdown"]) {
2863
+ await fire(event, { reason: "quit" });
2864
+ }
2865
+ check(
2866
+ ui.visibilityCalls === 0,
2867
+ `Calm off called setWorkingVisible ${ui.visibilityCalls} times from the run lifecycle`,
2868
+ );
2869
+ check(ui.widgetOps.length === 0, `Calm off registered a working widget: ${JSON.stringify(ui.widgetOps)}`);
2870
+ check(liveTimers === 0, `Calm off started ${liveTimers} animation timers`);
2871
+
2872
+ // --- Turning Calm on while idle shows no boat until a run starts -------------------
2873
+ reset();
2874
+ await calmCommand.handler("", ctx);
2875
+ check(ui.widgetOps.length === 0, "toggling Calm on while idle installed a working widget");
2876
+ check(liveTimers === 0, "toggling Calm on while idle started an animation timer");
2877
+
2878
+ // --- Calm on plus an active run shows the boat instead of the stock row -----------
2879
+ reset();
2880
+ await fire("agent_start");
2881
+ check(
2882
+ ui.widgetOps.length === 1 &&
2883
+ ui.widgetOps[0].key === CALM_WORKING_SHIP_WIDGET_KEY &&
2884
+ ui.widgetOps[0].action === "set",
2885
+ `Calm on did not install exactly one working widget: ${JSON.stringify(ui.widgetOps)}`,
2886
+ );
2887
+ check(ui.widgetOps[0].placement === undefined, "Calm working widget asked for a non-default placement");
2888
+ check(
2889
+ ui.workingVisible[ui.workingVisible.length - 1] === false,
2890
+ "Calm on did not hide Pi's stock working row",
2891
+ );
2892
+ check(liveTimers === 1, `Calm on kept ${liveTimers} animation timers instead of one`);
2893
+
2894
+ const widget = shipWidget();
2895
+ check(!!widget, "Calm on did not install the working-ship widget");
2896
+ check(typeof widget.render === "function", "working widget has no render(width)");
2897
+ check(typeof widget.invalidate === "function", "working widget has no invalidate()");
2898
+ check(typeof widget.dispose === "function", "working widget has no dispose()");
2899
+ // A focusable widget could steal input or swallow Escape; this one takes no keys.
2900
+ check(widget.handleInput === undefined, "working widget accepts keyboard input");
2901
+ check(widget.wantsKeyRelease === undefined, "working widget asked for key release events");
2902
+ check(widget.render(60).length === 2, "installed working widget did not render the two-row sprite");
2903
+ check(
2904
+ widget.render(60).every((line) => visibleWidth(line) <= 60),
2905
+ "installed working widget rendered a line wider than its viewport",
2906
+ );
2907
+
2908
+ // --- Repeated low-level starts inside one logical run never duplicate anything -----
2909
+ reset();
2910
+ for (let repeat = 0; repeat < 5; repeat += 1) await fire("agent_start");
2911
+ check(ui.widgetOps.length === 0, `repeated starts churned the working widget: ${JSON.stringify(ui.widgetOps)}`);
2912
+ check(liveTimers === 1, `repeated starts left ${liveTimers} animation timers`);
2913
+ check(ui.widgets.size === 1, `repeated starts left ${ui.widgets.size} widgets`);
2914
+ check(shipWidget() === widget, "repeated starts replaced the running widget");
2915
+
2916
+ // --- The animation drives Pi's renderer -------------------------------------------
2917
+ {
2918
+ const before = renderRequests;
2919
+ await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * 3));
2920
+ check(renderRequests > before, "the working animation never requested a TUI render");
2921
+ }
2922
+
2923
+ // --- Settling removes the boat, stops the animation, and restores the stock row ----
2924
+ // Drive the live widget far enough that a left-edge reset would be observable.
2925
+ {
2926
+ const moving = shipWidget();
2927
+ check(!!moving, "continuity setup lost the live working widget");
2928
+ moving.render(40);
2929
+ await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE * 5 + 40));
2930
+ moving.render(40);
2931
+ }
2932
+ const hullColumn = (widget) => strip(widget.render(40)[1]).indexOf("\\__/");
2933
+ const freezeColumn = hullColumn(shipWidget());
2934
+ const freezeSail = sailOf(shipWidget().render(40));
2935
+ check(freezeColumn > 0, `lifecycle continuity setup never left the left edge: ${freezeColumn}`);
2936
+
2937
+ reset();
2938
+ await fire("agent_settled");
2939
+ check(
2940
+ ui.widgetOps.length === 1 &&
2941
+ ui.widgetOps[0].key === CALM_WORKING_SHIP_WIDGET_KEY &&
2942
+ ui.widgetOps[0].action === "clear",
2943
+ `settling did not clear the working widget: ${JSON.stringify(ui.widgetOps)}`,
2944
+ );
2945
+ check(liveTimers === 0, `settling left ${liveTimers} animation timers`);
2946
+ check(ui.widgets.size === 0, "settling left a residual widget");
2947
+ check(
2948
+ ui.workingVisible[ui.workingVisible.length - 1] === true,
2949
+ "settling did not restore Pi's stock working row",
2950
+ );
2951
+ {
2952
+ // No stale rows survive the removal: the widget renders nothing once disposed.
2953
+ const renderRequestsAfterDispose = renderRequests;
2954
+ await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE * 3));
2955
+ check(
2956
+ renderRequests === renderRequestsAfterDispose,
2957
+ "the animation kept running after the widget was removed",
2958
+ );
2959
+ }
2960
+
2961
+ // --- Later working period resumes the frozen column and direction -----------------
2962
+ reset();
2963
+ await fire("agent_start");
2964
+ check(liveTimers === 1, `resume start left ${liveTimers} animation timers instead of one`);
2965
+ check(ui.widgets.size === 1, "resume start did not install exactly one working widget");
2966
+ const resumedWidget = shipWidget();
2967
+ const resumeColumn = hullColumn(resumedWidget);
2968
+ const resumeSail = sailOf(resumedWidget.render(40));
2969
+ check(
2970
+ resumeColumn === freezeColumn && resumeSail === freezeSail,
2971
+ `resume reset the boat instead of continuing: froze ${freezeColumn}/${freezeSail}, resumed ${resumeColumn}/${resumeSail}`,
2972
+ );
2973
+ // Repeated start/settle cycles must not duplicate scheduler or widget ownership.
2974
+ for (let cycle = 0; cycle < 3; cycle += 1) {
2975
+ await fire("agent_settled");
2976
+ check(liveTimers === 0, `cycle ${cycle} settle left ${liveTimers} timers`);
2977
+ check(ui.widgets.size === 0, `cycle ${cycle} settle left a residual widget`);
2978
+ await fire("agent_start");
2979
+ check(liveTimers === 1, `cycle ${cycle} start left ${liveTimers} timers`);
2980
+ check(ui.widgets.size === 1, `cycle ${cycle} start left ${ui.widgets.size} widgets`);
2981
+ check(
2982
+ hullColumn(shipWidget()) >= freezeColumn,
2983
+ `cycle ${cycle} lost continuity after repeated settle/start`,
2984
+ );
2985
+ }
2986
+ await fire("agent_settled");
2987
+ check(liveTimers === 0 && ui.widgets.size === 0, "repeated continuity cycles did not finish clean");
2988
+
2989
+ // A genuine fresh session resets to the normal initial position.
2990
+ reset();
2991
+ await fire("session_start", { reason: "new" });
2992
+ check(liveTimers === 0 && ui.widgets.size === 0, "fresh session left a stale boat");
2993
+ await fire("agent_start");
2994
+ check(hullColumn(shipWidget()) === 0, "fresh session did not restart at the left edge");
2995
+ check(sailOf(shipWidget().render(40)) === "<|", "fresh session lost the initial rightward sail");
2996
+ await fire("agent_settled");
2997
+
2998
+ // --- Abort and failure share Pi's agent_settled path ------------------------------
2999
+ // Pi emits agent_settled from a finally block, so an aborted or failed run reaches
3000
+ // exactly this handler; the real-TUI regression covers the Escape abort path.
3001
+ for (const outcome of ["abort", "failure"]) {
3002
+ reset();
3003
+ await fire("agent_start");
3004
+ check(liveTimers === 1, `${outcome} setup did not start the animation`);
3005
+ await fire("agent_settled");
3006
+ check(liveTimers === 0, `${outcome} left ${liveTimers} animation timers`);
3007
+ check(ui.widgets.size === 0, `${outcome} left a residual widget`);
3008
+ check(
3009
+ ui.workingVisible[ui.workingVisible.length - 1] === true,
3010
+ `${outcome} did not restore Pi's stock working row`,
3011
+ );
3012
+ }
3013
+
3014
+ // --- Shutdown, reload, and session replacement all clean up -----------------------
3015
+ for (const reason of ["quit", "reload", "new", "resume", "fork"]) {
3016
+ reset();
3017
+ await fire("agent_start");
3018
+ check(liveTimers === 1, `${reason} setup did not start the animation`);
3019
+ await fire("session_shutdown", { reason });
3020
+ check(liveTimers === 0, `session_shutdown(${reason}) left ${liveTimers} animation timers`);
3021
+ check(ui.widgets.size === 0, `session_shutdown(${reason}) left a residual widget`);
3022
+ check(
3023
+ ui.workingVisible[ui.workingVisible.length - 1] === true,
3024
+ `session_shutdown(${reason}) did not restore Pi's stock working row`,
3025
+ );
3026
+ if (reason === "quit") continue;
3027
+ reset();
3028
+ await fire("session_start", { reason });
3029
+ check(ui.widgets.size === 0, `session_start(${reason}) installed a stale widget`);
3030
+ check(liveTimers === 0, `session_start(${reason}) left ${liveTimers} animation timers`);
3031
+ }
3032
+
3033
+ // --- Toggling Calm off during an active run restores the stock row immediately -----
3034
+ await fire("session_start", { reason: "startup" });
3035
+ reset();
3036
+ await fire("agent_start");
3037
+ check(liveTimers === 1, "active-run setup did not start the animation");
3038
+ await calmCommand.handler("", ctx);
3039
+ check(liveTimers === 0, "toggling Calm off during a run left the animation running");
3040
+ check(ui.widgets.size === 0, "toggling Calm off during a run left the boat on screen");
3041
+ check(
3042
+ ui.workingVisible[ui.workingVisible.length - 1] === true,
3043
+ "toggling Calm off during a run did not restore Pi's stock working row",
3044
+ );
3045
+
3046
+ // Toggling Calm back on during the same run returns the boat.
3047
+ reset();
3048
+ await calmCommand.handler("", ctx);
3049
+ check(liveTimers === 1, "toggling Calm on during a run did not return the boat");
3050
+ check(
3051
+ ui.workingVisible[ui.workingVisible.length - 1] === false,
3052
+ "toggling Calm on during a run did not hide Pi's stock working row",
3053
+ );
3054
+ await fire("agent_settled");
3055
+ check(liveTimers === 0, "the toggled-on run did not clean up");
3056
+
3057
+ // A run started after toggling Calm on while idle uses the boat.
3058
+ reset();
3059
+ await calmCommand.handler("", ctx);
3060
+ await calmCommand.handler("", ctx);
3061
+ await fire("agent_start");
3062
+ check(liveTimers === 1, "a later run did not use the boat after an idle Calm toggle");
3063
+ await fire("agent_settled");
3064
+ check(liveTimers === 0, "the later run did not clean up");
3065
+
3066
+ // --- The visual-only widget never touches session, transcript, or export data ------
3067
+ check(
3068
+ sessionWrites.length === 0,
3069
+ `the working presentation wrote session or transcript data: ${JSON.stringify(sessionWrites)}`,
3070
+ );
3071
+
3072
+ globalThis.setInterval = realSetInterval;
3073
+ globalThis.clearInterval = realClearInterval;
3074
+ JS
3075
+ )
3076
+ status=$?
3077
+ [ "$status" -eq 0 ] || fail "Pi Calm working-ship checks failed: $out"
3078
+ [ -z "$out" ] || fail "Pi Calm working-ship test printed output: $out"
3079
+ pass "Pi Calm working ship moves on a slow independent cadence over faster fixed-cell blue water, paints the complete boat standard yellow with balanced resets, keeps ANSI-stripped width exact, flips the directional sail on the exact bounce at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes column/direction across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and installs and removes one scheduler-owning widget across starts, settle, abort, failure, shutdown, reload, replacement, and Calm toggles while leaving Calm-off visibility untouched"
3080
+ }
3081
+
3082
+ test_interactive_terminal_e2e() {
3083
+ local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot export_settled_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait chrome_reap_wait active_wait active_screen_wait boat_frame_one boat_frame_two boat_resized_snapshot boat_focus_snapshot boat_cleared_snapshot boat_hull_line boat_sail_line boat_column_one boat_column_two boat_line boat_color_snapshot boat_color_line boat_water_snapshot boat_water_line boat_water_first boat_water_changed boat_narrow_snapshot boat_narrow_sails boat_freeze_snapshot boat_resume_snapshot boat_freeze_column boat_freeze_sail boat_resume_column boat_resume_sail
3084
+ if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then
3085
+ echo "skip: pi or tmux not found for Pi calm interactive E2E"
3086
+ return 0
3087
+ fi
3088
+ version=$(pi --version 2>/dev/null || true)
3089
+ record_pi_version_evidence "$version" "Pi calm interactive E2E"
3090
+
3091
+ project="$TMP_ROOT/e2e-project"
3092
+ config="$TMP_ROOT/e2e-config"
3093
+ home="$TMP_ROOT/e2e-home"
3094
+ session_file="$TMP_ROOT/calm-session.jsonl"
3095
+ export_file="$TMP_ROOT/calm-export.html"
3096
+ export_dom="$TMP_ROOT/calm-export-dom.html"
3097
+ default_snapshot="$TMP_ROOT/default.txt"
3098
+ expanded_snapshot="$TMP_ROOT/expanded.txt"
3099
+ hidden_snapshot="$TMP_ROOT/hidden.txt"
3100
+ active_before_snapshot="$TMP_ROOT/active-before.txt"
3101
+ active_hidden_snapshot="$TMP_ROOT/active-hidden.txt"
3102
+ export_snapshot="$TMP_ROOT/export.txt"
3103
+ export_settled_snapshot="$TMP_ROOT/export-settled.txt"
3104
+ restored_snapshot="$TMP_ROOT/restored.txt"
3105
+ working_snapshot="$TMP_ROOT/working.txt"
3106
+ working_response_snapshot="$TMP_ROOT/working-response.txt"
3107
+ boat_frame_one="$TMP_ROOT/boat-frame-one.txt"
3108
+ boat_frame_two="$TMP_ROOT/boat-frame-two.txt"
3109
+ boat_resized_snapshot="$TMP_ROOT/boat-resized.txt"
3110
+ boat_focus_snapshot="$TMP_ROOT/boat-focus.txt"
3111
+ boat_cleared_snapshot="$TMP_ROOT/boat-cleared.txt"
3112
+ boat_color_snapshot="$TMP_ROOT/boat-color.txt"
3113
+ boat_water_snapshot="$TMP_ROOT/boat-water.txt"
3114
+ boat_narrow_snapshot="$TMP_ROOT/boat-narrow.txt"
3115
+ boat_freeze_snapshot="$TMP_ROOT/boat-freeze.txt"
3116
+ boat_resume_snapshot="$TMP_ROOT/boat-resume.txt"
3117
+ restarted_snapshot="$TMP_ROOT/restarted.txt"
3118
+ resumed_restored_snapshot="$TMP_ROOT/resumed-restored.txt"
3119
+ mkdir -p "$project/.pi/extensions/lib" "$project/bin" "$project/state" "$config" "$home/config"
3120
+ fm_git_init_commit "$project"
3121
+ : > "$project/AGENTS.md"
3122
+ cp "$EXT" "$project/.pi/extensions/fm-calm.ts"
3123
+ cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts"
3124
+ cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts"
3125
+ cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts"
3126
+ cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts"
3127
+ cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$project/.pi/extensions/lib/fm-operational-input.ts"
3128
+ cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$project/.pi/extensions/lib/fm-branch-dispatch.ts"
3129
+ cp "$WATCH_EXT" "$project/.pi/extensions/fm-primary-pi-watch.ts"
3130
+ cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$project/.pi/extensions/fm-primary-turnend-guard.ts"
3131
+ cp \
3132
+ "$ROOT/bin/fm-sessionstart-run.sh" \
3133
+ "$ROOT/bin/fm-sessionstart-nudge.sh" \
3134
+ "$ROOT/bin/fm-primary-scope-lib.sh" \
3135
+ "$ROOT/bin/fm-gate-refuse-lib.sh" \
3136
+ "$ROOT/bin/fm-operational-input.sh" \
3137
+ "$project/bin/"
3138
+ # The real digest is out of scope here: this lab is about how Calm RENDERS the
3139
+ # session-open message and whether it keeps its operational provenance, not
3140
+ # about what session start reports. A stub keeps the run tier's real routing
3141
+ # and the extension's real encoding in the path without dragging a whole
3142
+ # fleet home into a rendering test.
3143
+ cat >"$project/bin/fm-session-start.sh" <<'SH'
3144
+ #!/usr/bin/env bash
3145
+ printf 'CALM_E2E_SESSION_START_DIGEST\n'
3146
+ exit 0
3147
+ SH
3148
+ chmod +x "$project/bin/"*.sh
3149
+ cat >"$project/.pi/extensions/fm-calm-e2e-inject.ts" <<'TS'
3150
+ import {
3151
+ type AssistantMessage,
3152
+ createAssistantMessageEventStream,
3153
+ } from "@earendil-works/pi-ai";
3154
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3155
+ import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts";
3156
+
3157
+ export default function (pi: ExtensionAPI): void {
3158
+ pi.registerProvider("calm-e2e", {
3159
+ baseUrl: "http://127.0.0.1/unused",
3160
+ apiKey: "test-only",
3161
+ api: "calm-e2e-api",
3162
+ models: [
3163
+ {
3164
+ id: "delayed",
3165
+ name: "Delayed Calm working-row fixture",
3166
+ reasoning: false,
3167
+ input: ["text"],
3168
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3169
+ contextWindow: 4096,
3170
+ maxTokens: 128,
3171
+ },
3172
+ {
3173
+ id: "delayed-boat",
3174
+ name: "Long-delay Calm working-ship fixture",
3175
+ reasoning: false,
3176
+ input: ["text"],
3177
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3178
+ contextWindow: 4096,
3179
+ maxTokens: 128,
3180
+ },
3181
+ {
3182
+ id: "operational-error",
3183
+ name: "Calm gapless operational-row fixture",
3184
+ reasoning: false,
3185
+ input: ["text"],
3186
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3187
+ contextWindow: 4096,
3188
+ maxTokens: 128,
3189
+ },
3190
+ ],
3191
+ streamSimple(model, _context, options) {
3192
+ const stream = createAssistantMessageEventStream();
3193
+ const output: AssistantMessage = {
3194
+ role: "assistant",
3195
+ content: [],
3196
+ api: model.api,
3197
+ provider: model.provider,
3198
+ model: model.id,
3199
+ usage: {
3200
+ input: 0,
3201
+ output: 0,
3202
+ cacheRead: 0,
3203
+ cacheWrite: 0,
3204
+ totalTokens: 0,
3205
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
3206
+ },
3207
+ stopReason: "stop",
3208
+ timestamp: Date.now(),
3209
+ };
3210
+ void (async () => {
3211
+ if (model.id === "operational-error") {
3212
+ await new Promise((resolve) => setTimeout(resolve, 25));
3213
+ output.stopReason = "error";
3214
+ output.errorMessage = "CALM_OPERATIONAL_E2E_ERROR";
3215
+ stream.push({ type: "error", reason: "error", error: output });
3216
+ stream.end();
3217
+ return;
3218
+ }
3219
+ // Wake as soon as the run is aborted so Escape settles the turn promptly.
3220
+ await new Promise<void>((resolve) => {
3221
+ const timer = setTimeout(resolve, model.id === "delayed-boat" ? 90000 : 1500);
3222
+ options?.signal?.addEventListener(
3223
+ "abort",
3224
+ () => {
3225
+ clearTimeout(timer);
3226
+ resolve();
3227
+ },
3228
+ { once: true },
3229
+ );
3230
+ });
3231
+ if (options?.signal?.aborted) {
3232
+ output.stopReason = "aborted";
3233
+ stream.push({ type: "error", reason: "aborted", error: output });
3234
+ stream.end();
3235
+ return;
3236
+ }
3237
+ stream.push({ type: "start", partial: output });
3238
+ const block = { type: "text" as const, text: "" };
3239
+ output.content.push(block);
3240
+ stream.push({ type: "text_start", contentIndex: 0, partial: output });
3241
+ block.text = "CALM_WORKING_E2E_RESPONSE";
3242
+ stream.push({ type: "text_delta", contentIndex: 0, delta: block.text, partial: output });
3243
+ stream.push({ type: "text_end", contentIndex: 0, content: block.text, partial: output });
3244
+ stream.push({ type: "done", reason: "stop", message: output });
3245
+ stream.end();
3246
+ })();
3247
+ return stream;
3248
+ },
3249
+ });
3250
+
3251
+ pi.registerCommand("calm-diagnostic-e2e", {
3252
+ description: "Add the Calm transient diagnostic fixture.",
3253
+ handler: async (_args, ctx) => {
3254
+ ctx.ui.notify("CALM_TRANSIENT_DIAGNOSTIC", "warning");
3255
+ },
3256
+ });
3257
+ pi.registerCommand("calm-inject-e2e", {
3258
+ description: "Inject one current Calm operational kind.",
3259
+ handler: async (args, ctx) => {
3260
+ const fixtures = new Map([
3261
+ ["watcher", "CURRENT_WATCHER_E2E /tmp/active-probe.status"],
3262
+ ["turn-end-guard", "CURRENT_TURN_END_E2E"],
3263
+ ["away-supervisor", "CURRENT_AWAY_E2E"],
3264
+ ["from-firstmate", "corr=0123456789abcdef CURRENT_FROM_FIRSTMATE_E2E"],
3265
+ ["launch-brief", "CURRENT_LAUNCH_BRIEF_E2E"],
3266
+ ] as const);
3267
+ const kind = args.trim() as Parameters<typeof encodeFirstmateOperationalInput>[0];
3268
+ const body = fixtures.get(kind);
3269
+ if (!body) throw new Error(`unknown current operational kind: ${kind}`);
3270
+ const model = ctx.modelRegistry.find("calm-e2e", "operational-error");
3271
+ if (!model || !(await pi.setModel(model))) {
3272
+ throw new Error("could not select the deterministic Calm operational-error model");
3273
+ }
3274
+ await pi.sendUserMessage(encodeFirstmateOperationalInput(kind, body), {
3275
+ deliverAs: "followUp",
3276
+ });
3277
+ },
3278
+ });
3279
+ pi.registerCommand("calm-boat-e2e", {
3280
+ description: "Start the long-delay working-ship fixture.",
3281
+ handler: async (_args, ctx) => {
3282
+ const model = ctx.modelRegistry.find("calm-e2e", "delayed-boat");
3283
+ if (!model || !(await pi.setModel(model))) {
3284
+ throw new Error("could not select the long-delay Calm E2E model");
3285
+ }
3286
+ await pi.sendUserMessage("CALM_BOAT_E2E_PROMPT");
3287
+ },
3288
+ });
3289
+ pi.registerCommand("calm-working-e2e", {
3290
+ description: "Start the delayed native Working-row fixture.",
3291
+ handler: async (_args, ctx) => {
3292
+ const model = ctx.modelRegistry.find("calm-e2e", "delayed");
3293
+ if (!model || !(await pi.setModel(model))) {
3294
+ throw new Error("could not select the deterministic Calm E2E model");
3295
+ }
3296
+ await pi.sendUserMessage("CALM_WORKING_E2E_PROMPT");
3297
+ },
3298
+ });
3299
+ }
3300
+ TS
3301
+ printf '%s\n' '{"tui.input.submit":"alt+s"}' >"$config/keybindings.json"
3302
+ printf '%s\n' '{"hideThinkingBlock":true}' >"$config/settings.json"
3303
+ now=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
3304
+ cat >"$session_file" <<JSON
3305
+ {"type":"session","version":3,"id":"11111111-1111-4111-8111-111111111111","timestamp":"$now","cwd":"$project"}
3306
+ {"type":"message","id":"a0000001","parentId":null,"timestamp":"$now","message":{"role":"user","content":[{"type":"text","text":"Show a deterministic tool example."}],"timestamp":1}}
3307
+ {"type":"message","id":"a0000002","parentId":"a0000001","timestamp":"$now","message":{"role":"assistant","content":[{"type":"thinking","thinking":"first internal reasoning block"},{"type":"text","text":"I will run one command."},{"type":"toolCall","id":"call_calm_e2e","name":"bash","arguments":{"command":"printf 'CALM_E2E_OUTPUT\\n'"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":2}}
3308
+ {"type":"message","id":"a0000003","parentId":"a0000002","timestamp":"$now","message":{"role":"toolResult","toolCallId":"call_calm_e2e","toolName":"bash","content":[{"type":"text","text":"CALM_E2E_OUTPUT"}],"details":{},"isError":false,"timestamp":3}}
3309
+ {"type":"message","id":"a0000004","parentId":"a0000003","timestamp":"$now","message":{"role":"assistant","content":[{"type":"thinking","thinking":"second internal reasoning block"},{"type":"toolCall","id":"call_grep_e2e","name":"grep","arguments":{"pattern":"CALM_EXPORT_GREP","path":"."}},{"type":"toolCall","id":"call_find_e2e","name":"find","arguments":{"pattern":"CALM_EXPORT_FIND*","path":"."}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":2,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":3,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":4}}
3310
+ {"type":"message","id":"a0000005","parentId":"a0000004","timestamp":"$now","message":{"role":"toolResult","toolCallId":"call_grep_e2e","toolName":"grep","content":[{"type":"text","text":"sample.txt:1:CALM_EXPORT_GREP"}],"details":{},"isError":false,"timestamp":5}}
3311
+ {"type":"message","id":"a0000006","parentId":"a0000005","timestamp":"$now","message":{"role":"toolResult","toolCallId":"call_find_e2e","toolName":"find","content":[{"type":"text","text":"CALM_EXPORT_FIND.txt"}],"details":{},"isError":false,"timestamp":6}}
3312
+ {"type":"message","id":"a0000007","parentId":"a0000006","timestamp":"$now","message":{"role":"assistant","content":[{"type":"thinking","thinking":"third internal reasoning block"},{"type":"toolCall","id":"call_watch_e2e","name":"fm_watch_arm_pi","arguments":{}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":2,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":3,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":7}}
3313
+ {"type":"message","id":"a0000008","parentId":"a0000007","timestamp":"$now","message":{"role":"toolResult","toolCallId":"call_watch_e2e","toolName":"fm_watch_arm_pi","content":[{"type":"text","text":"watcher: started Pi extension arm child 1"}],"details":{"ok":true,"message":"watcher: started Pi extension arm child 1"},"isError":false,"timestamp":8}}
3314
+ {"type":"custom","id":"a0000009","parentId":"a0000008","timestamp":"$now","customType":"firstmate-synthetic-input-presentation","data":{"content":"FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status\\n\\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.","kind":"watcher"}}
3315
+ {"type":"custom_message","id":"a0000010","parentId":"a0000009","timestamp":"$now","customType":"firstmate-synthetic-input","content":"FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status\\n\\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.","display":false,"details":{"kind":"watcher"}}
3316
+ {"type":"message","id":"a0000011","parentId":"a0000010","timestamp":"$now","message":{"role":"user","content":[{"type":"text","text":"FIRSTMATE WATCHER WAKE: can you explain this phrase?"}],"timestamp":11}}
3317
+ {"type":"message","id":"a0000012","parentId":"a0000011","timestamp":"$now","message":{"role":"user","content":[{"type":"text","text":"Captain quote: \u2063FIRSTMATE_OP: v1 watcher: QUOTED_CURRENT_NEAR_MISS"}],"timestamp":12}}
3318
+ {"type":"message","id":"a0000013","parentId":"a0000012","timestamp":"$now","message":{"role":"user","content":[{"type":"text","text":"FIRSTMATE_OP: v1 watcher: ASCII_ONLY_NEAR_MISS"}],"timestamp":13}}
3319
+ {"type":"message","id":"a0000014","parentId":"a0000013","timestamp":"$now","message":{"role":"user","content":[{"type":"text","text":"Ordinary captain text before \u2063FIRSTMATE_OP: v1 watcher: EMBEDDED_CURRENT_NEAR_MISS"}],"timestamp":14}}
3320
+ {"type":"message","id":"a0000015","parentId":"a0000014","timestamp":"$now","message":{"role":"user","content":[{"type":"text","text":"\u2063ordinary captain text after unrelated separator"}],"timestamp":15}}
3321
+ {"type":"message","id":"a0000016","parentId":"a0000015","timestamp":"$now","message":{"role":"assistant","content":[{"type":"text","text":"The deterministic tool example is complete."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":2,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":3,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":16}}
3322
+ JSON
3323
+
3324
+ tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \
3325
+ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30"
3326
+ wait_for_text "$default_snapshot" "The deterministic tool example is complete." \
3327
+ || fail "Pi calm E2E did not reach the restored session transcript"
3328
+ assert_contains "$(cat "$default_snapshot")" "CALM_E2E_OUTPUT" "calm mode was not off by default"
3329
+ assert_contains "$(cat "$default_snapshot")" "fm_watch_arm_pi" "Calm-off transcript did not show the Firstmate watcher tool"
3330
+ assert_contains "$(cat "$default_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "Calm-off transcript did not show the synthetic Firstmate presentation row"
3331
+ assert_contains "$(cat "$default_snapshot")" "Thinking..." "reasoning fixture did not render Pi's collapsed thinking label"
3332
+ assert_contains "$(cat "$default_snapshot")" "fm-calm.ts" "project-local Pi calm extension did not auto-load"
3333
+ # shellcheck disable=SC2016 # Backticks are literal prompt markup.
3334
+ assert_not_contains "$(cat "$default_snapshot")" 'Run `bin/fm-session-start.sh` now' \
3335
+ "native session-start context unexpectedly rendered while Calm was off"
3336
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-o
3337
+ wait_for_text "$expanded_snapshot" "escape to interrupt" \
3338
+ || fail "Ctrl+O did not retain Pi's ordinary startup and tool expansion behavior"
3339
+ # The expansion redraw lands a frame or two after the footer hint, so wait for the
3340
+ # tool output this block actually asserts instead of assuming one implies the other.
3341
+ wait_for_text "$expanded_snapshot" "CALM_E2E_OUTPUT" \
3342
+ || fail "ordinary Ctrl+O expansion hid tool activity while calm mode was off"
3343
+ assert_contains "$(cat "$expanded_snapshot")" "CALM_E2E_OUTPUT" "ordinary Ctrl+O expansion hid tool activity while calm mode was off"
3344
+
3345
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
3346
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3347
+ active_screen_wait=0
3348
+ while [ "$active_screen_wait" -lt 120 ]; do
3349
+ # Include scrollback: the built-in tool rows this documented bound keeps visible
3350
+ # (see below) lengthen the transcript enough to push earlier genuine content, such
3351
+ # as the original user prompt, above the plain viewport.
3352
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$hidden_snapshot"
3353
+ # Wait for the redraw this block actually asserts: the collapsed-thinking adapter
3354
+ # (unconditional, unaffected by the built-in tool gate below) hides, and the
3355
+ # retained genuine rows are back on screen. Built-in tool rows from before this
3356
+ # first-ever activation are a separate, documented exception (see fm-calm.ts's
3357
+ # file header and docs/calm.md): Pi gives no way to re-point an already-rendered
3358
+ # tool row at a definition registered later, so CALM_E2E_OUTPUT and friends stay
3359
+ # on screen through this whole redraw rather than disappearing with it.
3360
+ if ! grep -Fq "Thinking..." "$hidden_snapshot" &&
3361
+ ! grep -Fq "/calm" "$hidden_snapshot" &&
3362
+ ! grep -Fq "I will run one command." "$hidden_snapshot" &&
3363
+ grep -Fq "FIRSTMATE WATCHER WAKE: can you explain this phrase?" "$hidden_snapshot" &&
3364
+ grep -Fq "The deterministic tool example is complete." "$hidden_snapshot"; then
3365
+ break
3366
+ fi
3367
+ sleep 0.05
3368
+ active_screen_wait=$((active_screen_wait + 1))
3369
+ done
3370
+ # This session's built-in tool rows (bash/grep/find) were all rendered during the
3371
+ # initial session restore, before Calm's first-ever activation in this session had
3372
+ # claimed any built-in name; they keep their stock presentation for the rest of the
3373
+ # session. This is the captain-accepted, documented bound on the collision fix (see
3374
+ # fm-calm.ts's file header and docs/calm.md): the alternative was letting Calm
3375
+ # silently disable a differently loaded extension's own bash/read/etc override. A
3376
+ # fresh built-in tool call made after this same activation does hide correctly;
3377
+ # that path is covered by this file's own test_calm_activation_collision_and
3378
+ # _regression_bound against real Pi rendering components, not repeated here.
3379
+ assert_contains "$(cat "$hidden_snapshot")" "CALM_E2E_OUTPUT" "a pre-activation built-in tool row unexpectedly hid; the documented bound regressed"
3380
+ assert_not_contains "$(cat "$hidden_snapshot")" "calm transcript" "/calm added a persistent Calm status row"
3381
+ [ "$(cat "$home/config/calm")" = on ] || fail "/calm did not persist its active choice"
3382
+ assert_contains "$(cat "$hidden_snapshot")" "CALM_EXPORT_GREP" "a pre-activation grep row unexpectedly hid; the documented bound regressed"
3383
+ assert_contains "$(cat "$hidden_snapshot")" "CALM_EXPORT_FIND" "a pre-activation find row unexpectedly hid; the documented bound regressed"
3384
+ assert_not_contains "$(cat "$hidden_snapshot")" "Thinking..." "/calm left collapsed thinking labels in the transcript"
3385
+ assert_not_contains "$(cat "$hidden_snapshot")" "fm_watch_arm_pi" "/calm left the Firstmate watcher tool call shell in the transcript"
3386
+ assert_not_contains "$(cat "$hidden_snapshot")" "watcher: started Pi extension arm child" "/calm left the Firstmate watcher tool result in the transcript"
3387
+ assert_not_contains "$(cat "$hidden_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "/calm left a synthetic Firstmate user-role presentation in the transcript"
3388
+ assert_not_contains "$(cat "$hidden_snapshot")" "Tool activity is hidden where supported" "/calm appended its own command-status row"
3389
+ assert_contains "$(cat "$hidden_snapshot")" "Show a deterministic tool example." "/calm removed a genuine user prompt"
3390
+ assert_contains "$(cat "$hidden_snapshot")" "FIRSTMATE WATCHER WAKE: can you explain this phrase?" "/calm hid a genuine near-miss user prompt"
3391
+ for near_miss in \
3392
+ QUOTED_CURRENT_NEAR_MISS \
3393
+ ASCII_ONLY_NEAR_MISS \
3394
+ EMBEDDED_CURRENT_NEAR_MISS \
3395
+ "ordinary captain text after unrelated separator"
3396
+ do
3397
+ assert_contains "$(cat "$hidden_snapshot")" "$near_miss" "/calm hid the genuine operational near miss $near_miss"
3398
+ done
3399
+ # Mid-turn narration emitted alongside the tool call is a working note, which Calm
3400
+ # hides against the real Pi renderer; the genuine reply that ended the response stays.
3401
+ assert_not_contains "$(cat "$hidden_snapshot")" "I will run one command." "/calm left a mid-turn assistant working note in the transcript"
3402
+ assert_contains "$(cat "$hidden_snapshot")" "The deterministic tool example is complete." "/calm removed assistant conversation after a tool"
3403
+
3404
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-diagnostic-e2e"
3405
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3406
+ active_screen_wait=0
3407
+ while [ "$active_screen_wait" -lt 120 ]; do
3408
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$active_before_snapshot"
3409
+ if grep -Fq "Warning: CALM_TRANSIENT_DIAGNOSTIC" "$active_before_snapshot" &&
3410
+ ! grep -Fq "/calm-diagnostic-e2e" "$active_before_snapshot"; then
3411
+ break
3412
+ fi
3413
+ sleep 0.05
3414
+ active_screen_wait=$((active_screen_wait + 1))
3415
+ done
3416
+ assert_contains "$(cat "$active_before_snapshot")" "Warning: CALM_TRANSIENT_DIAGNOSTIC" "transient diagnostic fixture was not shown"
3417
+ assert_not_contains "$(cat "$active_before_snapshot")" "/calm-diagnostic-e2e" "transient diagnostic command did not leave the editor"
3418
+
3419
+ for fixture in \
3420
+ "watcher|CURRENT_WATCHER_E2E" \
3421
+ "turn-end-guard|CURRENT_TURN_END_E2E" \
3422
+ "away-supervisor|CURRENT_AWAY_E2E" \
3423
+ "from-firstmate|CURRENT_FROM_FIRSTMATE_E2E" \
3424
+ "launch-brief|CURRENT_LAUNCH_BRIEF_E2E"
3425
+ do
3426
+ kind=${fixture%%|*}
3427
+ needle=${fixture#*|}
3428
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-inject-e2e $kind"
3429
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3430
+ active_wait=0
3431
+ while ! grep -F '"role":"user"' "$session_file" 2>/dev/null |
3432
+ grep -Fq "$needle" && [ "$active_wait" -lt 120 ]; do
3433
+ sleep 0.05
3434
+ active_wait=$((active_wait + 1))
3435
+ done
3436
+ grep -F '"role":"user"' "$session_file" |
3437
+ grep -Fq "$needle" \
3438
+ || fail "current operational kind $kind did not retain user-role delivery while Calm was active"
3439
+ sleep 0.1
3440
+ done
3441
+ node - "$session_file" <<'JS' || fail "native Pi did not preserve every exact current operational kind"
3442
+ const fs = require("node:fs");
3443
+ const entries = fs.readFileSync(process.argv[2], "utf8").trim().split("\n").map(JSON.parse);
3444
+ const nativeSessionStart = entries.find((entry) =>
3445
+ entry.type === "custom_message" &&
3446
+ entry.customType === "firstmate-sessionstart-nudge"
3447
+ );
3448
+ if (
3449
+ !nativeSessionStart ||
3450
+ nativeSessionStart.display !== false ||
3451
+ nativeSessionStart.details?.kind !== "session-start" ||
3452
+ !nativeSessionStart.content?.startsWith("\u2063FIRSTMATE_OP: v1 session-start: ")
3453
+ ) {
3454
+ throw new Error(`native session-start provenance was not retained: ${JSON.stringify(nativeSessionStart)}`);
3455
+ }
3456
+ const expected = new Map([
3457
+ ["CURRENT_WATCHER_E2E", "watcher"],
3458
+ ["CURRENT_TURN_END_E2E", "turn-end-guard"],
3459
+ ["CURRENT_AWAY_E2E", "away-supervisor"],
3460
+ ["CURRENT_FROM_FIRSTMATE_E2E", "from-firstmate"],
3461
+ ["CURRENT_LAUNCH_BRIEF_E2E", "launch-brief"],
3462
+ ]);
3463
+ const current = entries.filter((entry) =>
3464
+ entry.type === "message" &&
3465
+ entry.message?.role === "user" &&
3466
+ [...expected.keys()].some((needle) => JSON.stringify(entry.message.content).includes(needle))
3467
+ );
3468
+ if (current.length !== expected.size) {
3469
+ throw new Error(`expected ${expected.size} user-role current entries, found ${current.length}: ${JSON.stringify(current)}`);
3470
+ }
3471
+ for (const [needle, kind] of expected) {
3472
+ const entry = current.find((candidate) => JSON.stringify(candidate.message.content).includes(needle));
3473
+ const text = entry?.message.content?.find((item) => item.type === "text")?.text;
3474
+ const exactEnvelope = kind === "from-firstmate"
3475
+ ? text?.startsWith("[fm-from-firstmate]\u2063corr=0123456789abcdef ")
3476
+ : text?.startsWith(`\u2063FIRSTMATE_OP: v1 ${kind}: `);
3477
+ if (!entry || !exactEnvelope) {
3478
+ throw new Error(`expected exact user-role ${needle} as ${kind}, found ${JSON.stringify(entry)}`);
3479
+ }
3480
+ }
3481
+ JS
3482
+ active_screen_wait=0
3483
+ while [ "$active_screen_wait" -lt 120 ]; do
3484
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$active_hidden_snapshot"
3485
+ if grep -Fq " Error:" "$active_hidden_snapshot" &&
3486
+ ! grep -Fq "/calm-inject-e2e" "$active_hidden_snapshot"; then
3487
+ break
3488
+ fi
3489
+ sleep 0.05
3490
+ active_screen_wait=$((active_screen_wait + 1))
3491
+ done
3492
+ assert_not_contains "$(cat "$active_hidden_snapshot")" "/calm-inject-e2e" "synthetic lifecycle command did not leave the editor"
3493
+ # shellcheck disable=SC2016 # Backticks are literal prompt markup.
3494
+ assert_not_contains "$(cat "$active_hidden_snapshot")" 'Run `bin/fm-session-start.sh` now' \
3495
+ "Calm showed the native session-start operational input"
3496
+ for hidden in \
3497
+ CURRENT_WATCHER_E2E \
3498
+ CURRENT_TURN_END_E2E \
3499
+ CURRENT_AWAY_E2E \
3500
+ CURRENT_FROM_FIRSTMATE_E2E \
3501
+ CURRENT_LAUNCH_BRIEF_E2E
3502
+ do
3503
+ assert_not_contains "$(cat "$active_hidden_snapshot")" "$hidden" "Calm rendered operational input $hidden"
3504
+ done
3505
+ assert_contains "$(cat "$active_hidden_snapshot")" "Warning: CALM_TRANSIENT_DIAGNOSTIC" "operational arrival lost its preceding transient diagnostic"
3506
+ assert_contains "$(cat "$active_hidden_snapshot")" " Error:" "operational delivery did not produce a transient provider diagnostic"
3507
+ hash_before=$(shasum -a 256 "$session_file" | awk '{print $1}')
3508
+
3509
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/export $export_file"
3510
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3511
+ wait_for_text "$export_snapshot" "Session exported to: $export_file" \
3512
+ || fail "/export did not complete while calm mode was on"
3513
+ node - "$export_file" <<'JS' || fail "calm-mode HTML export lost tool data or persisted synthetic provenance"
3514
+ const html = require("node:fs").readFileSync(process.argv[2], "utf8");
3515
+ const match = html.match(/<script id="session-data" type="application\/json">([^<]+)<\/script>/);
3516
+ if (!match) process.exit(1);
3517
+ const session = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
3518
+ for (const id of ["call_grep_e2e", "call_find_e2e", "call_watch_e2e"]) {
3519
+ const rendered = session.renderedTools?.[id];
3520
+ if (!rendered?.callHtml || !rendered?.resultHtmlExpanded) process.exit(1);
3521
+ }
3522
+ const entries = session.session?.entries ?? session.entries ?? [];
3523
+ const serialized = JSON.stringify(entries);
3524
+ if (!serialized.includes("firstmate-synthetic-input") || !serialized.includes("/tmp/probe.status")) process.exit(1);
3525
+ const synthetic = entries.find((entry) => entry.type === "custom_message" && entry.customType === "firstmate-synthetic-input");
3526
+ if (!synthetic || synthetic.display) process.exit(1);
3527
+ JS
3528
+ chrome=$(find_chrome) || fail "Chrome or Chromium is required for rendered export DOM assertions"
3529
+ "$chrome" \
3530
+ --headless=new \
3531
+ --disable-gpu \
3532
+ --no-sandbox \
3533
+ --user-data-dir="$TMP_ROOT/chrome-profile" \
3534
+ --virtual-time-budget=2000 \
3535
+ --dump-dom \
3536
+ "file://$export_file" >"$export_dom" 2>/dev/null &
3537
+ chrome_pid=$!
3538
+ chrome_wait=0
3539
+ while kill -0 "$chrome_pid" 2>/dev/null && [ "$chrome_wait" -lt 100 ]; do
3540
+ grep -Fq '</html>' "$export_dom" 2>/dev/null && break
3541
+ sleep 0.1
3542
+ chrome_wait=$((chrome_wait + 1))
3543
+ done
3544
+ kill "$chrome_pid" 2>/dev/null || true
3545
+ # Chrome can retain --headless=new after --dump-dom completes and ignore TERM,
3546
+ # so an unbounded wait can hang after the complete DOM has been captured.
3547
+ chrome_reap_wait=0
3548
+ while kill -0 "$chrome_pid" 2>/dev/null && [ "$chrome_reap_wait" -lt 20 ]; do
3549
+ sleep 0.1
3550
+ chrome_reap_wait=$((chrome_reap_wait + 1))
3551
+ done
3552
+ if kill -0 "$chrome_pid" 2>/dev/null; then
3553
+ kill "$chrome_pid" 2>/dev/null || true
3554
+ fi
3555
+ wait "$chrome_pid" 2>/dev/null || true
3556
+ grep -Fq '</html>' "$export_dom" 2>/dev/null \
3557
+ || fail "could not render calm-mode HTML export DOM"
3558
+ node - "$export_dom" <<'JS' || fail "rendered export DOM violated the Calm conversation boundary"
3559
+ const dom = require("node:fs").readFileSync(process.argv[2], "utf8");
3560
+ const messages = dom.match(/<div id="messages">([\s\S]*?)<\/main>/)?.[1];
3561
+ const tree = dom.match(/<div[^>]*id="tree-container"[^>]*>([\s\S]*?)<div[^>]*id="tree-status"/)?.[1];
3562
+ if (!messages || !tree) process.exit(1);
3563
+ if (!/<div class="user-message"[^>]*>[\s\S]*Show a deterministic tool example\./.test(messages)) process.exit(1);
3564
+ if (!/<div class="assistant-message"[^>]*>[\s\S]*The deterministic tool example is complete\./.test(messages)) process.exit(1);
3565
+ if (messages.includes('<div class="hook-message"')) process.exit(1);
3566
+ if (messages.includes("[firstmate-synthetic-input]")) process.exit(1);
3567
+ for (const current of ["CURRENT_WATCHER_E2E", "CURRENT_TURN_END_E2E", "CURRENT_AWAY_E2E", "CURRENT_FROM_FIRSTMATE_E2E", "CURRENT_LAUNCH_BRIEF_E2E"]) {
3568
+ if (!messages.includes(current)) process.exit(1);
3569
+ }
3570
+ if (!tree.includes("firstmate-synthetic-input") || !tree.includes("/tmp/probe.status")) process.exit(1);
3571
+ JS
3572
+ # Calm returns the transcript to its own presentation once the export has been
3573
+ # rendered. That repaint runs on the macrotask right after Pi prints the export
3574
+ # confirmation, so it must not overwrite it: the captain has to keep seeing where
3575
+ # their export landed. The export-data assertions above take seconds of real time,
3576
+ # so this snapshot is taken well after that repaint has settled rather than racing it.
3577
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$export_settled_snapshot"
3578
+ assert_contains "$(cat "$export_settled_snapshot")" "Session exported to: $export_file" \
3579
+ "Calm's post-export repaint overwrote Pi's export confirmation"
3580
+ assert_not_contains "$(cat "$export_settled_snapshot")" "fm_watch_arm_pi" \
3581
+ "/export left the Firstmate watcher tool call shell in the Calm transcript"
3582
+ assert_not_contains "$(cat "$export_settled_snapshot")" "watcher: started Pi extension arm child" \
3583
+ "/export left the Firstmate watcher tool result in the Calm transcript"
3584
+ assert_not_contains "$(cat "$export_settled_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" \
3585
+ "/export left a synthetic Firstmate user-role presentation in the Calm transcript"
3586
+ assert_not_contains "$(cat "$export_settled_snapshot")" "Thinking..." \
3587
+ "/export left collapsed thinking labels in the Calm transcript"
3588
+ assert_not_contains "$(cat "$export_settled_snapshot")" "I will run one command." \
3589
+ "/export left a mid-turn assistant working note in the Calm transcript"
3590
+ for hidden in \
3591
+ CURRENT_WATCHER_E2E \
3592
+ CURRENT_TURN_END_E2E \
3593
+ CURRENT_AWAY_E2E \
3594
+ CURRENT_FROM_FIRSTMATE_E2E \
3595
+ CURRENT_LAUNCH_BRIEF_E2E
3596
+ do
3597
+ assert_not_contains "$(cat "$export_settled_snapshot")" "$hidden" \
3598
+ "/export left operational input $hidden in the Calm transcript"
3599
+ done
3600
+ assert_contains "$(cat "$export_settled_snapshot")" "Show a deterministic tool example." \
3601
+ "/export removed a genuine user prompt from the Calm transcript"
3602
+ assert_contains "$(cat "$export_settled_snapshot")" "The deterministic tool example is complete." \
3603
+ "/export removed genuine assistant conversation from the Calm transcript"
3604
+
3605
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
3606
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3607
+ wait_for_text "$restored_snapshot" "CALM_E2E_OUTPUT" \
3608
+ || fail "second /calm did not restore tool result output"
3609
+ wait_for_text "$restored_snapshot" "/tmp/active-probe.status" \
3610
+ || fail "second /calm did not restore a synthetic row received while Calm was active"
3611
+ assert_contains "$(cat "$restored_snapshot")" "fm_watch_arm_pi" "second /calm did not restore the Firstmate watcher tool shell"
3612
+ assert_contains "$(cat "$restored_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "second /calm did not restore the synthetic Firstmate user row"
3613
+ for restored in \
3614
+ CURRENT_WATCHER_E2E \
3615
+ CURRENT_TURN_END_E2E \
3616
+ CURRENT_AWAY_E2E \
3617
+ CURRENT_FROM_FIRSTMATE_E2E \
3618
+ CURRENT_LAUNCH_BRIEF_E2E
3619
+ do
3620
+ assert_contains "$(cat "$restored_snapshot")" "$restored" "second /calm did not restore current operational kind $restored"
3621
+ done
3622
+ assert_contains "$(cat "$restored_snapshot")" "Warning: CALM_TRANSIENT_DIAGNOSTIC" "second /calm dropped a transient diagnostic"
3623
+ assert_contains "$(cat "$restored_snapshot")" " Error:" "second /calm dropped the synthetic delivery diagnostic"
3624
+ assert_not_contains "$(cat "$restored_snapshot")" "Navigated to selected point" "second /calm added a navigation status row"
3625
+ assert_contains "$(cat "$restored_snapshot")" "Thinking..." "second /calm did not restore Pi's collapsed thinking labels"
3626
+ assert_contains "$(cat "$restored_snapshot")" "I will run one command." "second /calm did not restore the mid-turn assistant working note"
3627
+ assert_contains "$(cat "$restored_snapshot")" "escape to interrupt" "/calm changed the active Ctrl+O expansion state"
3628
+
3629
+ hash_after=$(shasum -a 256 "$session_file" | awk '{print $1}')
3630
+ [ "$hash_before" = "$hash_after" ] || fail "/calm changed the persisted session or context data"
3631
+
3632
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
3633
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3634
+ active_screen_wait=0
3635
+ # CALM_E2E_OUTPUT is not a useful redraw signal here: it is the pre-activation
3636
+ # bash row covered by the documented bound above, so it never leaves the screen
3637
+ # again this session regardless of this toggle.
3638
+ while [ "$active_screen_wait" -lt 120 ]; do
3639
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot"
3640
+ if ! grep -Fq "/calm" "$working_snapshot" &&
3641
+ [ "$(cat "$home/config/calm")" = on ]; then
3642
+ break
3643
+ fi
3644
+ sleep 0.05
3645
+ active_screen_wait=$((active_screen_wait + 1))
3646
+ done
3647
+ [ "$(cat "$home/config/calm")" = on ] || fail "third /calm did not persist the active choice"
3648
+
3649
+ # Calm on plus a genuinely active run replaces Pi's stock working row with the boat.
3650
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-boat-e2e"
3651
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3652
+ active_screen_wait=0
3653
+ while [ "$active_screen_wait" -lt 200 ]; do
3654
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot"
3655
+ if grep -Fq '\__/' "$working_snapshot"; then
3656
+ break
3657
+ fi
3658
+ sleep 0.025
3659
+ active_screen_wait=$((active_screen_wait + 1))
3660
+ done
3661
+ cp "$working_snapshot" "$boat_frame_one"
3662
+ assert_contains "$(cat "$boat_frame_one")" '\__/' "Calm did not show the working ship during a real provider wait"
3663
+ assert_not_contains "$(cat "$boat_frame_one")" "Working..." "Calm left Pi's stock working row visible while the ship was shown"
3664
+ assert_not_contains "$(cat "$boat_frame_one")" "calm transcript" "the real provider wait showed a persistent Calm status row"
3665
+ assert_not_contains "$(cat "$boat_frame_one")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "the real provider wait restored a hidden operational row"
3666
+ boat_hull_line=$(grep -F '\__/' "$boat_frame_one" | head -1)
3667
+ boat_sail_line=$(grep -E '<\||\|>' "$boat_frame_one" | tail -1)
3668
+ case "$boat_sail_line" in
3669
+ *'<|'*|*'|>'*) : ;;
3670
+ *) fail "the working ship lost its directional mainsail" ;;
3671
+ esac
3672
+ assert_not_contains "$boat_hull_line" "Working" "the ship row carried extra status copy"
3673
+ case "$boat_hull_line" in
3674
+ *~*) : ;;
3675
+ *) fail "the working ship rendered no waves" ;;
3676
+ esac
3677
+ # Standard ANSI colors: blue water, yellow boat, no theme/bright/256/RGB escapes.
3678
+ tmux -L "$TMUX_SOCKET" capture-pane -p -e -t "$TMUX_SESSION" >"$boat_color_snapshot"
3679
+ boat_color_line=$(grep -F '\__/' "$boat_color_snapshot" | head -1)
3680
+ [ -n "$boat_color_line" ] || fail "could not capture a colored working-ship row"
3681
+ case "$boat_color_line" in
3682
+ *'[34m'*) : ;;
3683
+ *) fail "the water was not rendered with standard ANSI blue" ;;
3684
+ esac
3685
+ case "$boat_color_line" in
3686
+ *'[33m'*) : ;;
3687
+ *) fail "the boat was not rendered with standard ANSI yellow" ;;
3688
+ esac
3689
+ case "$boat_color_line" in
3690
+ *'[38;2;'*|*'[38;5;'*|*'[9'[0-9]'m'*) fail "the working ship used a non-standard color escape" ;;
3691
+ *) : ;;
3692
+ esac
3693
+
3694
+ # The water animates on its own faster cadence while the boat holds its column.
3695
+ boat_column_one=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_frame_one")
3696
+ boat_water_changed=0
3697
+ boat_water_first=$(grep -F '\__/' "$boat_frame_one" | head -1)
3698
+ active_screen_wait=0
3699
+ while [ "$active_screen_wait" -lt 60 ]; do
3700
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_water_snapshot"
3701
+ boat_water_line=$(grep -F '\__/' "$boat_water_snapshot" | head -1)
3702
+ boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_water_snapshot")
3703
+ if [ -n "$boat_water_line" ] && [ "$boat_column_two" = "$boat_column_one" ] &&
3704
+ [ "$boat_water_line" != "$boat_water_first" ]; then
3705
+ boat_water_changed=1
3706
+ break
3707
+ fi
3708
+ sleep 0.05
3709
+ active_screen_wait=$((active_screen_wait + 1))
3710
+ done
3711
+ [ "$boat_water_changed" -eq 1 ] \
3712
+ || fail "the water never animated while the working ship held its column"
3713
+
3714
+ # Two frames at different hull columns prove genuine horizontal motion.
3715
+ boat_column_two=""
3716
+ active_screen_wait=0
3717
+ while [ "$active_screen_wait" -lt 200 ]; do
3718
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_frame_two"
3719
+ boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_frame_two")
3720
+ if [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ]; then
3721
+ break
3722
+ fi
3723
+ sleep 0.05
3724
+ active_screen_wait=$((active_screen_wait + 1))
3725
+ done
3726
+ [ -n "$boat_column_two" ] || fail "the working ship disappeared between animation frames"
3727
+ [ "$boat_column_two" != "$boat_column_one" ] \
3728
+ || fail "the working ship never moved horizontally (stuck at column $boat_column_one)"
3729
+
3730
+ # The widget owns its own geometry, so resizing the same running TUI must reflow it.
3731
+ tmux -L "$TMUX_SOCKET" set-option -t "$TMUX_SESSION" window-size manual
3732
+ tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 100 -y 30
3733
+ active_screen_wait=0
3734
+ while [ "$active_screen_wait" -lt 200 ]; do
3735
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resized_snapshot"
3736
+ boat_hull_line=$(grep -F '\__/' "$boat_resized_snapshot" | head -1)
3737
+ if [ -n "$boat_hull_line" ] && [ "${#boat_hull_line}" -eq 100 ]; then
3738
+ break
3739
+ fi
3740
+ sleep 0.05
3741
+ active_screen_wait=$((active_screen_wait + 1))
3742
+ done
3743
+ assert_contains "$(cat "$boat_resized_snapshot")" '\__/' "the working ship left the screen after a resize"
3744
+ boat_hull_line=$(grep -F '\__/' "$boat_resized_snapshot" | head -1)
3745
+ [ "${#boat_hull_line}" -eq 100 ] \
3746
+ || fail "after resizing to 100 columns the ship row was ${#boat_hull_line} cells instead of exactly 100"
3747
+ # Exactly one wave row means the sprite reflowed rather than wrapping onto extra rows.
3748
+ [ "$(grep -c -F '\__/' "$boat_resized_snapshot")" -eq 1 ] \
3749
+ || fail "the working ship wrapped onto more than one water row after the resize"
3750
+ while IFS= read -r boat_line; do
3751
+ [ "${#boat_line}" -le 100 ] \
3752
+ || fail "a rendered line was ${#boat_line} cells after resizing to 100 columns"
3753
+ done <"$boat_resized_snapshot"
3754
+ boat_column_one=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resized_snapshot")
3755
+ [ "$boat_column_one" -le 97 ] \
3756
+ || fail "the working ship hull started at column $boat_column_one and cannot fit in 100 columns"
3757
+
3758
+ # Motion continues on-screen after the resize instead of jumping offscreen.
3759
+ boat_column_two=""
3760
+ active_screen_wait=0
3761
+ while [ "$active_screen_wait" -lt 200 ]; do
3762
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resized_snapshot"
3763
+ boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resized_snapshot")
3764
+ if [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ]; then
3765
+ break
3766
+ fi
3767
+ sleep 0.05
3768
+ active_screen_wait=$((active_screen_wait + 1))
3769
+ done
3770
+ [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ] \
3771
+ || fail "the working ship stopped moving after the resize"
3772
+ [ "$boat_column_two" -le 97 ] \
3773
+ || fail "the working ship moved offscreen after the resize"
3774
+
3775
+ # A narrow terminal shortens the track enough to observe both bounce directions.
3776
+ # The sail must show the heading it is about to travel, so a full traverse shows both.
3777
+ tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 12 -y 20
3778
+ boat_narrow_sails=""
3779
+ active_screen_wait=0
3780
+ while [ "$active_screen_wait" -lt 400 ]; do
3781
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_narrow_snapshot"
3782
+ if grep -Fq '<|' "$boat_narrow_snapshot"; then
3783
+ case "$boat_narrow_sails" in *R*) : ;; *) boat_narrow_sails="${boat_narrow_sails}R" ;; esac
3784
+ fi
3785
+ if grep -Fq '|>' "$boat_narrow_snapshot"; then
3786
+ case "$boat_narrow_sails" in *L*) : ;; *) boat_narrow_sails="${boat_narrow_sails}L" ;; esac
3787
+ fi
3788
+ case "$boat_narrow_sails" in
3789
+ *R*L*|*L*R*) break ;;
3790
+ esac
3791
+ sleep 0.1
3792
+ active_screen_wait=$((active_screen_wait + 1))
3793
+ done
3794
+ case "$boat_narrow_sails" in
3795
+ *R*L*|*L*R*) : ;;
3796
+ *) fail "the working ship never showed both sail headings on a narrow track (saw '$boat_narrow_sails')" ;;
3797
+ esac
3798
+ boat_hull_line=$(grep -F '\__/' "$boat_narrow_snapshot" | head -1)
3799
+ [ "${#boat_hull_line}" -eq 12 ] \
3800
+ || fail "the narrow working-ship row was ${#boat_hull_line} cells instead of exactly 12"
3801
+ tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 100 -y 30
3802
+
3803
+ # Typing still reaches the editor while the animation runs.
3804
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "FOCUSPROBE"
3805
+ wait_for_text "$boat_focus_snapshot" "FOCUSPROBE" \
3806
+ || fail "keyboard input did not reach the editor while the working ship animated"
3807
+ i=0
3808
+ while [ "$i" -lt 10 ]; do
3809
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" BSpace
3810
+ i=$((i + 1))
3811
+ done
3812
+
3813
+ # Capture the last on-screen column and sail before settling so the next working
3814
+ # period in this same Pi session can prove freeze/resume continuity.
3815
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_freeze_snapshot"
3816
+ boat_freeze_column=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_freeze_snapshot")
3817
+ boat_freeze_sail=$(grep -E '<\||\|>' "$boat_freeze_snapshot" | tail -1 || true)
3818
+ case "$boat_freeze_sail" in
3819
+ *'<|'*) boat_freeze_sail='<|' ;;
3820
+ *'|>'*) boat_freeze_sail='|>' ;;
3821
+ *) fail "could not read the freeze-frame sail heading" ;;
3822
+ esac
3823
+ [ -n "$boat_freeze_column" ] && [ "$boat_freeze_column" -gt 1 ] \
3824
+ || fail "freeze frame never left the left edge (column '${boat_freeze_column:-empty}')"
3825
+
3826
+ # Escape aborts the run, and the abort path removes the ship with no residue.
3827
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Escape
3828
+ active_screen_wait=0
3829
+ while [ "$active_screen_wait" -lt 200 ]; do
3830
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot"
3831
+ if ! grep -Fq '\__/' "$boat_cleared_snapshot"; then
3832
+ break
3833
+ fi
3834
+ sleep 0.05
3835
+ active_screen_wait=$((active_screen_wait + 1))
3836
+ done
3837
+ assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "Escape did not remove the working ship"
3838
+ assert_not_contains "$(cat "$boat_cleared_snapshot")" "CALM_WORKING_E2E_RESPONSE" "the long-delay fixture settled instead of aborting on Escape"
3839
+ assert_not_contains "$(cat "$boat_cleared_snapshot")" "FOCUSPROBE" "the editor kept the focus probe text after Escape"
3840
+
3841
+ # A later working period in the same Pi process must resume the frozen column and
3842
+ # sail rather than recreating the boat at the left edge. Capture the first resumed
3843
+ # frames quickly so the slow boat cadence cannot advance before the assertion.
3844
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-boat-e2e"
3845
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3846
+ boat_resume_column=""
3847
+ boat_resume_sail=""
3848
+ active_screen_wait=0
3849
+ while [ "$active_screen_wait" -lt 200 ]; do
3850
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resume_snapshot"
3851
+ if grep -Fq '\__/' "$boat_resume_snapshot"; then
3852
+ boat_resume_column=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resume_snapshot")
3853
+ boat_resume_sail=$(grep -E '<\||\|>' "$boat_resume_snapshot" | tail -1 || true)
3854
+ case "$boat_resume_sail" in
3855
+ *'<|'*) boat_resume_sail='<|' ;;
3856
+ *'|>'*) boat_resume_sail='|>' ;;
3857
+ esac
3858
+ break
3859
+ fi
3860
+ sleep 0.025
3861
+ active_screen_wait=$((active_screen_wait + 1))
3862
+ done
3863
+ [ -n "$boat_resume_column" ] \
3864
+ || fail "the second working period never showed the working ship"
3865
+ [ "$boat_resume_column" -eq "$boat_freeze_column" ] \
3866
+ || fail "the second working period reset the boat from column $boat_freeze_column to $boat_resume_column instead of resuming"
3867
+ [ "$boat_resume_sail" = "$boat_freeze_sail" ] \
3868
+ || fail "the second working period changed sail from $boat_freeze_sail to $boat_resume_sail"
3869
+ assert_not_contains "$(cat "$boat_resume_snapshot")" "Working..." \
3870
+ "the second working period left Pi's stock working row visible"
3871
+
3872
+ # Clear the resumed run before the Calm-off stock-row probe.
3873
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Escape
3874
+ active_screen_wait=0
3875
+ while [ "$active_screen_wait" -lt 200 ]; do
3876
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot"
3877
+ if ! grep -Fq '\__/' "$boat_cleared_snapshot"; then
3878
+ break
3879
+ fi
3880
+ sleep 0.05
3881
+ active_screen_wait=$((active_screen_wait + 1))
3882
+ done
3883
+ assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "Escape did not remove the resumed working ship"
3884
+
3885
+ # Calm off restores Pi's stock working row and never shows the ship.
3886
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
3887
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3888
+ active_screen_wait=0
3889
+ while [ "$active_screen_wait" -lt 200 ]; do
3890
+ if [ "$(cat "$home/config/calm")" = off ]; then
3891
+ break
3892
+ fi
3893
+ sleep 0.05
3894
+ active_screen_wait=$((active_screen_wait + 1))
3895
+ done
3896
+ [ "$(cat "$home/config/calm")" = off ] || fail "the Calm-off working-row probe did not turn Calm off"
3897
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-working-e2e"
3898
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3899
+ active_screen_wait=0
3900
+ while [ "$active_screen_wait" -lt 200 ]; do
3901
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot"
3902
+ if grep -Fq "Working..." "$working_snapshot"; then
3903
+ break
3904
+ fi
3905
+ sleep 0.025
3906
+ active_screen_wait=$((active_screen_wait + 1))
3907
+ done
3908
+ assert_contains "$(cat "$working_snapshot")" "Working..." "Calm off did not keep Pi's stock working row"
3909
+ assert_not_contains "$(cat "$working_snapshot")" '\__/' "Calm off showed the working ship"
3910
+ wait_for_text "$working_response_snapshot" "CALM_WORKING_E2E_RESPONSE" \
3911
+ || fail "the deterministic provider did not settle after proving Pi's stock working row"
3912
+
3913
+ # No blank-row residue: settling returns to the same layout Calm off started from.
3914
+ tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot"
3915
+ assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "a settled run left the working ship on screen"
3916
+
3917
+ # Restore Calm for the persistence restart below.
3918
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
3919
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3920
+ active_screen_wait=0
3921
+ while [ "$active_screen_wait" -lt 200 ]; do
3922
+ if [ "$(cat "$home/config/calm")" = on ]; then
3923
+ break
3924
+ fi
3925
+ sleep 0.05
3926
+ active_screen_wait=$((active_screen_wait + 1))
3927
+ done
3928
+ [ "$(cat "$home/config/calm")" = on ] || fail "Calm was not restored before the persistence restart"
3929
+ tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 180 -y 44
3930
+
3931
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit"
3932
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3933
+ wait_for_text "$working_response_snapshot" "PI_EXIT=0" \
3934
+ || fail "Pi did not exit cleanly before the Calm persistence restart"
3935
+ tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true
3936
+
3937
+ tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \
3938
+ "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30"
3939
+ wait_for_text "$restarted_snapshot" "CALM_WORKING_E2E_RESPONSE" \
3940
+ || fail "Pi did not restore the persisted session after restart"
3941
+ assert_not_contains "$(cat "$restarted_snapshot")" "CALM_E2E_OUTPUT" "restart/resume reset Calm and restored a tool row"
3942
+ assert_not_contains "$(cat "$restarted_snapshot")" "fm_watch_arm_pi" "restart/resume reset Calm and restored the Firstmate watcher tool"
3943
+ assert_not_contains "$(cat "$restarted_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "restart/resume reset Calm and restored a legacy presentation row"
3944
+ for hidden in \
3945
+ CURRENT_WATCHER_E2E \
3946
+ CURRENT_TURN_END_E2E \
3947
+ CURRENT_AWAY_E2E \
3948
+ CURRENT_FROM_FIRSTMATE_E2E \
3949
+ CURRENT_LAUNCH_BRIEF_E2E
3950
+ do
3951
+ assert_not_contains "$(cat "$restarted_snapshot")" "$hidden" "restart/resume rendered operational input $hidden"
3952
+ done
3953
+ assert_not_contains "$(cat "$restarted_snapshot")" "calm transcript" "restart/resume added a persistent Calm status row"
3954
+ assert_contains "$(cat "$restarted_snapshot")" "CALM_WORKING_E2E_PROMPT" "restart/resume removed a genuine user prompt"
3955
+ assert_contains "$(cat "$restarted_snapshot")" "CALM_WORKING_E2E_RESPONSE" "restart/resume removed a genuine assistant response"
3956
+ [ "$(cat "$home/config/calm")" = on ] || fail "restart/resume changed the persisted active choice"
3957
+
3958
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
3959
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3960
+ wait_for_text "$resumed_restored_snapshot" "CALM_E2E_OUTPUT" \
3961
+ || fail "/calm after restart did not restore ordinary transcript rows"
3962
+ [ "$(cat "$home/config/calm")" = off ] || fail "/calm after restart did not persist the inactive choice"
3963
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit"
3964
+ tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
3965
+ pass "Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that freezes and resumes across two working periods in one Pi session, clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering Calm-off, survives restart, and preserves export plus Ctrl+O behavior"
3966
+ }
3967
+
3968
+ test_home_resolution
3969
+ test_pi_compat_no_upper_bound
3970
+ test_pi_compat_degraded_adapter
3971
+ test_pi_compat_missing_adapter_exports
3972
+ test_builtin_gate_load_time
3973
+ test_calm_activation_collision_and_regression_bound
3974
+ test_rendering_and_session_lifecycle
3975
+ test_calm_mid_turn_working_notes
3976
+ test_operational_followup_turn_e2e
3977
+ test_hidden_block_geometry_e2e
3978
+ test_working_ship_geometry_and_lifecycle
3979
+ test_interactive_terminal_e2e