@zerwiz/ymir 0.1.3 → 0.1.5

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 (850) 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/backends/cmux.sh +649 -0
  27. package/.agents/backend/backends/herdr-eventwait.py +157 -0
  28. package/.agents/backend/backends/herdr-workspace-move.py +114 -0
  29. package/.agents/backend/backends/herdr.sh +3386 -0
  30. package/.agents/backend/backends/orca.sh +290 -0
  31. package/.agents/backend/backends/tmux.sh +357 -0
  32. package/.agents/backend/backends/zellij.sh +700 -0
  33. package/.agents/backend/fm-afk-launch.sh +654 -0
  34. package/.agents/backend/fm-afk-return.sh +241 -0
  35. package/.agents/backend/fm-afk-start.sh +172 -0
  36. package/.agents/backend/fm-arm-command-policy.mjs +960 -0
  37. package/.agents/backend/fm-arm-pretool-check.sh +200 -0
  38. package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
  39. package/.agents/backend/fm-backend.sh +981 -0
  40. package/.agents/backend/fm-backlog-handoff.sh +909 -0
  41. package/.agents/backend/fm-backlog-receive.sh +187 -0
  42. package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
  43. package/.agents/backend/fm-bearings-board.sh +204 -0
  44. package/.agents/backend/fm-bearings-snapshot.sh +546 -0
  45. package/.agents/backend/fm-bootstrap.sh +1568 -0
  46. package/.agents/backend/fm-branch-outcome.sh +203 -0
  47. package/.agents/backend/fm-branch-prompt.sh +101 -0
  48. package/.agents/backend/fm-brief.sh +452 -0
  49. package/.agents/backend/fm-busy-event.sh +234 -0
  50. package/.agents/backend/fm-busy-lib.sh +986 -0
  51. package/.agents/backend/fm-captain-hold.sh +1001 -0
  52. package/.agents/backend/fm-cd-command-policy.mjs +153 -0
  53. package/.agents/backend/fm-cd-pretool-check.sh +190 -0
  54. package/.agents/backend/fm-check-lib.sh +74 -0
  55. package/.agents/backend/fm-check-register.sh +42 -0
  56. package/.agents/backend/fm-check-unregister.sh +52 -0
  57. package/.agents/backend/fm-classify-lib.sh +1770 -0
  58. package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
  59. package/.agents/backend/fm-composer-lib.sh +1416 -0
  60. package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
  61. package/.agents/backend/fm-config-push.sh +254 -0
  62. package/.agents/backend/fm-control-lib.sh +251 -0
  63. package/.agents/backend/fm-control.sh +878 -0
  64. package/.agents/backend/fm-crew-state.sh +625 -0
  65. package/.agents/backend/fm-cursor-lib.sh +243 -0
  66. package/.agents/backend/fm-decision-hold.sh +232 -0
  67. package/.agents/backend/fm-doc-audience-check.sh +269 -0
  68. package/.agents/backend/fm-dod-lib.sh +67 -0
  69. package/.agents/backend/fm-ensure-agents-md.sh +253 -0
  70. package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
  71. package/.agents/backend/fm-extension.mjs +2577 -0
  72. package/.agents/backend/fm-extension.sh +16 -0
  73. package/.agents/backend/fm-ff-lib.sh +420 -0
  74. package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
  75. package/.agents/backend/fm-fleet-sync.sh +458 -0
  76. package/.agents/backend/fm-fleet-view.sh +96 -0
  77. package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
  78. package/.agents/backend/fm-guard.sh +242 -0
  79. package/.agents/backend/fm-harness.sh +195 -0
  80. package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
  81. package/.agents/backend/fm-herdr-lab.sh +346 -0
  82. package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
  83. package/.agents/backend/fm-home-seed.sh +984 -0
  84. package/.agents/backend/fm-home-summary-refresh.sh +255 -0
  85. package/.agents/backend/fm-hook-host-lib.sh +36 -0
  86. package/.agents/backend/fm-inactive-reconcile.sh +531 -0
  87. package/.agents/backend/fm-inbox.sh +399 -0
  88. package/.agents/backend/fm-install-actionlint.sh +84 -0
  89. package/.agents/backend/fm-install-herdr.sh +96 -0
  90. package/.agents/backend/fm-install-shellcheck.sh +84 -0
  91. package/.agents/backend/fm-install-treehouse.sh +95 -0
  92. package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
  93. package/.agents/backend/fm-lease-lib.sh +218 -0
  94. package/.agents/backend/fm-lease.sh +189 -0
  95. package/.agents/backend/fm-line-cap-lib.sh +51 -0
  96. package/.agents/backend/fm-lint-workflows.sh +137 -0
  97. package/.agents/backend/fm-lint.sh +591 -0
  98. package/.agents/backend/fm-lock-lib.sh +104 -0
  99. package/.agents/backend/fm-lock.sh +107 -0
  100. package/.agents/backend/fm-marker-lib.sh +12 -0
  101. package/.agents/backend/fm-merge-local.sh +74 -0
  102. package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
  103. package/.agents/backend/fm-nm-run-lib.sh +124 -0
  104. package/.agents/backend/fm-on.sh +125 -0
  105. package/.agents/backend/fm-operational-input.sh +253 -0
  106. package/.agents/backend/fm-peek.sh +46 -0
  107. package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
  108. package/.agents/backend/fm-pr-check.sh +135 -0
  109. package/.agents/backend/fm-pr-lib.sh +1016 -0
  110. package/.agents/backend/fm-pr-merge.sh +707 -0
  111. package/.agents/backend/fm-pr-poll.sh +110 -0
  112. package/.agents/backend/fm-primary-scope-lib.sh +33 -0
  113. package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
  114. package/.agents/backend/fm-procevent-lavish.sh +628 -0
  115. package/.agents/backend/fm-procevent-lib.sh +892 -0
  116. package/.agents/backend/fm-procevent-quota.sh +290 -0
  117. package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
  118. package/.agents/backend/fm-procevent-when.sh +504 -0
  119. package/.agents/backend/fm-procevent.sh +1466 -0
  120. package/.agents/backend/fm-project-mode.sh +92 -0
  121. package/.agents/backend/fm-project-origin-lib.sh +180 -0
  122. package/.agents/backend/fm-promote.sh +282 -0
  123. package/.agents/backend/fm-public-followup-emit.sh +260 -0
  124. package/.agents/backend/fm-public-followup-lib.sh +398 -0
  125. package/.agents/backend/fm-public-followup.sh +1330 -0
  126. package/.agents/backend/fm-push-transition-lib.sh +168 -0
  127. package/.agents/backend/fm-quota-axi-lib.sh +93 -0
  128. package/.agents/backend/fm-quota-choose.sh +384 -0
  129. package/.agents/backend/fm-remote-delta-read.sh +189 -0
  130. package/.agents/backend/fm-remote-doctor.sh +800 -0
  131. package/.agents/backend/fm-remote-entrypoint.sh +193 -0
  132. package/.agents/backend/fm-remote-file.sh +234 -0
  133. package/.agents/backend/fm-remote-home-provision.sh +259 -0
  134. package/.agents/backend/fm-remote-home-seed.sh +259 -0
  135. package/.agents/backend/fm-remote-inherit-push.sh +90 -0
  136. package/.agents/backend/fm-remote-inherit.sh +179 -0
  137. package/.agents/backend/fm-remote-job-lib.sh +1241 -0
  138. package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
  139. package/.agents/backend/fm-remote-job-worker.sh +1124 -0
  140. package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
  141. package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
  142. package/.agents/backend/fm-review-diff.sh +158 -0
  143. package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
  144. package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
  145. package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
  146. package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
  147. package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
  148. package/.agents/backend/fm-secondmate-report.sh +88 -0
  149. package/.agents/backend/fm-send.sh +1059 -0
  150. package/.agents/backend/fm-session-lock-lib.sh +176 -0
  151. package/.agents/backend/fm-session-start.sh +970 -0
  152. package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
  153. package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
  154. package/.agents/backend/fm-sessionstart-run.sh +145 -0
  155. package/.agents/backend/fm-spawn.sh +3153 -0
  156. package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
  157. package/.agents/backend/fm-startup-memory-budget.sh +94 -0
  158. package/.agents/backend/fm-startup-network.sh +642 -0
  159. package/.agents/backend/fm-stow-cascade.sh +251 -0
  160. package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
  161. package/.agents/backend/fm-supervise-daemon.sh +1732 -0
  162. package/.agents/backend/fm-supervision-instructions.sh +215 -0
  163. package/.agents/backend/fm-supervision-lib.sh +90 -0
  164. package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
  165. package/.agents/backend/fm-tangle-lib.sh +53 -0
  166. package/.agents/backend/fm-task-inbox-lib.sh +402 -0
  167. package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
  168. package/.agents/backend/fm-teardown.sh +2922 -0
  169. package/.agents/backend/fm-test-isolation-proof.sh +588 -0
  170. package/.agents/backend/fm-test-run.sh +2191 -0
  171. package/.agents/backend/fm-timeout-lib.sh +141 -0
  172. package/.agents/backend/fm-timing-lib.sh +179 -0
  173. package/.agents/backend/fm-tmux-lib.sh +291 -0
  174. package/.agents/backend/fm-tool-update-check.sh +898 -0
  175. package/.agents/backend/fm-trace-context-lib.sh +227 -0
  176. package/.agents/backend/fm-transition-lib.sh +103 -0
  177. package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
  178. package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
  179. package/.agents/backend/fm-turnend-guard.sh +432 -0
  180. package/.agents/backend/fm-update.sh +110 -0
  181. package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
  182. package/.agents/backend/fm-voice-client.py +1373 -0
  183. package/.agents/backend/fm-voice-relay.py +1256 -0
  184. package/.agents/backend/fm-wake-drain.sh +592 -0
  185. package/.agents/backend/fm-wake-grant.sh +127 -0
  186. package/.agents/backend/fm-wake-lib.sh +1849 -0
  187. package/.agents/backend/fm-watch-arm.sh +602 -0
  188. package/.agents/backend/fm-watch-checkpoint.sh +109 -0
  189. package/.agents/backend/fm-watch.sh +1962 -0
  190. package/.agents/backend/fm-x-dismiss.sh +116 -0
  191. package/.agents/backend/fm-x-followup.sh +286 -0
  192. package/.agents/backend/fm-x-lib.sh +1000 -0
  193. package/.agents/backend/fm-x-link.sh +234 -0
  194. package/.agents/backend/fm-x-poll.sh +185 -0
  195. package/.agents/backend/fm-x-reply.sh +385 -0
  196. package/.agents/backend/fm_voice_frame.py +166 -0
  197. package/.agents/backend/fm_voice_records.py +574 -0
  198. package/.agents/backend/model-bridge.py +290 -0
  199. package/.agents/backend/opencode-go-bridge.py +30 -0
  200. package/.agents/bus/README.md +12 -0
  201. package/.agents/bus/messages.json +1 -0
  202. package/.agents/config/agents.machine.example.yaml +16 -0
  203. package/.agents/config/agents.yaml +50 -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 +9 -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/config/tailscale-sync.yaml +27 -0
  211. package/.agents/filebrowser/.filebrowser.json +6 -0
  212. package/.agents/filebrowser/README.md +11 -0
  213. package/.agents/gateway/README.md +12 -0
  214. package/.agents/github/README.md +15 -0
  215. package/.agents/github/webhooks/.gitkeep +0 -0
  216. package/.agents/github/workflows/.gitkeep +0 -0
  217. package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
  218. package/.agents/harness/opencode/plugins/package.json +4 -0
  219. package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
  220. package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
  221. package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
  222. package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
  223. package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
  224. package/.agents/memory/README.md +46 -0
  225. package/.agents/memory/kaia.engram +0 -0
  226. package/.agents/memory/kaia.engram-shm +0 -0
  227. package/.agents/memory/kaia.engram-wal +0 -0
  228. package/.agents/memory/runes_audit.md +5 -0
  229. package/.agents/memory/well/episodes.jsonl +367 -0
  230. package/.agents/memory/well/workspace.jsonl +27 -0
  231. package/.agents/migrations/0001-hodd-layout.sh +38 -0
  232. package/.agents/migrations/0002-a2a-mcp.sh +27 -0
  233. package/.agents/migrations/0003-private-data-separation.sh +111 -0
  234. package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
  235. package/.agents/sandbox/Dockerfile.utgard +19 -0
  236. package/.agents/sandbox/README.md +14 -0
  237. package/.agents/sandbox/utgard.config.json +14 -0
  238. package/.agents/skills/README.md +59 -0
  239. package/.agents/skills/bragi-marketing/README.md +3 -0
  240. package/.agents/skills/bragi-marketing/SKILL.md +82 -0
  241. package/.agents/skills/eindri-homes/README.md +3 -0
  242. package/.agents/skills/eindri-homes/SKILL.md +291 -0
  243. package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
  244. package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
  245. package/.agents/skills/frigg-consent/README.md +3 -0
  246. package/.agents/skills/frigg-consent/SKILL.md +56 -0
  247. package/.agents/skills/galdr-ymirsystem/README.md +3 -0
  248. package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
  249. package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
  250. package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
  251. package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
  252. package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
  253. package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
  254. package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
  255. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
  256. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
  257. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
  258. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
  259. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
  260. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
  261. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
  262. package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
  263. package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
  264. package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
  265. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
  266. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
  267. package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
  268. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
  269. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
  270. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
  271. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
  272. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
  273. package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
  274. package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
  275. package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
  276. package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
  277. package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
  278. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
  279. package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
  280. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
  281. package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
  282. package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
  283. package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
  284. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
  285. package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
  286. package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
  287. package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
  288. package/.agents/skills/gjallarhorn-relay/README.md +3 -0
  289. package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
  290. package/.agents/skills/groa-update/SKILL.md +70 -0
  291. package/.agents/skills/hamr-adapters/README.md +3 -0
  292. package/.agents/skills/hamr-adapters/SKILL.md +123 -0
  293. package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
  294. package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
  295. package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
  296. package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
  297. package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
  298. package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
  299. package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
  300. package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
  301. package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
  302. package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
  303. package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
  304. package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
  305. package/.agents/skills/herdr-panes/README.md +3 -0
  306. package/.agents/skills/herdr-panes/SKILL.md +92 -0
  307. package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
  308. package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
  309. package/.agents/skills/hnoss-design/DESIGN.md +19 -0
  310. package/.agents/skills/hnoss-design/README.md +3 -0
  311. package/.agents/skills/hnoss-design/SKILL.md +83 -0
  312. package/.agents/skills/hvild-afk/README.md +3 -0
  313. package/.agents/skills/hvild-afk/SKILL.md +241 -0
  314. package/.agents/skills/jord-projects/README.md +3 -0
  315. package/.agents/skills/jord-projects/SKILL.md +92 -0
  316. package/.agents/skills/lifecycle/SKILL.md +56 -0
  317. package/.agents/skills/lifecycle/smoke_test.sh +52 -0
  318. package/.agents/skills/lifecycle/start.sh +21 -0
  319. package/.agents/skills/lifecycle/status.sh +35 -0
  320. package/.agents/skills/lifecycle/stop.sh +16 -0
  321. package/.agents/skills/muninn-stow/README.md +3 -0
  322. package/.agents/skills/muninn-stow/SKILL.md +309 -0
  323. package/.agents/skills/no-mistakes/SKILL.md +379 -0
  324. package/.agents/skills/nornir-schedule/README.md +3 -0
  325. package/.agents/skills/nornir-schedule/SKILL.md +25 -0
  326. package/.agents/skills/nornir-schedule/assets/events.md +137 -0
  327. package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
  328. package/.agents/skills/nsr-compliance/README.md +3 -0
  329. package/.agents/skills/nsr-compliance/SKILL.md +29 -0
  330. package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
  331. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
  332. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
  333. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
  334. package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
  335. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
  336. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
  337. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
  338. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
  339. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
  340. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
  341. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
  342. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
  343. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
  344. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
  345. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
  346. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
  347. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
  348. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
  349. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
  350. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
  351. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
  352. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
  353. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  354. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
  355. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
  356. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
  357. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
  358. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
  359. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
  360. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
  361. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
  362. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
  363. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
  364. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
  365. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
  366. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
  367. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
  368. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
  369. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
  370. package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
  371. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
  372. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
  373. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
  374. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
  375. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
  376. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
  377. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
  378. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
  379. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
  380. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
  381. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
  382. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
  383. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
  384. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
  385. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
  386. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
  387. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
  388. package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
  389. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
  390. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
  391. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
  392. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
  393. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
  394. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
  395. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
  396. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
  397. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
  398. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
  399. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
  400. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
  401. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
  402. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
  403. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
  404. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
  405. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
  406. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
  407. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
  408. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
  409. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
  410. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
  411. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
  412. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
  413. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
  414. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
  415. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
  416. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
  417. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
  418. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
  419. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
  420. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
  421. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
  422. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
  423. package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
  424. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
  425. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
  426. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
  427. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
  428. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
  429. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
  430. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
  431. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
  432. package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
  433. package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
  434. package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
  435. package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
  436. package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
  437. package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
  438. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
  439. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
  440. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
  441. package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
  442. package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
  443. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
  444. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
  445. package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
  446. package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
  447. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
  448. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
  449. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
  450. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
  451. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
  452. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
  453. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
  454. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
  455. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
  456. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
  457. package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
  458. package/.agents/skills/pr-ops/README.md +3 -0
  459. package/.agents/skills/pr-ops/SKILL.md +109 -0
  460. package/.agents/skills/ratatoskr-a2a/README.md +3 -0
  461. package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
  462. package/.agents/skills/rules-check-drift/SKILL.md +81 -0
  463. package/.agents/skills/saga-bearings/README.md +3 -0
  464. package/.agents/skills/saga-bearings/SKILL.md +26 -0
  465. package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
  466. package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
  467. package/.agents/skills/saga-bearings/assets/recap.md +56 -0
  468. package/.agents/skills/syn-recovery/README.md +3 -0
  469. package/.agents/skills/syn-recovery/SKILL.md +54 -0
  470. package/.agents/skills/tyr-check/README.md +3 -0
  471. package/.agents/skills/tyr-check/SKILL.md +218 -0
  472. package/.agents/skills/urdh-hold/README.md +3 -0
  473. package/.agents/skills/urdh-hold/SKILL.md +25 -0
  474. package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
  475. package/.agents/skills/urdh-hold/assets/hold.md +54 -0
  476. package/.agents/skills/vor-diagnostics/README.md +3 -0
  477. package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
  478. package/.agents/skills/ymir-host/README.md +3 -0
  479. package/.agents/skills/ymir-host/SKILL.md +34 -0
  480. package/.agents/skills/ymir-host/assets/desktop.md +36 -0
  481. package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
  482. package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
  483. package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
  484. package/.agents/skills/ymir-host/assets/update.md +64 -0
  485. package/.agents/tests/assets/board-render-harness.mjs +117 -0
  486. package/.agents/tests/cmux-test-safety.sh +51 -0
  487. package/.agents/tests/fixtures.sh +291 -0
  488. package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
  489. package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
  490. package/.agents/tests/fm-afk-launch.test.sh +961 -0
  491. package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
  492. package/.agents/tests/fm-afk-return.test.sh +283 -0
  493. package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
  494. package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
  495. package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
  496. package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
  497. package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
  498. package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
  499. package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
  500. package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
  501. package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
  502. package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
  503. package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
  504. package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
  505. package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
  506. package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
  507. package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
  508. package/.agents/tests/fm-backend-orca.test.sh +1357 -0
  509. package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
  510. package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
  511. package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
  512. package/.agents/tests/fm-backend.test.sh +1142 -0
  513. package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
  514. package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
  515. package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
  516. package/.agents/tests/fm-bearings-board.test.sh +414 -0
  517. package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
  518. package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
  519. package/.agents/tests/fm-bootstrap.test.sh +1178 -0
  520. package/.agents/tests/fm-branch-supervision.test.sh +552 -0
  521. package/.agents/tests/fm-brief.test.sh +785 -0
  522. package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
  523. package/.agents/tests/fm-busy-state.test.sh +465 -0
  524. package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
  525. package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
  526. package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
  527. package/.agents/tests/fm-check-unregister.test.sh +198 -0
  528. package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
  529. package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
  530. package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
  531. package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
  532. package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
  533. package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
  534. package/.agents/tests/fm-composer-ghost.test.sh +716 -0
  535. package/.agents/tests/fm-composer-lib.test.sh +666 -0
  536. package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
  537. package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
  538. package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
  539. package/.agents/tests/fm-control.test.sh +910 -0
  540. package/.agents/tests/fm-crew-state.test.sh +1610 -0
  541. package/.agents/tests/fm-cursor-harness.test.sh +404 -0
  542. package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
  543. package/.agents/tests/fm-cursor-primary.test.sh +708 -0
  544. package/.agents/tests/fm-daemon.test.sh +2740 -0
  545. package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
  546. package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
  547. package/.agents/tests/fm-extension-binding.test.sh +2187 -0
  548. package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
  549. package/.agents/tests/fm-fleet-sync.test.sh +721 -0
  550. package/.agents/tests/fm-gate-refuse.test.sh +356 -0
  551. package/.agents/tests/fm-gitignore-config.test.sh +89 -0
  552. package/.agents/tests/fm-gotmp.test.sh +230 -0
  553. package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
  554. package/.agents/tests/fm-grok-harness.test.sh +117 -0
  555. package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
  556. package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
  557. package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
  558. package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
  559. package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
  560. package/.agents/tests/fm-herdr-lab.test.sh +243 -0
  561. package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
  562. package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
  563. package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
  564. package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
  565. package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
  566. package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
  567. package/.agents/tests/fm-kimi-harness.test.sh +687 -0
  568. package/.agents/tests/fm-lint-workflows.test.sh +532 -0
  569. package/.agents/tests/fm-lint.test.sh +1023 -0
  570. package/.agents/tests/fm-muse-harness.test.sh +932 -0
  571. package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
  572. package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
  573. package/.agents/tests/fm-on.test.sh +507 -0
  574. package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
  575. package/.agents/tests/fm-operational-input.test.sh +160 -0
  576. package/.agents/tests/fm-peek-remote.test.sh +110 -0
  577. package/.agents/tests/fm-pending-reply.test.sh +1288 -0
  578. package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
  579. package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
  580. package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
  581. package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
  582. package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
  583. package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
  584. package/.agents/tests/fm-pr-merge.test.sh +2135 -0
  585. package/.agents/tests/fm-procevent-quota.test.sh +225 -0
  586. package/.agents/tests/fm-procevent-when.test.sh +406 -0
  587. package/.agents/tests/fm-procevent.test.sh +1745 -0
  588. package/.agents/tests/fm-project-origin.test.sh +110 -0
  589. package/.agents/tests/fm-public-followup.test.sh +2272 -0
  590. package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
  591. package/.agents/tests/fm-quota-choose.test.sh +626 -0
  592. package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
  593. package/.agents/tests/fm-remote-doctor.test.sh +625 -0
  594. package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
  595. package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
  596. package/.agents/tests/fm-remote-job.test.sh +768 -0
  597. package/.agents/tests/fm-remote-reply.test.sh +520 -0
  598. package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
  599. package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
  600. package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
  601. package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
  602. package/.agents/tests/fm-review-diff.test.sh +176 -0
  603. package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
  604. package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
  605. package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
  606. package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
  607. package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
  608. package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
  609. package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
  610. package/.agents/tests/fm-send-inbox.test.sh +352 -0
  611. package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
  612. package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
  613. package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
  614. package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
  615. package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
  616. package/.agents/tests/fm-send-settle.test.sh +147 -0
  617. package/.agents/tests/fm-send-strict.test.sh +241 -0
  618. package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
  619. package/.agents/tests/fm-session-start.test.sh +2515 -0
  620. package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
  621. package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
  622. package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
  623. package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
  624. package/.agents/tests/fm-spawn-batch.test.sh +148 -0
  625. package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
  626. package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
  627. package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
  628. package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
  629. package/.agents/tests/fm-startup-network.test.sh +712 -0
  630. package/.agents/tests/fm-stow-cascade.test.sh +370 -0
  631. package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
  632. package/.agents/tests/fm-supervision-events.test.sh +156 -0
  633. package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
  634. package/.agents/tests/fm-tangle-guard.test.sh +277 -0
  635. package/.agents/tests/fm-task-delivery.test.sh +420 -0
  636. package/.agents/tests/fm-task-inbox.test.sh +513 -0
  637. package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
  638. package/.agents/tests/fm-teardown.test.sh +2665 -0
  639. package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
  640. package/.agents/tests/fm-test-fixtures.test.sh +132 -0
  641. package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
  642. package/.agents/tests/fm-test-run.test.sh +1196 -0
  643. package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
  644. package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
  645. package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
  646. package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
  647. package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
  648. package/.agents/tests/fm-transition-lib.test.sh +54 -0
  649. package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
  650. package/.agents/tests/fm-update.test.sh +304 -0
  651. package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
  652. package/.agents/tests/fm-voice-relay.test.sh +4741 -0
  653. package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
  654. package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
  655. package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
  656. package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
  657. package/.agents/tests/fm-wake-queue.test.sh +1224 -0
  658. package/.agents/tests/fm-watch-arm.test.sh +821 -0
  659. package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
  660. package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
  661. package/.agents/tests/fm-watch-triage.test.sh +3889 -0
  662. package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
  663. package/.agents/tests/fm-x-mode.test.sh +3042 -0
  664. package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
  665. package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
  666. package/.agents/tests/herdr-test-safety.sh +42 -0
  667. package/.agents/tests/lib.sh +315 -0
  668. package/.agents/tests/remote-herdr-fixture.sh +125 -0
  669. package/.agents/tests/secondmate-helpers.sh +193 -0
  670. package/.agents/tests/smoke.test.sh +93 -0
  671. package/.agents/tests/wake-helpers.sh +338 -0
  672. package/.agents/tests/zellij-test-safety.sh +60 -0
  673. package/.agents/tools/README.md +10 -0
  674. package/.agents/tools/bin/tasks-cli +267 -0
  675. package/.agents/tools/tasks-cli.ts +216 -0
  676. package/AGENTS.md +454 -0
  677. package/CHANGELOG.md +881 -0
  678. package/LICENSE +1 -1
  679. package/NOTICE +63 -0
  680. package/README.md +113 -39
  681. package/RULES/01-domains.md +51 -0
  682. package/RULES/02-agents.md +78 -0
  683. package/RULES/03-houses.md +61 -0
  684. package/RULES/04-hoard.md +65 -0
  685. package/RULES/05-platforms.md +67 -0
  686. package/RULES/06-append-only.md +58 -0
  687. package/RULES/07-config.md +45 -0
  688. package/RULES/08-delivery-gate.md +75 -0
  689. package/RULES/README.md +20 -0
  690. package/bin/README.md +107 -0
  691. package/bin/a2a-mcp.sh +98 -0
  692. package/bin/a2a-serve.py +103 -0
  693. package/bin/a2a-serve.sh +8 -0
  694. package/bin/a2a-talk.sh +50 -0
  695. package/bin/a2abridge-ensure.sh +108 -0
  696. package/bin/agent-run.sh +43 -0
  697. package/bin/agents-config.sh +233 -0
  698. package/bin/apodex-smoke-test.sh +92 -0
  699. package/bin/bifrost-bridge.sh +112 -0
  700. package/bin/bifrost-ingress.sh +77 -0
  701. package/bin/bootstrap-macos.sh +107 -0
  702. package/bin/bootstrap-windows.ps1 +112 -0
  703. package/bin/branch-guard.sh +64 -0
  704. package/bin/brokk +73 -0
  705. package/bin/brokk-classify-lib.sh +1770 -0
  706. package/bin/brokk-lease-lib.sh +222 -0
  707. package/bin/brokk-lease.sh +193 -0
  708. package/bin/brokk-lint.sh +65 -0
  709. package/bin/brokk-send.sh +38 -0
  710. package/bin/brokk-update.sh +9 -0
  711. package/bin/brokk-wake-grant.sh +127 -0
  712. package/bin/brokk-wake-lib.sh +1849 -0
  713. package/bin/changelog-guard.sh +165 -0
  714. package/bin/crash-sense.sh +160 -0
  715. package/bin/desktop-place.sh +224 -0
  716. package/bin/docs-guard.sh +39 -0
  717. package/bin/editor-place.sh +207 -0
  718. package/bin/eindri-acclaim.sh +34 -0
  719. package/bin/eindri-control.sh +28 -0
  720. package/bin/eindri-role.sh +83 -0
  721. package/bin/eindri-seen.sh +41 -0
  722. package/bin/eindri-send.sh +15 -0
  723. package/bin/eindri-start.sh +85 -0
  724. package/bin/eindri-watch.sh +57 -0
  725. package/bin/einherjar-spawn.sh +576 -0
  726. package/bin/eir-doctor.sh +112 -0
  727. package/bin/erindi-brief.sh +271 -0
  728. package/bin/fleet-apply.sh +68 -0
  729. package/bin/github-deploy.sh +81 -0
  730. package/bin/gjallarhorn-expose.sh +102 -0
  731. package/bin/gjallarhorn-notify.sh +58 -0
  732. package/bin/gjallarhorn-purge.sh +54 -0
  733. package/bin/gjallarhorn-tunnel.sh +72 -0
  734. package/bin/gleipnir-lock-lib.sh +284 -0
  735. package/bin/groa-update.sh +122 -0
  736. package/bin/hall-snapshot.sh +92 -0
  737. package/bin/hamr-harness.sh +246 -0
  738. package/bin/heimdall-ssh-keys.sh +374 -0
  739. package/bin/herdr-agents.py +44 -0
  740. package/bin/herdr-ensure.sh +104 -0
  741. package/bin/herdr-run.sh +431 -0
  742. package/bin/hermes-ensure.sh +88 -0
  743. package/bin/hoard-lib.sh +25 -0
  744. package/bin/hodd.sh +78 -0
  745. package/bin/host-sense.sh +199 -0
  746. package/bin/huginn-research-worker.sh +108 -0
  747. package/bin/local-model-lock.sh +51 -0
  748. package/bin/mimir-bridge.py +226 -0
  749. package/bin/mimir-bridge.sh +87 -0
  750. package/bin/mimir-ingest.sh +131 -0
  751. package/bin/mimir.sh +113 -0
  752. package/bin/mjollnir-webhook.sh +59 -0
  753. package/bin/mjollnir.sh +85 -0
  754. package/bin/model-hardware.sh +82 -0
  755. package/bin/model-resolve.sh +147 -0
  756. package/bin/models-detect.sh +178 -0
  757. package/bin/nornir-cron-start.sh +162 -0
  758. package/bin/nornir-job-daily-briefing.sh +191 -0
  759. package/bin/nornir-job-git-sync.sh +152 -0
  760. package/bin/nornir-job-memory-housekeeping.sh +114 -0
  761. package/bin/nornir-job-observer.sh +166 -0
  762. package/bin/nornir-job-skillopt-sleep.sh +76 -0
  763. package/bin/omarchy-hook-install.sh +60 -0
  764. package/bin/omarchy-install.sh +148 -0
  765. package/bin/omarchy-plugins.sh +174 -0
  766. package/bin/omarchy-sense.sh +149 -0
  767. package/bin/perm-guard.sh +30 -0
  768. package/bin/pi-agent.sh +68 -0
  769. package/bin/pi-ensure.sh +77 -0
  770. package/bin/pi-local.sh +37 -0
  771. package/bin/pi-seat.sh +63 -0
  772. package/bin/prereq-ensure.sh +166 -0
  773. package/bin/private-guard.sh +38 -0
  774. package/bin/project-git.sh +55 -0
  775. package/bin/public-guard.sh +70 -0
  776. package/bin/ratatoskr.sh +49 -0
  777. package/bin/realm-lib.sh +32 -0
  778. package/bin/repo-scrub.sh +62 -0
  779. package/bin/rodd-operational-input.sh +250 -0
  780. package/bin/runes-append.sh +198 -0
  781. package/bin/saga-session-start.sh +172 -0
  782. package/bin/saga-sessionstart-run.sh +79 -0
  783. package/bin/saga-wake-drain.sh +41 -0
  784. package/bin/secret-guard.sh +63 -0
  785. package/bin/sessrumnir-ensure.sh +100 -0
  786. package/bin/sessrumnir-sync.sh +237 -0
  787. package/bin/sessrumnir.sh +91 -0
  788. package/bin/skillopt-setup.sh +76 -0
  789. package/bin/skuld-branch-outcome.sh +203 -0
  790. package/bin/skuld-branch-prompt.sh +101 -0
  791. package/bin/smidja-bootstrap.sh +90 -0
  792. package/bin/smidja-observe.sh +86 -0
  793. package/bin/syn-arm-pretool-check.sh +57 -0
  794. package/bin/syn-asset-pretool-check.sh +76 -0
  795. package/bin/syn-cd-pretool-check.sh +28 -0
  796. package/bin/syn-guard-pretool-check.sh +83 -0
  797. package/bin/syn-turnend-guard.sh +36 -0
  798. package/bin/syn-watch-arm.sh +99 -0
  799. package/bin/tailscale-sync.sh +190 -0
  800. package/bin/telegram-bot.sh +69 -0
  801. package/bin/toolchain.sh +74 -0
  802. package/bin/utgard.sh +111 -0
  803. package/bin/valhalla.sh +115 -0
  804. package/bin/valknut-load.sh +260 -0
  805. package/bin/vor-crew-state.sh +124 -0
  806. package/bin/wedge-notify.sh +47 -0
  807. package/bin/workspace-provision.sh +80 -0
  808. package/bin/workspace-rag.sh +150 -0
  809. package/bin/wyrd-db.sh +47 -0
  810. package/bin/yggdrasil.sh +152 -0
  811. package/bin/ymir-edit.sh +145 -0
  812. package/bin/ymir-install.sh +641 -0
  813. package/bin/ymir-invite.sh +93 -0
  814. package/bin/ymir-isolation.sh +88 -0
  815. package/bin/ymir-migrate.sh +61 -0
  816. package/bin/ymir-platform.sh +348 -0
  817. package/bin/ymir-say.sh +97 -0
  818. package/bin/ymir-setup-auth.sh +108 -0
  819. package/bin/ymir-validate.sh +173 -0
  820. package/bin/ymir.js +74 -16
  821. package/docs/Architecture.md +339 -0
  822. package/docs/design.md +351 -0
  823. package/docs/installations/README.md +63 -0
  824. package/docs/installations/ymir-home.md +93 -0
  825. package/docs/integration.md +105 -0
  826. package/docs/lore.md +1056 -0
  827. package/docs/research/agent-harnesses.md +290 -0
  828. package/docs/runbooks/README.md +19 -0
  829. package/docs/runbooks/a2a.md +91 -0
  830. package/docs/runbooks/agent-permissions.md +99 -0
  831. package/docs/runbooks/agents.md +57 -0
  832. package/docs/runbooks/models.md +69 -0
  833. package/docs/runbooks/realm-onboarding.md +60 -0
  834. package/docs/runbooks/secrets-and-hoard.md +60 -0
  835. package/docs/runbooks/updates-and-migrations.md +110 -0
  836. package/docs/runbooks/worktrees-isolation.md +72 -0
  837. package/docs/session-start.md +105 -0
  838. package/docs/skillopt-integration.md +321 -0
  839. package/docs/sync-system.md +66 -0
  840. package/docs/workspaces.md +104 -0
  841. package/docs/ymir-rut.md +260 -0
  842. package/package.json +44 -14
  843. package/packaging/build.sh +74 -0
  844. package/packaging/macos/Ymir Installer.command +43 -0
  845. package/packaging/windows/ymir-setup.nsi +74 -0
  846. package/scripts/README.md +22 -0
  847. package/scripts/electron.sh +210 -0
  848. package/scripts/start.sh +167 -0
  849. package/scripts/stop.sh +113 -0
  850. package/install.sh +0 -35
