@zerwiz/ymir 0.1.4 → 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.
- package/.agents/README.md +291 -0
- package/.agents/agents/bragi-marketer.md +111 -0
- package/.agents/agents/brokk.md +352 -0
- package/.agents/agents/forseti-reviewer.md +93 -0
- package/.agents/agents/frigg-consent.md +77 -0
- package/.agents/agents/groa-update.md +77 -0
- package/.agents/agents/hnoss-designer.md +69 -0
- package/.agents/agents/huginn-researcher.md +99 -0
- package/.agents/agents/jord-projects.md +78 -0
- package/.agents/agents/kvasir-scout.md +91 -0
- package/.agents/agents/mimir-planner.md +92 -0
- package/.agents/agents/muninn-stow.md +77 -0
- package/.agents/agents/saga-bearings.md +78 -0
- package/.agents/agents/sindri-developer.md +114 -0
- package/.agents/agents/snotra-documenter.md +105 -0
- package/.agents/agents/syn-recovery.md +78 -0
- package/.agents/agents/tyr-check.md +78 -0
- package/.agents/agents/urdh-hold.md +77 -0
- package/.agents/agents/volundr-smith.md +80 -0
- package/.agents/agents/vor-diagnostics.md +77 -0
- package/.agents/assets/agents/naming.md +50 -0
- package/.agents/assets/agents/registry.md +93 -0
- package/.agents/assets/agents/runtime.md +36 -0
- package/.agents/assets/agents/toon-tasks-cli.md +42 -0
- package/.agents/assets/templates/company_entity.template.md +37 -0
- package/.agents/backend/backends/cmux.sh +649 -0
- package/.agents/backend/backends/herdr-eventwait.py +157 -0
- package/.agents/backend/backends/herdr-workspace-move.py +114 -0
- package/.agents/backend/backends/herdr.sh +3386 -0
- package/.agents/backend/backends/orca.sh +290 -0
- package/.agents/backend/backends/tmux.sh +357 -0
- package/.agents/backend/backends/zellij.sh +700 -0
- package/.agents/backend/fm-afk-launch.sh +654 -0
- package/.agents/backend/fm-afk-return.sh +241 -0
- package/.agents/backend/fm-afk-start.sh +172 -0
- package/.agents/backend/fm-arm-command-policy.mjs +960 -0
- package/.agents/backend/fm-arm-pretool-check.sh +200 -0
- package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
- package/.agents/backend/fm-backend.sh +981 -0
- package/.agents/backend/fm-backlog-handoff.sh +909 -0
- package/.agents/backend/fm-backlog-receive.sh +187 -0
- package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
- package/.agents/backend/fm-bearings-board.sh +204 -0
- package/.agents/backend/fm-bearings-snapshot.sh +546 -0
- package/.agents/backend/fm-bootstrap.sh +1568 -0
- package/.agents/backend/fm-branch-outcome.sh +203 -0
- package/.agents/backend/fm-branch-prompt.sh +101 -0
- package/.agents/backend/fm-brief.sh +452 -0
- package/.agents/backend/fm-busy-event.sh +234 -0
- package/.agents/backend/fm-busy-lib.sh +986 -0
- package/.agents/backend/fm-captain-hold.sh +1001 -0
- package/.agents/backend/fm-cd-command-policy.mjs +153 -0
- package/.agents/backend/fm-cd-pretool-check.sh +190 -0
- package/.agents/backend/fm-check-lib.sh +74 -0
- package/.agents/backend/fm-check-register.sh +42 -0
- package/.agents/backend/fm-check-unregister.sh +52 -0
- package/.agents/backend/fm-classify-lib.sh +1770 -0
- package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
- package/.agents/backend/fm-composer-lib.sh +1416 -0
- package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
- package/.agents/backend/fm-config-push.sh +254 -0
- package/.agents/backend/fm-control-lib.sh +251 -0
- package/.agents/backend/fm-control.sh +878 -0
- package/.agents/backend/fm-crew-state.sh +625 -0
- package/.agents/backend/fm-cursor-lib.sh +243 -0
- package/.agents/backend/fm-decision-hold.sh +232 -0
- package/.agents/backend/fm-doc-audience-check.sh +269 -0
- package/.agents/backend/fm-dod-lib.sh +67 -0
- package/.agents/backend/fm-ensure-agents-md.sh +253 -0
- package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
- package/.agents/backend/fm-extension.mjs +2577 -0
- package/.agents/backend/fm-extension.sh +16 -0
- package/.agents/backend/fm-ff-lib.sh +420 -0
- package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
- package/.agents/backend/fm-fleet-sync.sh +458 -0
- package/.agents/backend/fm-fleet-view.sh +96 -0
- package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
- package/.agents/backend/fm-guard.sh +242 -0
- package/.agents/backend/fm-harness.sh +195 -0
- package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
- package/.agents/backend/fm-herdr-lab.sh +346 -0
- package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
- package/.agents/backend/fm-home-seed.sh +984 -0
- package/.agents/backend/fm-home-summary-refresh.sh +255 -0
- package/.agents/backend/fm-hook-host-lib.sh +36 -0
- package/.agents/backend/fm-inactive-reconcile.sh +531 -0
- package/.agents/backend/fm-inbox.sh +399 -0
- package/.agents/backend/fm-install-actionlint.sh +84 -0
- package/.agents/backend/fm-install-herdr.sh +96 -0
- package/.agents/backend/fm-install-shellcheck.sh +84 -0
- package/.agents/backend/fm-install-treehouse.sh +95 -0
- package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
- package/.agents/backend/fm-lease-lib.sh +218 -0
- package/.agents/backend/fm-lease.sh +189 -0
- package/.agents/backend/fm-line-cap-lib.sh +51 -0
- package/.agents/backend/fm-lint-workflows.sh +137 -0
- package/.agents/backend/fm-lint.sh +591 -0
- package/.agents/backend/fm-lock-lib.sh +104 -0
- package/.agents/backend/fm-lock.sh +107 -0
- package/.agents/backend/fm-marker-lib.sh +12 -0
- package/.agents/backend/fm-merge-local.sh +74 -0
- package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
- package/.agents/backend/fm-nm-run-lib.sh +124 -0
- package/.agents/backend/fm-on.sh +125 -0
- package/.agents/backend/fm-operational-input.sh +253 -0
- package/.agents/backend/fm-peek.sh +46 -0
- package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
- package/.agents/backend/fm-pr-check.sh +135 -0
- package/.agents/backend/fm-pr-lib.sh +1016 -0
- package/.agents/backend/fm-pr-merge.sh +707 -0
- package/.agents/backend/fm-pr-poll.sh +110 -0
- package/.agents/backend/fm-primary-scope-lib.sh +33 -0
- package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
- package/.agents/backend/fm-procevent-lavish.sh +628 -0
- package/.agents/backend/fm-procevent-lib.sh +892 -0
- package/.agents/backend/fm-procevent-quota.sh +290 -0
- package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
- package/.agents/backend/fm-procevent-when.sh +504 -0
- package/.agents/backend/fm-procevent.sh +1466 -0
- package/.agents/backend/fm-project-mode.sh +92 -0
- package/.agents/backend/fm-project-origin-lib.sh +180 -0
- package/.agents/backend/fm-promote.sh +282 -0
- package/.agents/backend/fm-public-followup-emit.sh +260 -0
- package/.agents/backend/fm-public-followup-lib.sh +398 -0
- package/.agents/backend/fm-public-followup.sh +1330 -0
- package/.agents/backend/fm-push-transition-lib.sh +168 -0
- package/.agents/backend/fm-quota-axi-lib.sh +93 -0
- package/.agents/backend/fm-quota-choose.sh +384 -0
- package/.agents/backend/fm-remote-delta-read.sh +189 -0
- package/.agents/backend/fm-remote-doctor.sh +800 -0
- package/.agents/backend/fm-remote-entrypoint.sh +193 -0
- package/.agents/backend/fm-remote-file.sh +234 -0
- package/.agents/backend/fm-remote-home-provision.sh +259 -0
- package/.agents/backend/fm-remote-home-seed.sh +259 -0
- package/.agents/backend/fm-remote-inherit-push.sh +90 -0
- package/.agents/backend/fm-remote-inherit.sh +179 -0
- package/.agents/backend/fm-remote-job-lib.sh +1241 -0
- package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
- package/.agents/backend/fm-remote-job-worker.sh +1124 -0
- package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
- package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
- package/.agents/backend/fm-review-diff.sh +158 -0
- package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
- package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
- package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
- package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
- package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
- package/.agents/backend/fm-secondmate-report.sh +88 -0
- package/.agents/backend/fm-send.sh +1059 -0
- package/.agents/backend/fm-session-lock-lib.sh +176 -0
- package/.agents/backend/fm-session-start.sh +970 -0
- package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
- package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
- package/.agents/backend/fm-sessionstart-run.sh +145 -0
- package/.agents/backend/fm-spawn.sh +3153 -0
- package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
- package/.agents/backend/fm-startup-memory-budget.sh +94 -0
- package/.agents/backend/fm-startup-network.sh +642 -0
- package/.agents/backend/fm-stow-cascade.sh +251 -0
- package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
- package/.agents/backend/fm-supervise-daemon.sh +1732 -0
- package/.agents/backend/fm-supervision-instructions.sh +215 -0
- package/.agents/backend/fm-supervision-lib.sh +90 -0
- package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
- package/.agents/backend/fm-tangle-lib.sh +53 -0
- package/.agents/backend/fm-task-inbox-lib.sh +402 -0
- package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
- package/.agents/backend/fm-teardown.sh +2922 -0
- package/.agents/backend/fm-test-isolation-proof.sh +588 -0
- package/.agents/backend/fm-test-run.sh +2191 -0
- package/.agents/backend/fm-timeout-lib.sh +141 -0
- package/.agents/backend/fm-timing-lib.sh +179 -0
- package/.agents/backend/fm-tmux-lib.sh +291 -0
- package/.agents/backend/fm-tool-update-check.sh +898 -0
- package/.agents/backend/fm-trace-context-lib.sh +227 -0
- package/.agents/backend/fm-transition-lib.sh +103 -0
- package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
- package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
- package/.agents/backend/fm-turnend-guard.sh +432 -0
- package/.agents/backend/fm-update.sh +110 -0
- package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
- package/.agents/backend/fm-voice-client.py +1373 -0
- package/.agents/backend/fm-voice-relay.py +1256 -0
- package/.agents/backend/fm-wake-drain.sh +592 -0
- package/.agents/backend/fm-wake-grant.sh +127 -0
- package/.agents/backend/fm-wake-lib.sh +1849 -0
- package/.agents/backend/fm-watch-arm.sh +602 -0
- package/.agents/backend/fm-watch-checkpoint.sh +109 -0
- package/.agents/backend/fm-watch.sh +1962 -0
- package/.agents/backend/fm-x-dismiss.sh +116 -0
- package/.agents/backend/fm-x-followup.sh +286 -0
- package/.agents/backend/fm-x-lib.sh +1000 -0
- package/.agents/backend/fm-x-link.sh +234 -0
- package/.agents/backend/fm-x-poll.sh +185 -0
- package/.agents/backend/fm-x-reply.sh +385 -0
- package/.agents/backend/fm_voice_frame.py +166 -0
- package/.agents/backend/fm_voice_records.py +574 -0
- package/.agents/backend/model-bridge.py +290 -0
- package/.agents/backend/opencode-go-bridge.py +30 -0
- package/.agents/bus/README.md +12 -0
- package/.agents/bus/messages.json +1 -0
- package/.agents/config/agents.machine.example.yaml +16 -0
- package/.agents/config/agents.yaml +50 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +9 -0
- package/.agents/config/eindri-dispatch.json +67 -0
- package/.agents/config/eindri-harness +1 -0
- package/.agents/config/startup-memory-budget +1 -0
- package/.agents/config/tailscale-sync.yaml +27 -0
- package/.agents/filebrowser/.filebrowser.json +6 -0
- package/.agents/filebrowser/README.md +11 -0
- package/.agents/gateway/README.md +12 -0
- package/.agents/github/README.md +15 -0
- package/.agents/github/webhooks/.gitkeep +0 -0
- package/.agents/github/workflows/.gitkeep +0 -0
- package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
- package/.agents/harness/opencode/plugins/package.json +4 -0
- package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
- package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
- package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
- package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
- package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
- package/.agents/memory/README.md +46 -0
- package/.agents/memory/kaia.engram +0 -0
- package/.agents/memory/kaia.engram-shm +0 -0
- package/.agents/memory/kaia.engram-wal +0 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/memory/well/episodes.jsonl +367 -0
- package/.agents/memory/well/workspace.jsonl +27 -0
- package/.agents/migrations/0001-hodd-layout.sh +38 -0
- package/.agents/migrations/0002-a2a-mcp.sh +27 -0
- package/.agents/migrations/0003-private-data-separation.sh +111 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
- package/.agents/sandbox/Dockerfile.utgard +19 -0
- package/.agents/sandbox/README.md +14 -0
- package/.agents/sandbox/utgard.config.json +14 -0
- package/.agents/skills/README.md +59 -0
- package/.agents/skills/bragi-marketing/README.md +3 -0
- package/.agents/skills/bragi-marketing/SKILL.md +82 -0
- package/.agents/skills/eindri-homes/README.md +3 -0
- package/.agents/skills/eindri-homes/SKILL.md +291 -0
- package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
- package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
- package/.agents/skills/frigg-consent/README.md +3 -0
- package/.agents/skills/frigg-consent/SKILL.md +56 -0
- package/.agents/skills/galdr-ymirsystem/README.md +3 -0
- package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
- package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
- package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
- package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
- package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
- package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
- package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
- package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
- package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
- package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
- package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
- package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
- package/.agents/skills/gjallarhorn-relay/README.md +3 -0
- package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
- package/.agents/skills/groa-update/SKILL.md +70 -0
- package/.agents/skills/hamr-adapters/README.md +3 -0
- package/.agents/skills/hamr-adapters/SKILL.md +123 -0
- package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
- package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
- package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
- package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
- package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
- package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
- package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
- package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
- package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
- package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
- package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
- package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
- package/.agents/skills/herdr-panes/README.md +3 -0
- package/.agents/skills/herdr-panes/SKILL.md +92 -0
- package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
- package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
- package/.agents/skills/hnoss-design/DESIGN.md +19 -0
- package/.agents/skills/hnoss-design/README.md +3 -0
- package/.agents/skills/hnoss-design/SKILL.md +83 -0
- package/.agents/skills/hvild-afk/README.md +3 -0
- package/.agents/skills/hvild-afk/SKILL.md +241 -0
- package/.agents/skills/jord-projects/README.md +3 -0
- package/.agents/skills/jord-projects/SKILL.md +92 -0
- package/.agents/skills/lifecycle/SKILL.md +56 -0
- package/.agents/skills/lifecycle/smoke_test.sh +52 -0
- package/.agents/skills/lifecycle/start.sh +21 -0
- package/.agents/skills/lifecycle/status.sh +35 -0
- package/.agents/skills/lifecycle/stop.sh +16 -0
- package/.agents/skills/muninn-stow/README.md +3 -0
- package/.agents/skills/muninn-stow/SKILL.md +309 -0
- package/.agents/skills/no-mistakes/SKILL.md +379 -0
- package/.agents/skills/nornir-schedule/README.md +3 -0
- package/.agents/skills/nornir-schedule/SKILL.md +25 -0
- package/.agents/skills/nornir-schedule/assets/events.md +137 -0
- package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
- package/.agents/skills/nsr-compliance/README.md +3 -0
- package/.agents/skills/nsr-compliance/SKILL.md +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/pr-ops/README.md +3 -0
- package/.agents/skills/pr-ops/SKILL.md +109 -0
- package/.agents/skills/ratatoskr-a2a/README.md +3 -0
- package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
- package/.agents/skills/rules-check-drift/SKILL.md +81 -0
- package/.agents/skills/saga-bearings/README.md +3 -0
- package/.agents/skills/saga-bearings/SKILL.md +26 -0
- package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
- package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
- package/.agents/skills/saga-bearings/assets/recap.md +56 -0
- package/.agents/skills/syn-recovery/README.md +3 -0
- package/.agents/skills/syn-recovery/SKILL.md +54 -0
- package/.agents/skills/tyr-check/README.md +3 -0
- package/.agents/skills/tyr-check/SKILL.md +218 -0
- package/.agents/skills/urdh-hold/README.md +3 -0
- package/.agents/skills/urdh-hold/SKILL.md +25 -0
- package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
- package/.agents/skills/urdh-hold/assets/hold.md +54 -0
- package/.agents/skills/vor-diagnostics/README.md +3 -0
- package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
- package/.agents/skills/ymir-host/README.md +3 -0
- package/.agents/skills/ymir-host/SKILL.md +34 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -0
- package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
- package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
- package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
- package/.agents/skills/ymir-host/assets/update.md +64 -0
- package/.agents/tests/assets/board-render-harness.mjs +117 -0
- package/.agents/tests/cmux-test-safety.sh +51 -0
- package/.agents/tests/fixtures.sh +291 -0
- package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
- package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
- package/.agents/tests/fm-afk-launch.test.sh +961 -0
- package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
- package/.agents/tests/fm-afk-return.test.sh +283 -0
- package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
- package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
- package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
- package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
- package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
- package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
- package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
- package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
- package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
- package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
- package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
- package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
- package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
- package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
- package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
- package/.agents/tests/fm-backend-orca.test.sh +1357 -0
- package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
- package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
- package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
- package/.agents/tests/fm-backend.test.sh +1142 -0
- package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
- package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
- package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
- package/.agents/tests/fm-bearings-board.test.sh +414 -0
- package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
- package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
- package/.agents/tests/fm-bootstrap.test.sh +1178 -0
- package/.agents/tests/fm-branch-supervision.test.sh +552 -0
- package/.agents/tests/fm-brief.test.sh +785 -0
- package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
- package/.agents/tests/fm-busy-state.test.sh +465 -0
- package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
- package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
- package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
- package/.agents/tests/fm-check-unregister.test.sh +198 -0
- package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
- package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
- package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
- package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
- package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
- package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
- package/.agents/tests/fm-composer-ghost.test.sh +716 -0
- package/.agents/tests/fm-composer-lib.test.sh +666 -0
- package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
- package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
- package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
- package/.agents/tests/fm-control.test.sh +910 -0
- package/.agents/tests/fm-crew-state.test.sh +1610 -0
- package/.agents/tests/fm-cursor-harness.test.sh +404 -0
- package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
- package/.agents/tests/fm-cursor-primary.test.sh +708 -0
- package/.agents/tests/fm-daemon.test.sh +2740 -0
- package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
- package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
- package/.agents/tests/fm-extension-binding.test.sh +2187 -0
- package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
- package/.agents/tests/fm-fleet-sync.test.sh +721 -0
- package/.agents/tests/fm-gate-refuse.test.sh +356 -0
- package/.agents/tests/fm-gitignore-config.test.sh +89 -0
- package/.agents/tests/fm-gotmp.test.sh +230 -0
- package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
- package/.agents/tests/fm-grok-harness.test.sh +117 -0
- package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
- package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
- package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
- package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
- package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
- package/.agents/tests/fm-herdr-lab.test.sh +243 -0
- package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
- package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
- package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
- package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
- package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
- package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
- package/.agents/tests/fm-kimi-harness.test.sh +687 -0
- package/.agents/tests/fm-lint-workflows.test.sh +532 -0
- package/.agents/tests/fm-lint.test.sh +1023 -0
- package/.agents/tests/fm-muse-harness.test.sh +932 -0
- package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
- package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
- package/.agents/tests/fm-on.test.sh +507 -0
- package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
- package/.agents/tests/fm-operational-input.test.sh +160 -0
- package/.agents/tests/fm-peek-remote.test.sh +110 -0
- package/.agents/tests/fm-pending-reply.test.sh +1288 -0
- package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
- package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
- package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
- package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
- package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
- package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
- package/.agents/tests/fm-pr-merge.test.sh +2135 -0
- package/.agents/tests/fm-procevent-quota.test.sh +225 -0
- package/.agents/tests/fm-procevent-when.test.sh +406 -0
- package/.agents/tests/fm-procevent.test.sh +1745 -0
- package/.agents/tests/fm-project-origin.test.sh +110 -0
- package/.agents/tests/fm-public-followup.test.sh +2272 -0
- package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
- package/.agents/tests/fm-quota-choose.test.sh +626 -0
- package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
- package/.agents/tests/fm-remote-doctor.test.sh +625 -0
- package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
- package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
- package/.agents/tests/fm-remote-job.test.sh +768 -0
- package/.agents/tests/fm-remote-reply.test.sh +520 -0
- package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
- package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
- package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
- package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
- package/.agents/tests/fm-review-diff.test.sh +176 -0
- package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
- package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
- package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
- package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
- package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
- package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
- package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
- package/.agents/tests/fm-send-inbox.test.sh +352 -0
- package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
- package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
- package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
- package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
- package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
- package/.agents/tests/fm-send-settle.test.sh +147 -0
- package/.agents/tests/fm-send-strict.test.sh +241 -0
- package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
- package/.agents/tests/fm-session-start.test.sh +2515 -0
- package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
- package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
- package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
- package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
- package/.agents/tests/fm-spawn-batch.test.sh +148 -0
- package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
- package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
- package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
- package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
- package/.agents/tests/fm-startup-network.test.sh +712 -0
- package/.agents/tests/fm-stow-cascade.test.sh +370 -0
- package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
- package/.agents/tests/fm-supervision-events.test.sh +156 -0
- package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
- package/.agents/tests/fm-tangle-guard.test.sh +277 -0
- package/.agents/tests/fm-task-delivery.test.sh +420 -0
- package/.agents/tests/fm-task-inbox.test.sh +513 -0
- package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
- package/.agents/tests/fm-teardown.test.sh +2665 -0
- package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
- package/.agents/tests/fm-test-fixtures.test.sh +132 -0
- package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
- package/.agents/tests/fm-test-run.test.sh +1196 -0
- package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
- package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
- package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
- package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
- package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
- package/.agents/tests/fm-transition-lib.test.sh +54 -0
- package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
- package/.agents/tests/fm-update.test.sh +304 -0
- package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
- package/.agents/tests/fm-voice-relay.test.sh +4741 -0
- package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
- package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
- package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
- package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
- package/.agents/tests/fm-wake-queue.test.sh +1224 -0
- package/.agents/tests/fm-watch-arm.test.sh +821 -0
- package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
- package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
- package/.agents/tests/fm-watch-triage.test.sh +3889 -0
- package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
- package/.agents/tests/fm-x-mode.test.sh +3042 -0
- package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
- package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
- package/.agents/tests/herdr-test-safety.sh +42 -0
- package/.agents/tests/lib.sh +315 -0
- package/.agents/tests/remote-herdr-fixture.sh +125 -0
- package/.agents/tests/secondmate-helpers.sh +193 -0
- package/.agents/tests/smoke.test.sh +93 -0
- package/.agents/tests/wake-helpers.sh +338 -0
- package/.agents/tests/zellij-test-safety.sh +60 -0
- package/.agents/tools/README.md +10 -0
- package/.agents/tools/bin/tasks-cli +267 -0
- package/.agents/tools/tasks-cli.ts +216 -0
- package/AGENTS.md +454 -0
- package/CHANGELOG.md +881 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +113 -59
- package/RULES/01-domains.md +51 -0
- package/RULES/02-agents.md +78 -0
- package/RULES/03-houses.md +61 -0
- package/RULES/04-hoard.md +65 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +58 -0
- package/RULES/07-config.md +45 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/README.md +20 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +98 -0
- package/bin/a2a-serve.py +103 -0
- package/bin/a2a-serve.sh +8 -0
- package/bin/a2a-talk.sh +50 -0
- package/bin/a2abridge-ensure.sh +108 -0
- package/bin/agent-run.sh +43 -0
- package/bin/agents-config.sh +233 -0
- package/bin/apodex-smoke-test.sh +92 -0
- package/bin/bifrost-bridge.sh +112 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +107 -0
- package/bin/bootstrap-windows.ps1 +112 -0
- package/bin/branch-guard.sh +64 -0
- package/bin/brokk +73 -0
- package/bin/brokk-classify-lib.sh +1770 -0
- package/bin/brokk-lease-lib.sh +222 -0
- package/bin/brokk-lease.sh +193 -0
- package/bin/brokk-lint.sh +65 -0
- package/bin/brokk-send.sh +38 -0
- package/bin/brokk-update.sh +9 -0
- package/bin/brokk-wake-grant.sh +127 -0
- package/bin/brokk-wake-lib.sh +1849 -0
- package/bin/changelog-guard.sh +165 -0
- package/bin/crash-sense.sh +160 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +207 -0
- package/bin/eindri-acclaim.sh +34 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seen.sh +41 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +85 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +112 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/fleet-apply.sh +68 -0
- package/bin/github-deploy.sh +81 -0
- package/bin/gjallarhorn-expose.sh +102 -0
- package/bin/gjallarhorn-notify.sh +58 -0
- package/bin/gjallarhorn-purge.sh +54 -0
- package/bin/gjallarhorn-tunnel.sh +72 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +122 -0
- package/bin/hall-snapshot.sh +92 -0
- package/bin/hamr-harness.sh +246 -0
- package/bin/heimdall-ssh-keys.sh +374 -0
- package/bin/herdr-agents.py +44 -0
- package/bin/herdr-ensure.sh +104 -0
- package/bin/herdr-run.sh +431 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hoard-lib.sh +25 -0
- package/bin/hodd.sh +78 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +108 -0
- package/bin/local-model-lock.sh +51 -0
- package/bin/mimir-bridge.py +226 -0
- package/bin/mimir-bridge.sh +87 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +59 -0
- package/bin/mjollnir.sh +85 -0
- package/bin/model-hardware.sh +82 -0
- package/bin/model-resolve.sh +147 -0
- package/bin/models-detect.sh +178 -0
- package/bin/nornir-cron-start.sh +162 -0
- package/bin/nornir-job-daily-briefing.sh +191 -0
- package/bin/nornir-job-git-sync.sh +152 -0
- package/bin/nornir-job-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +148 -0
- package/bin/omarchy-plugins.sh +174 -0
- package/bin/omarchy-sense.sh +149 -0
- package/bin/perm-guard.sh +30 -0
- package/bin/pi-agent.sh +68 -0
- package/bin/pi-ensure.sh +77 -0
- package/bin/pi-local.sh +37 -0
- package/bin/pi-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +38 -0
- package/bin/project-git.sh +55 -0
- package/bin/public-guard.sh +70 -0
- package/bin/ratatoskr.sh +49 -0
- package/bin/realm-lib.sh +32 -0
- package/bin/repo-scrub.sh +62 -0
- package/bin/rodd-operational-input.sh +250 -0
- package/bin/runes-append.sh +198 -0
- package/bin/saga-session-start.sh +172 -0
- package/bin/saga-sessionstart-run.sh +79 -0
- package/bin/saga-wake-drain.sh +41 -0
- package/bin/secret-guard.sh +63 -0
- package/bin/sessrumnir-ensure.sh +100 -0
- package/bin/sessrumnir-sync.sh +237 -0
- package/bin/sessrumnir.sh +91 -0
- package/bin/skillopt-setup.sh +76 -0
- package/bin/skuld-branch-outcome.sh +203 -0
- package/bin/skuld-branch-prompt.sh +101 -0
- package/bin/smidja-bootstrap.sh +90 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +76 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +190 -0
- package/bin/telegram-bot.sh +69 -0
- package/bin/toolchain.sh +74 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +260 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +47 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +150 -0
- package/bin/wyrd-db.sh +47 -0
- package/bin/yggdrasil.sh +152 -0
- package/bin/ymir-edit.sh +145 -0
- package/bin/ymir-install.sh +641 -0
- package/bin/ymir-invite.sh +93 -0
- package/bin/ymir-isolation.sh +88 -0
- package/bin/ymir-migrate.sh +61 -0
- package/bin/ymir-platform.sh +348 -0
- package/bin/ymir-say.sh +97 -0
- package/bin/ymir-setup-auth.sh +108 -0
- package/bin/ymir-validate.sh +173 -0
- package/bin/ymir.js +74 -16
- package/docs/Architecture.md +339 -0
- package/docs/design.md +351 -0
- package/docs/installations/README.md +63 -0
- package/docs/installations/ymir-home.md +93 -0
- package/docs/integration.md +105 -0
- package/docs/lore.md +1056 -0
- package/docs/research/agent-harnesses.md +290 -0
- package/docs/runbooks/README.md +19 -0
- package/docs/runbooks/a2a.md +91 -0
- package/docs/runbooks/agent-permissions.md +99 -0
- package/docs/runbooks/agents.md +57 -0
- package/docs/runbooks/models.md +69 -0
- package/docs/runbooks/realm-onboarding.md +60 -0
- package/docs/runbooks/secrets-and-hoard.md +60 -0
- package/docs/runbooks/updates-and-migrations.md +110 -0
- package/docs/runbooks/worktrees-isolation.md +72 -0
- package/docs/session-start.md +105 -0
- package/docs/skillopt-integration.md +321 -0
- package/docs/sync-system.md +66 -0
- package/docs/workspaces.md +104 -0
- package/docs/ymir-rut.md +260 -0
- package/package.json +44 -14
- package/packaging/build.sh +74 -0
- package/packaging/macos/Ymir Installer.command +43 -0
- package/packaging/windows/ymir-setup.nsi +74 -0
- package/scripts/README.md +22 -0
- package/scripts/electron.sh +210 -0
- package/scripts/start.sh +167 -0
- package/scripts/stop.sh +113 -0
- 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
|
+
}
|