@@ -0,0 +1,3386 @@
1
+ #!/usr/bin/env bash
2
+ # bin/backends/herdr.sh - the herdr session-provider adapter (EXPERIMENTAL).
3
+ #
4
+ # Design: data/fm-backend-design-d7/herdr-addendum.md ("Interface mapping",
5
+ # decisions D1-D6) and the empirical verification recorded in
6
+ # data/fm-backend-design-d7/herdr-verification-p2.md (real herdr v0.7.1,
7
+ # protocol 14, macOS aarch64), refined by docs/herdr-backend.md's
8
+ # "workspace-per-home" pass (AGENTS.md task herdr-sm-spaces-k4). Herdr is a
9
+ # session provider ONLY (D3): the worktree provider stays treehouse, exactly
10
+ # like tmux. Sourced only through bin/fm-backend.sh's fm_backend_source in
11
+ # normal operation; the unit tests source it directly, so the FM_HOME fallback
12
+ # below keeps that path sane without fm-backend.sh's preamble.
13
+ #
14
+ # Default container shape (D4, decided empirically - see
15
+ # herdr-verification-p2.md "Task container shape", refined by
16
+ # docs/herdr-backend.md "Default task container shape"): ONE herdr workspace PER
17
+ # FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB
18
+ # per task inside its home's workspace. The default-on presentation projection
19
+ # creates a disposable workspace for a clean fresh task instead unless the home
20
+ # opts out. That
21
+ # workspace is a non-authoritative visual projection containing only the normal
22
+ # task pane. Its random token and mutable label never authorize lookup,
23
+ # adoption, reuse, closure, deletion, task ownership, or endpoint selection.
24
+ # A version 2 journal can participate in replacing only its exact same-identity
25
+ # endpoint after metadata, home, session, workspace, tab, pane, parent, shape,
26
+ # focus, and agent-absence checks all agree under the session lock.
27
+ # Every ambiguous recovered launch uses the default flat home workspace when
28
+ # duplicate-agent risk is independently absent.
29
+ # Target resolution stays parallel to the tmux adapter in both layouts.
30
+ # Projected create, move, and cleanup operations capture the named session's
31
+ # exact active workspace and tab. On Herdr 0.7.5, an explicit close that
32
+ # empties a non-focused workspace moves focus to that workspace's neighbor
33
+ # (upstream discussion #1328, fixed by PR #1877), while a pane-death removal
34
+ # preserves focus exactly when the dying workspace sits behind the focused
35
+ # one or the focused one is last (upstream issue #1621, fixed by PR #1912);
36
+ # both fixes first shipped in Herdr 0.8.0, which is the version floor for
37
+ # default-on projection (FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION). Projected cleanup
38
+ # therefore serializes under the session lock, repositions a doomed workspace
39
+ # behind the focused one when needed, and ends its verified lone idle shell
40
+ # so Herdr removes the emptied workspace through the focus-preserving
41
+ # pane-death path, with the exact pre-close tab restore as the backstop and a
42
+ # refusal to close the active tab itself.
43
+ #
44
+ # Target string shape: "<herdr-session>:<pane-id>", e.g. "default:w1:p2" (the
45
+ # pane id itself contains a colon; the session is always the FIRST field, the
46
+ # remainder is the whole pane id - fm_backend_herdr_parse_target splits on the
47
+ # first colon only). This is the value stored in a herdr task's meta window=
48
+ # field and is what fm_backend_resolve_selector already returns unchanged for
49
+ # exact task-id, legacy fm-<id>, and explicit backend-target forms (that
50
+ # function has no herdr-specific logic; it just returns meta's window=
51
+ # verbatim).
52
+ #
53
+ # Authoritative task recovery/orphan discovery (ids may not deterministically match live state
54
+ # after a server restart in a differently-configured session; see the
55
+ # verification doc) uses LABEL matching (fm-<id> tab labels), never trusts a
56
+ # stored pane id blindly: fm_backend_herdr_list_live. The presentation journal
57
+ # is deliberately excluded from that path.
58
+ #
59
+ # Requires: herdr (CLI + socket), jq (JSON parsing). Bootstrap detects these
60
+ # through fm_backend_required_tools only when herdr is the resolved backend;
61
+ # this adapter also gates them again before spawning.
62
+
63
+ # FM_HOME fallback: every real caller (fm-spawn.sh, fm-peek.sh, fm-send.sh,
64
+ # fm-teardown.sh, fm-watch.sh, fm-crew-state.sh) already sets FM_HOME as a
65
+ # global before sourcing fm-backend.sh (which sources this file), so this
66
+ # never overrides a real invocation. It exists only so this file's own unit
67
+ # tests, which source it directly without that preamble, resolve to a sane
68
+ # default (the firstmate repo root - never a secondmate home, so
69
+ # fm_backend_herdr_workspace_label falls through to "firstmate" exactly like
70
+ # pre-P3 behavior when a test does not care about home-specific labeling).
71
+ FM_BACKEND_HERDR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
72
+ FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_ROOT}}"
73
+ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
74
+
75
+ # Shared composer-content classifier (empty|pending|unknown, and the fleet-wide
76
+ # dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by
77
+ # every backend so the decision cannot drift.
78
+ # shellcheck source=bin/fm-composer-lib.sh
79
+ . "$FM_BACKEND_HERDR_ROOT/bin/fm-composer-lib.sh"
80
+
81
+ # Shared, backend-neutral normalized-transition shape and the single-owner
82
+ # status->action policy table (bin/fm-transition-lib.sh). This adapter's event
83
+ # subscriber (fm_backend_herdr_wait_transition) normalizes every
84
+ # pane.agent_status_changed edge through fm_transition_record and routes it
85
+ # through fm_transition_policy - it never re-encodes the mapping.
86
+ # shellcheck source=bin/fm-transition-lib.sh
87
+ . "$FM_BACKEND_HERDR_ROOT/bin/fm-transition-lib.sh"
88
+
89
+ FM_BACKEND_HERDR_MIN_PROTOCOL=14
90
+ # events.subscribe (the native pane.agent_status_changed push stream) and its
91
+ # subscription_event schema first shipped at protocol 16 (verified: herdr
92
+ # 0.7.3). Below this, or with the events surface absent from `herdr api schema`,
93
+ # the event fast-path fails closed to the watcher's poll loop
94
+ # (fm_backend_herdr_events_capable). Distinct from FM_BACKEND_HERDR_MIN_PROTOCOL
95
+ # (14): the adapter's spawn/capture/send primitives work on 14, only the push
96
+ # subscriber needs 16.
97
+ FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL=16
98
+ # workspace.move first appears in the protocol-16 schema.
99
+ # The installed CLI does not expose it as a workspace subcommand, so the
100
+ # presentation path uses one narrowly whitelisted raw-socket request after
101
+ # verifying the exact method and parameter schema.
102
+ FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL=16
103
+ # The version floor for DEFAULT-ON presentation projection. Projection turns
104
+ # every crewmate teardown into a workspace-emptying removal, and the focus-safe
105
+ # removal plan can only avoid Herdr's focus-stealing explicit close while the
106
+ # doomed pane holds a provably lone idle childless shell; a persistent child of
107
+ # that shell (gitstatusd, a zsh-async worker, direnv) makes the plan fall back
108
+ # to the plain explicit close, which steals focus on every release without the
109
+ # two upstream focus fixes (PR #1877 commit 165dca45, PR #1912 commit a979916).
110
+ # Herdr 0.8.0 is the first release carrying both, so a home that configured
111
+ # nothing is projected only at or above it. An explicit "on" is still honored
112
+ # below the floor.
113
+ # Protocol 19 is the structural signal for that floor, measured against the real
114
+ # macOS aarch64 release binaries (docs/verification/runtime-backends.md
115
+ # "Presentation version floor"): 0.7.3 and 0.7.4 report 16, 0.7.5 reports 17,
116
+ # the first post-fix preview reports 18, and 0.8.0 reports 19. No build lacking
117
+ # both fixes reaches 19, and the pre-fix builds top out at 17.
118
+ FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL=19
119
+ FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION=0.8.0
120
+ # One-warning-per-release dedupe marker prefix, under the state dir. The
121
+ # projection decision is remade on every spawn, so an undeduplicated
122
+ # below-floor warning would repeat on every crewmate; the key is the detected
123
+ # release, so an upgrade or a downgrade is announced again.
124
+ FM_BACKEND_HERDR_PRESENTATION_FLOOR_MARKER_PREFIX=".herdr-presentation-floor-"
125
+ # Per-pane escalation dedupe marker prefix, under the state dir. One marker per
126
+ # window (keyed like the watcher's own .stale-<key>): set when a ->blocked edge
127
+ # is enqueued, cleared on any working edge, so exactly one wake fires per
128
+ # ->blocked edge and a reconnect level-reconcile never re-delivers a still-
129
+ # blocked pane. Mirrors bin/fm-watch.sh's .stale-<key> naming.
130
+ FM_BACKEND_HERDR_ESCALATED_PREFIX=".herdr-escalated-"
131
+ # .fm-secondmate-home is written by bin/fm-home-seed.sh (AGENTS.md section 6)
132
+ # at a seeded secondmate home's root, containing exactly that secondmate's id.
133
+ # The primary firstmate home never carries this marker.
134
+ FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home"
135
+ # The presentation projection is intentionally separate from the authoritative
136
+ # task endpoint record.
137
+ # A per-task journal lives under state/ as <id>.herdr-presentation.
138
+ # Version 1 records only the attempted projection's random correlator.
139
+ # Version 2 additionally binds the successful projection's exact home,
140
+ # session, workspace, tab, pane, parent, and presentation labels so a resumed
141
+ # spawn can replace one verified agent-free husk under the session lock.
142
+ # No send, capture, Treehouse, or general task-ownership path reads it.
143
+ FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation"
144
+
145
+ # The config item a home writes to opt out of, or explicitly in to, the
146
+ # projection.
147
+ FM_BACKEND_HERDR_PRESENTATION_CONFIG="herdr-presentation-spaces"
148
+
149
+ # fm_backend_herdr_presentation_preference <config-dir>: the single owner of
150
+ # config/herdr-presentation-spaces parsing. Echoes exactly one of "off", "on"
151
+ # (a deliberate opt-in, honored even below the version floor), or "default"
152
+ # (this home configured nothing, so the floor decides).
153
+ # Values are read with the whole-file whitespace-stripped convention the other
154
+ # scalar config items already use (config/backlog-backend, config/crew-harness),
155
+ # plus case folding. An empty file is the historical presence-based opt-in form
156
+ # and still means an explicit "on", so no home that deliberately enabled the
157
+ # projection can lose it. An unrecognized value warns and falls back to the
158
+ # default rather than failing a spawn over a purely visual setting, so a typo is
159
+ # visible instead of silently deciding anything.
160
+ fm_backend_herdr_presentation_preference() { # <config-dir>
161
+ local config_dir=${1:-} file value
162
+ [ -n "$config_dir" ] || { printf 'default\n'; return 0; }
163
+ file="$config_dir/$FM_BACKEND_HERDR_PRESENTATION_CONFIG"
164
+ [ -f "$file" ] || { printf 'default\n'; return 0; }
165
+ value=$(tr -d '[:space:]' < "$file" 2>/dev/null | tr '[:upper:]' '[:lower:]') || value=""
166
+ case "$value" in
167
+ off) printf 'off\n' ;;
168
+ ''|on) printf 'on\n' ;;
169
+ *)
170
+ echo "warning: $file: unrecognized value \"$value\"; herdr presentation spaces fall back to the default (write \"off\" to opt out, \"on\" to force the projection on)" >&2
171
+ printf 'default\n'
172
+ ;;
173
+ esac
174
+ }
175
+
176
+ # fm_backend_herdr_version_at_least <candidate> <floor>: numeric dotted-release
177
+ # comparison. Return codes: 0 candidate >= floor, 1 candidate < floor, 2 the
178
+ # candidate is unparseable. Any prerelease or build suffix is stripped first, so
179
+ # a 0.8.0-preview build compares as 0.8.0 (it is built from the 0.8.0 line and
180
+ # carries its fixes) while a 0.7.5-preview build compares as 0.7.5.
181
+ fm_backend_herdr_version_at_least() { # <candidate> <floor>
182
+ local candidate=${1:-} floor=${2:-} c f
183
+ candidate=${candidate%%[-+]*}
184
+ case "$candidate" in ''|*[!0-9.]*) return 2 ;; esac
185
+ while [ -n "$floor" ]; do
186
+ c=${candidate%%.*}
187
+ f=${floor%%.*}
188
+ [ -n "$c" ] || c=0
189
+ [ "$c" -gt "$f" ] 2>/dev/null && return 0
190
+ [ "$c" -lt "$f" ] 2>/dev/null && return 1
191
+ case "$candidate" in *.*) candidate=${candidate#*.} ;; *) candidate= ;; esac
192
+ case "$floor" in *.*) floor=${floor#*.} ;; *) floor= ;; esac
193
+ done
194
+ return 0
195
+ }
196
+
197
+ # fm_backend_herdr_release_floor_verdict <protocol> <version>: the pure
198
+ # classifier for the presentation version floor. Return codes: 0 at or above the
199
+ # floor, 1 provably below it, 2 indeterminate.
200
+ # Two independent signals are read so no single field is load-bearing, and
201
+ # either one can carry a positive verdict: the protocol number, which is the
202
+ # structural signal this adapter already uses for every other capability gate,
203
+ # and the release core of the version string. A signal that is unreadable or
204
+ # unparseable simply cannot carry a verdict; a readable protocol below the floor
205
+ # is decisive on its own, and only losing BOTH signals reports indeterminate.
206
+ fm_backend_herdr_release_floor_verdict() { # <protocol> <version>
207
+ local protocol=${1:-} version=${2:-} protocol_known=0 version_status=0
208
+ case "$protocol" in
209
+ ''|*[!0-9]*) ;;
210
+ *)
211
+ protocol_known=1
212
+ [ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL" ] && return 0
213
+ ;;
214
+ esac
215
+ fm_backend_herdr_version_at_least "$version" "$FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION" \
216
+ || version_status=$?
217
+ [ "$version_status" -eq 0 ] && return 0
218
+ { [ "$protocol_known" -eq 1 ] || [ "$version_status" -eq 1 ]; } && return 1
219
+ return 2
220
+ }
221
+
222
+ # fm_backend_herdr_presentation_release_supported: run the floor classifier
223
+ # against the installed client and, when one exists, the selected session's
224
+ # running server. A running server and client compose conservatively: both must
225
+ # be supported. When status positively reports no running server, only the
226
+ # client that will start it is applicable. Same return codes as
227
+ # fm_backend_herdr_release_floor_verdict, and sets
228
+ # FM_BACKEND_HERDR_PRESENTATION_RELEASE to the identifier a caller's warning
229
+ # names. An unreadable server-running state is indeterminate rather than
230
+ # permission to substitute the client release.
231
+ fm_backend_herdr_presentation_release_supported() { # [<session>]
232
+ local session=${1:-} status running client_protocol client_version client_verdict=0
233
+ local server_protocol server_version server_verdict=0
234
+ FM_BACKEND_HERDR_PRESENTATION_RELEASE="an unreadable release"
235
+ command -v herdr >/dev/null 2>&1 || return 2
236
+ command -v jq >/dev/null 2>&1 || return 2
237
+ [ -n "$session" ] || session=$(fm_backend_herdr_session)
238
+ status=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null) || return 2
239
+ client_protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null) || return 2
240
+ client_version=$(printf '%s' "$status" | jq -r '.client.version // empty' 2>/dev/null) || return 2
241
+ fm_backend_herdr_release_floor_verdict "$client_protocol" "$client_version" || client_verdict=$?
242
+ running=$(printf '%s' "$status" | jq -r '
243
+ if .server.running == true then "true"
244
+ elif .server.running == false then "false"
245
+ else "unknown"
246
+ end
247
+ ' 2>/dev/null) || return 2
248
+ case "$running" in
249
+ true)
250
+ server_protocol=$(printf '%s' "$status" | jq -r '.server.protocol // empty' 2>/dev/null) || return 2
251
+ server_version=$(printf '%s' "$status" | jq -r '.server.version // empty' 2>/dev/null) || return 2
252
+ fm_backend_herdr_release_floor_verdict "$server_protocol" "$server_version" || server_verdict=$?
253
+ if [ "$server_verdict" -eq 1 ]; then
254
+ FM_BACKEND_HERDR_PRESENTATION_RELEASE="server version ${server_version:-unknown} (protocol ${server_protocol:-unknown})"
255
+ return 1
256
+ fi
257
+ if [ "$client_verdict" -eq 1 ]; then
258
+ FM_BACKEND_HERDR_PRESENTATION_RELEASE="version ${client_version:-unknown} (protocol ${client_protocol:-unknown})"
259
+ return 1
260
+ fi
261
+ if [ "$server_verdict" -ne 0 ]; then
262
+ FM_BACKEND_HERDR_PRESENTATION_RELEASE="server version ${server_version:-unknown} (protocol ${server_protocol:-unknown})"
263
+ return 2
264
+ fi
265
+ if [ "$client_verdict" -ne 0 ]; then
266
+ FM_BACKEND_HERDR_PRESENTATION_RELEASE="version ${client_version:-unknown} (protocol ${client_protocol:-unknown})"
267
+ return 2
268
+ fi
269
+ return 0
270
+ ;;
271
+ false)
272
+ FM_BACKEND_HERDR_PRESENTATION_RELEASE="version ${client_version:-unknown} (protocol ${client_protocol:-unknown})"
273
+ return "$client_verdict"
274
+ ;;
275
+ *) return 2 ;;
276
+ esac
277
+ }
278
+
279
+ # fm_backend_herdr_presentation_floor_warn <state-dir> <verdict>: emit the one
280
+ # clear below-floor warning, deduplicated per home per detected release when a
281
+ # usable state dir is given. Without one the warning is emitted every call,
282
+ # which is what a one-shot caller wants.
283
+ fm_backend_herdr_presentation_floor_warn() { # <state-dir> <verdict>
284
+ local state_dir=${1:-} verdict=${2:-2} release=${FM_BACKEND_HERDR_PRESENTATION_RELEASE:-an unreadable release} key marker reason tmp=""
285
+ if [ "$verdict" -eq 1 ]; then
286
+ reason="herdr $release is older than the $FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION floor for presentation spaces, where projected cleanup can steal the active workspace"
287
+ else
288
+ reason="the selected herdr release could not be read, so the $FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION floor for presentation spaces cannot be verified"
289
+ fi
290
+ if [ -n "$state_dir" ] && [ -d "$state_dir" ] && [ ! -L "$state_dir" ]; then
291
+ key=${release//[^a-zA-Z0-9]/-}
292
+ marker="$state_dir/$FM_BACKEND_HERDR_PRESENTATION_FLOOR_MARKER_PREFIX$key"
293
+ { [ -e "$marker" ] || [ -L "$marker" ]; } && return 0
294
+ tmp=$(umask 077; mktemp "$state_dir/.herdr-presentation-floor.XXXXXX" 2>/dev/null) || tmp=""
295
+ if [ -n "$tmp" ]; then
296
+ if ln "$tmp" "$marker" 2>/dev/null; then
297
+ rm -f -- "$tmp"
298
+ else
299
+ rm -f -- "$tmp"
300
+ { [ -e "$marker" ] || [ -L "$marker" ]; } && return 0
301
+ fi
302
+ fi
303
+ fi
304
+ echo "warning: $reason; using the ordinary flat layout instead. Upgrade herdr to $FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION or newer (herdr update) to restore the projection, or write \"on\" into config/$FM_BACKEND_HERDR_PRESENTATION_CONFIG to force it on this release." >&2
305
+ return 0
306
+ }
307
+
308
+ # fm_backend_herdr_presentation_default_supported <state-dir> [<session>]:
309
+ # compose the applicable release verdict and the shared warning contract for
310
+ # one unconfigured home.
311
+ fm_backend_herdr_presentation_default_supported() { # <state-dir> [<session>]
312
+ local state_dir=${1:-} session=${2:-} verdict=0
313
+ fm_backend_herdr_presentation_release_supported "$session" || verdict=$?
314
+ [ "$verdict" -eq 0 ] && return 0
315
+ fm_backend_herdr_presentation_floor_warn "$state_dir" "$verdict"
316
+ return 1
317
+ }
318
+
319
+ # fm_backend_herdr_presentation_enabled <config-dir> [<state-dir>]: the one gate
320
+ # bin/fm-spawn.sh consults before projecting this home's children into
321
+ # disposable one-task workspaces (docs/herdr-backend.md "Presentation spaces"
322
+ # owns the full contract). An explicit "off" or "on" is obeyed as written; a
323
+ # home that configured nothing is projected only at or above the version floor,
324
+ # and otherwise falls back to the flat layout with one warning. Sets
325
+ # FM_BACKEND_HERDR_PRESENTATION_PREFERENCE for the new-projection boundary to
326
+ # distinguish an unconfigured default from an explicit opt-in.
327
+ fm_backend_herdr_presentation_enabled() { # <config-dir> [<state-dir>]
328
+ local config_dir=${1:-} state_dir=${2:-} preference
329
+ preference=$(fm_backend_herdr_presentation_preference "$config_dir")
330
+ # bin/fm-spawn.sh reads this out-parameter after sourcing this adapter.
331
+ # shellcheck disable=SC2034
332
+ FM_BACKEND_HERDR_PRESENTATION_PREFERENCE=$preference
333
+ case "$preference" in
334
+ off) return 1 ;;
335
+ on) return 0 ;;
336
+ esac
337
+ fm_backend_herdr_presentation_default_supported "$state_dir"
338
+ }
339
+
340
+ # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace
341
+ # label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no
342
+ # secondmate marker) resolves to the constant "firstmate", byte-identical to
343
+ # every pre-existing task's recorded label - no forced migration. A SECONDMATE
344
+ # home resolves to "2ndmate-<secondmate-id>", so its tasks land in their own
345
+ # workspace, obviously distinguishable from the primary's (and from every
346
+ # other secondmate's) in herdr's spaces sidebar. Read fresh from FM_HOME on
347
+ # every call rather than cached at source time: FM_HOME is the home's own
348
+ # durable identity, not env plumbing threaded through a call chain, so the
349
+ # label is automatically stable across every respawn/recovery for the life of
350
+ # that home. fm-spawn.sh briefly shadows FM_HOME to a secondmate's own home
351
+ # when the PRIMARY spawns that secondmate (its own process's FM_HOME still
352
+ # names the primary at that point) - see fm-spawn.sh's herdr case arm.
353
+ fm_backend_herdr_workspace_label() {
354
+ local marker="$FM_HOME/$FM_BACKEND_HERDR_SECONDMATE_MARKER" id
355
+ if [ -f "$marker" ]; then
356
+ id=$(tr -d '[:space:]' < "$marker" 2>/dev/null)
357
+ if [ -n "$id" ]; then
358
+ printf '2ndmate-%s' "$id"
359
+ return 0
360
+ fi
361
+ fi
362
+ printf 'firstmate'
363
+ }
364
+
365
+ # fm_backend_herdr_cli: run `herdr <args...>` scoped to <session>, setting
366
+ # BOTH the HERDR_SESSION env var AND appending a trailing `--session <name>`
367
+ # CLI flag. Verified empirically (docs/herdr-backend.md "Session targeting: the
368
+ # --session flag, not HERDR_SESSION alone"): on the installed herdr 0.7.1
369
+ # client, the HERDR_SESSION env var is NOT reliably honored by CLI subcommands
370
+ # once ANY other herdr server is already bound on the machine - queries
371
+ # silently fall back to whatever server IS running (the wrong one) instead of
372
+ # routing to the requested session or refusing. The `--session <name>` global
373
+ # flag (verified in both leading and trailing position; trailing used here to
374
+ # keep every call site a minimal, append-only diff) always routes correctly,
375
+ # including starting a genuinely separate, isolated server process. The env
376
+ # var is kept alongside it - harmless, self-documenting, and forward-
377
+ # compatible if a future herdr build honors it. Never used by
378
+ # fm_backend_herdr_version_check, which is intentionally session-independent
379
+ # (reads only .client.* fields).
380
+ fm_backend_herdr_cli() { # <session> <herdr-subcommand-and-args...>
381
+ local session=$1
382
+ shift
383
+ HERDR_SESSION="$session" herdr "$@" --session "$session"
384
+ }
385
+
386
+ # fm_backend_herdr_tool_check: refuse loudly if herdr or jq is missing.
387
+ fm_backend_herdr_tool_check() {
388
+ command -v herdr >/dev/null 2>&1 || { echo "error: backend=herdr selected but the 'herdr' CLI is not installed (https://herdr.dev) (dual-licensed AGPL-3.0-or-later/commercial)" >&2; return 1; }
389
+ command -v jq >/dev/null 2>&1 || { echo "error: backend=herdr selected but 'jq' is not installed (required to parse herdr's JSON output)" >&2; return 1; }
390
+ return 0
391
+ }
392
+
393
+ # fm_backend_herdr_version_check: refuse loudly on a missing/incompatible
394
+ # herdr client. Verified locally: v0.7.1, protocol 14 (herdr status --json's
395
+ # .client.protocol; client info is session-independent, unlike .server).
396
+ fm_backend_herdr_version_check() {
397
+ fm_backend_herdr_tool_check || return 1
398
+ local status protocol version
399
+ status=$(herdr status --json 2>/dev/null) || { echo "error: 'herdr status --json' failed; is herdr installed correctly?" >&2; return 1; }
400
+ protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null)
401
+ version=$(printf '%s' "$status" | jq -r '.client.version // empty' 2>/dev/null)
402
+ case "$protocol" in
403
+ ''|*[!0-9]*)
404
+ echo "error: could not read herdr client protocol from 'herdr status --json'; refusing to use an unverified herdr build" >&2
405
+ return 1
406
+ ;;
407
+ esac
408
+ if [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_PROTOCOL" ]; then
409
+ echo "error: herdr protocol $protocol (version ${version:-unknown}) is older than the verified minimum $FM_BACKEND_HERDR_MIN_PROTOCOL; update herdr (herdr update) before using backend=herdr" >&2
410
+ return 1
411
+ fi
412
+ return 0
413
+ }
414
+
415
+ # fm_backend_herdr_session: resolve which named herdr session this normal
416
+ # spawn/op uses. HERDR_SESSION mirrors tmux's $TMUX ambient-selection for
417
+ # adapter workspace/tab/pane operations: an operator (or firstmate's own
418
+ # isolated test harness) sets it explicitly; absent means herdr's own
419
+ # "default" session. Do not use HERDR_SESSION alone for destructive test
420
+ # cleanup; tests/herdr-test-safety.sh documents and guards that path.
421
+ fm_backend_herdr_session() {
422
+ printf '%s' "${HERDR_SESSION:-default}"
423
+ }
424
+
425
+ # fm_backend_herdr_projection_id: generate a compact 128-bit base64url token.
426
+ # The token is a non-adversarial visual correlator, never destructive
427
+ # authority.
428
+ fm_backend_herdr_projection_id() {
429
+ local token
430
+ token=$(dd if=/dev/urandom bs=16 count=1 2>/dev/null \
431
+ | base64 \
432
+ | tr '+/' '-_' \
433
+ | tr -d '=\r\n') || return 1
434
+ [ "${#token}" -eq 22 ] || return 1
435
+ case "$token" in
436
+ *[!A-Za-z0-9_-]*) return 1 ;;
437
+ esac
438
+ printf '%s' "$token"
439
+ }
440
+
441
+ fm_backend_herdr_projection_journal_path() { # <state-dir> <task-id>
442
+ printf '%s/%s%s' "$1" "$2" "$FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX"
443
+ }
444
+
445
+ # fm_backend_herdr_projection_journal_create: atomically publish the
446
+ # non-authoritative attempt journal before any projection workspace create.
447
+ # A hard-link publication in the same state directory gives create-if-absent
448
+ # semantics, so concurrent attempts cannot overwrite each other's token.
449
+ fm_backend_herdr_projection_journal_create() { # <state-dir> <task-id>
450
+ local state=$1 id=$2 journal token tmp
451
+ case "$id" in
452
+ ''|.*|*[!A-Za-z0-9._-]*)
453
+ echo "error: invalid task id for herdr presentation journal" >&2
454
+ return 1
455
+ ;;
456
+ esac
457
+ mkdir -p "$state" || return 1
458
+ journal=$(fm_backend_herdr_projection_journal_path "$state" "$id")
459
+ if [ -e "$journal" ] || [ -L "$journal" ]; then
460
+ echo "error: herdr presentation journal already exists for $id; refusing a concurrent or repeated projected create" >&2
461
+ return 1
462
+ fi
463
+ token=$(fm_backend_herdr_projection_id) || {
464
+ echo "error: could not generate a 128-bit herdr presentation projection id" >&2
465
+ return 1
466
+ }
467
+ tmp=$(mktemp "$state/.${id}.herdr-presentation.XXXXXX") || return 1
468
+ chmod 0600 "$tmp" || { rm -f "$tmp"; return 1; }
469
+ if ! {
470
+ printf 'version=1\n'
471
+ printf 'task_id=%s\n' "$id"
472
+ printf 'projection_id=%s\n' "$token"
473
+ } > "$tmp"; then
474
+ rm -f "$tmp"
475
+ return 1
476
+ fi
477
+ if ! ln "$tmp" "$journal" 2>/dev/null; then
478
+ rm -f "$tmp"
479
+ echo "error: herdr presentation journal appeared concurrently for $id; refusing projected create" >&2
480
+ return 1
481
+ fi
482
+ rm -f "$tmp"
483
+ printf '%s' "$token"
484
+ }
485
+
486
+ fm_backend_herdr_projection_journal_field() { # <journal> <key>
487
+ local journal=$1 key=$2 count
488
+ count=$(grep -c "^${key}=" "$journal" 2>/dev/null || true)
489
+ [ "$count" = 1 ] || return 1
490
+ grep "^${key}=" "$journal" 2>/dev/null | cut -d= -f2-
491
+ }
492
+
493
+ # fm_backend_herdr_projection_journal_snapshot: validate a version 1 attempt
494
+ # journal or a version 2 exact projection binding without sourcing shell code.
495
+ # Version 2 sets FM_BACKEND_HERDR_JOURNAL_* globals for same-process callers.
496
+ fm_backend_herdr_projection_journal_snapshot() { # <journal> <task-id>
497
+ local journal=$1 id=$2 lines expected_label expected_task_label exact
498
+ FM_BACKEND_HERDR_JOURNAL_VERSION=""
499
+ FM_BACKEND_HERDR_JOURNAL_TASK_ID=""
500
+ FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID=""
501
+ FM_BACKEND_HERDR_JOURNAL_HOME=""
502
+ FM_BACKEND_HERDR_JOURNAL_SESSION=""
503
+ FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID=""
504
+ FM_BACKEND_HERDR_JOURNAL_TAB_ID=""
505
+ FM_BACKEND_HERDR_JOURNAL_PANE_ID=""
506
+ FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID=""
507
+ FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL=""
508
+ FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL=""
509
+ FM_BACKEND_HERDR_JOURNAL_TASK_LABEL=""
510
+ [ -f "$journal" ] && [ ! -L "$journal" ] || return 1
511
+ lines=$(wc -l < "$journal" 2>/dev/null | tr -d '[:space:]')
512
+ FM_BACKEND_HERDR_JOURNAL_VERSION=$(fm_backend_herdr_projection_journal_field "$journal" version) || return 1
513
+ FM_BACKEND_HERDR_JOURNAL_TASK_ID=$(fm_backend_herdr_projection_journal_field "$journal" task_id) || return 1
514
+ FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID=$(fm_backend_herdr_projection_journal_field "$journal" projection_id) || return 1
515
+ [ "$FM_BACKEND_HERDR_JOURNAL_TASK_ID" = "$id" ] || return 1
516
+ [ "${#FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID}" -eq 22 ] || return 1
517
+ case "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" in
518
+ *[!A-Za-z0-9_-]*) return 1 ;;
519
+ esac
520
+ case "$FM_BACKEND_HERDR_JOURNAL_VERSION:$lines" in
521
+ 1:3) return 0 ;;
522
+ 2:12) ;;
523
+ *) return 1 ;;
524
+ esac
525
+ FM_BACKEND_HERDR_JOURNAL_HOME=$(fm_backend_herdr_projection_journal_field "$journal" home) || return 1
526
+ FM_BACKEND_HERDR_JOURNAL_SESSION=$(fm_backend_herdr_projection_journal_field "$journal" session) || return 1
527
+ FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID=$(fm_backend_herdr_projection_journal_field "$journal" workspace_id) || return 1
528
+ FM_BACKEND_HERDR_JOURNAL_TAB_ID=$(fm_backend_herdr_projection_journal_field "$journal" tab_id) || return 1
529
+ FM_BACKEND_HERDR_JOURNAL_PANE_ID=$(fm_backend_herdr_projection_journal_field "$journal" pane_id) || return 1
530
+ FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_journal_field "$journal" parent_workspace_id) || return 1
531
+ FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" parent_label) || return 1
532
+ FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" workspace_label) || return 1
533
+ FM_BACKEND_HERDR_JOURNAL_TASK_LABEL=$(fm_backend_herdr_projection_journal_field "$journal" task_label) || return 1
534
+ case "$FM_BACKEND_HERDR_JOURNAL_HOME" in
535
+ /*) ;;
536
+ *) return 1 ;;
537
+ esac
538
+ for exact in \
539
+ "$FM_BACKEND_HERDR_JOURNAL_SESSION" \
540
+ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" \
541
+ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" \
542
+ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" \
543
+ "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID"; do
544
+ case "$exact" in
545
+ ''|*[[:space:]]*) return 1 ;;
546
+ esac
547
+ done
548
+ [ -n "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" ] \
549
+ && [ -n "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" ] \
550
+ && [ -n "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" ] || return 1
551
+ expected_label=$(fm_backend_herdr_projection_workspace_label "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID")
552
+ expected_task_label="fm-$id"
553
+ [ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" = "$expected_label" ] \
554
+ && [ "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" = "$expected_task_label" ]
555
+ }
556
+
557
+ # fm_backend_herdr_projection_journal_token: validate and read either journal
558
+ # version's non-authoritative visual correlator.
559
+ fm_backend_herdr_projection_journal_token() { # <journal> <task-id>
560
+ fm_backend_herdr_projection_journal_snapshot "$1" "$2" || return 1
561
+ printf '%s' "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID"
562
+ }
563
+
564
+ fm_backend_herdr_projection_home_identity() { # <home>
565
+ local home=$1
566
+ [ -d "$home" ] || return 1
567
+ (cd "$home" 2>/dev/null && pwd -P)
568
+ }
569
+
570
+ fm_backend_herdr_projection_journal_write_v2() { # <journal> <task-id> <token> <home> <session> <workspace> <tab> <pane> <parent-workspace> <parent-label> <workspace-label> <task-label>
571
+ local journal=$1 id=$2 token=$3 home=$4 session=$5 workspace=$6 tab=$7 pane=$8
572
+ local parent_workspace=$9 parent_label=${10} workspace_label=${11} task_label=${12} state tmp
573
+ state=$(dirname "$journal")
574
+ tmp=$(mktemp "$state/.${id}.herdr-presentation.bind.XXXXXX") || return 1
575
+ chmod 0600 "$tmp" || { rm -f "$tmp"; return 1; }
576
+ if ! {
577
+ printf 'version=2\n'
578
+ printf 'task_id=%s\n' "$id"
579
+ printf 'projection_id=%s\n' "$token"
580
+ printf 'home=%s\n' "$home"
581
+ printf 'session=%s\n' "$session"
582
+ printf 'workspace_id=%s\n' "$workspace"
583
+ printf 'tab_id=%s\n' "$tab"
584
+ printf 'pane_id=%s\n' "$pane"
585
+ printf 'parent_workspace_id=%s\n' "$parent_workspace"
586
+ printf 'parent_label=%s\n' "$parent_label"
587
+ printf 'workspace_label=%s\n' "$workspace_label"
588
+ printf 'task_label=%s\n' "$task_label"
589
+ } > "$tmp"; then
590
+ rm -f "$tmp"
591
+ return 1
592
+ fi
593
+ [ -f "$journal" ] && [ ! -L "$journal" ] || { rm -f "$tmp"; return 1; }
594
+ mv -f "$tmp" "$journal"
595
+ }
596
+
597
+ # fm_backend_herdr_projection_journal_bind: upgrade one exact version 1
598
+ # attempt to a version 2 binding after the live projection and parent relation
599
+ # have both been verified under the session lock.
600
+ fm_backend_herdr_projection_journal_bind() { # <journal> <task-id> <home> <session> <workspace> <tab> <pane> <parent-workspace> <parent-label> <workspace-label> <task-label>
601
+ local journal=$1 id=$2 home=$3 session=$4 workspace=$5 tab=$6 pane=$7
602
+ local parent_workspace=$8 parent_label=$9 workspace_label=${10} task_label=${11} token
603
+ fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1
604
+ [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 1 ] || return 1
605
+ token=$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID
606
+ fm_backend_herdr_projection_journal_write_v2 \
607
+ "$journal" "$id" "$token" "$home" "$session" "$workspace" "$tab" "$pane" \
608
+ "$parent_workspace" "$parent_label" "$workspace_label" "$task_label"
609
+ }
610
+
611
+ # fm_backend_herdr_projection_journal_replace_endpoint: atomically advance one
612
+ # exact version 2 binding after its old husk was replaced successfully.
613
+ fm_backend_herdr_projection_journal_replace_endpoint() { # <journal> <task-id> <old-tab> <old-pane> <new-tab> <new-pane>
614
+ local journal=$1 id=$2 old_tab=$3 old_pane=$4 new_tab=$5 new_pane=$6
615
+ fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1
616
+ [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" = 2 ] \
617
+ && [ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" = "$old_tab" ] \
618
+ && [ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" = "$old_pane" ] || return 1
619
+ fm_backend_herdr_projection_journal_write_v2 \
620
+ "$journal" "$id" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \
621
+ "$FM_BACKEND_HERDR_JOURNAL_HOME" "$FM_BACKEND_HERDR_JOURNAL_SESSION" \
622
+ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" "$new_tab" "$new_pane" \
623
+ "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" \
624
+ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL"
625
+ }
626
+
627
+ # fm_backend_herdr_projection_concise_task_label: strip redundant owner
628
+ # prefixes from a task id used only in the presentation workspace label.
629
+ # Removes firstmate/, 2ndmate-<id>/, and a presentation-level fm- owner
630
+ # prefix when present. The ordinary task tab remains fm-<id> and is not
631
+ # built by this helper.
632
+ fm_backend_herdr_projection_concise_task_label() { # <task-id>
633
+ local task=$1
634
+ case "$task" in
635
+ firstmate/*) task=${task#firstmate/} ;;
636
+ 2ndmate-*/*) task=${task#*/} ;;
637
+ esac
638
+ case "$task" in
639
+ fm-*) task=${task#fm-} ;;
640
+ esac
641
+ printf '%s' "$task"
642
+ }
643
+
644
+ # fm_backend_herdr_projection_workspace_label: presentation-only child label.
645
+ # Format is literal U+2514 BOX DRAWINGS LIGHT UP AND RIGHT, one space, the
646
+ # concise task label, then the unchanged · p:<full-22-char-token> suffix.
647
+ # Labels and tokens remain non-authoritative correlators only.
648
+ fm_backend_herdr_projection_workspace_label() { # <task-id> <projection-id>
649
+ printf '└ %s · p:%s' "$(fm_backend_herdr_projection_concise_task_label "$1")" "$2"
650
+ }
651
+
652
+ # fm_backend_herdr_presentation_session_lock_path: one machine-private lock
653
+ # path per live named Herdr session/socket, shared across every Firstmate home
654
+ # that uses that session.
655
+ # The path is never under any one home's state/ and secondmates never write the
656
+ # primary home. Returns non-zero when the named session's socket cannot be
657
+ # resolved unambiguously.
658
+ fm_backend_herdr_presentation_lock_namespace() {
659
+ printf '%s' '/tmp/firstmate-herdr-presentation'
660
+ }
661
+
662
+ fm_backend_herdr_presentation_lock_namespace_mode() {
663
+ if [ "$(uname -s 2>/dev/null)" = Darwin ]; then
664
+ stat -f '%Lp' "$1" 2>/dev/null
665
+ else
666
+ stat -c '%a' "$1" 2>/dev/null
667
+ fi
668
+ }
669
+
670
+ fm_backend_herdr_presentation_lock_namespace_uid() {
671
+ if [ "$(uname -s 2>/dev/null)" = Darwin ]; then
672
+ stat -f '%u' "$1" 2>/dev/null
673
+ else
674
+ stat -c '%u' "$1" 2>/dev/null
675
+ fi
676
+ }
677
+
678
+ fm_backend_herdr_presentation_lock_namespace_valid() {
679
+ local dir=$1 expected_uid owner mode
680
+ [ -d "$dir" ] && [ ! -L "$dir" ] || return 1
681
+ expected_uid=$(id -u 2>/dev/null) || return 1
682
+ owner=$(fm_backend_herdr_presentation_lock_namespace_uid "$dir") || return 1
683
+ mode=$(fm_backend_herdr_presentation_lock_namespace_mode "$dir") || return 1
684
+ [ "$owner" = "$expected_uid" ] && [ "$mode" = 700 ]
685
+ }
686
+
687
+ # Resolve the one verified running named-session socket path as an absolute
688
+ # string. Requires JSON string type and non-empty length (jq -r is never used:
689
+ # it would turn JSON null into the literal string "null"). Canonicalizes the
690
+ # parent directory when that directory exists so symlink parents such as /tmp
691
+ # -> /private/tmp cannot yield two lock identities for the same socket.
692
+ # fm_backend_herdr_canonical_socket_path: normalize one absolute Unix-socket
693
+ # path so two spellings of the same socket compare equal. Refuses a relative
694
+ # or empty path. An unresolvable directory is left as-is rather than treated as
695
+ # a failure, so a socket whose directory was removed still compares by its own
696
+ # literal path. Single owner for every socket-identity comparison in this
697
+ # adapter (the presentation session lock and the launcher-identity same-session
698
+ # proof both use it).
699
+ fm_backend_herdr_canonical_socket_path() { # <socket-path>
700
+ local socket=$1 sock_dir sock_base
701
+ [ -n "$socket" ] || return 1
702
+ case "$socket" in
703
+ /*) ;;
704
+ *) return 1 ;;
705
+ esac
706
+ sock_dir=$(dirname "$socket")
707
+ sock_base=$(basename "$socket")
708
+ [ -n "$sock_dir" ] && [ -n "$sock_base" ] || return 1
709
+ if [ -d "$sock_dir" ]; then
710
+ sock_dir=$(cd "$sock_dir" 2>/dev/null && pwd -P) || return 1
711
+ socket="$sock_dir/$sock_base"
712
+ fi
713
+ printf '%s' "$socket"
714
+ }
715
+
716
+ fm_backend_herdr_presentation_session_socket_path() { # <session>
717
+ local session=$1 sessions socket
718
+ [ -n "$session" ] || return 1
719
+ sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1
720
+ socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" '
721
+ [.sessions[]?
722
+ | select(.name == $want and .running == true)
723
+ | select((.socket_path | type) == "string")
724
+ | select((.socket_path | length) > 0)
725
+ | .socket_path]
726
+ | if length == 1 then .[0] else empty end
727
+ ' 2>/dev/null) || return 1
728
+ fm_backend_herdr_canonical_socket_path "$socket"
729
+ }
730
+
731
+ fm_backend_herdr_presentation_session_lock_path() { # <session>
732
+ local session=$1 socket key dir hash
733
+ [ -n "$session" ] || return 1
734
+ socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || return 1
735
+ if command -v shasum >/dev/null 2>&1; then
736
+ hash=$(printf '%s\0%s' "$session" "$socket" | shasum -a 256 2>/dev/null | awk '{print $1}')
737
+ elif command -v sha256sum >/dev/null 2>&1; then
738
+ hash=$(printf '%s\0%s' "$session" "$socket" | sha256sum 2>/dev/null | awk '{print $1}')
739
+ else
740
+ return 1
741
+ fi
742
+ [ -n "$hash" ] || return 1
743
+ key=${hash:0:32}
744
+ dir=$(fm_backend_herdr_presentation_lock_namespace) || return 1
745
+ [ -n "$dir" ] || return 1
746
+ if [ ! -e "$dir" ] && [ ! -L "$dir" ]; then
747
+ if ! mkdir -m 700 "$dir" 2>/dev/null; then
748
+ fm_backend_herdr_presentation_lock_namespace_valid "$dir" || return 1
749
+ fi
750
+ fi
751
+ fm_backend_herdr_presentation_lock_namespace_valid "$dir" || return 1
752
+ printf '%s/order-%s.lock' "$dir" "$key"
753
+ }
754
+
755
+ # fm_backend_herdr_projection_focus_snapshot: print the exact active
756
+ # workspace and tab ids as one tab-separated record.
757
+ # Presentation mutations use this read-only snapshot as their sole focus
758
+ # restoration authority.
759
+ # Labels, workspace order, and ambient client state are never focus authority.
760
+ fm_backend_herdr_projection_focus_snapshot() { # <session>
761
+ local session=$1 list snapshot workspace tab tabs
762
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1
763
+ snapshot=$(printf '%s' "$list" | jq -r '
764
+ [.result.workspaces[]? | select(.focused == true)]
765
+ | select(length == 1)
766
+ | .[0]
767
+ | select((.workspace_id | type) == "string" and (.workspace_id | length) > 0)
768
+ | select((.active_tab_id | type) == "string" and (.active_tab_id | length) > 0)
769
+ | [.workspace_id, .active_tab_id]
770
+ | @tsv
771
+ ' 2>/dev/null) || return 1
772
+ [ -n "$snapshot" ] || return 1
773
+ workspace=${snapshot%%$'\t'*}
774
+ tab=${snapshot#*$'\t'}
775
+ [ -n "$workspace" ] && [ -n "$tab" ] && [ "$workspace" != "$tab" ] || return 1
776
+ tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$workspace" 2>/dev/null) || return 1
777
+ printf '%s' "$tabs" | jq -e --arg tab "$tab" '
778
+ (.result.tabs | type) == "array"
779
+ and ([.result.tabs[] | select(.focused == true)] | length) == 1
780
+ and ([.result.tabs[] | select(.focused == true)][0].tab_id == $tab)
781
+ ' >/dev/null 2>&1 || return 1
782
+ printf '%s\t%s' "$workspace" "$tab"
783
+ }
784
+
785
+ # fm_backend_herdr_projection_focus_restore: verify that one presentation
786
+ # mutation preserved the exact active workspace and tab captured immediately
787
+ # before it.
788
+ # This is the backstop for every focus-unsafe instant: on Herdr 0.7.5 an
789
+ # explicit pane.close that empties a non-focused workspace moves focus to
790
+ # that workspace's neighbor (upstream #1328/#1877), and a pane-death removal
791
+ # before a non-last focused workspace moves focus to the focused workspace's
792
+ # right neighbor (upstream #1621/#1912); both fixes are unreleased.
793
+ # A single tab.focus on the exact response-independent pre-operation tab id
794
+ # restores both the workspace and tab atomically.
795
+ fm_backend_herdr_projection_focus_restore() { # <session> <snapshot> <operation>
796
+ local session=$1 before=$2 operation=$3 workspace tab after info restored
797
+ [ -n "$before" ] || {
798
+ echo "warning: herdr presentation $operation had no unambiguous pre-operation focus snapshot" >&2
799
+ return 1
800
+ }
801
+ after=$(fm_backend_herdr_projection_focus_snapshot "$session") || after=
802
+ [ "$after" != "$before" ] || return 0
803
+ workspace=${before%%$'\t'*}
804
+ tab=${before#*$'\t'}
805
+ info=$(fm_backend_herdr_cli "$session" tab get "$tab" 2>/dev/null) || {
806
+ echo "warning: herdr presentation $operation changed focus and the exact prior tab could not be verified for restoration" >&2
807
+ return 1
808
+ }
809
+ if ! printf '%s' "$info" | jq -e --arg workspace "$workspace" --arg tab "$tab" '
810
+ .result.tab.workspace_id == $workspace and .result.tab.tab_id == $tab
811
+ ' >/dev/null 2>&1; then
812
+ echo "warning: herdr presentation $operation changed focus and the exact prior tab response was ambiguous" >&2
813
+ return 1
814
+ fi
815
+ fm_backend_herdr_cli "$session" tab focus "$tab" >/dev/null 2>&1 || {
816
+ echo "warning: herdr presentation $operation changed focus and exact-tab restoration failed" >&2
817
+ return 1
818
+ }
819
+ restored=$(fm_backend_herdr_projection_focus_snapshot "$session") || restored=
820
+ if [ "$restored" != "$before" ]; then
821
+ echo "warning: herdr presentation $operation did not restore the exact prior workspace and tab" >&2
822
+ return 1
823
+ fi
824
+ return 0
825
+ }
826
+
827
+ # fm_backend_herdr_projection_close_pane_focus_preserving: close one exact
828
+ # response-derived projection pane without leaving the captain focused
829
+ # anywhere else.
830
+ # If the target belongs to the active tab, exact tab preservation is
831
+ # impossible, so cleanup refuses instead of changing focus.
832
+ # When the close would empty the target workspace, Herdr 0.7.5's explicit
833
+ # close moves focus to the workspace's neighbor, so the close is planned by
834
+ # fm_backend_herdr_emptying_close_plan: reposition the doomed workspace
835
+ # behind the focused one when needed, then end the pane's verified lone idle
836
+ # shell so Herdr removes the emptied workspace through its focus-preserving
837
+ # pane-death path. The exact-tab restore below remains the backstop, and any
838
+ # ambiguity falls back to the plain explicit close, which the backstop masks
839
+ # exactly as before this hardening.
840
+ fm_backend_herdr_projection_close_pane_focus_preserving() { # <session> <pane-id> [required-agent-state]
841
+ local session=$1 pane_id=$2 required_agent_state=${3:-}
842
+ local before active_tab info target_pane target_tab target_ws close_status state plan plan_shell_pid plan_move_record workspace_presence
843
+ FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE=""
844
+ [ -n "$pane_id" ] || return 0
845
+ before=$(fm_backend_herdr_projection_focus_snapshot "$session") || {
846
+ echo "warning: herdr presentation cleanup could not capture exact active workspace and tab; refusing focus-unsafe pane close" >&2
847
+ return 1
848
+ }
849
+ active_tab=${before#*$'\t'}
850
+ info=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>/dev/null) || {
851
+ echo "warning: herdr presentation cleanup could not verify the exact pane; refusing focus-unsafe pane close" >&2
852
+ return 1
853
+ }
854
+ target_pane=$(printf '%s' "$info" | jq -r '.result.pane.pane_id // empty' 2>/dev/null)
855
+ target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null)
856
+ target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null)
857
+ if [ "$target_pane" != "$pane_id" ] || [ -z "$target_tab" ]; then
858
+ echo "warning: herdr presentation cleanup received an ambiguous exact-pane response; refusing focus-unsafe pane close" >&2
859
+ return 1
860
+ fi
861
+ if [ "$target_tab" = "$active_tab" ]; then
862
+ echo "warning: herdr presentation cleanup target is the captain's active tab; refusing a close that cannot preserve focus" >&2
863
+ return 1
864
+ fi
865
+ if [ -n "$required_agent_state" ]; then
866
+ state=$(fm_backend_herdr_pane_agent_state "$session" "$pane_id")
867
+ FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE=$state
868
+ [ "$state" = "$required_agent_state" ] || return 1
869
+ fi
870
+ plan=plain
871
+ plan_shell_pid=
872
+ plan_move_record=
873
+ if [ -n "$target_ws" ]; then
874
+ plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane_id" "$target_ws" "$target_tab" "${before%%$'\t'*}")
875
+ case "$plan" in
876
+ moved$'\t'*)
877
+ plan_move_record=${plan%%$'\n'*}
878
+ plan=${plan##*$'\n'}
879
+ ;;
880
+ esac
881
+ case "$plan" in
882
+ death\ *)
883
+ plan_shell_pid=${plan#death }
884
+ plan=death
885
+ ;;
886
+ *)
887
+ plan=plain
888
+ ;;
889
+ esac
890
+ fi
891
+ if [ "$plan" = death ]; then
892
+ if fm_backend_herdr_death_close_pane "$session" "$pane_id" "$plan_shell_pid"; then
893
+ close_status=0
894
+ elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then
895
+ close_status=0
896
+ else
897
+ close_status=1
898
+ fi
899
+ elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then
900
+ close_status=0
901
+ else
902
+ close_status=1
903
+ fi
904
+ if [ "$close_status" -eq 0 ] && [ -n "$plan_move_record" ]; then
905
+ workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws")
906
+ if [ "$workspace_presence" != dead ]; then
907
+ echo "warning: herdr presentation cleanup did not confirm removal of the repositioned workspace" >&2
908
+ close_status=1
909
+ fi
910
+ fi
911
+ if [ "$close_status" -ne 0 ]; then
912
+ fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true
913
+ fi
914
+ fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || return 2
915
+ [ "$close_status" -eq 0 ]
916
+ }
917
+
918
+ # Herdr 0.7.5 workspace-removal focus rules (verified against the installed
919
+ # 0.7.5 binary, its v0.7.5 tag source, and the isolated named lab):
920
+ # - An EXPLICIT close that empties a workspace (API pane.close of its last
921
+ # pane, tab close, or workspace close) routes through
922
+ # close_selected_workspace, which assigns focus to the closing workspace's
923
+ # right neighbor (or the new last workspace when it was last), ignoring the
924
+ # previously focused workspace entirely (upstream discussion #1328, fixed
925
+ # by PR #1877, commit 165dca45).
926
+ # - A PANE-DEATH removal (handle_pane_died) keeps the focused index stale,
927
+ # which preserves the exact focused workspace whenever the dying workspace
928
+ # sat behind it (or the focused workspace was last), and moves focus to the
929
+ # focused workspace's right neighbor otherwise (upstream issue #1621, fixed
930
+ # by PR #1912, commit a979916).
931
+ # Both fixes first shipped in Herdr 0.8.0 (protocol 19), verified 2026-08-05.
932
+ # Firstmate therefore removes a doomed non-focused workspace by ending its
933
+ # verified lone idle shell (the pane-death path), repositioning it behind the
934
+ # focused workspace first when needed. Moving it to the end preserves every
935
+ # other workspace's relative order, so no presentation ordering change
936
+ # persists.
937
+ # That reasoning covers the pane-death route only. The plan's plain-close
938
+ # FALLBACK is reachable exactly when the doomed pane's shell cannot be proved
939
+ # lone, childless, and idle - a persistent gitstatusd, zsh-async worker, or
940
+ # direnv fails that proof permanently - and on a release without both fixes the
941
+ # fallback is the focus-stealing close itself, so the mitigation is conditional
942
+ # rather than unconditional and a version gate IS required. Default-on
943
+ # projection is therefore floored at FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION,
944
+ # where every removal primitive preserves focus and the proof stops being
945
+ # load-bearing. That floor has ONE owner, the spawn-time gate
946
+ # fm_backend_herdr_presentation_enabled, so every new projection is either on a
947
+ # supported release or is a home's deliberate below-floor opt-in. Session-start
948
+ # cleanup deliberately retires a leftover projection husk on every release,
949
+ # including below the floor. The accepted exposure is limited to the rare
950
+ # downgrade path where a home projected on Herdr 0.8.0 or newer and then moved
951
+ # to a 0.7.x release, and occurs once per leftover workspace at session start
952
+ # rather than once per task teardown; the exact prior-tab restore bounds it.
953
+ # Refusing that close below the floor would leak workspaces that nothing else
954
+ # removes and block teardown because fm-teardown treats an unconfirmed close as
955
+ # a hard stop. That cleanup is therefore authorized containment rather than a
956
+ # second gate, and the spawn-time gate remains the floor's sole owner.
957
+
958
+ # fm_backend_herdr_workspace_move_capable: verify that one guarded raw
959
+ # workspace.move request is possible in <session>: python3 for the transport,
960
+ # the minimum protocol, and the exact whitelisted method and parameter
961
+ # schema. Silent; each caller owns its own warning wording.
962
+ # Return codes: 1 python3 missing, 2 protocol unreadable, 3 protocol too old,
963
+ # 4 schema unreadable, 5 method or parameter schema unsupported.
964
+ fm_backend_herdr_workspace_move_capable() { # <session>
965
+ local session=$1 protocol schema
966
+ command -v python3 >/dev/null 2>&1 || return 1
967
+ protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null)
968
+ case "$protocol" in
969
+ ''|*[!0-9]*) return 2 ;;
970
+ esac
971
+ [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ] && return 3
972
+ schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || return 4
973
+ printf '%s' "$schema" | jq -e '
974
+ any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move")
975
+ and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"]
976
+ and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer"
977
+ ' >/dev/null 2>&1 || return 5
978
+ }
979
+
980
+ # fm_backend_herdr_emptying_close_plan: choose the focus-safe removal for one
981
+ # exact pane. The LAST echoed line is the plan: "plain" (use the ordinary
982
+ # explicit close; below the presentation version floor the exact-tab restore
983
+ # backstop masks the focus move it causes when it empties a non-focused
984
+ # workspace) or "death <shell-pid>" (end the proved lone idle shell so Herdr
985
+ # removes the emptied workspace through its focus-preserving pane-death path).
986
+ # Whenever the repositioning mover was invoked, a preceding
987
+ # "moved<TAB><ws><TAB><original-index><TAB><socket><TAB><focused><TAB><pre-move-order-json>"
988
+ # record line is echoed first so the caller can hand it to
989
+ # fm_backend_herdr_emptying_move_rollback when removal is not confirmed.
990
+ # Never fails; every ambiguity plans "plain".
991
+ # The death plan requires the close to empty the workspace (exactly one tab
992
+ # and one pane, both the target), the target workspace to sit behind the
993
+ # focused one (repositioned to the end first when it does not, with the move
994
+ # verified against the server-returned order and focus), and the exact pane
995
+ # to hold one provably lone idle recognized shell.
996
+ fm_backend_herdr_emptying_close_plan() { # <session> <pane-id> <workspace-id> <tab-id> <focused-workspace-id>
997
+ local session=$1 pane_id=$2 ws_id=$3 tab_id=$4 focused_ws=$5
998
+ local tabs panes list indices r rest a len capable socket mover response move_status shell_pid before_order
999
+ [ -n "$ws_id" ] && [ -n "$tab_id" ] && [ -n "$focused_ws" ] || { printf 'plain\n'; return 0; }
1000
+ tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; }
1001
+ printf '%s' "$tabs" | jq -e --arg tab "$tab_id" '
1002
+ (.result.tabs | type) == "array" and (.result.tabs | length) == 1
1003
+ and .result.tabs[0].tab_id == $tab
1004
+ ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; }
1005
+ panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; }
1006
+ printf '%s' "$panes" | jq -e --arg pane "$pane_id" '
1007
+ (.result.panes | type) == "array" and (.result.panes | length) == 1
1008
+ and .result.panes[0].pane_id == $pane
1009
+ ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; }
1010
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { printf 'plain\n'; return 0; }
1011
+ indices=$(printf '%s' "$list" | jq -r --arg ws "$ws_id" --arg focused "$focused_ws" '
1012
+ (.result.workspaces // null) as $s
1013
+ | select(($s | type) == "array" and ($s | length) > 1)
1014
+ | ([range(0; $s | length) | select($s[.].workspace_id == $ws)]) as $w
1015
+ | ([range(0; $s | length) | select($s[.].workspace_id == $focused)]) as $f
1016
+ | select(($w | length) == 1 and ($f | length) == 1 and $w[0] != $f[0])
1017
+ | "\($w[0])\t\($f[0])\t\($s | length)"
1018
+ ' 2>/dev/null) || indices=
1019
+ if [ -z "$indices" ]; then
1020
+ printf 'plain\n'
1021
+ return 0
1022
+ fi
1023
+ r=${indices%%$'\t'*}
1024
+ rest=${indices#*$'\t'}
1025
+ a=${rest%%$'\t'*}
1026
+ len=${rest#*$'\t'}
1027
+ case "$r:$a:$len" in
1028
+ *[!0-9:]*)
1029
+ printf 'plain\n'
1030
+ return 0
1031
+ ;;
1032
+ esac
1033
+ if [ "$r" -lt "$a" ] && [ "$a" -lt $((len - 1)) ]; then
1034
+ # The doomed workspace sits before the focused one, where the pane-death
1035
+ # path would land focus on the focused workspace's right neighbor.
1036
+ # Reposition it behind everything first: insert_index equal to the list
1037
+ # length is the verified move-to-last form, and removing the moved
1038
+ # workspace afterward leaves every other relative order untouched.
1039
+ if fm_backend_herdr_workspace_move_capable "$session"; then
1040
+ capable=0
1041
+ else
1042
+ capable=$?
1043
+ fi
1044
+ if [ "$capable" -ne 0 ]; then
1045
+ echo "warning: herdr presentation cleanup could not verify workspace.move support; closing without the focus-safe removal path" >&2
1046
+ printf 'plain\n'
1047
+ return 0
1048
+ fi
1049
+ socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || {
1050
+ echo "warning: herdr presentation cleanup found an ambiguous named session socket; closing without the focus-safe removal path" >&2
1051
+ printf 'plain\n'
1052
+ return 0
1053
+ }
1054
+ mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py}
1055
+ before_order=$(printf '%s' "$list" | jq -c '[.result.workspaces[].workspace_id]' 2>/dev/null)
1056
+ if response=$("$mover" "$socket" "$ws_id" "$len" 2>/dev/null); then
1057
+ move_status=0
1058
+ else
1059
+ move_status=$?
1060
+ fi
1061
+ # Every mover invocation is recorded, even an unverified one, so a later
1062
+ # unconfirmed removal can restore the exact original order; restoring an
1063
+ # unmoved workspace to its own position is a verified no-op.
1064
+ printf 'moved\t%s\t%s\t%s\t%s\t%s\n' "$ws_id" "$r" "$socket" "$focused_ws" "$before_order"
1065
+ if [ "$move_status" -ne 0 ] \
1066
+ || ! printf '%s' "$response" | jq -e --arg ws "$ws_id" --arg focused "$focused_ws" \
1067
+ --argjson before "$before_order" '
1068
+ ($before | map(select(. != $ws)) + [$ws]) as $expected
1069
+ | .result.type == "workspace_list"
1070
+ and ([.result.workspaces[].workspace_id] == $expected)
1071
+ and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused])
1072
+ ' >/dev/null 2>&1; then
1073
+ echo "warning: herdr presentation cleanup could not move the doomed workspace behind the focused one; closing without the focus-safe removal path" >&2
1074
+ printf 'plain\n'
1075
+ return 0
1076
+ fi
1077
+ fi
1078
+ if shell_pid=$(fm_backend_herdr_pane_idle_shell_pid "$session" "$pane_id"); then
1079
+ printf 'death %s\n' "$shell_pid"
1080
+ else
1081
+ printf 'plain\n'
1082
+ fi
1083
+ }
1084
+
1085
+ # fm_backend_herdr_emptying_move_rollback: restore the exact pre-move
1086
+ # workspace order recorded by an emptying-close plan whose removal was not
1087
+ # confirmed, under the caller's still-held session lock.
1088
+ # <move-record> is the plan's tab-separated
1089
+ # "moved<TAB><ws><TAB><original-index><TAB><socket><TAB><focused><TAB><pre-move-order-json>"
1090
+ # line, or empty for a no-op when no move was attempted.
1091
+ # The rollback is verified against the mover's returned order and focus and
1092
+ # warns on any failure, so a lasting reorder is never silent.
1093
+ fm_backend_herdr_emptying_move_rollback() { # <move-record>
1094
+ local record=$1 marker ws index socket focused order mover response
1095
+ [ -n "$record" ] || return 0
1096
+ IFS=$'\t' read -r marker ws index socket focused order <<FMEOF
1097
+ $record
1098
+ FMEOF
1099
+ if [ "$marker" != moved ] || [ -z "$ws" ] || [ -z "$socket" ] || [ -z "$order" ]; then
1100
+ echo "warning: herdr presentation cleanup has a malformed move record after a failed removal; the workspace order may remain changed" >&2
1101
+ return 1
1102
+ fi
1103
+ case "$index" in
1104
+ ''|*[!0-9]*)
1105
+ echo "warning: herdr presentation cleanup has a malformed move record after a failed removal; the workspace order may remain changed" >&2
1106
+ return 1
1107
+ ;;
1108
+ esac
1109
+ mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py}
1110
+ if ! response=$("$mover" "$socket" "$ws" "$index" 2>/dev/null) \
1111
+ || ! printf '%s' "$response" | jq -e --argjson expected "$order" --arg focused "$focused" '
1112
+ .result.type == "workspace_list"
1113
+ and ([.result.workspaces[].workspace_id] == $expected)
1114
+ and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused])
1115
+ ' >/dev/null 2>&1; then
1116
+ echo "warning: herdr presentation cleanup could not restore the original workspace order after a failed removal" >&2
1117
+ return 1
1118
+ fi
1119
+ }
1120
+
1121
+ # fm_backend_herdr_death_close_pane: end the exact pane's proved lone idle
1122
+ # shell so Herdr removes the emptied workspace through its focus-preserving
1123
+ # pane-death path, then confirm the pane is gone.
1124
+ # Each signal is sent only while the exact pane still owns the recorded pid
1125
+ # as its lone idle shell: SIGHUP relies on the proof taken just before, and
1126
+ # the SIGKILL escalation re-reads the pane's process information and refuses
1127
+ # unless the same pid is still the pane's strict bare idle shell, so an
1128
+ # exited or reused pid is never signaled.
1129
+ # Returns 0 only when the pane is confirmed gone.
1130
+ fm_backend_herdr_death_close_pane() { # <session> <pane-id> <shell-pid>
1131
+ local session=$1 pane_id=$2 shell_pid=$3 ps_bin attempt max_attempts presence resampled_pid
1132
+ ps_bin=${FM_HERDR_PS_BIN:-ps}
1133
+ case "$shell_pid" in
1134
+ ''|*[!0-9]*) return 1 ;;
1135
+ esac
1136
+ command -v "$ps_bin" >/dev/null 2>&1 || return 1
1137
+ max_attempts=${FM_BACKEND_HERDR_DEATH_CLOSE_POLLS:-40}
1138
+ fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1
1139
+ kill -HUP "$shell_pid" 2>/dev/null || true
1140
+ attempt=0
1141
+ while [ "$attempt" -lt "$max_attempts" ]; do
1142
+ presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id")
1143
+ [ "$presence" = dead ] && return 0
1144
+ sleep 0.05
1145
+ attempt=$((attempt + 1))
1146
+ done
1147
+ # SIGKILL escalation revalidates exact pane ownership, not just the pid: a
1148
+ # fresh strict pane sample must still name the SAME shell pid, so a pid
1149
+ # that exited and was reused by an unrelated process is never signaled.
1150
+ resampled_pid=$(fm_backend_herdr_pane_idle_shell_sample "$session" "$pane_id") || return 1
1151
+ [ "$resampled_pid" = "$shell_pid" ] || return 1
1152
+ fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1
1153
+ kill -KILL "$shell_pid" 2>/dev/null || true
1154
+ attempt=0
1155
+ while [ "$attempt" -lt "$max_attempts" ]; do
1156
+ presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id")
1157
+ [ "$presence" = dead ] && return 0
1158
+ sleep 0.05
1159
+ attempt=$((attempt + 1))
1160
+ done
1161
+ return 1
1162
+ }
1163
+
1164
+ # fm_backend_herdr_pid_is_bare_shell: <pid> currently resolves to a bare
1165
+ # recognized shell process per <ps-bin>.
1166
+ # BSD ps reports comm as argv0, so a login shell arrives as "-zsh"; strip the
1167
+ # login dash exactly like the idle-shell proof's argv0 normalization.
1168
+ fm_backend_herdr_pid_is_bare_shell() { # <ps-bin> <pid>
1169
+ local comm
1170
+ comm=$("$1" -p "$2" -o comm= 2>/dev/null) || return 1
1171
+ comm=$(printf '%s' "$comm" | tr -d '[:space:]')
1172
+ comm=${comm#-}
1173
+ comm=${comm##*/}
1174
+ case "$comm" in sh|bash|zsh|dash|ksh|fish) return 0 ;; esac
1175
+ return 1
1176
+ }
1177
+
1178
+ # fm_backend_herdr_pane_idle_shell_pid: print the shell pid of <pane-id> only
1179
+ # when the exact pane provably holds one lone idle recognized shell: pane
1180
+ # process-info agrees on the pane id, the shell pid is both the foreground
1181
+ # process group and the sole foreground process, the foreground process name
1182
+ # and argv0 resolve to the same recognized shell, the operating-system
1183
+ # process table shows exactly that one shell row with no child process, and
1184
+ # the shell sits in a sleeping or idle state.
1185
+ # An idle interactive shell transiently hosts short-lived prompt helpers
1186
+ # (verified on the real 0.7.5 lab: a workspace.move relayout makes zsh redraw
1187
+ # its prompt, spawning starship as a second foreground process for a few
1188
+ # samples), so the proof retries strict single samples for a bounded settle
1189
+ # window and succeeds on the first fully clean one; a genuinely busy pane
1190
+ # fails every sample and still refuses.
1191
+ # This is the single owner of the idle-shell proof; the session-start
1192
+ # projection cleanup and every pane-death close path both rely on it.
1193
+ fm_backend_herdr_pane_idle_shell_pid() { # <session> <pane-id>
1194
+ local attempt=0 max_attempts=${FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS:-10}
1195
+ while :; do
1196
+ if fm_backend_herdr_pane_idle_shell_sample "$1" "$2"; then
1197
+ return 0
1198
+ fi
1199
+ attempt=$((attempt + 1))
1200
+ [ "$attempt" -lt "$max_attempts" ] || return 1
1201
+ sleep 0.1
1202
+ done
1203
+ }
1204
+
1205
+ # fm_backend_herdr_pane_idle_shell_sample: one strict instantaneous
1206
+ # observation for fm_backend_herdr_pane_idle_shell_pid, which owns the proof
1207
+ # contract and the settle retry.
1208
+ fm_backend_herdr_pane_idle_shell_sample() { # <session> <pane-id>
1209
+ local session=$1 pane=$2 info shell_pid foreground_pgid count
1210
+ local process_pid name argv0 shell_name rows stat ps_bin
1211
+ info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane" 2>/dev/null) || return 1
1212
+ printf '%s' "$info" | jq -e --arg pane "$pane" '
1213
+ .result.type == "pane_process_info"
1214
+ and .result.process_info.pane_id == $pane
1215
+ ' >/dev/null 2>&1 || return 1
1216
+ shell_pid=$(printf '%s' "$info" | jq -er \
1217
+ '.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1
1218
+ foreground_pgid=$(printf '%s' "$info" | jq -er \
1219
+ '.result.process_info.foreground_process_group_id | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1
1220
+ [ "$foreground_pgid" = "$shell_pid" ] || return 1
1221
+ count=$(printf '%s' "$info" | jq -er \
1222
+ '.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) || return 1
1223
+ [ "$count" -eq 1 ] || return 1
1224
+ process_pid=$(printf '%s' "$info" | jq -er \
1225
+ '.result.process_info.foreground_processes[0].pid | select(type == "number") | floor' 2>/dev/null) || return 1
1226
+ [ "$process_pid" = "$shell_pid" ] || return 1
1227
+ name=$(printf '%s' "$info" | jq -er \
1228
+ '.result.process_info.foreground_processes[0].name | select(type == "string" and length > 0)' 2>/dev/null) || return 1
1229
+ argv0=$(printf '%s' "$info" | jq -er '
1230
+ .result.process_info.foreground_processes[0] as $process
1231
+ | ($process.argv0 // $process.argv[0])
1232
+ | select(type == "string" and length > 0)
1233
+ ' 2>/dev/null) || return 1
1234
+ shell_name=${name##*/}
1235
+ argv0=${argv0#-}
1236
+ argv0=${argv0##*/}
1237
+ [ "$argv0" = "$shell_name" ] || return 1
1238
+ case "$shell_name" in sh|bash|zsh|dash|ksh|fish) ;; *) return 1 ;; esac
1239
+
1240
+ ps_bin=${FM_HERDR_PS_BIN:-ps}
1241
+ command -v "$ps_bin" >/dev/null 2>&1 || return 1
1242
+ rows=$("$ps_bin" -axo pid=,ppid= 2>/dev/null) || return 1
1243
+ printf '%s\n' "$rows" | awk -v shell="$shell_pid" '
1244
+ $1 == shell { found++ }
1245
+ $2 == shell { child++ }
1246
+ END { exit(found == 1 && child == 0 ? 0 : 1) }
1247
+ ' || return 1
1248
+ stat=$("$ps_bin" -p "$shell_pid" -o stat= 2>/dev/null | tr -d '[:space:]') || return 1
1249
+ case "$stat" in S*|I*) ;; *) return 1 ;; esac
1250
+ printf '%s\n' "$shell_pid"
1251
+ }
1252
+
1253
+ # fm_backend_herdr_projection_order_best_effort: place the exact workspace id
1254
+ # returned by THIS projected create immediately after its owning parent's
1255
+ # contiguous child block and before the next parent.
1256
+ #
1257
+ # <parent-label> is the owning FM_HOME label (firstmate or 2ndmate-<id>).
1258
+ # Optional <parent-workspace-id> is that parent's EXACT id, which the caller
1259
+ # already resolved from the launching agent's own herdr identity. When given it
1260
+ # anchors the owning parent by id, so two workspaces sharing the home label no
1261
+ # longer make the whole layout ambiguous; when omitted the parent is located by
1262
+ # label exactly as before. With a unique label the two select the same
1263
+ # workspace, so ordering behavior is unchanged in the ordinary case.
1264
+ # New-format └ ... · p:<token> children and, for compatibility only, already
1265
+ # adjacent old-format firstmate/... or 2ndmate-<id>/... projections may extend
1266
+ # the block read-only; they are never renamed or moved.
1267
+ #
1268
+ # This is presentation-only and always returns success.
1269
+ # Every unavailable, ambiguous, failed, or unverifiable ordering step prints a
1270
+ # warning and leaves the safely-created worker running in Herdr's current
1271
+ # order.
1272
+ # It never looks up a task endpoint, adopts or reuses a workspace, retries an
1273
+ # ambiguous move, or calls any close/delete/rename primitive.
1274
+ # The sole move target is <created-workspace-id>, captured directly from the
1275
+ # current workspace-create response.
1276
+ # After a successful move, every pre-existing workspace id sequence excluding
1277
+ # the new id must be byte-identical to the pre-move sequence.
1278
+ fm_backend_herdr_projection_order_best_effort() { # <session> <created-workspace-id> <parent-label> [<parent-workspace-id>]
1279
+ local session=$1 created=$2 parent=$3 parent_ws=${4:-} list analysis current desired socket mover response move_status focus_before move_capable
1280
+ local before_existing after_existing
1281
+ [ -n "$parent" ] || {
1282
+ echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2
1283
+ return 0
1284
+ }
1285
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || {
1286
+ echo "warning: herdr presentation ordering could not list workspaces; leaving worker in Herdr's current order" >&2
1287
+ return 0
1288
+ }
1289
+ analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" '
1290
+ def is_parent:
1291
+ if ($parent_ws | length) > 0
1292
+ then .workspace_id == $parent_ws
1293
+ else (.label | type) == "string" and .label == $parent
1294
+ end;
1295
+ def is_top_level_parent:
1296
+ (.label | type) == "string"
1297
+ and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$")));
1298
+ def is_new_child:
1299
+ (.label | type) == "string"
1300
+ and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$"));
1301
+ def is_legacy_child:
1302
+ (.label | type) == "string"
1303
+ and (.label | test("^(firstmate|2ndmate-[^/]+)/.+ · p:[A-Za-z0-9_-]{22}$"));
1304
+ def is_legacy_child_for($owner):
1305
+ is_legacy_child and (.label | startswith($owner + "/"));
1306
+ def is_child_for($owner):
1307
+ is_new_child or is_legacy_child_for($owner);
1308
+ (.result.workspaces // null) as $spaces
1309
+ | select(($spaces | type) == "array" and ($spaces | length) > 0)
1310
+ | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $created)]) as $matches
1311
+ | select(($matches | length) == 1)
1312
+ | ($matches[0]) as $current
1313
+ | select($current == (($spaces | length) - 1))
1314
+ | ([range(0; $spaces | length) | select($spaces[.] | is_parent)]) as $parents
1315
+ | select(($parents | length) == 1)
1316
+ | ($parents[0]) as $pidx
1317
+ | select($pidx < $current)
1318
+ | (
1319
+ reduce range($pidx + 1; $current) as $i (
1320
+ 0;
1321
+ if ($spaces[$i] | is_child_for($parent)) and (. == ($i - $pidx - 1))
1322
+ then . + 1
1323
+ else .
1324
+ end
1325
+ )
1326
+ ) as $block
1327
+ | (reduce range($pidx + 1 + $block; $current) as $i (
1328
+ {valid: true, active_parent: null};
1329
+ if .valid == false then .
1330
+ elif ($spaces[$i] | is_top_level_parent) then
1331
+ .active_parent = $spaces[$i].label
1332
+ elif ($spaces[$i] | is_new_child) then
1333
+ if .active_parent == null then .valid = false else . end
1334
+ elif ($spaces[$i] | is_legacy_child) then
1335
+ .active_parent as $owner
1336
+ | if $owner == null then
1337
+ .valid = false
1338
+ elif (($spaces[$i] | is_legacy_child_for($owner)) | not) then
1339
+ .valid = false
1340
+ else
1341
+ .
1342
+ end
1343
+ else
1344
+ .active_parent = null
1345
+ end
1346
+ )) as $remainder
1347
+ | select($remainder.valid == true)
1348
+ | {
1349
+ current: $current,
1350
+ desired: ($pidx + 1 + $block),
1351
+ parent_index: $pidx,
1352
+ existing: [$spaces[] | select(.workspace_id != $created) | .workspace_id]
1353
+ }
1354
+ ' 2>/dev/null) || analysis=
1355
+ [ -n "$analysis" ] || {
1356
+ echo "warning: herdr presentation ordering found an ambiguous workspace layout; leaving worker in Herdr's current order" >&2
1357
+ return 0
1358
+ }
1359
+ current=$(printf '%s' "$analysis" | jq -r '.current // empty' 2>/dev/null)
1360
+ desired=$(printf '%s' "$analysis" | jq -r '.desired // empty' 2>/dev/null)
1361
+ case "$current:$desired" in
1362
+ *[!0-9:]*)
1363
+ echo "warning: herdr presentation ordering could not parse the target position; leaving worker in Herdr's current order" >&2
1364
+ return 0
1365
+ ;;
1366
+ esac
1367
+ [ "$current" != "$desired" ] || return 0
1368
+
1369
+ if fm_backend_herdr_workspace_move_capable "$session"; then
1370
+ move_capable=0
1371
+ else
1372
+ move_capable=$?
1373
+ fi
1374
+ case "$move_capable" in
1375
+ 0) ;;
1376
+ 1)
1377
+ echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2
1378
+ return 0
1379
+ ;;
1380
+ 2)
1381
+ echo "warning: herdr presentation ordering could not verify the client protocol; leaving worker in Herdr's current order" >&2
1382
+ return 0
1383
+ ;;
1384
+ 3)
1385
+ echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2
1386
+ return 0
1387
+ ;;
1388
+ 4)
1389
+ echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2
1390
+ return 0
1391
+ ;;
1392
+ *)
1393
+ echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2
1394
+ return 0
1395
+ ;;
1396
+ esac
1397
+ socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || {
1398
+ echo "warning: herdr presentation ordering found an ambiguous named session socket; leaving worker in Herdr's current order" >&2
1399
+ return 0
1400
+ }
1401
+
1402
+ mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py}
1403
+ focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || {
1404
+ echo "warning: herdr presentation ordering could not capture exact active workspace and tab; leaving worker in Herdr's current order" >&2
1405
+ return 0
1406
+ }
1407
+ if response=$("$mover" "$socket" "$created" "$desired" 2>/dev/null); then
1408
+ move_status=0
1409
+ else
1410
+ move_status=$?
1411
+ fi
1412
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "workspace move" || true
1413
+ if [ "$move_status" -ne 0 ]; then
1414
+ echo "warning: herdr presentation workspace move failed or had an ambiguous response; leaving worker running without cleanup" >&2
1415
+ return 0
1416
+ fi
1417
+ if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" --argjson desired "$desired" '
1418
+ def is_parent:
1419
+ if ($parent_ws | length) > 0
1420
+ then .workspace_id == $parent_ws
1421
+ else (.label | type) == "string" and .label == $parent
1422
+ end;
1423
+ .result.type == "workspace_list"
1424
+ and (.result.workspaces | type) == "array"
1425
+ and .result.workspaces[$desired].workspace_id == $created
1426
+ and ([.result.workspaces[] | select(is_parent)] | length) == 1
1427
+ and (
1428
+ [range(0; .result.workspaces | length) as $i
1429
+ | select(.result.workspaces[$i] | is_parent)
1430
+ | $i][0] < $desired
1431
+ )
1432
+ ' >/dev/null 2>&1; then
1433
+ echo "warning: herdr presentation workspace move returned an unverifiable order; leaving worker running without cleanup" >&2
1434
+ return 0
1435
+ fi
1436
+
1437
+ before_existing=$(printf '%s' "$analysis" | jq -c '.existing' 2>/dev/null)
1438
+ after_existing=$(printf '%s' "$response" | jq -c --arg created "$created" '[.result.workspaces[] | select(.workspace_id != $created) | .workspace_id]' 2>/dev/null)
1439
+ if [ "$after_existing" != "$before_existing" ]; then
1440
+ echo "warning: herdr presentation workspace move did not preserve relative order; leaving worker running without cleanup" >&2
1441
+ fi
1442
+ return 0
1443
+ }
1444
+
1445
+ # fm_backend_herdr_server_ensure: start the herdr server for <session>
1446
+ # headless (no TUI client) if not already running, mirroring tmux's `tmux
1447
+ # has-session || tmux new-session -d`. Verified: a bare socket CLI call does
1448
+ # NOT auto-start the server, so this must run before any workspace/tab/pane
1449
+ # call. Bounded poll for the server to report running.
1450
+ fm_backend_herdr_server_ensure() { # <session>
1451
+ local session=$1 running out i
1452
+ running=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.server.running // false' 2>/dev/null)
1453
+ [ "$running" = "true" ] && return 0
1454
+ ( fm_backend_herdr_cli "$session" server >/dev/null 2>&1 & ) || return 1
1455
+ for i in $(seq 1 20); do
1456
+ running=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.server.running // false' 2>/dev/null)
1457
+ [ "$running" = "true" ] && return 0
1458
+ sleep 0.5
1459
+ done
1460
+ echo "error: herdr server for session '$session' did not report running within 10s" >&2
1461
+ return 1
1462
+ }
1463
+
1464
+ # fm_backend_herdr_workspace_find_all: EVERY workspace id inside <session>
1465
+ # whose label equals this HOME's own label (fm_backend_herdr_workspace_label),
1466
+ # one per line, in herdr's own list order (normally creation order, oldest
1467
+ # first). Empty when none match. Never creates anything.
1468
+ #
1469
+ # Single owner of the home-label workspace query. Herdr enforces no workspace
1470
+ # label uniqueness at all (docs/herdr-backend.md "Label collisions"), so this
1471
+ # can legitimately return MORE THAN ONE id: a captain-owned workspace can
1472
+ # collide by label, a cwd-basename-derived label can coincide, and concurrent
1473
+ # first spawns can mint two same-labeled home workspaces. Callers decide what a
1474
+ # duplicate means for them - fm_backend_herdr_workspace_ensure refuses to guess
1475
+ # which one is the caller's, while the read-only recovery path below keeps its
1476
+ # historical first-match behavior.
1477
+ fm_backend_herdr_workspace_find_all() { # <session>
1478
+ local session=$1 label list
1479
+ label=$(fm_backend_herdr_workspace_label)
1480
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 0
1481
+ # NOTE: the jq variable is $want, NOT $label - `label` is a jq reserved
1482
+ # keyword (label/break), so declaring a jq variable named "label" is a
1483
+ # compile error that `2>/dev/null` would silently swallow, making this find
1484
+ # ALWAYS return empty and every spawn mint a fresh "firstmate" workspace
1485
+ # (the workspace leak).
1486
+ printf '%s' "$list" | jq -r --arg want "$label" \
1487
+ '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null
1488
+ }
1489
+
1490
+ # fm_backend_herdr_workspace_find: this HOME's own workspace id inside
1491
+ # <session>, or empty (never creates). Read-only, safe for recovery/list
1492
+ # paths, which address panes they already recorded and only need a container
1493
+ # to scan. Keeps the historical FIRST-match behavior on a label collision -
1494
+ # identical in spirit to the pre-existing tab duplicate-label check below.
1495
+ # NOT the spawn-time resolver: placing a new worker by first label match is
1496
+ # exactly the defect fm_backend_herdr_workspace_ensure now refuses.
1497
+ fm_backend_herdr_workspace_find() { # <session>
1498
+ fm_backend_herdr_workspace_find_all "$1" | head -1
1499
+ }
1500
+
1501
+ # fm_backend_herdr_launcher_identity: the EXACT herdr workspace that the
1502
+ # process making this spawn is itself running in.
1503
+ #
1504
+ # Herdr 0.7.5 injects HERDR_ENV=1, HERDR_PANE_ID, HERDR_SESSION,
1505
+ # HERDR_SOCKET_PATH, HERDR_TAB_ID, and HERDR_WORKSPACE_ID into every process it
1506
+ # manages a pane for (docs/verification/runtime-backends.md), and a firstmate
1507
+ # or secondmate agent's own tool calls inherit them. Older injection shapes are
1508
+ # unverified and cannot establish launcher ancestry without both pane and
1509
+ # socket identity. Workspace LABELS are mutable and herdr enforces no
1510
+ # uniqueness on them, so a label search cannot tell one `firstmate` workspace
1511
+ # from another, and herdr's globally focused workspace is whatever the captain
1512
+ # happens to be looking at, not the launcher's.
1513
+ #
1514
+ # The injected HERDR_TAB_ID/HERDR_WORKSPACE_ID are deliberately NOT read as the
1515
+ # answer. They are a snapshot taken when the pane's process started, and herdr
1516
+ # can move a pane between tabs and workspaces afterwards without being able to
1517
+ # rewrite a running process's environment. Only a live read is the CURRENT
1518
+ # parent, which is what placement has to bind to.
1519
+ #
1520
+ # Sets, only on a 0 return:
1521
+ # FM_BACKEND_HERDR_LAUNCHER_PANE_ID
1522
+ # FM_BACKEND_HERDR_LAUNCHER_TAB_ID
1523
+ # FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID
1524
+ #
1525
+ # Returns:
1526
+ # 0 - one exact, self-consistent launcher pane/tab/workspace in <session>.
1527
+ # 2 - this process is NOT running in a herdr pane (no HERDR_PANE_ID at all),
1528
+ # so there is no launcher workspace to inherit and the caller falls back
1529
+ # to its per-home container. HERDR_ENV=1 on its own is only a backend
1530
+ # SELECTION marker (bin/fm-backend.sh's fm_backend_detect), never a
1531
+ # parent binding - herdr always injects the pane id alongside it.
1532
+ # 1 - a launcher pane IS claimed but its binding is missing, stale,
1533
+ # contradictory, or belongs to another herdr session. The caller must
1534
+ # refuse before creating or publishing any worker endpoint rather than
1535
+ # degrading to a label search.
1536
+ fm_backend_herdr_launcher_identity() { # <session>
1537
+ local session=$1 pane=${HERDR_PANE_ID:-} claimed_session claimed_socket session_socket
1538
+ local pane_out tab_out list tab workspace
1539
+ FM_BACKEND_HERDR_LAUNCHER_PANE_ID=""
1540
+ FM_BACKEND_HERDR_LAUNCHER_TAB_ID=""
1541
+ FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID=""
1542
+ [ -n "$pane" ] || return 2
1543
+
1544
+ # Same-session proof, before the pane id is trusted at all: herdr pane ids
1545
+ # ("w2:p1") restart at the same low numbers in every session, so a pane id
1546
+ # borrowed from another session can silently resolve to a real but unrelated
1547
+ # workspace here. The injected socket path is the server identity herdr
1548
+ # exposes, and the session name independently binds the named session.
1549
+ claimed_session=$(fm_backend_herdr_session)
1550
+ if [ "$claimed_session" != "$session" ]; then
1551
+ echo "error: herdr launcher pane '$pane' reports session '$claimed_session' but this spawn targets session '$session'; refusing to place a worker from a cross-session parent identity" >&2
1552
+ return 1
1553
+ fi
1554
+ claimed_socket=${HERDR_SOCKET_PATH:-}
1555
+ if [ -z "$claimed_socket" ]; then
1556
+ echo "error: herdr launcher pane '$pane' has no injected socket identity; refusing to place a worker from an unverifiable parent identity" >&2
1557
+ return 1
1558
+ fi
1559
+ claimed_socket=$(fm_backend_herdr_canonical_socket_path "$claimed_socket") || {
1560
+ echo "error: herdr launcher pane '$pane' reports an unusable socket path; refusing to place a worker from an unverifiable parent identity" >&2
1561
+ return 1
1562
+ }
1563
+ session_socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || {
1564
+ echo "error: herdr session '$session' has no unambiguous socket to match against the launcher pane's own; refusing to place a worker from an unverifiable parent identity" >&2
1565
+ return 1
1566
+ }
1567
+ if [ "$claimed_socket" != "$session_socket" ]; then
1568
+ echo "error: herdr launcher pane '$pane' belongs to the server at '$claimed_socket', not session '$session' at '$session_socket'; refusing to place a worker from a cross-session parent identity" >&2
1569
+ return 1
1570
+ fi
1571
+
1572
+ pane_out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || {
1573
+ echo "error: herdr launcher pane '$pane' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2
1574
+ return 1
1575
+ }
1576
+ tab=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" '
1577
+ select(.result.pane.pane_id == $pane)
1578
+ | select((.result.pane.tab_id | type) == "string" and (.result.pane.tab_id | length) > 0)
1579
+ | .result.pane.tab_id
1580
+ ' 2>/dev/null)
1581
+ workspace=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" '
1582
+ select(.result.pane.pane_id == $pane)
1583
+ | select((.result.pane.workspace_id | type) == "string" and (.result.pane.workspace_id | length) > 0)
1584
+ | .result.pane.workspace_id
1585
+ ' 2>/dev/null)
1586
+ if [ -z "$tab" ] || [ -z "$workspace" ]; then
1587
+ echo "error: herdr launcher pane '$pane' returned an ambiguous tab or workspace identity in session '$session'; refusing to place a worker without its exact parent workspace" >&2
1588
+ return 1
1589
+ fi
1590
+
1591
+ # Independent second read: the tab must agree that it lives in the same
1592
+ # workspace the pane just claimed. A restored-but-stale pane record that
1593
+ # disagrees with its own tab is exactly the contradictory binding this must
1594
+ # refuse rather than resolve.
1595
+ tab_out=$(fm_backend_herdr_cli "$session" tab get "$tab" 2>/dev/null) || {
1596
+ echo "error: herdr launcher tab '$tab' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2
1597
+ return 1
1598
+ }
1599
+ if ! printf '%s' "$tab_out" | jq -e --arg tab "$tab" --arg workspace "$workspace" '
1600
+ .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace
1601
+ ' >/dev/null 2>&1; then
1602
+ echo "error: herdr launcher pane '$pane' and tab '$tab' disagree about their workspace in session '$session'; refusing to place a worker from a contradictory parent identity" >&2
1603
+ return 1
1604
+ fi
1605
+
1606
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || {
1607
+ echo "error: could not list herdr workspaces in session '$session' to confirm the launcher's own workspace '$workspace'; refusing to place a worker without its exact parent workspace" >&2
1608
+ return 1
1609
+ }
1610
+ if ! printf '%s' "$list" | jq -e --arg workspace "$workspace" '
1611
+ (.result.workspaces | type) == "array"
1612
+ and ([.result.workspaces[] | select(.workspace_id == $workspace)] | length) == 1
1613
+ ' >/dev/null 2>&1; then
1614
+ echo "error: herdr launcher workspace '$workspace' is missing or duplicated in session '$session'; refusing to place a worker from a stale parent identity" >&2
1615
+ return 1
1616
+ fi
1617
+
1618
+ # shellcheck disable=SC2034 # callers consume the verified binding's parts
1619
+ FM_BACKEND_HERDR_LAUNCHER_PANE_ID=$pane
1620
+ # shellcheck disable=SC2034 # callers consume the verified binding's parts
1621
+ FM_BACKEND_HERDR_LAUNCHER_TAB_ID=$tab
1622
+ FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID=$workspace
1623
+ return 0
1624
+ }
1625
+
1626
+ # fm_backend_herdr_workspace_prune_seeded_default_tab: close EXACTLY
1627
+ # <seeded_tab_id>, the auto-created default tab id that THIS SAME
1628
+ # fm_backend_herdr_workspace_ensure call captured straight from its own
1629
+ # `workspace create` response (never re-derived from a label pattern at
1630
+ # create_task time - see the incident note below). Best-effort: a failure
1631
+ # here never fails the caller, mirroring the fm_backend_herdr_kill `|| true`
1632
+ # contract.
1633
+ #
1634
+ # Live-fire incident fix (2026-07-02): the prior implementation
1635
+ # (fm_backend_herdr_workspace_prune_default_tabs, removed) re-derived
1636
+ # "prunable" at create_task time from a pure label heuristic - exactly one
1637
+ # tab, labeled "1" - run against whatever workspace fm_backend_herdr_workspace_find
1638
+ # had just resolved. Herdr enforces no label uniqueness (docs/herdr-backend.md
1639
+ # "Label collisions") and derives an unlabeled workspace's DISPLAYED label from
1640
+ # its pane cwd's basename, so a captain launching herdr directly inside a
1641
+ # directory named "firstmate" produces a workspace that looks byte-identical,
1642
+ # by label alone, to firstmate's own auto-created container - one tab, label
1643
+ # "1". workspace_find adopted that pre-existing (captain-owned, LIVE) workspace
1644
+ # by the label match, the heuristic matched too, and the very next spawn
1645
+ # closed the captain's own live pane 27ms after creating its task tab. The
1646
+ # fix is structural, not another heuristic: only a workspace THIS SAME
1647
+ # fm_backend_herdr_workspace_ensure call just created carries a non-empty
1648
+ # seeded_tab_id at all (see FM_BACKEND_HERDR_WS_SEEDED_TAB_ID below); an
1649
+ # ADOPTED workspace's seeded_tab_id is always empty, so create_task never
1650
+ # calls this function for one, regardless of how its tabs happen to be
1651
+ # labeled.
1652
+ #
1653
+ # Defense in depth on top of that gate (not the primary safety mechanism):
1654
+ # re-verify <seeded_tab_id> is still present, still carries label "1" (a
1655
+ # human could have renamed or repurposed it in the interim), and refuse to
1656
+ # close it if its pane hosts an actively working agent per herdr's own
1657
+ # agent-state detection (`agent get`) - belt-and-suspenders against any other
1658
+ # unforeseen path landing a live agent in a tab this function was about to
1659
+ # close.
1660
+ #
1661
+ # Verified real-herdr behavior (not modeled by the canned-response fake-CLI
1662
+ # unit tests; modeled by make_herdr_statefake): closing a workspace's LAST
1663
+ # remaining tab deletes the whole workspace, not just the tab. So this must
1664
+ # never run while the seeded default tab is still the ONLY tab in the
1665
+ # workspace - callers only invoke it once at least one other (real task) tab
1666
+ # exists alongside it, never right after workspace creation - and this
1667
+ # function independently re-checks the tab count as a second layer.
1668
+ fm_backend_herdr_workspace_prune_seeded_default_tab() { # <session> <workspace_id> <seeded_tab_id> [focus-preserving]
1669
+ local session=$1 wsid=$2 tab_id=$3 close_mode=${4:-direct} tabs tab_count current_label pane_id agent_out agent_status
1670
+ [ -n "$tab_id" ] || return 0
1671
+ tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null) || return 0
1672
+ tab_count=$(printf '%s' "$tabs" | jq -r '.result.tabs? // [] | length' 2>/dev/null)
1673
+ case "$tab_count" in ''|*[!0-9]*|0|1) return 0 ;; esac
1674
+ current_label=$(printf '%s' "$tabs" | jq -r --arg t "$tab_id" '.result.tabs[]? | select(.tab_id == $t) | .label' 2>/dev/null)
1675
+ [ "$current_label" = "1" ] || return 0
1676
+ pane_id=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$tab_id") || return 0
1677
+ [ -n "$pane_id" ] || return 0
1678
+ agent_out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>/dev/null)
1679
+ agent_status=$(printf '%s' "$agent_out" | jq -r '.result.agent.agent_status // empty' 2>/dev/null)
1680
+ [ "$agent_status" = working ] && return 0
1681
+ if [ "$close_mode" = focus-preserving ]; then
1682
+ fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$pane_id"
1683
+ else
1684
+ fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || true
1685
+ fi
1686
+ }
1687
+
1688
+ # fm_backend_herdr_workspace_ensure: the workspace this spawn's task tab
1689
+ # belongs in inside <session> - the launching agent's own exact workspace when
1690
+ # it has one, otherwise this HOME's persistent workspace, created in <cwd> if
1691
+ # absent. Must be called as a PLAIN STATEMENT, never through command
1692
+ # substitution ($(...)) - it communicates through these globals, not solely
1693
+ # through stdout, and a command substitution forks a subshell that would
1694
+ # discard them:
1695
+ # FM_BACKEND_HERDR_WS_ID - the resolved workspace_id (also echoed,
1696
+ # for callers that only need the id)
1697
+ # FM_BACKEND_HERDR_WS_SEEDED_TAB_ID - non-empty ONLY when THIS call just
1698
+ # CREATED the workspace: the tab_id of
1699
+ # the auto-created default tab herdr
1700
+ # seeded it with, read straight from the
1701
+ # `workspace create` response's
1702
+ # `.result.tab.tab_id` (verified
1703
+ # empirically against the real binary -
1704
+ # no follow-up tab-list call needed).
1705
+ # Empty whenever this call instead
1706
+ # ADOPTED a pre-existing workspace -
1707
+ # either the launcher's own
1708
+ # (fm_backend_herdr_launcher_identity) or
1709
+ # a single label match
1710
+ # (fm_backend_herdr_workspace_find_all -
1711
+ # docs/herdr-backend.md "Label
1712
+ # collisions": that match can never
1713
+ # distinguish an explicitly
1714
+ # `--label`-created workspace from one
1715
+ # whose label only coincidentally
1716
+ # matches this home's own, e.g. a
1717
+ # cwd-basename-derived label). An
1718
+ # ADOPTED workspace's tabs are NEVER
1719
+ # inspected or identified as prunable by
1720
+ # this function, no matter what they are
1721
+ # labeled - see
1722
+ # fm_backend_herdr_workspace_prune_seeded_default_tab.
1723
+ # --no-focus (docs/herdr-backend.md "Focus behavior"): verified that workspace
1724
+ # create does NOT focus by default once at least one workspace already exists
1725
+ # in the session, matching pre-existing (flagless) behavior; the ONE exception
1726
+ # is the very first workspace ever created in a brand-new session, which
1727
+ # focuses regardless of --no-focus (herdr always needs something focused to
1728
+ # attach to). --no-focus is passed unconditionally anyway, for defense in
1729
+ # depth and because it is a no-op in the already-safe case.
1730
+ #
1731
+ # <launcher-relationship> (3rd arg, default "launcher-home") says whether the
1732
+ # container being ensured belongs to the SAME firstmate home as the process
1733
+ # calling this:
1734
+ # launcher-home - a crewmate or scout for the caller's own home. When the
1735
+ # caller is itself running in a herdr pane, the worker MUST
1736
+ # land in that exact workspace
1737
+ # (fm_backend_herdr_launcher_identity), never in whichever
1738
+ # same-labeled workspace happens to sort first.
1739
+ # other-home - a --secondmate launch, which stands up a DIFFERENT home's
1740
+ # own per-home workspace by design. The launcher's workspace
1741
+ # is deliberately not inherited here.
1742
+ # With no herdr ancestry at all there is no launcher workspace to inherit, so
1743
+ # the per-home label lookup below stays the resolver - but it must then resolve
1744
+ # to exactly ONE workspace. Two same-labeled home workspaces with no launcher
1745
+ # identity to disambiguate them is an unresolvable placement, and adopting
1746
+ # either one is the very defect this refuses.
1747
+ #
1748
+ # Returns 0 on success, 3 for a refusal whose exact reason is already on
1749
+ # stderr, and 1 for a failed or unparseable herdr call.
1750
+ fm_backend_herdr_workspace_ensure() { # <session> <cwd> [<launcher-relationship>]
1751
+ local session=$1 cwd=$2 relationship=${3:-launcher-home} wsid out label matches count status
1752
+ FM_BACKEND_HERDR_WS_ID=""
1753
+ FM_BACKEND_HERDR_WS_SEEDED_TAB_ID=""
1754
+ if [ "$relationship" = launcher-home ]; then
1755
+ fm_backend_herdr_launcher_identity "$session" && status=0 || status=$?
1756
+ case "$status" in
1757
+ 0)
1758
+ FM_BACKEND_HERDR_WS_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID
1759
+ printf '%s' "$FM_BACKEND_HERDR_WS_ID"
1760
+ return 0
1761
+ ;;
1762
+ 2) ;;
1763
+ *) return 3 ;;
1764
+ esac
1765
+ fi
1766
+ label=$(fm_backend_herdr_workspace_label)
1767
+ matches=$(fm_backend_herdr_workspace_find_all "$session")
1768
+ count=$(printf '%s' "$matches" | grep -c '[^[:space:]]' || true)
1769
+ if [ "$count" -gt 1 ]; then
1770
+ echo "error: ${count} herdr workspaces in session '$session' are labeled '$label' (${matches//$'\n'/ }) and this spawn has no herdr parent pane to identify which one is its own; rename or close the extras, or run firstmate inside the workspace its workers belong in" >&2
1771
+ return 3
1772
+ fi
1773
+ wsid=${matches%%$'\n'*}
1774
+ if [ -n "$wsid" ]; then
1775
+ FM_BACKEND_HERDR_WS_ID=$wsid
1776
+ printf '%s' "$wsid"
1777
+ return 0
1778
+ fi
1779
+ out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" --label "$label" --no-focus 2>/dev/null) || return 1
1780
+ wsid=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null)
1781
+ [ -n "$wsid" ] || return 1
1782
+ FM_BACKEND_HERDR_WS_ID=$wsid
1783
+ # Herdr seeds a new workspace with one auto-created default tab firstmate
1784
+ # never uses. It is NOT pruned here: at this instant it is the workspace's
1785
+ # ONLY tab, and closing a workspace's last tab deletes the workspace itself
1786
+ # (verified against the real herdr binary) - pruning here would destroy the
1787
+ # workspace we just created. fm_backend_herdr_create_task prunes it instead,
1788
+ # once the first real task tab exists alongside it, and only ever targets
1789
+ # this exact captured tab_id.
1790
+ FM_BACKEND_HERDR_WS_SEEDED_TAB_ID=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null)
1791
+ printf '%s' "$wsid"
1792
+ }
1793
+
1794
+ # fm_backend_herdr_container_ensure: the full spawn-time container-ensure
1795
+ # sequence (version gate, server, workspace). Echoes
1796
+ # "<session>:<workspace_id>\t<seeded_default_tab_id>" - a single TAB character
1797
+ # always separates the two fields (the second is empty for an ADOPTED
1798
+ # workspace) so a caller can split unambiguously with
1799
+ # CONTAINER=${RAW%%$'\t'*}; SEEDED_TAB_ID=${RAW#*$'\t'}. The seeded tab id
1800
+ # must be threaded through to fm_backend_herdr_create_task, which is the only
1801
+ # function allowed to prune it (fm_backend_herdr_workspace_prune_seeded_default_tab).
1802
+ # <launcher-relationship> is passed straight through to
1803
+ # fm_backend_herdr_workspace_ensure, which owns its meaning.
1804
+ fm_backend_herdr_container_ensure() { # <cwd-for-a-fresh-workspace> [<launcher-relationship>]
1805
+ local cwd=${1:-$PWD} relationship=${2:-launcher-home} session label status
1806
+ fm_backend_herdr_version_check || return 1
1807
+ session=$(fm_backend_herdr_session)
1808
+ fm_backend_herdr_server_ensure "$session" || return 1
1809
+ fm_backend_herdr_workspace_ensure "$session" "$cwd" "$relationship" >/dev/null && status=0 || status=$?
1810
+ # A 3 already reported the exact placement it refused to guess at; adding the
1811
+ # generic message here would bury it.
1812
+ [ "$status" -ne 3 ] || return 1
1813
+ if [ "$status" -ne 0 ] || [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then
1814
+ label=$(fm_backend_herdr_workspace_label)
1815
+ echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2
1816
+ return 1
1817
+ fi
1818
+ printf '%s:%s\t%s' "$session" "$FM_BACKEND_HERDR_WS_ID" "$FM_BACKEND_HERDR_WS_SEEDED_TAB_ID"
1819
+ }
1820
+
1821
+ # fm_backend_herdr_pane_presence_state: classify one exact pane get response
1822
+ # as dead|present|unknown from its JSON body, never from process exit status.
1823
+ fm_backend_herdr_pane_presence_state() { # <session> <pane_id>
1824
+ local session=$1 pane_id=$2 out code pid
1825
+ out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1)
1826
+ code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null)
1827
+ if [ -n "$code" ]; then
1828
+ [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown'
1829
+ return 0
1830
+ fi
1831
+ pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null)
1832
+ [ "$pid" = "$pane_id" ] && printf 'present' || printf 'unknown'
1833
+ }
1834
+
1835
+ fm_backend_herdr_workspace_presence_state() { # <session> <workspace_id>
1836
+ local session=$1 workspace_id=$2 out matches
1837
+ out=$(fm_backend_herdr_cli "$session" workspace list 2>&1)
1838
+ matches=$(printf '%s' "$out" | jq -r --arg workspace "$workspace_id" '
1839
+ select((.result.workspaces | type) == "array")
1840
+ | [.result.workspaces[] | select(.workspace_id == $workspace)] | length
1841
+ ' 2>/dev/null) || matches=
1842
+ case "$matches" in
1843
+ 0) printf 'dead' ;;
1844
+ 1) printf 'present' ;;
1845
+ *) printf 'unknown' ;;
1846
+ esac
1847
+ }
1848
+
1849
+ # fm_backend_herdr_explicit_close_pane_confirmed: issue one explicit close and
1850
+ # succeed only when a structured follow-up proves the exact pane is gone.
1851
+ fm_backend_herdr_explicit_close_pane_confirmed() { # <session> <pane_id>
1852
+ local session=$1 pane_id=$2 presence
1853
+ fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || return 1
1854
+ presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id")
1855
+ [ "$presence" = dead ]
1856
+ }
1857
+
1858
+ # fm_backend_herdr_pane_agent_state: classify <pane_id> in <session> as one of
1859
+ # dead|no-agent|live|unknown, purely from the JSON body of two read-only
1860
+ # calls - never from process exit status, since a business-logic "not found"
1861
+ # response is a normal, expected outcome here, not a call failure (real herdr
1862
+ # 0.7.1 exits 1 for it; the canned-response test fakes exit 0; parsing only
1863
+ # the JSON keeps this function correct against either).
1864
+ #
1865
+ # dead - `pane get` responds with error code pane_not_found: the pane
1866
+ # itself is gone (closed, or its process died and herdr already
1867
+ # reaped it - verified empirically: killing a pane's shell pid
1868
+ # on a live server makes herdr immediately drop both the pane
1869
+ # and its tab from `pane get`/`tab list`).
1870
+ # no-agent - `pane get` succeeds (the pane structurally exists) but `agent
1871
+ # get` responds with error code agent_not_found: nothing is
1872
+ # registered in it - exactly what a herdr session-layout restore
1873
+ # produces (verified empirically: `session stop` + fresh `herdr
1874
+ # server` restart leaves the pane alive, agent_status "unknown",
1875
+ # agent get -> agent_not_found - docs/herdr-backend.md "ID
1876
+ # stability across a server restart"), and what a future
1877
+ # `resume_agents_on_restore = false` restore would produce too
1878
+ # (a plain shell, never an agent).
1879
+ # live - `agent get` succeeds and reports a real agent_status (working,
1880
+ # idle, done, or blocked - any registered value). An idle or
1881
+ # blocked agent is still a genuine, still-registered agent, not
1882
+ # a restored husk, so it is never a close-and-replace candidate.
1883
+ # unknown - anything else: an unparseable/unexpected response from either
1884
+ # call, or a `pane get` success whose own echoed pane_id does not
1885
+ # round-trip (guards against misreading a herdr response shape
1886
+ # change as "the pane exists"). The caller must fail safe toward
1887
+ # refusal here, never toward closing - this is the conservative
1888
+ # backstop the husk check depends on.
1889
+ fm_backend_herdr_pane_agent_state() { # <session> <pane_id>
1890
+ local session=$1 pane_id=$2 out code presence status
1891
+ presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id")
1892
+ if [ "$presence" != present ]; then
1893
+ case "$presence" in
1894
+ dead|unknown) printf '%s' "$presence" ;;
1895
+ *) printf 'unknown' ;;
1896
+ esac
1897
+ return 0
1898
+ fi
1899
+ out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>&1)
1900
+ code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null)
1901
+ if [ -n "$code" ]; then
1902
+ [ "$code" = "agent_not_found" ] && printf 'no-agent' || printf 'unknown'
1903
+ return 0
1904
+ fi
1905
+ status=$(printf '%s' "$out" | jq -r '.result.agent.agent_status // empty' 2>/dev/null)
1906
+ case "$status" in
1907
+ working|idle|done|blocked) printf 'live' ;;
1908
+ *) printf 'unknown' ;;
1909
+ esac
1910
+ }
1911
+
1912
+ # fm_backend_herdr_tab_is_husk: true (0) only for the two conservative husk
1913
+ # states (dead, no-agent) fm_backend_herdr_pane_agent_state can positively
1914
+ # confirm; live and unknown both refuse (1), so an inconclusive read never
1915
+ # licenses closing anything. Restored-layout recovery depends on this
1916
+ # fail-safe-toward-refusal behavior.
1917
+ fm_backend_herdr_tab_is_husk() { # <session> <pane_id>
1918
+ case "$(fm_backend_herdr_pane_agent_state "$1" "$2")" in
1919
+ dead|no-agent) return 0 ;;
1920
+ *) return 1 ;;
1921
+ esac
1922
+ }
1923
+
1924
+ # fm_backend_herdr_agent_state: recovery-grade state for the same session-start
1925
+ # sweep as the tmux classifier. It reuses the husk classifier rather than
1926
+ # creating a second Herdr state machine: a structurally gone pane is `missing`,
1927
+ # a confirmed agent-less pane is `dead`, a registered agent is `alive`, and an
1928
+ # unexpected or failed API read is `unreadable`.
1929
+ fm_backend_herdr_agent_state() { # <target>
1930
+ local target=$1
1931
+ fm_backend_herdr_parse_target "$target" || { printf 'unreadable'; return 0; }
1932
+ case "$(fm_backend_herdr_pane_agent_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")" in
1933
+ dead) printf 'missing' ;;
1934
+ no-agent) printf 'dead' ;;
1935
+ live) printf 'alive' ;;
1936
+ *) printf 'unreadable' ;;
1937
+ esac
1938
+ }
1939
+
1940
+ # Backward-compatible three-state view for callers that only need a yes/no
1941
+ # agent verdict. The detailed state contract is owned by fm_backend_agent_state.
1942
+ fm_backend_herdr_agent_alive() { # <target>
1943
+ case "$(fm_backend_herdr_agent_state "$1")" in
1944
+ alive) printf 'alive' ;;
1945
+ dead|missing) printf 'dead' ;;
1946
+ *) printf 'unknown' ;;
1947
+ esac
1948
+ }
1949
+
1950
+ # fm_backend_herdr_create_task: create the task's tab (one pane) in
1951
+ # <container> ("session:workspace_id"). Herdr does NOT enforce label
1952
+ # uniqueness itself (verified: two tabs can share a label), so the duplicate
1953
+ # check is ours, mirroring tmux's manual check.
1954
+ #
1955
+ # A same-labeled tab already existing no longer means an automatic refusal:
1956
+ # herdr persists and restores its whole session layout (workspaces/tabs/
1957
+ # panes) across a server restart, including a reboot, and a restored fm-<id>
1958
+ # task tab comes back a HUSK - a dead pane, or (today, and unconditionally
1959
+ # once a future `resume_agents_on_restore = false` config ships) a plain
1960
+ # agent-less shell sitting in the saved cwd, never the crewmate that used to
1961
+ # be there. Before this fix, every fleet respawn after such a restart needed
1962
+ # the operator to manually close each husk pane first before firstmate could
1963
+ # spawn into it again. fm_backend_herdr_tab_is_husk classifies the existing
1964
+ # tab's pane conservatively (dead or no-agent only; anything live or
1965
+ # ambiguous refuses exactly as before) and, when it is a confirmed husk,
1966
+ # this function CLOSES AND REPLACES it instead of refusing.
1967
+ #
1968
+ # Ordering is deliberate: the REPLACEMENT tab is created FIRST, and the husk
1969
+ # is closed only AFTER that succeeds - never the reverse. Closing a
1970
+ # workspace's LAST remaining tab deletes the whole workspace on real herdr
1971
+ # (docs/herdr-backend.md "Default workspace lifecycle"), and a session-restore husk
1972
+ # can legitimately be that workspace's only tab (e.g. its own seeded default
1973
+ # tab was already pruned, long before the restart, by a prior real task tab
1974
+ # existing alongside it). Herdr's lack of label-uniqueness enforcement is
1975
+ # exactly what makes this safe: the new and the husk tab can briefly share
1976
+ # the same label with no error, so the workspace never drops to zero tabs.
1977
+ # This mirrors fm_backend_herdr_workspace_prune_seeded_default_tab's own
1978
+ # create-before-close safety argument.
1979
+ #
1980
+ # --no-focus: verified tab create never focuses by default regardless of
1981
+ # sibling tabs, so this is defense in depth rather than a behavior change.
1982
+ # <seeded_default_tab_id> (4th arg, may be empty) is exactly the value
1983
+ # fm_backend_herdr_workspace_ensure captured as FM_BACKEND_HERDR_WS_SEEDED_TAB_ID
1984
+ # for THIS SAME container - non-empty only when this spawn's own
1985
+ # container_ensure call just created the workspace. Once the real task tab
1986
+ # above is created, this is the ONLY input that may trigger a prune, and it is
1987
+ # passed by the caller, never re-derived here from tab list contents or
1988
+ # labels (the live-fire self-kill fix - see
1989
+ # fm_backend_herdr_workspace_prune_seeded_default_tab for the incident and
1990
+ # the safety argument). An ADOPTED workspace's caller always passes an empty
1991
+ # 4th arg, so this function never even queries for a prune candidate in that
1992
+ # case. Echoes "<tab_id> <pane_id>" on success.
1993
+ fm_backend_herdr_create_task() { # <container> <label> <cwd> <seeded_default_tab_id>
1994
+ local container=$1 label=$2 cwd=$3 seeded_tab_id=${4:-} session wsid list dup_tabs dup dup_pane dup_tab_ids out tab_id pane_id remaining_dup_tabs
1995
+ session=${container%%:*}
1996
+ wsid=${container#*:}
1997
+ list=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null) || return 1
1998
+ dup_tabs=$(printf '%s' "$list" | jq -r --arg want "$label" 'if (.result.tabs | type) == "array" then .result.tabs[] | select(.label == $want) | .tab_id else error("missing result.tabs") end' 2>/dev/null) || {
1999
+ echo "error: could not parse herdr tab list output for workspace $wsid (session $session)" >&2
2000
+ return 1
2001
+ }
2002
+ dup_tab_ids=""
2003
+ if [ -n "$dup_tabs" ]; then
2004
+ while IFS= read -r dup; do
2005
+ [ -n "$dup" ] || continue
2006
+ dup_pane=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$dup")
2007
+ if [ -z "$dup_pane" ] || ! fm_backend_herdr_tab_is_husk "$session" "$dup_pane"; then
2008
+ echo "error: herdr tab '$label' already exists in workspace $wsid (session $session)" >&2
2009
+ return 1
2010
+ fi
2011
+ dup_tab_ids="${dup_tab_ids}${dup}"$'\n'
2012
+ done <<EOF
2013
+ $dup_tabs
2014
+ EOF
2015
+ fi
2016
+ out=$(fm_backend_herdr_cli "$session" tab create --workspace "$wsid" --cwd "$cwd" --label "$label" --no-focus 2>/dev/null) || return 1
2017
+ tab_id=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null)
2018
+ pane_id=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null)
2019
+ if [ -z "$tab_id" ] || [ -z "$pane_id" ]; then
2020
+ echo "error: could not parse tab/pane id from herdr tab create output" >&2
2021
+ return 1
2022
+ fi
2023
+ [ -z "$seeded_tab_id" ] || fm_backend_herdr_workspace_prune_seeded_default_tab "$session" "$wsid" "$seeded_tab_id"
2024
+ if [ -n "$dup_tab_ids" ]; then
2025
+ while IFS= read -r dup; do
2026
+ [ -n "$dup" ] || continue
2027
+ fm_backend_herdr_cli "$session" tab close "$dup" >/dev/null 2>&1 || true
2028
+ done <<EOF
2029
+ $dup_tab_ids
2030
+ EOF
2031
+ list=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null) || {
2032
+ echo "error: could not verify herdr husk removal for tab '$label' in workspace $wsid (session $session)" >&2
2033
+ return 1
2034
+ }
2035
+ if ! printf '%s' "$list" | jq -e '(.result.tabs | type) == "array"' >/dev/null 2>&1; then
2036
+ echo "error: could not parse herdr tab list output for workspace $wsid (session $session)" >&2
2037
+ return 1
2038
+ fi
2039
+ remaining_dup_tabs=$(printf '%s' "$list" | jq -r --arg want "$label" --arg replacement "$tab_id" \
2040
+ '.result.tabs[]? | select(.label == $want and .tab_id != $replacement) | .tab_id' 2>/dev/null)
2041
+ remaining_dup_tabs=${remaining_dup_tabs//$'\n'/ }
2042
+ if [ -n "$remaining_dup_tabs" ]; then
2043
+ echo "error: failed to remove preexisting herdr tab(s) $remaining_dup_tabs for label '$label' in workspace $wsid (session $session)" >&2
2044
+ return 1
2045
+ fi
2046
+ fi
2047
+ printf '%s %s' "$tab_id" "$pane_id"
2048
+ }
2049
+
2050
+ # fm_backend_herdr_projection_create_task: create one disposable presentation
2051
+ # workspace and its normal fm-<id> task tab without looking up, adopting, or
2052
+ # reusing any existing workspace.
2053
+ # The caller must atomically publish the projection journal first.
2054
+ # This function sets exact response-derived globals and prints nothing:
2055
+ # FM_BACKEND_HERDR_PROJECTION_SESSION
2056
+ # FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID
2057
+ # FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID
2058
+ # FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID
2059
+ # FM_BACKEND_HERDR_PROJECTION_TAB_ID
2060
+ # FM_BACKEND_HERDR_PROJECTION_PANE_ID
2061
+ # FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE
2062
+ # CLEANUP_SAFE becomes 1 only after both creates returned complete exact IDs.
2063
+ # A missing, failed, or malformed create response stays ambiguous and grants no
2064
+ # cleanup authority.
2065
+ fm_backend_herdr_projection_create_task() { # <cwd> <workspace-label> <task-label>
2066
+ local cwd=$1 workspace_label=$2 task_label=$3 session out tabs panes tab_count pane_count focus_before
2067
+ FM_BACKEND_HERDR_PROJECTION_SESSION=""
2068
+ FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID=""
2069
+ FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID=""
2070
+ FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID=""
2071
+ FM_BACKEND_HERDR_PROJECTION_TAB_ID=""
2072
+ FM_BACKEND_HERDR_PROJECTION_PANE_ID=""
2073
+ FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE=0
2074
+
2075
+ fm_backend_herdr_version_check || return 1
2076
+ session=$(fm_backend_herdr_session)
2077
+ fm_backend_herdr_server_ensure "$session" || return 1
2078
+ focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || {
2079
+ echo "error: herdr presentation workspace create could not capture exact active workspace and tab; refusing a focus-unsafe projection" >&2
2080
+ return 1
2081
+ }
2082
+ if out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" --label "$workspace_label" --no-focus 2>/dev/null); then
2083
+ :
2084
+ else
2085
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "workspace create" || true
2086
+ echo "error: herdr presentation workspace create failed ambiguously; leaving its journal quarantined" >&2
2087
+ return 1
2088
+ fi
2089
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "workspace create" || {
2090
+ echo "error: herdr presentation workspace create did not preserve exact active focus; leaving its journal quarantined" >&2
2091
+ return 1
2092
+ }
2093
+ # shellcheck disable=SC2034 # caller consumes the response-derived global
2094
+ FM_BACKEND_HERDR_PROJECTION_SESSION=$session
2095
+ FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null)
2096
+ FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null)
2097
+ FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null)
2098
+ if [ -z "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" ] \
2099
+ || [ -z "$FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID" ] \
2100
+ || [ -z "$FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID" ]; then
2101
+ echo "error: herdr presentation workspace create returned incomplete IDs; leaving its journal quarantined" >&2
2102
+ return 1
2103
+ fi
2104
+
2105
+ focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || {
2106
+ echo "error: herdr presentation task-tab create could not capture exact active workspace and tab; refusing a focus-unsafe projection" >&2
2107
+ return 1
2108
+ }
2109
+ if out=$(fm_backend_herdr_cli "$session" tab create \
2110
+ --workspace "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" \
2111
+ --cwd "$cwd" --label "$task_label" --no-focus 2>/dev/null); then
2112
+ :
2113
+ else
2114
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "task-tab create" || true
2115
+ echo "error: herdr presentation task-tab create failed ambiguously; leaving its journal quarantined" >&2
2116
+ return 1
2117
+ fi
2118
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "task-tab create" || {
2119
+ echo "error: herdr presentation task-tab create did not preserve exact active focus; leaving its journal quarantined" >&2
2120
+ return 1
2121
+ }
2122
+ FM_BACKEND_HERDR_PROJECTION_TAB_ID=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null)
2123
+ FM_BACKEND_HERDR_PROJECTION_PANE_ID=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null)
2124
+ if [ -z "$FM_BACKEND_HERDR_PROJECTION_TAB_ID" ] || [ -z "$FM_BACKEND_HERDR_PROJECTION_PANE_ID" ]; then
2125
+ echo "error: herdr presentation task-tab create returned incomplete IDs; leaving its journal quarantined" >&2
2126
+ return 1
2127
+ fi
2128
+ # shellcheck disable=SC2034 # caller consumes the same-process cleanup gate
2129
+ FM_BACKEND_HERDR_PROJECTION_CLEANUP_SAFE=1
2130
+ focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || {
2131
+ echo "error: herdr presentation seeded-tab prune could not capture exact active workspace and tab; refusing a focus-unsafe prune" >&2
2132
+ return 1
2133
+ }
2134
+ if ! fm_backend_herdr_workspace_prune_seeded_default_tab \
2135
+ "$session" \
2136
+ "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" \
2137
+ "$FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID" \
2138
+ focus-preserving; then
2139
+ echo "error: herdr presentation seeded-tab prune refused a focus-unsafe close; leaving its journal quarantined" >&2
2140
+ return 1
2141
+ fi
2142
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "seeded-tab prune" || {
2143
+ echo "error: herdr presentation seeded-tab prune did not preserve exact active focus; leaving its journal quarantined" >&2
2144
+ return 1
2145
+ }
2146
+
2147
+ tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" 2>/dev/null) || {
2148
+ echo "error: could not verify the disposable herdr presentation workspace shape" >&2
2149
+ return 1
2150
+ }
2151
+ panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$FM_BACKEND_HERDR_PROJECTION_WORKSPACE_ID" 2>/dev/null) || {
2152
+ echo "error: could not verify the disposable herdr presentation pane shape" >&2
2153
+ return 1
2154
+ }
2155
+ if ! printf '%s' "$tabs" | jq -e '(.result.tabs | type) == "array"' >/dev/null 2>&1 \
2156
+ || ! printf '%s' "$panes" | jq -e '(.result.panes | type) == "array"' >/dev/null 2>&1; then
2157
+ echo "error: could not parse the disposable herdr presentation workspace shape" >&2
2158
+ return 1
2159
+ fi
2160
+ tab_count=$(printf '%s' "$tabs" | jq -r '.result.tabs | length' 2>/dev/null)
2161
+ pane_count=$(printf '%s' "$panes" | jq -r '.result.panes | length' 2>/dev/null)
2162
+ if [ "$tab_count" != 1 ] || [ "$pane_count" != 1 ] \
2163
+ || ! printf '%s' "$tabs" | jq -e --arg task "$FM_BACKEND_HERDR_PROJECTION_TAB_ID" \
2164
+ --arg seeded "$FM_BACKEND_HERDR_PROJECTION_SEEDED_TAB_ID" \
2165
+ '.result.tabs[0].tab_id == $task and ([.result.tabs[] | select(.tab_id == $seeded)] | length) == 0' >/dev/null 2>&1 \
2166
+ || ! printf '%s' "$panes" | jq -e --arg pane "$FM_BACKEND_HERDR_PROJECTION_PANE_ID" \
2167
+ --arg tab "$FM_BACKEND_HERDR_PROJECTION_TAB_ID" \
2168
+ '.result.panes[0].pane_id == $pane and .result.panes[0].tab_id == $tab' >/dev/null 2>&1; then
2169
+ echo "error: disposable herdr presentation workspace did not converge to exactly one task pane" >&2
2170
+ return 1
2171
+ fi
2172
+ return 0
2173
+ }
2174
+
2175
+ # fm_backend_herdr_projection_cleanup_exact: same-process abort cleanup for a
2176
+ # projection whose create calls returned complete exact IDs.
2177
+ # It performs no lookup and never calls workspace close.
2178
+ fm_backend_herdr_projection_cleanup_exact() { # <session> <task-pane> <seeded-pane>
2179
+ local session=$1 task_pane=$2 seeded_pane=$3
2180
+ [ -z "$task_pane" ] || fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$task_pane" || true
2181
+ if [ -n "$seeded_pane" ] && [ "$seeded_pane" != "$task_pane" ]; then
2182
+ fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$seeded_pane" || true
2183
+ fi
2184
+ }
2185
+
2186
+ # fm_backend_herdr_projection_parent_workspace_exact: resolve one exact parent
2187
+ # workspace only when its presentation label is unique in the named session.
2188
+ fm_backend_herdr_projection_parent_workspace_exact() { # <session> <parent-label>
2189
+ local session=$1 parent_label=$2 list
2190
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1
2191
+ printf '%s' "$list" | jq -er --arg parent_label "$parent_label" '
2192
+ (.result.workspaces // null) as $spaces
2193
+ | select(($spaces | type) == "array")
2194
+ | [$spaces[]? | select(.label == $parent_label)]
2195
+ | if length == 1
2196
+ and (.[0].workspace_id | type) == "string"
2197
+ and (.[0].workspace_id | length) > 0
2198
+ then .[0].workspace_id
2199
+ else empty
2200
+ end
2201
+ ' 2>/dev/null
2202
+ }
2203
+
2204
+ # fm_backend_herdr_projection_live_binding_matches: verify one exact projected
2205
+ # workspace, its single task tab/pane, its unique token label, and its current
2206
+ # position inside the exact parent workspace's contiguous child block.
2207
+ # This read-only predicate grants no mutation authority by itself.
2208
+ fm_backend_herdr_projection_live_binding_matches() { # <session> <token> <workspace> <tab> <pane> <parent-workspace> <parent-label> <workspace-label> <task-label>
2209
+ local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6
2210
+ local parent_label=$7 workspace_label=$8 task_label=$9 list tabs panes
2211
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1
2212
+ printf '%s' "$list" | jq -e \
2213
+ --arg token "$token" \
2214
+ --arg workspace "$workspace" \
2215
+ --arg parent_workspace "$parent_workspace" \
2216
+ --arg parent_label "$parent_label" \
2217
+ --arg workspace_label "$workspace_label" '
2218
+ def is_new_child:
2219
+ (.label | type) == "string"
2220
+ and (.label | test("^└ .+ · p:[A-Za-z0-9_-]{22}$"));
2221
+ def is_legacy_child_for($owner):
2222
+ (.label | type) == "string"
2223
+ and (.label | test("^(firstmate|2ndmate-[^/]+)/.+ · p:[A-Za-z0-9_-]{22}$"))
2224
+ and (.label | startswith($owner + "/"));
2225
+ (.result.workspaces // null) as $spaces
2226
+ | select(($spaces | type) == "array")
2227
+ | select(([$spaces[]? | select(.workspace_id == $workspace)] | length) == 1)
2228
+ | select(([$spaces[]? | select(.workspace_id == $workspace and .label == $workspace_label)] | length) == 1)
2229
+ | select(([$spaces[]? | select((.label | type) == "string" and (.label | endswith(" · p:" + $token)))] | length) == 1)
2230
+ | select(([$spaces[]? | select((.label | type) == "string" and (.label | endswith(" · p:" + $token)) and .workspace_id == $workspace)] | length) == 1)
2231
+ | select(([$spaces[]? | select(.workspace_id == $parent_workspace and .label == $parent_label)] | length) == 1)
2232
+ | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $parent_workspace)]) as $parents
2233
+ | ([range(0; $spaces | length) | select($spaces[.].workspace_id == $workspace)]) as $children
2234
+ | select(($parents | length) == 1 and ($children | length) == 1)
2235
+ | ($parents[0]) as $parent_index
2236
+ | ($children[0]) as $child_index
2237
+ | select($child_index > $parent_index)
2238
+ | reduce range($parent_index + 1; $child_index) as $i
2239
+ (true; . and (($spaces[$i] | is_new_child) or ($spaces[$i] | is_legacy_child_for($parent_label))))
2240
+ | select(. == true)
2241
+ ' >/dev/null 2>&1 || return 1
2242
+ tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$workspace" 2>/dev/null) || return 1
2243
+ printf '%s' "$tabs" | jq -e --arg tab "$tab" --arg task_label "$task_label" '
2244
+ (.result.tabs | type) == "array"
2245
+ and (.result.tabs | length) == 1
2246
+ and .result.tabs[0].tab_id == $tab
2247
+ and .result.tabs[0].label == $task_label
2248
+ ' >/dev/null 2>&1 || return 1
2249
+ panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$workspace" 2>/dev/null) || return 1
2250
+ printf '%s' "$panes" | jq -e --arg tab "$tab" --arg pane "$pane" '
2251
+ (.result.panes | type) == "array"
2252
+ and (.result.panes | length) == 1
2253
+ and .result.panes[0].pane_id == $pane
2254
+ and .result.panes[0].tab_id == $tab
2255
+ ' >/dev/null 2>&1
2256
+ }
2257
+
2258
+ fm_backend_herdr_projection_reclaim_rollback() { # <session> <new-pane>
2259
+ local session=$1 new_pane=$2 state
2260
+ state=$(fm_backend_herdr_pane_agent_state "$session" "$new_pane")
2261
+ case "$state" in
2262
+ dead) return 0 ;;
2263
+ no-agent) ;;
2264
+ live|unknown) return 1 ;;
2265
+ esac
2266
+ fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$new_pane" no-agent || return 1
2267
+ [ "$(fm_backend_herdr_pane_agent_state "$session" "$new_pane")" = dead ]
2268
+ }
2269
+
2270
+ # fm_backend_herdr_projection_reclaim_task: replace one exact agent-free
2271
+ # restored projection husk inside its original workspace.
2272
+ # The caller holds the session presentation lock and has already established
2273
+ # that flat fallback is safe across every token match.
2274
+ # Return 0 means exact reclaim, 2 means non-mutating or exactly rolled-back
2275
+ # refusal with flat fallback permitted, and 1 means a live/unknown or
2276
+ # post-mutation uncertainty that must refuse the launch.
2277
+ fm_backend_herdr_projection_reclaim_task() { # <session> <journal> <task-id> <home> <meta-workspace> <meta-tab> <meta-pane> <parent-label> <task-label> <cwd>
2278
+ local session=$1 journal=$2 id=$3 home=$4 meta_workspace=$5 meta_tab=$6 meta_pane=$7
2279
+ local parent_label=$8 task_label=$9 cwd=${10} canonical_home state focus_before active_tab out new_tab new_pane info close_status
2280
+ FM_BACKEND_HERDR_PROJECTION_TAB_ID=""
2281
+ FM_BACKEND_HERDR_PROJECTION_PANE_ID=""
2282
+ fm_backend_herdr_projection_journal_snapshot "$journal" "$id" || return 1
2283
+ if [ "$FM_BACKEND_HERDR_JOURNAL_VERSION" != 2 ]; then
2284
+ echo "warning: herdr presentation journal for $id has no exact restart binding; spawning flat" >&2
2285
+ return 2
2286
+ fi
2287
+ canonical_home=$(fm_backend_herdr_projection_home_identity "$home") || {
2288
+ echo "warning: herdr presentation home for $id could not be resolved exactly; spawning flat" >&2
2289
+ return 2
2290
+ }
2291
+ if [ "$FM_BACKEND_HERDR_JOURNAL_HOME" != "$canonical_home" ] \
2292
+ || [ "$FM_BACKEND_HERDR_JOURNAL_SESSION" != "$session" ] \
2293
+ || [ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_ID" != "$meta_workspace" ] \
2294
+ || [ "$FM_BACKEND_HERDR_JOURNAL_TAB_ID" != "$meta_tab" ] \
2295
+ || [ "$FM_BACKEND_HERDR_JOURNAL_PANE_ID" != "$meta_pane" ] \
2296
+ || [ "$FM_BACKEND_HERDR_JOURNAL_PARENT_LABEL" != "$parent_label" ] \
2297
+ || [ "$FM_BACKEND_HERDR_JOURNAL_TASK_LABEL" != "$task_label" ]; then
2298
+ echo "warning: herdr presentation binding for $id does not match its exact home, endpoint, or parent; spawning flat" >&2
2299
+ return 2
2300
+ fi
2301
+ if ! fm_backend_herdr_projection_live_binding_matches \
2302
+ "$session" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \
2303
+ "$meta_workspace" "$meta_tab" "$meta_pane" \
2304
+ "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$parent_label" \
2305
+ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$task_label"; then
2306
+ echo "warning: herdr presentation binding for $id has an ambiguous, renamed, foreign, or non-nested live shape; spawning flat" >&2
2307
+ return 2
2308
+ fi
2309
+ state=$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane")
2310
+ case "$state" in
2311
+ no-agent) ;;
2312
+ dead)
2313
+ echo "warning: exact herdr presentation pane for $id is gone; spawning flat" >&2
2314
+ return 2
2315
+ ;;
2316
+ live|unknown)
2317
+ echo "error: exact herdr presentation pane for $id is $state; refusing duplicate launch" >&2
2318
+ return 1
2319
+ ;;
2320
+ esac
2321
+ focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || {
2322
+ echo "warning: herdr presentation reclaim for $id could not capture exact focus; spawning flat" >&2
2323
+ return 2
2324
+ }
2325
+ active_tab=${focus_before#*$'\t'}
2326
+ if [ "$active_tab" = "$meta_tab" ]; then
2327
+ echo "warning: herdr presentation reclaim for $id would replace the active tab; spawning flat" >&2
2328
+ return 2
2329
+ fi
2330
+ if ! out=$(fm_backend_herdr_cli "$session" tab create \
2331
+ --workspace "$meta_workspace" --cwd "$cwd" --label "$task_label" --no-focus 2>/dev/null); then
2332
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1
2333
+ echo "warning: herdr presentation reclaim for $id could not create an exact replacement; spawning flat" >&2
2334
+ return 2
2335
+ fi
2336
+ new_tab=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null)
2337
+ new_pane=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null)
2338
+ if [ -z "$new_tab" ] || [ -z "$new_pane" ]; then
2339
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1
2340
+ echo "warning: herdr presentation reclaim for $id returned ambiguous replacement ids; spawning flat" >&2
2341
+ return 2
2342
+ fi
2343
+ fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "husk replacement create" || return 1
2344
+ info=$(fm_backend_herdr_cli "$session" tab get "$new_tab" 2>/dev/null) || info=
2345
+ if ! printf '%s' "$info" | jq -e --arg tab "$new_tab" --arg workspace "$meta_workspace" '
2346
+ .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace
2347
+ ' >/dev/null 2>&1; then
2348
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2349
+ echo "warning: herdr presentation reclaim for $id could not verify its replacement tab; spawning flat" >&2
2350
+ return 2
2351
+ fi
2352
+ info=$(fm_backend_herdr_cli "$session" pane get "$new_pane" 2>/dev/null) || info=
2353
+ if ! printf '%s' "$info" | jq -e --arg pane "$new_pane" --arg tab "$new_tab" --arg workspace "$meta_workspace" '
2354
+ .result.pane.pane_id == $pane
2355
+ and .result.pane.tab_id == $tab
2356
+ and .result.pane.workspace_id == $workspace
2357
+ ' >/dev/null 2>&1; then
2358
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2359
+ echo "warning: herdr presentation reclaim for $id could not verify its replacement pane; spawning flat" >&2
2360
+ return 2
2361
+ fi
2362
+ state=$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane")
2363
+ case "$state" in
2364
+ no-agent) ;;
2365
+ live|unknown)
2366
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2367
+ echo "error: herdr presentation pane for $id became $state during reclaim; refusing duplicate launch" >&2
2368
+ return 1
2369
+ ;;
2370
+ dead)
2371
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2372
+ echo "warning: herdr presentation pane for $id disappeared during reclaim; spawning flat" >&2
2373
+ return 2
2374
+ ;;
2375
+ esac
2376
+ if fm_backend_herdr_projection_close_pane_focus_preserving "$session" "$meta_pane" no-agent; then
2377
+ close_status=0
2378
+ else
2379
+ close_status=$?
2380
+ fi
2381
+ if [ "$close_status" -ne 0 ]; then
2382
+ if [ "$close_status" -eq 2 ]; then
2383
+ return 1
2384
+ fi
2385
+ state=$FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE
2386
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2387
+ case "$state" in
2388
+ live|unknown)
2389
+ echo "error: herdr presentation pane for $id became $state at the close boundary; refusing duplicate launch" >&2
2390
+ return 1
2391
+ ;;
2392
+ esac
2393
+ echo "warning: herdr presentation reclaim for $id could not close the exact old husk; spawning flat" >&2
2394
+ return 2
2395
+ fi
2396
+ if [ "$(fm_backend_herdr_pane_agent_state "$session" "$meta_pane")" != dead ]; then
2397
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2398
+ return 1
2399
+ fi
2400
+ if ! fm_backend_herdr_projection_live_binding_matches \
2401
+ "$session" "$FM_BACKEND_HERDR_JOURNAL_PROJECTION_ID" \
2402
+ "$meta_workspace" "$new_tab" "$new_pane" \
2403
+ "$FM_BACKEND_HERDR_JOURNAL_PARENT_WORKSPACE_ID" "$parent_label" \
2404
+ "$FM_BACKEND_HERDR_JOURNAL_WORKSPACE_LABEL" "$task_label"; then
2405
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2406
+ echo "warning: herdr presentation reclaim for $id did not converge exactly; spawning flat" >&2
2407
+ return 2
2408
+ fi
2409
+ if ! fm_backend_herdr_projection_journal_replace_endpoint \
2410
+ "$journal" "$id" "$meta_tab" "$meta_pane" "$new_tab" "$new_pane"; then
2411
+ fm_backend_herdr_projection_reclaim_rollback "$session" "$new_pane" || return 1
2412
+ echo "warning: herdr presentation reclaim for $id could not publish its replacement binding; spawning flat" >&2
2413
+ return 2
2414
+ fi
2415
+ FM_BACKEND_HERDR_PROJECTION_TAB_ID=$new_tab
2416
+ FM_BACKEND_HERDR_PROJECTION_PANE_ID=$new_pane
2417
+ return 0
2418
+ }
2419
+
2420
+ # fm_backend_herdr_projection_recovery_allows_flat: inspect an existing
2421
+ # journal's exact token matches without adopting, reusing, renaming, closing,
2422
+ # or deleting anything.
2423
+ # Missing matches safely degrade to the normal flat workspace.
2424
+ # One or more matches allow flat fallback only when every pane is positively
2425
+ # dead or agent-free; a live or unknown pane refuses a duplicate launch.
2426
+ fm_backend_herdr_projection_recovery_allows_flat() { # <session> <journal> <task-id>
2427
+ local session=$1 journal=$2 id=$3 token list wsids count wsid panes pane_ids pane state
2428
+ token=$(fm_backend_herdr_projection_journal_token "$journal" "$id") || {
2429
+ echo "error: malformed herdr presentation journal for $id; refusing duplicate launch" >&2
2430
+ return 1
2431
+ }
2432
+ fm_backend_herdr_server_ensure "$session" || {
2433
+ echo "error: could not inspect the quarantined herdr presentation for $id; refusing duplicate launch" >&2
2434
+ return 1
2435
+ }
2436
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || {
2437
+ echo "error: could not list herdr workspaces while inspecting the quarantined presentation for $id" >&2
2438
+ return 1
2439
+ }
2440
+ if ! printf '%s' "$list" | jq -e '(.result.workspaces | type) == "array"' >/dev/null 2>&1; then
2441
+ echo "error: could not parse herdr workspaces while inspecting the quarantined presentation for $id" >&2
2442
+ return 1
2443
+ fi
2444
+ wsids=$(printf '%s' "$list" | jq -r --arg suffix " · p:$token" \
2445
+ '.result.workspaces[]? | select((.label | type) == "string" and (.label | endswith($suffix))) | .workspace_id' 2>/dev/null)
2446
+ count=$(printf '%s\n' "$wsids" | awk 'NF { n += 1 } END { print n + 0 }')
2447
+ if [ "$count" -eq 0 ]; then
2448
+ echo "warning: no exact herdr presentation token match for $id; leaving any stale space untouched and spawning flat" >&2
2449
+ return 0
2450
+ fi
2451
+ if [ "$count" -gt 1 ]; then
2452
+ echo "warning: $count exact herdr presentation token matches for $id are quarantined; inspecting only for duplicate-agent risk" >&2
2453
+ fi
2454
+ while IFS= read -r wsid; do
2455
+ [ -n "$wsid" ] || continue
2456
+ panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$wsid" 2>/dev/null) || {
2457
+ echo "error: could not inspect herdr presentation workspace $wsid for $id; refusing duplicate launch" >&2
2458
+ return 1
2459
+ }
2460
+ if ! printf '%s' "$panes" | jq -e '(.result.panes | type) == "array"' >/dev/null 2>&1; then
2461
+ echo "error: could not parse herdr presentation workspace $wsid for $id; refusing duplicate launch" >&2
2462
+ return 1
2463
+ fi
2464
+ pane_ids=$(printf '%s' "$panes" | jq -r '.result.panes[]? | .pane_id' 2>/dev/null)
2465
+ while IFS= read -r pane; do
2466
+ [ -n "$pane" ] || continue
2467
+ state=$(fm_backend_herdr_pane_agent_state "$session" "$pane")
2468
+ case "$state" in
2469
+ dead|no-agent) : ;;
2470
+ live|unknown)
2471
+ echo "error: quarantined herdr presentation for $id has a $state pane; refusing duplicate launch" >&2
2472
+ return 1
2473
+ ;;
2474
+ esac
2475
+ done <<EOF
2476
+ $pane_ids
2477
+ EOF
2478
+ done <<EOF
2479
+ $wsids
2480
+ EOF
2481
+ echo "warning: quarantined herdr presentation for $id is dead or agent-free; exact bound reclaim may proceed, otherwise spawning flat" >&2
2482
+ return 0
2483
+ }
2484
+
2485
+ # fm_backend_herdr_projection_endpoint_matches_journal: read-only correlation
2486
+ # for retiring a successful projection journal after normal exact-pane
2487
+ # teardown.
2488
+ # Exactly one token-bearing workspace must match the endpoint workspace.
2489
+ # This verdict never authorizes a Herdr mutation.
2490
+ fm_backend_herdr_projection_endpoint_matches_journal() { # <session> <workspace-id> <journal> <task-id>
2491
+ local session=$1 workspace_id=$2 journal=$3 id=$4 token list matches
2492
+ token=$(fm_backend_herdr_projection_journal_token "$journal" "$id") || return 1
2493
+ list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1
2494
+ printf '%s' "$list" | jq -e '(.result.workspaces | type) == "array"' >/dev/null 2>&1 || return 1
2495
+ matches=$(printf '%s' "$list" | jq -r --arg suffix " · p:$token" \
2496
+ '.result.workspaces[]? | select((.label | type) == "string" and (.label | endswith($suffix))) | .workspace_id' 2>/dev/null)
2497
+ [ "$matches" = "$workspace_id" ]
2498
+ }
2499
+
2500
+ # fm_backend_herdr_parse_target: split "<session>:<pane_id>" (pane_id itself
2501
+ # contains a colon, e.g. "w1:p2") on the FIRST colon only. Sets
2502
+ # FM_BACKEND_HERDR_SESSION and FM_BACKEND_HERDR_PANE for the caller.
2503
+ fm_backend_herdr_parse_target() { # <target>
2504
+ local target=$1
2505
+ FM_BACKEND_HERDR_SESSION=${target%%:*}
2506
+ FM_BACKEND_HERDR_PANE=${target#*:}
2507
+ [ -n "$FM_BACKEND_HERDR_SESSION" ] && [ -n "$FM_BACKEND_HERDR_PANE" ] && [ "$FM_BACKEND_HERDR_PANE" != "$target" ]
2508
+ }
2509
+
2510
+ fm_backend_herdr_target_ready() { # <target>
2511
+ fm_backend_herdr_parse_target "$1" || return 1
2512
+ fm_backend_herdr_server_ensure "$FM_BACKEND_HERDR_SESSION" || return 1
2513
+ }
2514
+
2515
+ # fm_backend_herdr_current_path: the live FOREGROUND process's cwd, or empty on
2516
+ # any error. Mirrors tmux's pane_current_path poll used for worktree-path
2517
+ # discovery after `treehouse get`.
2518
+ #
2519
+ # Verified pitfall: `pane get`'s `.result.pane.cwd` is the pane's cwd AT
2520
+ # CREATION TIME - the top-level shell's cwd - and does NOT update when that
2521
+ # shell `cd`s or enters a subshell (as `treehouse get` does). Reading it here
2522
+ # would make fm-spawn.sh's worktree-discovery poll never see the pane "leave"
2523
+ # the project directory, since `cwd` stays frozen at the original path forever.
2524
+ # `.result.pane.foreground_cwd` tracks the ACTUALLY RUNNING foreground
2525
+ # process's cwd instead, which is what changes when `treehouse get` enters its
2526
+ # worktree subshell - confirmed live against a real treehouse acquisition.
2527
+ fm_backend_herdr_current_path() { # <target>
2528
+ fm_backend_herdr_target_ready "$1" || return 0
2529
+ fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane get "$FM_BACKEND_HERDR_PANE" 2>/dev/null \
2530
+ | jq -r '.result.pane.foreground_cwd // empty' 2>/dev/null
2531
+ }
2532
+
2533
+ # fm_backend_herdr_send_text_line: send one line of TEXT then submit,
2534
+ # ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed
2535
+ # spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types
2536
+ # the command and submits it in one call (verified).
2537
+ fm_backend_herdr_send_text_line() { # <target> <text>
2538
+ fm_backend_herdr_target_ready "$1" || return 1
2539
+ fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane run "$FM_BACKEND_HERDR_PANE" "$2" >/dev/null 2>&1
2540
+ }
2541
+
2542
+ # fm_backend_herdr_send_literal: send TEXT as literal, UNSUBMITTED input - the
2543
+ # caller sends Enter separately. Mirrors tmux's `send-keys -t T -l text`.
2544
+ # Verified: `pane send-text` does NOT auto-submit (contrary to the addendum's
2545
+ # original guess); it behaves exactly like tmux's `-l` literal send.
2546
+ fm_backend_herdr_send_literal() { # <target> <text>
2547
+ fm_backend_herdr_target_ready "$1" || return 1
2548
+ fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane send-text "$FM_BACKEND_HERDR_PANE" "$2" >/dev/null 2>&1
2549
+ }
2550
+
2551
+ # fm_backend_herdr_normalize_key: map firstmate's key vocabulary (Enter,
2552
+ # Escape, C-c, as used by fm-send.sh --key and stuck-crewmate-recovery) onto
2553
+ # herdr's `pane send-keys` names. Verified empirically: enter, escape/esc, and
2554
+ # both ctrl+c/C-c all work (case-insensitive on herdr's side, but normalize
2555
+ # explicitly rather than relying on that).
2556
+ fm_backend_herdr_normalize_key() { # <key>
2557
+ case "$1" in
2558
+ Enter|enter) printf 'enter' ;;
2559
+ Escape|escape|Esc|esc) printf 'escape' ;;
2560
+ C-c|c-c|ctrl+c|Ctrl+C) printf 'ctrl+c' ;;
2561
+ # C-u clears a composer line. fm-send.sh's muse interrupt path needs it to
2562
+ # drop the prompt muse restores into the composer after Escape.
2563
+ C-u|c-u|ctrl+u|Ctrl+U) printf 'ctrl+u' ;;
2564
+ *) printf '%s' "$1" ;;
2565
+ esac
2566
+ }
2567
+
2568
+ # fm_backend_herdr_send_key: one named special key. Mirrors fm-send.sh's --key
2569
+ # path (tmux's `send-keys -t T key`).
2570
+ fm_backend_herdr_send_key() { # <target> <key>
2571
+ fm_backend_herdr_target_ready "$1" || return 1
2572
+ local key
2573
+ key=$(fm_backend_herdr_normalize_key "$2")
2574
+ fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane send-keys "$FM_BACKEND_HERDR_PANE" "$key" >/dev/null 2>&1
2575
+ }
2576
+
2577
+ # fm_backend_herdr_capture: bounded plain-text pane capture. Mirrors
2578
+ # fm-peek.sh's/fm-watch.sh's `tmux capture-pane -p -t T -S -N`. --source recent
2579
+ # is the closest herdr analogue to tmux's scrollback-bounded capture.
2580
+ #
2581
+ # Verified CLI quirk (herdr-verification-p2.md "pane read --lines bug", v0.7.1):
2582
+ # `pane read --source recent --lines N` returns COMPLETELY EMPTY output when N
2583
+ # is smaller than the pane's current viewport height (observed threshold ~23
2584
+ # rows for a default-sized pane), instead of clamping to the last N lines - it
2585
+ # does not merely ignore the bound, it drops the read entirely. This silently
2586
+ # broke exactly the small bounded reads this adapter relies on most (including
2587
+ # the composer-state guard/fallback reads around submit and injection). Workaround:
2588
+ # always request a generous fetch far above any realistic viewport height, then
2589
+ # trim to the caller's requested bound ourselves with `tail`.
2590
+ fm_backend_herdr_capture() { # <target> <lines>
2591
+ fm_backend_herdr_target_ready "$1" || return 1
2592
+ local lines=${2:-200} fetch out
2593
+ case "$lines" in ''|*[!0-9]*) lines=200 ;; esac
2594
+ fetch=$lines
2595
+ case "$fetch" in ''|*[!0-9]*) fetch=200 ;; *) [ "$fetch" -ge 200 ] || fetch=200 ;; esac
2596
+ out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane read "$FM_BACKEND_HERDR_PANE" --source recent --lines "$fetch" 2>/dev/null) || return 1
2597
+ printf '%s' "$out" | tail -n "$lines"
2598
+ }
2599
+
2600
+ fm_backend_herdr_capture_ansi() { # <target> <lines>
2601
+ fm_backend_herdr_target_ready "$1" || return 1
2602
+ local lines=${2:-200} fetch out
2603
+ case "$lines" in ''|*[!0-9]*) lines=200 ;; esac
2604
+ fetch=$lines
2605
+ case "$fetch" in ''|*[!0-9]*) fetch=200 ;; *) [ "$fetch" -ge 200 ] || fetch=200 ;; esac
2606
+ out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane read "$FM_BACKEND_HERDR_PANE" --source recent --lines "$fetch" --format ansi 2>/dev/null) || return 1
2607
+ printf '%s' "$out" | tail -n "$lines"
2608
+ }
2609
+
2610
+ # --- herdr composer capture and capability primitives -----------------------
2611
+ #
2612
+ # These functions are the ONLY herdr-specific composer knowledge left: the
2613
+ # ANSI pane capture (with its small-N workaround), the native `agent get`
2614
+ # identity probe, and the capability descriptor. Every shape - the bordered
2615
+ # box, the bare agent-glyph row, opencode's left-bar, and pi's
2616
+ # identity-gated separated pair (which this adapter pioneered) - now lives in
2617
+ # the shared owner (bin/fm-composer-lib.sh, fm_composer_classify_screen), so
2618
+ # a new harness shape is taught there once and every backend learns it in the
2619
+ # same commit. The muse `⟩` glyph this adapter's local bare-prompt pattern
2620
+ # silently omitted is exactly the drift class that consolidation removes.
2621
+
2622
+ fm_backend_herdr_agent_identity_raw() { # <session> <pane> -> <agent>\t<status>
2623
+ local out
2624
+ out=$(fm_backend_herdr_cli "$1" agent get "$2" 2>/dev/null) || return 1
2625
+ printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null
2626
+ }
2627
+
2628
+ # fm_backend_herdr_composer_identity: the native agent identity/state probe
2629
+ # backing the shared classifier's separated (pi) shape - the genuine herdr
2630
+ # primitive no other backend has natively.
2631
+ fm_backend_herdr_composer_identity() { # <target> -> "<agent>\t<status>"
2632
+ fm_backend_herdr_parse_target "$1" || return 1
2633
+ fm_backend_herdr_agent_identity_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE"
2634
+ }
2635
+
2636
+ # fm_backend_herdr_composer_state: thin adapter - capture plus capabilities
2637
+ # in, shared verdict out. The ANSI capture is preferred (styled=1 lets the
2638
+ # shared classifier strip ghost/placeholder text); when it fails on an older
2639
+ # herdr, the plain capture degrades the descriptor to styled=0 rather than
2640
+ # letting ghost text be misread as typed input. Identity is fetched lazily,
2641
+ # only when the classifier reports the verdict depends on it (a pi separator
2642
+ # pair below every other candidate), preserving this adapter's original
2643
+ # consult-only-when-needed behavior.
2644
+ fm_backend_herdr_composer_state() { # <target> -> empty|pending|pending-unproven|unknown
2645
+ local target=$1 cap caps verdict identity
2646
+ fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
2647
+ if cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_COMPOSER_CAPTURE_LINES" 2>/dev/null); then
2648
+ caps=$(printf 'styled=1\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES")
2649
+ elif cap=$(fm_backend_herdr_capture "$target" "$FM_COMPOSER_CAPTURE_LINES"); then
2650
+ caps=$(printf 'styled=0\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES")
2651
+ else
2652
+ printf 'unknown'
2653
+ return 0
2654
+ fi
2655
+ verdict=$(fm_composer_classify_screen "$caps" "$cap")
2656
+ if [ "$verdict" = need-identity ]; then
2657
+ if ! identity=$(fm_backend_herdr_composer_identity "$target" 2>/dev/null) || [ -z "$identity" ]; then
2658
+ identity=probe-absent
2659
+ fi
2660
+ verdict=$(fm_composer_classify_screen "$caps" "$cap" '' "$identity")
2661
+ [ "$verdict" != need-identity ] || verdict=unknown
2662
+ fi
2663
+ printf '%s' "$verdict"
2664
+ }
2665
+
2666
+ # fm_backend_herdr_rendered_busy_state: busy|idle|unknown from the pane's
2667
+ # RENDERED busy footer, the same delivery-only signal bin/fm-tmux-lib.sh's
2668
+ # fm_pane_busy_state reads, scanning the same 40-line tail folded to its last
2669
+ # 12 non-blank rows. This is NOT a worker-state source: herdr's native
2670
+ # agent-state (fm_backend_herdr_busy_state) stays the semantic owner, and this
2671
+ # read exists only so the submit core below can confirm a delivery for a
2672
+ # harness whose native state never transitions. Without a harness argument the
2673
+ # shared matcher uses its union of verified tokens, which is what the submit
2674
+ # core wants: it has no recorded harness for the pane.
2675
+ fm_backend_herdr_rendered_busy_state() { # <target> [harness] -> busy|idle|unknown
2676
+ local target=$1 harness=${2:-} cap visible
2677
+ cap=$(fm_backend_herdr_capture "$target" 40) || { printf 'unknown'; return 0; }
2678
+ visible=$(printf '%s' "$cap" | grep -v '^[[:space:]]*$' | tail -12)
2679
+ [ -n "$visible" ] || { printf 'unknown'; return 0; }
2680
+ if printf '%s' "$visible" | fm_busy_lines_match "$harness"; then
2681
+ printf 'busy'
2682
+ else
2683
+ printf 'idle'
2684
+ fi
2685
+ }
2686
+
2687
+ # fm_backend_herdr_send_text_submit: type <text> into <target> once (raw,
2688
+ # unsubmitted, via send_literal), then submit with a named Enter key, retried
2689
+ # (Enter only, never retyped) until native agent-state, a cleared composer, or
2690
+ # fm_composer_queued_enter_verdict confirms delivery. Verified hazard
2691
+ # (herdr-verification-p2.md "slash/$ autocomplete popup"): a `/`- or
2692
+ # `$`-prefixed send opens a completion popup within ~0.1s, exactly like tmux's
2693
+ # claude/codex popups, so the caller's <settle> before the first Enter matters
2694
+ # here the same way it does for tmux.
2695
+ #
2696
+ # Confirmation signal: when the target is legibly idle before Enter,
2697
+ # submission is confirmed by fm_backend_herdr_wait_for_working observing a
2698
+ # submit-active agent_status after Enter. Live Claude on Herdr 0.8.0 can
2699
+ # keep agent_status idle for a whole landed turn, so an idle native result
2700
+ # falls through to the shared composer verdict: empty is positive delivery,
2701
+ # proven pending retries Enter, and retries-exhausted pending plus a
2702
+ # generating busy signal is a queued Enter via
2703
+ # fm_composer_queued_enter_verdict (bin/fm-composer-lib.sh).
2704
+ #
2705
+ # Incident (2026-07-07, followed up on 2026-07-08): a redelivery loop in the
2706
+ # away-mode daemon. Root cause: composer-content submit confirmation was too
2707
+ # sensitive to harness rendering details. Real claude/codex use bare prompt
2708
+ # rows, and real codex adds dynamic idle suggestions after `›`; the later
2709
+ # ANSI-aware composer classifier now handles that Codex shape, and idle-baseline
2710
+ # submit confirmation still prefers native agent-state so a faint idle tip
2711
+ # cannot block a landed send. Composer content is consulted only after native
2712
+ # state stays idle, as the empty/pending owner, and for submit attempts whose
2713
+ # pre-Enter agent-state baseline is not legibly idle.
2714
+ #
2715
+ # This also still correctly handles the earlier 2026-07-03 incident (a
2716
+ # slash-command popup selection/placeholder-fill on the FIRST Enter is not a
2717
+ # genuine submission) without any popup-specific logic at all: filling a
2718
+ # composer placeholder never starts a turn, so agent_status simply never
2719
+ # reports "working" for that Enter, the composer stays pending, and the retry
2720
+ # loop below sends a second Enter exactly as it did before - the fix
2721
+ # generalizes instead of special-casing the popup shape.
2722
+ #
2723
+ # Failure-mode analysis (the two directions the caller-facing contract must
2724
+ # not get wrong - see docs/herdr-backend.md "Native agent-state submit
2725
+ # confirmation" for the empirical timing behind this):
2726
+ # - Slow transition: fm_backend_herdr_wait_for_working samples repeatedly
2727
+ # across herdr's per-attempt confirmation budget (not once at the end), so a
2728
+ # transition landing partway through a window is still caught before this
2729
+ # loop gives up and sends a needless extra Enter.
2730
+ # - Instant round-trip or a native status that never leaves idle: bounded by
2731
+ # the composer fallback. A cleared composer is delivery; a proven-pending
2732
+ # composer on an idle pane is a swallow; extra Enter on an already-empty
2733
+ # composer is a no-op, not a duplicate delivery of <text>.
2734
+ # Fallback path, for a harness whose native agent-state is never legibly idle
2735
+ # (measured live: herdr reports a cursor pane `blocked` in every state - idle,
2736
+ # mid-turn, and after - so the idle-baseline path above is structurally
2737
+ # unreachable for it). That harness always lands in the composer branch, and
2738
+ # cursor's mid-turn composer row renders its own placeholder beside a
2739
+ # right-aligned `ctrl+c to stop`, so the content verdict is `pending` on a
2740
+ # composer that holds no user text at all and every steer reported delivery
2741
+ # unconfirmed on a message that had actually landed.
2742
+ # The escape is the SAME semantic signal the idle-baseline path uses, read from
2743
+ # the pane's verified busy footer instead of native agent-state, and it is the
2744
+ # rendered-footer twin of the tmux submit core's turn-started confirmation
2745
+ # (bin/fm-tmux-lib.sh): an idle-to-busy transition ACROSS our Enter is proof the
2746
+ # harness accepted the submission. The baseline is taken before the first Enter
2747
+ # and only when the native baseline was not legibly idle, so the idle-baseline
2748
+ # path still never reads pane content until native stays idle. A pane already
2749
+ # mid-turn cannot use a rendered-footer transition as proof of this Enter;
2750
+ # only the separate retries-exhausted, proven-pending queued-Enter verdict can
2751
+ # confirm delivery from its native working state.
2752
+ # Queued-while-busy Enter (OpenCode 1.18.4, and any harness that keeps typed
2753
+ # text visible until the current turn ends): after the retry budget, a proven
2754
+ # pending composer plus native agent_status=working is delivered, not swallowed.
2755
+ # blocked is not working, so a Cursor pane that is blocked in every state does
2756
+ # not receive this conversion. On an idle native baseline, a rendered busy
2757
+ # footer may supply the same generating signal because live Claude never leaves
2758
+ # idle. The policy is fm_composer_queued_enter_verdict; this adapter only
2759
+ # supplies the busy primitive.
2760
+ # Echoes empty|pending|unknown|send-failed, a subset of the proof-carrying
2761
+ # submit vocabulary. Empty means confirmed submitted for every backend; how
2762
+ # each backend confirms it is an internal decision.
2763
+ #
2764
+ # fm_backend_herdr_queued_enter_busy: delivery-busy for the shared queued-Enter
2765
+ # conversion. Native agent_status=working is generating; blocked is not (a
2766
+ # permission prompt, or Cursor's always-blocked native state, is not a queued
2767
+ # mid-turn). When <allow-rendered> is 1, an idle native baseline may also take
2768
+ # the pane's rendered busy footer, because live Claude keeps agent_status idle
2769
+ # through a whole turn.
2770
+ fm_backend_herdr_queued_enter_busy() { # <target> <allow-rendered>
2771
+ local target=$1 allow_rendered=${2:-0} raw
2772
+ raw=$(fm_backend_herdr_agent_status_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")
2773
+ case "$raw" in
2774
+ working) printf 'busy'; return 0 ;;
2775
+ esac
2776
+ if [ "$allow_rendered" = 1 ]; then
2777
+ fm_backend_herdr_rendered_busy_state "$target"
2778
+ else
2779
+ printf 'idle'
2780
+ fi
2781
+ }
2782
+
2783
+ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep> <settle>
2784
+ local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep
2785
+ local raw_status footer_baseline='' allow_rendered=0 enter_sent=0
2786
+ fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
2787
+ fm_backend_herdr_send_literal "$target" "$text" || { printf 'send-failed'; return 0; }
2788
+ sleep "$settle"
2789
+ raw_status=$(fm_backend_herdr_agent_status_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")
2790
+ baseline=$(fm_backend_herdr_classify_submit_agent_status "$raw_status")
2791
+ confirm_sleep=$(fm_backend_herdr_submit_confirm_budget "$sleep_s")
2792
+ # Typing never starts a turn, so a footer read taken after the literal send
2793
+ # and before the first Enter is still a pre-submission baseline.
2794
+ if [ "$baseline" = idle ]; then
2795
+ allow_rendered=1
2796
+ else
2797
+ footer_baseline=$(fm_backend_herdr_rendered_busy_state "$target")
2798
+ fi
2799
+ while :; do
2800
+ if fm_backend_herdr_send_key "$target" Enter; then
2801
+ enter_sent=1
2802
+ elif [ "$enter_sent" -eq 0 ]; then
2803
+ i=$((i + 1))
2804
+ if [ "$i" -ge "$retries" ]; then
2805
+ printf 'send-failed'
2806
+ return 0
2807
+ fi
2808
+ sleep "$sleep_s"
2809
+ continue
2810
+ fi
2811
+ if [ "$baseline" = idle ]; then
2812
+ verdict=$(fm_backend_herdr_wait_for_working "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" \
2813
+ "$confirm_sleep" "$FM_BACKEND_HERDR_SUBMIT_POLLS")
2814
+ case "$verdict" in
2815
+ busy) printf 'empty'; return 0 ;;
2816
+ unknown) printf 'unknown'; return 0 ;;
2817
+ esac
2818
+ # Native stayed idle. Composer empty is positive delivery (a landed
2819
+ # Claude turn that never flipped agent_status). Proven pending retries.
2820
+ verdict=$(fm_backend_herdr_composer_state "$target")
2821
+ case "$verdict" in
2822
+ empty) printf 'empty'; return 0 ;;
2823
+ pending|pending-unproven) ;;
2824
+ *) printf '%s' "$verdict"; return 0 ;;
2825
+ esac
2826
+ else
2827
+ sleep "$sleep_s"
2828
+ verdict=$(fm_backend_herdr_composer_state "$target")
2829
+ if [ "$verdict" = pending ] && [ "$raw_status" != working ] \
2830
+ && [ "$footer_baseline" = idle ] \
2831
+ && [ "$(fm_backend_herdr_rendered_busy_state "$target")" = busy ]; then
2832
+ verdict=busy
2833
+ fi
2834
+ case "$verdict" in
2835
+ busy) printf 'empty'; return 0 ;;
2836
+ empty) printf 'empty'; return 0 ;;
2837
+ unknown) printf 'unknown'; return 0 ;;
2838
+ esac
2839
+ fi
2840
+ i=$((i + 1))
2841
+ if [ "$i" -ge "$retries" ]; then
2842
+ if [ "$enter_sent" -eq 0 ]; then
2843
+ printf 'send-failed'
2844
+ else
2845
+ fm_composer_queued_enter_verdict "$verdict" \
2846
+ "$(fm_backend_herdr_queued_enter_busy "$target" "$allow_rendered")"
2847
+ fi
2848
+ return 0
2849
+ fi
2850
+ done
2851
+ }
2852
+
2853
+ # fm_backend_herdr_kill: remove the task's pane, best-effort (mirrors
2854
+ # tmux-kill-window's `|| true` contract). Verified: closing a tab's only pane
2855
+ # closes the tab too, so a separate tab close is unnecessary.
2856
+ # When the close would empty a non-focused workspace, Herdr 0.7.5's explicit
2857
+ # close moves focus to that workspace's neighbor with no restore anywhere in
2858
+ # this path, so the kill follows the same focus-safe removal plan as
2859
+ # projected cleanup (a verified pane-death removal with the doomed workspace
2860
+ # repositioned behind the focused one when needed), keeping the exact-tab
2861
+ # restore as the backstop. A close that empties the FOCUSED workspace moves
2862
+ # focus legitimately, and every in-lock planning ambiguity or failure falls
2863
+ # back to the plain close, matching the pre-hardening contract.
2864
+ fm_backend_herdr_kill_serialized() { # <session> <pane>
2865
+ local session=$1 pane=$2
2866
+ local before active_tab info target_pane target_tab target_ws plan shell_pid plan_move_record close_failed workspace_presence
2867
+ before=$(fm_backend_herdr_projection_focus_snapshot "$session") || before=
2868
+ if [ -n "$before" ]; then
2869
+ active_tab=${before#*$'\t'}
2870
+ info=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || info=
2871
+ target_pane=$(printf '%s' "$info" | jq -r '.result.pane.pane_id // empty' 2>/dev/null)
2872
+ target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null)
2873
+ target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null)
2874
+ if [ "$target_pane" = "$pane" ] && [ -n "$target_tab" ] && [ "$target_tab" != "$active_tab" ]; then
2875
+ plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane" "$target_ws" "$target_tab" "${before%%$'\t'*}")
2876
+ plan_move_record=
2877
+ case "$plan" in
2878
+ moved$'\t'*)
2879
+ plan_move_record=${plan%%$'\n'*}
2880
+ plan=${plan##*$'\n'}
2881
+ ;;
2882
+ esac
2883
+ close_failed=0
2884
+ case "$plan" in
2885
+ death\ *)
2886
+ shell_pid=${plan#death }
2887
+ if ! fm_backend_herdr_death_close_pane "$session" "$pane" "$shell_pid" \
2888
+ && ! fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane"; then
2889
+ close_failed=1
2890
+ fi
2891
+ ;;
2892
+ *)
2893
+ fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || close_failed=1
2894
+ ;;
2895
+ esac
2896
+ if [ "$close_failed" = 0 ] && [ -n "$plan_move_record" ]; then
2897
+ workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws")
2898
+ if [ "$workspace_presence" != dead ]; then
2899
+ echo "warning: herdr task kill did not confirm removal of the repositioned workspace" >&2
2900
+ close_failed=1
2901
+ fi
2902
+ fi
2903
+ if [ "$close_failed" = 1 ]; then
2904
+ fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true
2905
+ fi
2906
+ fm_backend_herdr_projection_focus_restore "$session" "$before" "task kill" || true
2907
+ return 0
2908
+ fi
2909
+ fi
2910
+ fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || true
2911
+ }
2912
+
2913
+ fm_backend_herdr_kill() { # <target>
2914
+ fm_backend_herdr_target_ready "$1" || return 0
2915
+ local session=$FM_BACKEND_HERDR_SESSION pane=$FM_BACKEND_HERDR_PANE
2916
+ local lock_path attempt=0 lock_held=0
2917
+ if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then
2918
+ # shellcheck source=bin/fm-wake-lib.sh
2919
+ . "$FM_BACKEND_HERDR_ROOT/bin/fm-wake-lib.sh"
2920
+ fi
2921
+ if lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session"); then
2922
+ while [ "$attempt" -lt 50 ]; do
2923
+ if fm_lock_try_acquire "$lock_path"; then
2924
+ lock_held=1
2925
+ break
2926
+ fi
2927
+ sleep 0.1
2928
+ attempt=$((attempt + 1))
2929
+ done
2930
+ fi
2931
+ if [ "$lock_held" = 1 ]; then
2932
+ fm_backend_herdr_kill_serialized "$session" "$pane"
2933
+ fm_lock_release "$lock_path" || true
2934
+ else
2935
+ echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2
2936
+ fi
2937
+ }
2938
+
2939
+ # fm_backend_herdr_endpoint_confirmed_gone: gate durable-record removal on
2940
+ # the exact recorded pane's structured presence
2941
+ # (fm_backend_herdr_pane_presence_state), read-only, so a refused, skipped,
2942
+ # or failed close never erases a live task's endpoint identity.
2943
+ # Only a structured pane_not_found proves the endpoint gone; present and
2944
+ # unknown presence refuse after every close path, and a missing or malformed
2945
+ # target identity is ambiguity that also refuses, never proof of a gone pane.
2946
+ fm_backend_herdr_endpoint_confirmed_gone() { # <target>
2947
+ local presence
2948
+ fm_backend_herdr_parse_target "$1" || return 1
2949
+ presence=$(fm_backend_herdr_pane_presence_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")
2950
+ [ "$presence" = dead ]
2951
+ }
2952
+
2953
+ # fm_backend_herdr_classify_agent_status: map a raw `agent get` agent_status
2954
+ # value to the adapter's watcher busy|idle|unknown vocabulary. working ->
2955
+ # busy (actively generating); idle/done -> idle; blocked -> idle (a blocked
2956
+ # agent is stuck waiting on the human, not grinding - the watcher should
2957
+ # treat it like a stale pane needing attention, not suppress it as busy);
2958
+ # unknown/unparseable/empty -> unknown, the caller's cue to fall back to
2959
+ # pane-regex detection.
2960
+ fm_backend_herdr_classify_agent_status() { # <raw-agent_status>
2961
+ case "$1" in
2962
+ working) printf 'busy' ;;
2963
+ idle|done) printf 'idle' ;;
2964
+ blocked) printf 'idle' ;;
2965
+ *) printf 'unknown' ;;
2966
+ esac
2967
+ }
2968
+
2969
+ fm_backend_herdr_classify_submit_agent_status() { # <raw-agent_status>
2970
+ case "$1" in
2971
+ working|blocked) printf 'busy' ;;
2972
+ idle|done) printf 'idle' ;;
2973
+ *) printf 'unknown' ;;
2974
+ esac
2975
+ }
2976
+
2977
+ # fm_backend_herdr_agent_status_raw: one `agent get` read, echoing the raw
2978
+ # agent_status string (working/idle/done/blocked/...), or empty on any
2979
+ # failure. Deliberately skips fm_backend_herdr_target_ready's server-ensure
2980
+ # round trip (an extra `status --json` call) that fm_backend_herdr_busy_state
2981
+ # pays on every call: fm_backend_herdr_wait_for_working polls this in a tight
2982
+ # loop right after a caller has already parsed the target and confirmed the
2983
+ # server is live (e.g. fm_backend_herdr_send_text_submit, immediately after a
2984
+ # successful send-text), so re-checking server liveness on every poll would
2985
+ # only add latency without adding safety.
2986
+ fm_backend_herdr_agent_status_raw() { # <session> <pane_id>
2987
+ local session=$1 pane_id=$2 out
2988
+ out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>/dev/null) || { printf ''; return 0; }
2989
+ printf '%s' "$out" | jq -r '.result.agent.agent_status // empty' 2>/dev/null
2990
+ }
2991
+
2992
+ # fm_backend_herdr_busy_state: semantic busy state from herdr's native
2993
+ # agent-state detection (agent.get), the "first backend where fm_session_busy_state
2994
+ # gets real semantics" per the design report. See
2995
+ # fm_backend_herdr_classify_agent_status for the status->busy/idle/unknown
2996
+ # mapping.
2997
+ fm_backend_herdr_busy_state() { # <target>
2998
+ fm_backend_herdr_target_ready "$1" || { printf 'unknown'; return 0; }
2999
+ fm_backend_herdr_classify_agent_status \
3000
+ "$(fm_backend_herdr_agent_status_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE")"
3001
+ }
3002
+
3003
+ # fm_backend_herdr_wait_for_working: poll <session>:<pane_id>'s NATIVE
3004
+ # agent-state (agent get) up to <polls> times spread evenly across
3005
+ # <budget-seconds>, returning on stdout the STRONGEST signal observed:
3006
+ #
3007
+ # busy - a submit-active status was observed at least once. This is
3008
+ # confirmation that a real turn started or reached a prompt -
3009
+ # the submit landed - independent of
3010
+ # whatever the composer's own text happens to show (docs/
3011
+ # herdr-backend.md "Incident (2026-07-07)": composer content is
3012
+ # what fooled the OLD confirmation on codex's dynamic idle-tip
3013
+ # text). Returned the INSTANT it is seen, without waiting out the
3014
+ # rest of the budget.
3015
+ # idle - the target was legibly read at least once and never reported
3016
+ # "busy" across the whole window. This is readable but
3017
+ # inconclusive: native state can remain idle for a landed turn,
3018
+ # so the caller falls through to composer confirmation.
3019
+ # unknown - EVERY poll in the window failed to read the target at all (a
3020
+ # hard I/O failure - pane gone, socket error - not a timing
3021
+ # race). The caller must not keep retrying Enter against a target
3022
+ # it cannot even read.
3023
+ #
3024
+ # <polls> spread across <budget-seconds> (rather than one check at the end)
3025
+ # lets the fast path catch a native transition that lands partway through the
3026
+ # window. A whole-window idle result remains inconclusive and is resolved by
3027
+ # the caller's shared composer fallback.
3028
+ # FM_BACKEND_HERDR_SUBMIT_POLLS (default 6): how many samples
3029
+ # fm_backend_herdr_send_text_submit spreads across each Enter attempt's
3030
+ # confirmation budget. Overridable for tests (a value of 1
3031
+ # reproduces the old single-check-at-the-end timing exactly, for byte-for-byte
3032
+ # call-count assertions).
3033
+ FM_BACKEND_HERDR_SUBMIT_POLLS=${FM_BACKEND_HERDR_SUBMIT_POLLS:-6}
3034
+ FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=${FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP:-0.6}
3035
+
3036
+ fm_backend_herdr_submit_confirm_budget() { # <caller-budget-seconds>
3037
+ awk -v b="${1:-0}" -v m="$FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP" 'BEGIN {
3038
+ b += 0
3039
+ m += 0
3040
+ if (b < 0) b = 0
3041
+ if (m < 0) m = 0
3042
+ if (m > b) b = m
3043
+ printf "%.4f", b
3044
+ }' 2>/dev/null || printf '%s' "${1:-0}"
3045
+ }
3046
+
3047
+ fm_backend_herdr_wait_for_working() { # <session> <pane_id> <budget-seconds> <polls>
3048
+ local session=$1 pane_id=$2 budget=$3 polls=${4:-1} i interval raw bs saw_idle=0
3049
+ case "$polls" in ''|*[!0-9]*|0) polls=1 ;; esac
3050
+ interval=$(awk -v b="$budget" -v p="$polls" 'BEGIN { d = p - 1; if (d < 1) d = 1; v = b / d; if (v < 0) v = 0; printf "%.4f", v }' 2>/dev/null)
3051
+ case "$interval" in ''|*[!0-9.]*) interval=0 ;; esac
3052
+ for ((i = 0; i < polls; i++)); do
3053
+ if [ "$polls" -eq 1 ] || [ "$i" -gt 0 ]; then
3054
+ sleep "$interval"
3055
+ fi
3056
+ raw=$(fm_backend_herdr_agent_status_raw "$session" "$pane_id")
3057
+ bs=$(fm_backend_herdr_classify_submit_agent_status "$raw")
3058
+ case "$bs" in
3059
+ busy) printf 'busy'; return 0 ;;
3060
+ idle) saw_idle=1 ;;
3061
+ esac
3062
+ done
3063
+ if [ "$saw_idle" -eq 1 ]; then
3064
+ printf 'idle'
3065
+ else
3066
+ printf 'unknown'
3067
+ fi
3068
+ }
3069
+
3070
+ # fm_backend_herdr_pane_for_tab: the root pane id for <tab_id> in <workspace_id>
3071
+ # of <session>, via one pane list call filtered by tab_id (never assumes a
3072
+ # tab-number/pane-number correspondence - herdr numbers them independently).
3073
+ fm_backend_herdr_pane_for_tab() { # <session> <workspace_id> <tab_id>
3074
+ local session=$1 wsid=$2 tab_id=$3 panes
3075
+ panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$wsid" 2>/dev/null) || return 1
3076
+ printf '%s' "$panes" | jq -r --arg tab "$tab_id" \
3077
+ '.result.panes[]? | select(.tab_id == $tab) | .pane_id' 2>/dev/null | head -1
3078
+ }
3079
+
3080
+ # fm_backend_herdr_resolve_bare_selector: the live-tab-listing fallback for an
3081
+ # ad hoc selector with no meta (mirrors tmux's list-windows grep). Searches
3082
+ # every RUNNING named herdr session (herdr session list) for a tab whose label
3083
+ # matches <name>, since herdr sessions are not addressed by one ambient
3084
+ # server the way a single tmux server is. Rare path in practice (herdr tasks
3085
+ # normally carry meta), best-effort.
3086
+ fm_backend_herdr_resolve_bare_selector() { # <name>
3087
+ local name=$1 sessions session tabs tab_id wsid pane_id
3088
+ sessions=$(herdr session list --json 2>/dev/null | jq -r '.sessions[]? | select(.running == true) | .name' 2>/dev/null)
3089
+ while IFS= read -r session; do
3090
+ [ -n "$session" ] || continue
3091
+ tabs=$(fm_backend_herdr_cli "$session" tab list 2>/dev/null) || continue
3092
+ tab_id=$(printf '%s' "$tabs" | jq -r --arg want "$name" \
3093
+ '.result.tabs[]? | select(.label == $want) | .tab_id' 2>/dev/null | head -1)
3094
+ [ -n "$tab_id" ] || continue
3095
+ wsid=$(printf '%s' "$tabs" | jq -r --arg tab "$tab_id" '.result.tabs[]? | select(.tab_id == $tab) | .workspace_id' 2>/dev/null | head -1)
3096
+ [ -n "$wsid" ] || continue
3097
+ pane_id=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$tab_id") || continue
3098
+ [ -n "$pane_id" ] || continue
3099
+ printf '%s:%s' "$session" "$pane_id"
3100
+ return 0
3101
+ done <<EOF
3102
+ $sessions
3103
+ EOF
3104
+ echo "error: no herdr tab named $name in any running session" >&2
3105
+ return 1
3106
+ }
3107
+
3108
+ # fm_backend_herdr_list_live: recovery/orphan discovery. Lists every tab whose
3109
+ # label looks like a firstmate task window (fm-<id>) in <session>'s, THIS
3110
+ # HOME'S OWN workspace (fm_backend_herdr_workspace_label - never another
3111
+ # home's), by LABEL - never by trusting a stored pane id, since ids are not
3112
+ # guaranteed stable across every server lifecycle (see herdr-verification-p2.md
3113
+ # "ID stability"). A caller running as a given home (e.g. a secondmate
3114
+ # recovering its own in-flight work) naturally scopes to that home's own
3115
+ # workspace because FM_HOME already names it - no glue needed, unlike the
3116
+ # primary-spawns-a-secondmate path in fm-spawn.sh. Read-only: a session/
3117
+ # workspace that does not exist yet simply lists nothing. One
3118
+ # "<session>:<pane_id>\t<label>" line per live task tab.
3119
+ fm_backend_herdr_list_live() { # <session>
3120
+ local session=$1 wsid tabs tab_id label pane_id
3121
+ wsid=$(fm_backend_herdr_workspace_find "$session") || return 0
3122
+ [ -n "$wsid" ] || return 0
3123
+ tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null) || return 0
3124
+ while IFS=$'\t' read -r tab_id label; do
3125
+ [ -n "$tab_id" ] || continue
3126
+ pane_id=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$tab_id") || continue
3127
+ [ -n "$pane_id" ] || continue
3128
+ printf '%s:%s\t%s\n' "$session" "$pane_id" "$label"
3129
+ done < <(printf '%s' "$tabs" | jq -r '.result.tabs[]? | select(.label | startswith("fm-")) | "\(.tab_id)\t\(.label)"' 2>/dev/null)
3130
+ }
3131
+
3132
+ # --- native event push: pane.agent_status_changed subscriber -----------------
3133
+ #
3134
+ # The push half of the immediate blocked-state escalation (AGENTS.md section 8,
3135
+ # docs/herdr-backend.md "Native pane.agent_status_changed push escalation").
3136
+ # fm_backend_herdr_wait_transition is the watcher's bounded wait primitive for
3137
+ # herdr homes: instead of a blind sleep, it blocks on herdr's native event
3138
+ # stream and returns the instant a subscribed pane transitions to `blocked`, so
3139
+ # a crew waiting on the human wakes its supervisor sub-second instead of after
3140
+ # the ~240s stale-pane wedge timer. Everything not `blocked` is streamed too
3141
+ # (the policy, not the subscription, makes `blocked` the sole immediate action)
3142
+ # so `working` edges clear the per-pane dedupe marker. Polling stays the
3143
+ # permanent fail-closed backstop: below-capability, a connect/subscribe failure,
3144
+ # or a missing reader all fall back to the caller sleeping the same budget.
3145
+
3146
+ # fm_backend_herdr_socket_path: the control-socket path for <session>, read from
3147
+ # `herdr session list --json` (the default session's socket differs from a named
3148
+ # session's - verified: default -> ~/.config/herdr/herdr.sock, named ->
3149
+ # ~/.config/herdr/sessions/<name>/herdr.sock). Empty on any failure.
3150
+ fm_backend_herdr_socket_path() { # <session>
3151
+ local session=$1
3152
+ herdr session list --json 2>/dev/null \
3153
+ | jq -r --arg name "$session" '.sessions[]? | select(.name == $name) | .socket_path // empty' 2>/dev/null \
3154
+ | head -1
3155
+ }
3156
+
3157
+ # fm_backend_herdr_events_capable: the version/capability gate for the event
3158
+ # fast-path (report section 5c trigger 1). Fails closed to the poll loop unless
3159
+ # ALL hold: herdr+jq present; the raw-socket reader available (python3, unless a
3160
+ # reader override is configured); client protocol >= FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL;
3161
+ # and both `events.subscribe` and `pane.agent_status_changed` present in `herdr
3162
+ # api schema`. FM_BACKEND_HERDR_EVENTS_FORCE overrides the whole verdict for
3163
+ # tests (1 = capable, 0 = incapable) without touching the real binary. The
3164
+ # `api schema` read is ~220KB, so callers (the watcher) memoize this per session
3165
+ # for a process lifetime rather than probing every poll.
3166
+ fm_backend_herdr_events_capable() { # <session>
3167
+ local session=$1 protocol schema
3168
+ case "${FM_BACKEND_HERDR_EVENTS_FORCE:-}" in
3169
+ 1) return 0 ;;
3170
+ 0) return 1 ;;
3171
+ esac
3172
+ fm_backend_herdr_tool_check || return 1
3173
+ if [ -z "${FM_BACKEND_HERDR_EVENT_READER:-}" ]; then
3174
+ command -v python3 >/dev/null 2>&1 || return 1
3175
+ fi
3176
+ protocol=$(herdr status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null)
3177
+ case "$protocol" in ''|*[!0-9]*) return 1 ;; esac
3178
+ [ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_EVENTS_PROTOCOL" ] || return 1
3179
+ schema=$(herdr api schema --json 2>/dev/null) || return 1
3180
+ printf '%s' "$schema" | grep -Fq 'events.subscribe' || return 1
3181
+ printf '%s' "$schema" | grep -Fq 'pane.agent_status_changed' || return 1
3182
+ return 0
3183
+ }
3184
+
3185
+ # fm_backend_herdr_normalize_event: THE single normalize point (report section 5
3186
+ # refinement: one backend transition shape, one parse point). Both the stream
3187
+ # reader's projected lines AND the level-reconcile's `agent get` reads flow
3188
+ # through here into the shared normalized-transition record. herdr's event
3189
+ # carries no previous status and its stream is edge-triggered, so from_status is
3190
+ # left empty; to_status drives the policy.
3191
+ fm_backend_herdr_normalize_event() { # <pane_id> <workspace_id> <agent_status> <agent>
3192
+ fm_transition_record "${1:-}" "${2:-}" "" "${3:-}" "${4:-}"
3193
+ }
3194
+
3195
+ # fm_backend_herdr_event_reader_cmd: emit the reader argv (one word per line) for
3196
+ # the raw-socket subscriber. Default: `python3 <this dir>/herdr-eventwait.py`.
3197
+ # FM_BACKEND_HERDR_EVENT_READER overrides it with a whitespace-split command so
3198
+ # tests can substitute a fake reader that replays canned stream lines.
3199
+ fm_backend_herdr_event_reader_cmd() {
3200
+ local word
3201
+ if [ -n "${FM_BACKEND_HERDR_EVENT_READER:-}" ]; then
3202
+ for word in $FM_BACKEND_HERDR_EVENT_READER; do
3203
+ printf '%s\n' "$word"
3204
+ done
3205
+ return 0
3206
+ fi
3207
+ printf 'python3\n'
3208
+ printf '%s\n' "$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-eventwait.py"
3209
+ }
3210
+
3211
+ # fm_backend_herdr_escalation_marker: the per-pane dedupe marker path for a
3212
+ # <window> ("<session>:<pane_id>"), keyed identically to the watcher's
3213
+ # .stale-<key> (tr ':/.' '___'), under <state_dir>.
3214
+ fm_backend_herdr_escalation_marker() { # <state_dir> <window>
3215
+ local state=$1 window=$2 key
3216
+ key=$(printf '%s' "$window" | tr ':/.' '___')
3217
+ printf '%s/%s%s' "$state" "$FM_BACKEND_HERDR_ESCALATED_PREFIX" "$key"
3218
+ }
3219
+
3220
+ # fm_backend_herdr_apply_transition: route one normalized record through the
3221
+ # shared policy table, maintaining the per-pane dedupe marker under <state_dir>.
3222
+ # On a fresh `actionable` (blocked) edge - policy actionable AND no marker yet -
3223
+ # it prints the record on stdout and returns 0 (the caller stops and hands the
3224
+ # record up). The caller commits the marker only after handling the record.
3225
+ # `absorb` (working) clears the marker and
3226
+ # returns 1. `defer`/`fallback`, and an already-marked `actionable`, return 1
3227
+ # with no output. <session> reconstructs the window ("<session>:<pane_id>") for
3228
+ # the marker key, matching the watcher's own key scheme.
3229
+ fm_backend_herdr_apply_transition() { # <state_dir> <session> <record>
3230
+ local state=$1 session=$2 record=$3 pane_id to action window marker
3231
+ pane_id=$(fm_transition_pane_id "$record")
3232
+ [ -n "$pane_id" ] || return 1
3233
+ to=$(fm_transition_to_status "$record")
3234
+ action=$(fm_transition_policy "$to")
3235
+ window="$session:$pane_id"
3236
+ marker=$(fm_backend_herdr_escalation_marker "$state" "$window")
3237
+ case "$action" in
3238
+ actionable)
3239
+ if [ ! -e "$marker" ]; then
3240
+ printf '%s' "$record"
3241
+ return 0
3242
+ fi
3243
+ ;;
3244
+ absorb)
3245
+ rm -f "$marker" 2>/dev/null || true
3246
+ ;;
3247
+ esac
3248
+ return 1
3249
+ }
3250
+
3251
+ fm_backend_herdr_commit_transition() { # <state_dir> <session> <record>
3252
+ local state=$1 session=$2 record=$3 pane_id window marker
3253
+ pane_id=$(fm_transition_pane_id "$record")
3254
+ [ -n "$pane_id" ] || return 1
3255
+ window="$session:$pane_id"
3256
+ marker=$(fm_backend_herdr_escalation_marker "$state" "$window")
3257
+ : > "$marker"
3258
+ }
3259
+
3260
+ fm_backend_herdr_clear_transition() { # <state_dir> <window>
3261
+ local state=$1 window=$2 marker
3262
+ [ -n "$window" ] || return 0
3263
+ marker=$(fm_backend_herdr_escalation_marker "$state" "$window")
3264
+ rm -f "$marker" 2>/dev/null || true
3265
+ }
3266
+
3267
+ # fm_backend_herdr_wait_transition: the bounded event wait. Blocks up to
3268
+ # <timeout_secs> for one of <pane_window...> ("<session>:<pane_id>") to reach a
3269
+ # fresh `blocked` edge, then prints the normalized record and returns 0.
3270
+ # Returns 1 on a clean timeout (the reader ran the full budget, no fresh
3271
+ # actionable edge - the caller has effectively already slept and just continues)
3272
+ # and 2 when the event path is unusable (not capable, socket unresolved, reader
3273
+ # failed to run/subscribe - the caller sleeps the budget itself, the fail-closed
3274
+ # backstop). See the header block above for the full contract.
3275
+ fm_backend_herdr_wait_transition() { # <session> <timeout_secs> <state_dir> <pane_window...>
3276
+ local session=$1 timeout=$2 state=$3
3277
+ shift 3
3278
+ local windows=("$@")
3279
+ [ "${#windows[@]}" -gt 0 ] || return 2
3280
+ if [ "${FM_BACKEND_EVENTS_CAPABILITY_CONFIRMED:-0}" != 1 ]; then
3281
+ fm_backend_herdr_events_capable "$session" || return 2
3282
+ fi
3283
+ local sock
3284
+ sock=$(fm_backend_herdr_socket_path "$session")
3285
+ [ -n "$sock" ] || return 2
3286
+
3287
+ # Map each window to its herdr pane id (strip the leading "<session>:").
3288
+ local w pane_id
3289
+ local pane_ids=()
3290
+ for w in "${windows[@]}"; do
3291
+ pane_id=${w#*:}
3292
+ if [ -z "$pane_id" ] || [ "$pane_id" = "$w" ]; then
3293
+ continue
3294
+ fi
3295
+ pane_ids+=("$pane_id")
3296
+ done
3297
+ [ "${#pane_ids[@]}" -gt 0 ] || return 2
3298
+
3299
+ # Start the raw-socket reader and wait for its subscription acknowledgement
3300
+ # before level reconciliation, so edges occurring during reconciliation are
3301
+ # already buffered in the live stream.
3302
+ local reader=()
3303
+ while IFS= read -r w; do
3304
+ reader+=("$w")
3305
+ done < <(fm_backend_herdr_event_reader_cmd)
3306
+ [ "${#reader[@]}" -gt 0 ] || return 2
3307
+
3308
+ local fifo_dir fifo reader_pid line ws status agent raw record hit rc=1 reader_rc=0
3309
+ fifo_dir=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-eventwait.XXXXXX") || return 2
3310
+ fifo="$fifo_dir/events"
3311
+ if ! mkfifo "$fifo" 2>/dev/null; then
3312
+ rm -rf "$fifo_dir" 2>/dev/null || true
3313
+ return 2
3314
+ fi
3315
+ "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null &
3316
+ reader_pid=$!
3317
+ if ! exec 9< "$fifo"; then
3318
+ kill "$reader_pid" 2>/dev/null || true
3319
+ wait "$reader_pid" 2>/dev/null || true
3320
+ rm -rf "$fifo_dir" 2>/dev/null || true
3321
+ return 2
3322
+ fi
3323
+ if ! IFS= read -r -u 9 line || [ "$line" != "@subscribed" ]; then
3324
+ rc=2
3325
+ fi
3326
+
3327
+ # Level reconcile on (re)connect (report section 3d): a pane already `blocked`
3328
+ # during the gap since the last subscription is returned now, once, while
3329
+ # newer edges accumulate in the active stream. `working` panes clear their
3330
+ # marker here too.
3331
+ if [ "$rc" -ne 2 ]; then
3332
+ for w in "${windows[@]}"; do
3333
+ pane_id=${w#*:}
3334
+ if [ -z "$pane_id" ] || [ "$pane_id" = "$w" ]; then
3335
+ continue
3336
+ fi
3337
+ raw=$(fm_backend_herdr_agent_status_raw "$session" "$pane_id")
3338
+ [ -n "$raw" ] || continue
3339
+ record=$(fm_backend_herdr_normalize_event "$pane_id" "" "$raw" "")
3340
+ if hit=$(fm_backend_herdr_apply_transition "$state" "$session" "$record"); then
3341
+ printf '%s' "$hit"
3342
+ rc=0
3343
+ break
3344
+ fi
3345
+ done
3346
+ fi
3347
+
3348
+ # Drain stream edges until a fresh blocked edge or the timeout. The reader is
3349
+ # a subprocess of this call (NOT a second watcher), and is killed the instant
3350
+ # a blocked edge is found.
3351
+ # Split each raw projected line (pane_id\tworkspace_id\tagent_status\tagent)
3352
+ # with `cut`, NOT `IFS=$'\t' read`: a tab is IFS-whitespace, so `read` would
3353
+ # collapse an empty middle field (e.g. an absent workspace_id) and shift the
3354
+ # status into the wrong column. `cut` preserves empty fields.
3355
+ while [ "$rc" -eq 1 ] && IFS= read -r line <&9; do
3356
+ [ -n "$line" ] || continue
3357
+ pane_id=$(printf '%s' "$line" | cut -f1)
3358
+ ws=$(printf '%s' "$line" | cut -f2)
3359
+ status=$(printf '%s' "$line" | cut -f3)
3360
+ agent=$(printf '%s' "$line" | cut -f4)
3361
+ [ -n "$pane_id" ] || continue
3362
+ record=$(fm_backend_herdr_normalize_event "$pane_id" "$ws" "$status" "$agent")
3363
+ if hit=$(fm_backend_herdr_apply_transition "$state" "$session" "$record"); then
3364
+ printf '%s' "$hit"
3365
+ rc=0
3366
+ break
3367
+ fi
3368
+ done
3369
+ if [ "$rc" -eq 0 ]; then
3370
+ kill "$reader_pid" 2>/dev/null || true
3371
+ fi
3372
+ if [ "$rc" -eq 2 ]; then
3373
+ kill "$reader_pid" 2>/dev/null || true
3374
+ fi
3375
+ # No actionable edge: distinguish a clean full-budget wait (reader exit 0 ->
3376
+ # return 1, caller already waited) from a reader error (connect/subscribe
3377
+ # failure, exit non-zero -> return 2, caller sleeps and counts toward the
3378
+ # runtime-disable threshold).
3379
+ wait "$reader_pid" 2>/dev/null || reader_rc=$?
3380
+ exec 9<&-
3381
+ rm -rf "$fifo_dir" 2>/dev/null || true
3382
+ [ "$rc" -eq 0 ] && return 0
3383
+ [ "$rc" -eq 2 ] && return 2
3384
+ [ "$reader_rc" -eq 0 ] && return 1
3385
+ return 2
3386
+ }