forked from kunchenguid/firstmate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfm-spawn.sh
More file actions
executable file
·1554 lines (1503 loc) · 68.6 KB
/
Copy pathfm-spawn.sh
File metadata and controls
executable file
·1554 lines (1503 loc) · 68.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a
# secondmate in its isolated firstmate home.
# Usage: fm-spawn.sh <task-id> <project-dir> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--scout]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] --secondmate
# --harness <name> is the explicit per-spawn harness/profile adapter. The old
# positional harness arg still works for back-compat.
# --model <name> and --effort <low|medium|high|xhigh|max> are concrete profile
# axes chosen by firstmate at intake. They are only threaded into harnesses whose
# installed CLIs were verified to support that axis; unsupported axes are omitted
# from that harness's launch rather than guessed.
# --backend <name> is the explicit runtime session-provider backend for this
# spawn. Without it, the script resolves FM_BACKEND, then config/backend, then
# runtime auto-detection (the runtime firstmate itself is executing inside -
# $TMUX, HERDR_ENV=1, or cmux runtime signals; bin/fm-backend.sh's
# fm_backend_detect, with cmux fallback details in docs/cmux-backend.md),
# then tmux.
# Spawn-capable backends are the reference tmux adapter and experimental
# herdr, zellij, orca, and cmux. Orca owns both the task worktree and
# terminal, so ship/scout Orca spawns do not run treehouse get; cmux is a
# session provider only, exactly like herdr/zellij, so it does. An
# auto-detected herdr or cmux spawn prints a loud stderr notice;
# auto-detected tmux stays silent; zellij and orca are never auto-detected.
# codex-app is not a known backend yet; docs/codex-app-backend.md owns that
# blocked backend contract. Default tmux spawns do not write backend= to meta;
# absent backend= means tmux. cmux does not support --secondmate spawns yet.
# A backend spawn refusal (missing dependency, version gate, unauthenticated
# socket, or unsupported secondmate mode) is terminal for that selected backend;
# callers must surface it instead of silently retrying another backend.
# Herdr additionally supports a default-off presentation-only layout when the
# local config/herdr-presentation-spaces flag exists. A clean fresh task first
# writes state/<id>.herdr-presentation atomically, then creates a disposable
# workspace containing only the ordinary task pane. A successful clean create
# upgrades its attempt journal with exact home, session, workspace, tab, pane,
# parent, and label bindings. On a same-identity restart, that complete binding
# plus authoritative metadata may replace one exact agent-free husk in place.
# The journal, visible token, and labels alone are never endpoint or ownership
# authority, and every ambiguous recovery stays on the flat fallback after
# duplicate-agent risk is independently absent. Treehouse allocation and task
# metadata are unchanged.
# A clean projected create or exact resume makes one bounded attempt to hold
# the one session-scoped presentation-order lock (keyed by named session plus
# canonical socket, outside any home's state/) through launch handoff. Lock
# contention warns and falls back to the ordinary flat layout before any
# projection mutation. The exact response-derived new workspace is inserted
# immediately after its owning parent (firstmate or 2ndmate-<id>) contiguous
# child block. Ordering never authorizes lifecycle cleanup, and any
# unavailable, ambiguous, or failed move warns while the spawn continues.
# Every projected create, prune, and move captures and verifies the named
# session's exact active workspace and tab. A detected focus change restores
# only that exact tab id; an ambiguous pre-operation snapshot refuses the
# focus-sensitive presentation mutation.
# Every single-task invocation holds one task-id-scoped lock across backend
# creation through metadata publication, so concurrent same-id spawns serialize
# even when they select different backends.
# With no harness arg, a crewmate/scout spawn resolves the CREW harness only when
# config/crew-dispatch.json is absent. When that file exists, crewmate/scout
# spawns require an explicit harness so firstmate cannot silently skip dispatch
# profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE
# harness (config/secondmate-harness -> config/crew-harness -> own), so the
# secondmate-vs-crewmate split is DURABLE across every respawn (recovery,
# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi)
# overrides it for this spawn (either kind). A non-flag string containing
# whitespace is treated as a RAW launch command - the escape hatch for verifying
# new adapters. pi-signed launches that exact executable name from PATH and
# refuses before endpoint creation when it is unavailable; it never falls back to pi.
# config/secondmate-harness may also carry an optional model and effort as extra
# whitespace-separated tokens ("<harness> [<model>] [<effort>]"). For a
# --secondmate spawn, those tokens apply only when this spawn also resolves its
# harness from config/secondmate-harness. An explicit per-spawn --harness,
# positional harness arg, or raw launch command starts with clean model/effort
# defaults unless the caller also passes explicit --model/--effort flags. When
# the file governs the spawn, its model/effort tokens are re-resolved on every
# respawn exactly like the harness axis, and explicit --model/--effort flags
# still win over the file's tokens.
# A --secondmate spawn also propagates the primary's declared inherited local
# material, so the secondmate's OWN crewmates inherit primary config and the
# secondmate receives the primary's read-only shared captain-preference file
# (fm-config-inherit-lib.sh). A successful launch clears pending inherited
# config reread generations because the new agent reads the converged files.
# --scout records kind=scout in the task's meta (report deliverable, scratch worktree;
# see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a
# provisioned firstmate home; the default is kind=ship.
# Before a secondmate launch, the home is locally fast-forwarded to the primary
# default-branch commit when safe; skipped syncs warn and launch unchanged.
# Ship/scout spawns refuse to launch unless the resolved task path is a real
# git worktree root distinct from the primary project checkout.
# Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g.
# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout]
# Each pair re-execs this script in single-task mode, so the single path stays the only
# source of truth; shared --scout/--harness/--model/--effort/--backend applies to every pair.
# If config/crew-dispatch.json exists, shared --harness is required for crewmate
# and scout batches. The loop lives here, in bash, so callers never hand-write a
# multi-task shell loop (the tool shell is zsh, which does not word-split unquoted
# $vars and silently breaks ad-hoc `for ... in $pairs` loops).
# Launch templates live in launch_template() below; placeholders replaced before launch:
# __BRIEF__ absolute path to data/<task-id>/brief.md
# __TURNEND__ absolute path to state/<task-id>.turn-ended (for harnesses whose
# turn-end signal rides the launch command, e.g. codex -c notify=[...])
# __PIEXT__ absolute path to state/<task-id>.pi-ext.ts (pi turn-end extension,
# written by this script; outside the worktree to avoid pi's trust gate)
# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home
# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home
# __OPINPUT__ absolute path to the canonical operational-input encoder
# Verified per-harness turn-end hooks are installed automatically where enabled; some live outside the worktree.
# Kimi uses one surgically installed Firstmate region in $HOME/.kimi-code/config.toml,
# a firstmate-owned global hook and registry, and a gitignored per-task pointer.
# grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks
# plus a gitignored .fm-grok-turnend worktree pointer and a state token.
# On success prints: spawned <id> harness=<name> kind=<ship|scout|secondmate> mode=<mode> yolo=<on|off> window=<backend-target> worktree=<path>
# mode/yolo are resolved per-project from data/projects.md for ship/scout tasks;
# secondmate spawns record mode=secondmate, yolo=off, home=, and projects=.
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
sed -n '2,78p' "$0" | sed 's/^# \{0,1\}//'
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
SUB_HOME_MARKER=".fm-secondmate-home"
# shellcheck source=bin/fm-ff-lib.sh
. "$SCRIPT_DIR/fm-ff-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-config-inherit-lib.sh
. "$SCRIPT_DIR/fm-config-inherit-lib.sh"
# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn
# a direct report (see bin/fm-gate-refuse-lib.sh).
fm_refuse_if_gate_agent
# Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is
# set by the batch loop below), so the guard runs once for the batch, not once per pair.
[ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true
KIND=ship
HARNESS_ARG=
MODEL=
EFFORT=
BACKEND_ARG=
HARNESS_SET=0
MODEL_SET=0
EFFORT_SET=0
BACKEND_SET=0
POS=()
want_value=
for a in "$@"; do
if [ -n "$want_value" ]; then
case "$a" in
--*) echo "error: --$want_value requires a value" >&2; exit 1 ;;
esac
case "$want_value" in
harness) HARNESS_ARG=$a; HARNESS_SET=1 ;;
model) MODEL=$a; MODEL_SET=1 ;;
effort) EFFORT=$a; EFFORT_SET=1 ;;
backend) BACKEND_ARG=$a; BACKEND_SET=1 ;;
*) echo "error: internal parser state for --$want_value" >&2; exit 1 ;;
esac
want_value=
continue
fi
case "$a" in
--scout) KIND=scout ;;
--secondmate) KIND=secondmate ;;
--harness) want_value=harness ;;
--harness=*) HARNESS_ARG=${a#--harness=}; HARNESS_SET=1 ;;
--model) want_value=model ;;
--model=*) MODEL=${a#--model=}; MODEL_SET=1 ;;
--effort) want_value=effort ;;
--effort=*) EFFORT=${a#--effort=}; EFFORT_SET=1 ;;
--backend) want_value=backend ;;
--backend=*) BACKEND_ARG=${a#--backend=}; BACKEND_SET=1 ;;
*) POS+=("$a") ;;
esac
done
[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; }
[ "$HARNESS_SET" -eq 0 ] || [ -n "$HARNESS_ARG" ] || { echo "error: --harness requires a non-empty value" >&2; exit 1; }
[ "$MODEL_SET" -eq 0 ] || [ -n "$MODEL" ] || { echo "error: --model requires a non-empty value" >&2; exit 1; }
[ "$EFFORT_SET" -eq 0 ] || [ -n "$EFFORT" ] || { echo "error: --effort requires a non-empty value" >&2; exit 1; }
[ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; }
case "$EFFORT" in
''|low|medium|high|xhigh|max) ;;
*) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;;
esac
# Backend selection (data/fm-backend-design-d7): explicit --backend, else
# FM_BACKEND env, else config/backend, else runtime auto-detection, else
# default tmux (fm_backend_name). fm_backend_validate_spawn refuses unknown or
# non-spawn-capable backends. The resolved value is
# recorded in meta only when it is NOT tmux (fm-teardown.sh and fm-watch.sh's
# window_backend/fm_backend_of_meta already treat an absent backend= as tmux),
# so the default path's meta stays byte-identical.
if [ "$BACKEND_SET" -eq 1 ]; then
BACKEND=$BACKEND_ARG
else
BACKEND=$(fm_backend_name)
fi
fm_backend_validate_spawn "$BACKEND" || exit 1
fm_backend_source "$BACKEND" || exit 1
if [ "$BACKEND" = orca ] && [ "$KIND" = secondmate ]; then
echo "error: backend=orca does not support --secondmate spawns yet" >&2
exit 1
fi
if [ "$BACKEND" = cmux ] && [ "$KIND" = secondmate ]; then
echo "error: backend=cmux does not support --secondmate spawns yet" >&2
exit 1
fi
if [ "$BACKEND" = orca ]; then
fm_backend_orca_runtime_check || exit 1
fi
ORCA_ABORT_CLEANUP=0
ORCA_WORKTREE_ID=
ORCA_TERMINAL=
HERDR_PROJECTION_ABORT_CLEANUP=0
HERDR_PROJECTION_ABORT_SESSION=
HERDR_PROJECTION_ABORT_TASK_PANE=
HERDR_PROJECTION_ABORT_SEEDED_PANE=
HERDR_PRESENTATION_ORDER_LOCK=
HERDR_PRESENTATION_ORDER_LOCK_HELD=0
SPAWN_TASK_LOCK=
SPAWN_TASK_LOCK_HELD=0
CONFIG_INHERIT_LOCK=
CONFIG_INHERIT_LOCK_HELD=0
parse_orca_worktree_result() {
local raw=$1 rest
ORCA_WORKTREE_ID=${raw%%$'\t'*}
if [ "$raw" = "$ORCA_WORKTREE_ID" ]; then
WT=
ORCA_TERMINAL=
return 1
fi
rest=${raw#*$'\t'}
WT=${rest%%$'\t'*}
if [ "$rest" != "$WT" ]; then
ORCA_TERMINAL=${rest#*$'\t'}
else
ORCA_TERMINAL=
fi
}
spawn_abort_cleanup() {
local status=$?
if [ "$HERDR_PROJECTION_ABORT_CLEANUP" = 1 ] \
&& [ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" != 1 ]; then
if ! spawn_herdr_presentation_order_lock_acquire "${HERDR_PROJECTION_ABORT_SESSION:-}"; then
echo "warning: herdr presentation focus lock unavailable; retaining the projection journal and refusing concurrent abort cleanup" >&2
HERDR_PROJECTION_ABORT_CLEANUP=0
fi
fi
if [ "$HERDR_PROJECTION_ABORT_CLEANUP" = 1 ]; then
HERDR_PROJECTION_ABORT_CLEANUP=0
fm_backend_herdr_projection_cleanup_exact \
"$HERDR_PROJECTION_ABORT_SESSION" \
"$HERDR_PROJECTION_ABORT_TASK_PANE" \
"$HERDR_PROJECTION_ABORT_SEEDED_PANE" || true
fi
if [ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" = 1 ]; then
HERDR_PRESENTATION_ORDER_LOCK_HELD=0
fm_lock_release "$HERDR_PRESENTATION_ORDER_LOCK" || true
fi
if [ "$ORCA_ABORT_CLEANUP" = 1 ]; then
ORCA_ABORT_CLEANUP=0
if [ -n "${ORCA_TERMINAL:-}" ]; then
fm_backend_kill orca "$ORCA_TERMINAL" 2>/dev/null || true
fi
if [ -n "${ORCA_WORKTREE_ID:-}" ]; then
if ! fm_backend_remove_worktree orca "$ORCA_WORKTREE_ID" 2>/dev/null; then
mkdir -p "$STATE" 2>/dev/null || true
if [ -d "$STATE" ]; then
{
echo "window=$W"
echo "worktree=${WT:-}"
echo "project=$PROJ_ABS"
echo "harness=$HARNESS"
echo "kind=$KIND"
echo "mode=${MODE:-no-mistakes}"
echo "yolo=${YOLO:-off}"
echo "tasktmp=${TASK_TMP:-}"
echo "model=${MODEL:-default}"
echo "effort=${EFFORT:-default}"
echo "backend=orca"
echo "orca_worktree_id=$ORCA_WORKTREE_ID"
[ -z "${ORCA_TERMINAL:-}" ] || echo "terminal=$ORCA_TERMINAL"
} > "$STATE/$ID.meta" 2>/dev/null || true
fi
fi
fi
fi
if [ "$SPAWN_TASK_LOCK_HELD" = 1 ]; then
SPAWN_TASK_LOCK_HELD=0
fm_lock_release "$SPAWN_TASK_LOCK" || true
fi
if [ "$CONFIG_INHERIT_LOCK_HELD" = 1 ]; then
CONFIG_INHERIT_LOCK_HELD=0
fm_lock_release "$CONFIG_INHERIT_LOCK" || true
fi
return "$status"
}
trap spawn_abort_cleanup EXIT
# One bounded lock per live Herdr session/socket, shared across all homes.
# <session> is required so secondmate and primary spawns serialize against the
# same session without writing any other home's state directory.
spawn_herdr_presentation_order_lock_acquire() {
local session=${1:-} attempt lock_path
[ -n "$session" ] || session=$(fm_backend_herdr_session)
lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") || return 1
HERDR_PRESENTATION_ORDER_LOCK="$lock_path"
attempt=0
while [ "$attempt" -lt 50 ]; do
if fm_lock_try_acquire "$HERDR_PRESENTATION_ORDER_LOCK"; then
HERDR_PRESENTATION_ORDER_LOCK_HELD=1
return 0
fi
sleep 0.1
attempt=$((attempt + 1))
done
return 1
}
spawn_herdr_presentation_order_lock_release() {
[ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" = 1 ] || return 0
HERDR_PRESENTATION_ORDER_LOCK_HELD=0
fm_lock_release "$HERDR_PRESENTATION_ORDER_LOCK" || true
}
# Batch dispatch (see header): when the first positional is an `id=repo` pair, treat every
# positional as one and spawn each by re-execing this script in single-task mode. We use
# the FM_ROOT path (not $0) so it works whatever cwd or relative path invoked us, and reuse
# the single path verbatim. A failed pair is reported and skipped; the rest still launch;
# exit is non-zero if any pair failed. Single-task invocations never carry an '=' in arg
# one (task ids are bare slugs), so they fall straight through to the logic below.
idpart=${POS[0]:-}
idpart=${idpart%%=*}
if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in */*) false ;; *) true ;; esac; then
if [ "$KIND" != secondmate ] && [ -z "$HARNESS_ARG" ] && [ -f "$CONFIG/crew-dispatch.json" ]; then
echo "error: config/crew-dispatch.json is active - pass an explicit harness resolved from the dispatch rules (the consultation backstop, so the rules are never silently skipped)." >&2
exit 1
fi
rc=0
shared_args=()
[ -z "$HARNESS_ARG" ] || shared_args+=(--harness "$HARNESS_ARG")
[ -z "$MODEL" ] || shared_args+=(--model "$MODEL")
[ -z "$EFFORT" ] || shared_args+=(--effort "$EFFORT")
[ -z "$BACKEND_ARG" ] || shared_args+=(--backend "$BACKEND_ARG")
for pair in "${POS[@]}"; do
case "$pair" in
*=*) : ;;
*) echo "error: batch dispatch expects every argument as id=repo; got '$pair'" >&2; rc=2; continue ;;
esac
if [ "$KIND" = secondmate ]; then
echo "error: batch dispatch does not support --secondmate; spawn each secondmate explicitly" >&2
rc=2
continue
elif [ "$KIND" = scout ]; then
if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" "${shared_args[@]+"${shared_args[@]}"}" --scout; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi
else
if FM_SPAWN_NO_GUARD=1 "$FM_ROOT/bin/fm-spawn.sh" "${pair%%=*}" "${pair#*=}" "${shared_args[@]+"${shared_args[@]}"}"; then :; else echo "batch: FAILED to spawn ${pair%%=*} (${pair#*=})" >&2; rc=1; fi
fi
done
exit "$rc"
fi
ID=${POS[0]}
fm_task_id_creation_valid "$ID" || { echo "error: invalid task id" >&2; exit 2; }
SPAWN_TASK_LOCK="$STATE/.spawn-$ID.lock"
if ! fm_lock_try_acquire "$SPAWN_TASK_LOCK"; then
echo "error: another spawn is already creating task $ID" >&2
exit 1
fi
SPAWN_TASK_LOCK_HELD=1
PROJ=
ARG3=
FIRSTMATE_HOME=
if [ "$KIND" = secondmate ]; then
case "${POS[1]:-}" in
''|claude|codex|opencode|pi|pi-signed|grok|kimi)
ARG3=${POS[1]:-}
;;
*' '*)
if [ "${#POS[@]}" -gt 2 ] || [ -d "${POS[1]}" ]; then
FIRSTMATE_HOME=${POS[1]}
ARG3=${POS[2]:-}
else
ARG3=${POS[1]}
fi
;;
*)
FIRSTMATE_HOME=${POS[1]}
ARG3=${POS[2]:-}
;;
esac
else
PROJ=${POS[1]}
ARG3=${POS[2]:-}
fi
[ -z "$HARNESS_ARG" ] || ARG3=$HARNESS_ARG
# The verified launch command per adapter. The knowledge half of each adapter
# (busy signature, exit command, dialogs, quirks) lives in the harness-adapters skill.
launch_template() {
local harness=$1 kind=${2:-ship}
# shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here
case "$harness" in
# CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive
# predicted-next-prompt ghost text, which renders as dim/faint text inside an
# otherwise-empty composer and would otherwise read like real typed input when
# firstmate captures the pane (see the harness-adapters skill). It is a per-launch env
# prefix scoped to this firstmate-launched agent; it never touches the captain's
# global config. The CLI's --prompt-suggestions flag is print/SDK-mode only and
# does NOT suppress the interactive ghost text (verified empirically), so the env
# var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is
# the defense-in-depth backstop for any pane this flag cannot reach.
claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;;
codex)
if [ "$kind" = secondmate ]; then
printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
else
printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
fi
;;
opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;;
pi|pi-signed)
if [ "$kind" = secondmate ]; then
printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
else
printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
fi
;;
# grok (Grok Build TUI): a positional prompt starts the supervised interactive
# session. --always-approve auto-approves every tool execution (verified: the
# crewmate runs fully autonomously, no permission gate), which an unattended
# crewmate needs; it is the targeted equivalent of claude's
# --dangerously-skip-permissions. grok's turn-end signal does NOT ride the
# launch command - it is a Stop-event hook installed below (global hook +
# per-task pointer), so the template is identical for ship/scout/secondmate.
grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;;
# Kimi Code rejects a positional prompt, so it launches bare and receives
# only an absolute brief pointer after the TUI readiness gate below.
# Its turn-end signal is a globally configured Stop hook plus a guarded
# per-task worktree token, so no launch placeholder belongs here.
kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;;
*) return 1 ;;
esac
}
case "$ARG3" in
*' '*) # raw launch command (unverified-adapter escape hatch)
LAUNCH=$ARG3
HARNESS=""
for word in $LAUNCH; do
case "$word" in [A-Za-z_]*=*) continue ;; *) HARNESS=$(basename "$word"); break ;; esac
done
;;
'')
# No explicit harness: resolve from config. A secondmate AGENT launches on the
# secondmate harness (config/secondmate-harness -> config/crew-harness -> own);
# every other kind uses the crew harness only when no dispatch profile file is
# active. Resolving here on every spawn is what makes the split DURABLE - a
# respawn (recovery, /updatefirstmate, restart) re-resolves, so
# config/secondmate-harness keeps governing secondmate launches across restarts.
# The launch_template lookup below is the unverified-adapter guard for both
# kinds: a harness with no template aborts the spawn.
if [ "$KIND" = secondmate ]; then
HARNESS=$("$FM_ROOT/bin/fm-harness.sh" secondmate)
harness_src='config/secondmate-harness (falling back to config/crew-harness)'
else
if [ -f "$CONFIG/crew-dispatch.json" ]; then
echo "error: config/crew-dispatch.json is active - pass an explicit harness resolved from the dispatch rules (the consultation backstop, so the rules are never silently skipped)." >&2
exit 1
fi
HARNESS=$("$FM_ROOT/bin/fm-harness.sh" crew)
harness_src='config/crew-harness'
fi
LAUNCH=$(launch_template "$HARNESS" "$KIND") || { echo "error: no launch template for harness '$HARNESS' (from $harness_src or detection); pass a raw launch command to use an unverified adapter" >&2; exit 1; }
;;
*)
HARNESS=$ARG3
LAUNCH=$(launch_template "$HARNESS" "$KIND") || { echo "error: unknown harness '$HARNESS'; pass a raw launch command to use an unverified adapter" >&2; exit 1; }
;;
esac
case "$HARNESS" in
pi|pi-signed) LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" ;;
esac
# pi-signed is an explicitly selected executable identity, not an alias that may
# silently fall back to pi. Resolve it from PATH before creating an endpoint and
# retain the literal name in the launch command and task metadata.
if [ "$HARNESS" = pi-signed ] && ! command -v pi-signed >/dev/null 2>&1; then
echo "error: pi-signed executable not found on PATH; install the signed Pi wrapper or select a different verified harness" >&2
exit 1
fi
# config/secondmate-harness may carry optional model/effort tokens alongside the
# harness ("<harness> [<model>] [<effort>]"). They apply only when this is a
# --secondmate spawn and no explicit per-spawn harness/raw launch was supplied, so
# the harness itself came from the secondmate config fallback chain. Resolving
# here on every spawn makes the pin durable across respawns. Precedence: explicit
# --model/--effort flags still win over the file's tokens.
if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then
if [ "$MODEL_SET" -eq 0 ]; then
SM_MODEL=$("$SCRIPT_DIR/fm-harness.sh" secondmate-model)
[ -z "$SM_MODEL" ] || MODEL=$SM_MODEL
fi
if [ "$EFFORT_SET" -eq 0 ]; then
SM_EFFORT=$("$SCRIPT_DIR/fm-harness.sh" secondmate-effort)
if [ -n "$SM_EFFORT" ]; then
case "$SM_EFFORT" in
low|medium|high|xhigh|max) EFFORT=$SM_EFFORT ;;
*) echo "warning: config/secondmate-harness effort token '$SM_EFFORT' is not one of low, medium, high, xhigh, max; ignoring" >&2 ;;
esac
fi
fi
fi
secondmate_registry_value() {
local id=$1 key=$2 reg line value
reg="$DATA/secondmates.md"
[ -f "$reg" ] || return 1
line=$(grep -E "^- $id( |$)" "$reg" | tail -1 || true)
[ -n "$line" ] || return 1
case "$key" in
home) value=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p') ;;
projects) value=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: [^;)]*; scope: [^;)]*; projects: \([^;)]*\); added .*/\1/p') ;;
*) return 1 ;;
esac
[ -n "$value" ] || return 1
printf '%s\n' "$value"
}
shell_quote() {
printf "'"
printf '%s' "$1" | sed "s/'/'\\\\''/g"
printf "'"
}
resolve_kimi_binary() {
local candidate dir fallback
candidate=$(command -v kimi 2>/dev/null || true)
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
case "$candidate" in
/*) printf '%s\n' "$candidate"; return 0 ;;
*)
dir=$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P) || dir=
if [ -n "$dir" ]; then
printf '%s/%s\n' "$dir" "$(basename "$candidate")"
return 0
fi
;;
esac
fi
fallback="${HOME:-}/.kimi-code/bin/kimi"
if [ -n "${HOME:-}" ] && [ -x "$fallback" ]; then
printf '%s\n' "$fallback"
return 0
fi
echo "error: kimi executable not found; searched PATH for 'kimi' and fallback '$fallback'" >&2
return 1
}
model_flag_for_harness() {
local harness=$1 model=$2
[ -n "$model" ] && [ "$model" != default ] || return 0
case "$harness" in
claude|codex|opencode|pi|pi-signed|grok|kimi)
printf -- '--model %s ' "$(shell_quote "$model")"
;;
esac
}
effort_flag_for_harness() {
local harness=$1 effort=$2
[ -n "$effort" ] && [ "$effort" != default ] || return 0
case "$harness" in
claude)
case "$effort" in
low|medium|high|xhigh|max) printf -- '--effort %s ' "$(shell_quote "$effort")" ;;
esac
;;
codex)
# The installed codex config schema uses model_reasoning_effort, and the
# bundled model catalog advertises low|medium|high|xhigh. Omit max rather
# than passing an unsupported value.
case "$effort" in
low|medium|high|xhigh) printf -- '-c %s ' "$(shell_quote "model_reasoning_effort=\"$effort\"")" ;;
esac
;;
grok)
# grok exposes both --effort and --reasoning-effort; firstmate's profile
# axis is the reasoning knob. As of grok 0.2.99, --reasoning-effort accepts
# only low|medium|high and rejects both xhigh and max, so omit those rather
# than passing a known-bad value.
case "$effort" in
low|medium|high) printf -- '--reasoning-effort %s ' "$(shell_quote "$effort")" ;;
esac
;;
pi|pi-signed)
# Pi 0.80.6 accepts the full shared effort vocabulary, including max, through
# its --thinking flag.
case "$effort" in
low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;;
esac
;;
# opencode's interactive `opencode --prompt` launch has a verified --model
# flag but no verified effort flag. Its `opencode run --variant` flag belongs
# to a different, non-interactive launch mode, so fm-spawn does not pass it.
# kimi likewise has no reasoning-effort flag; the requested axis stays in
# task metadata but never reaches the launch command.
esac
}
case "$LAUNCH" in
*__KIMIBIN__*)
KIMI_BIN=$(resolve_kimi_binary) || exit 1
LAUNCH=${LAUNCH//__KIMIBIN__/$(shell_quote "$KIMI_BIN")}
if [ "$KIND" != secondmate ]; then
"$FM_ROOT/bin/fm-kimi-turnend-hook.sh" install || {
echo "error: refusing Kimi spawn because the global turn-end hook could not be installed safely" >&2
exit 1
}
fi
;;
esac
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
resolved_existing_dir() {
local path=$1
[ -d "$path" ] || { echo "error: firstmate home does not exist or is not a directory: $path" >&2; return 1; }
cd "$path" && pwd -P
}
resolve_project_dir_arg() {
local path=$1
case "$path" in
projects/*) printf '%s/%s\n' "$PROJECTS" "${path#projects/}" ;;
*) printf '%s\n' "$path" ;;
esac
}
path_is_ancestor_of() {
local ancestor=$1 path=$2
[ -n "$ancestor" ] || return 1
[ -n "$path" ] || return 1
[ "$ancestor" != "$path" ] || return 1
case "$path" in
"$ancestor"/*) return 0 ;;
esac
return 1
}
validate_firstmate_home_for_spawn() {
local id=$1 home=$2 abs_home abs_active_home abs_root marker_id
abs_home=$(resolved_existing_dir "$home") || return 1
abs_active_home=$(resolved_existing_dir "$FM_HOME")
abs_root=$(resolved_existing_dir "$FM_ROOT")
if [ "$abs_home" = "/" ]; then
echo "error: secondmate home cannot be the filesystem root: $home" >&2
return 1
fi
if [ "$abs_home" = "$abs_active_home" ]; then
echo "error: secondmate home cannot be the active firstmate home: $home" >&2
return 1
fi
if [ "$abs_home" = "$abs_root" ]; then
echo "error: secondmate home cannot be the firstmate repo: $home" >&2
return 1
fi
if path_is_ancestor_of "$abs_active_home" "$abs_home"; then
echo "error: secondmate home cannot be inside the active firstmate home: $home" >&2
return 1
fi
if path_is_ancestor_of "$abs_root" "$abs_home"; then
echo "error: secondmate home cannot be inside the firstmate repo: $home" >&2
return 1
fi
if path_is_ancestor_of "$abs_home" "$abs_active_home"; then
echo "error: secondmate home cannot be an ancestor of the active firstmate home: $home" >&2
return 1
fi
if path_is_ancestor_of "$abs_home" "$abs_root"; then
echo "error: secondmate home cannot be an ancestor of the firstmate repo: $home" >&2
return 1
fi
validate_firstmate_operational_dirs "$abs_home" "$abs_active_home" "$abs_root" || return 1
if [ ! -f "$abs_home/$SUB_HOME_MARKER" ]; then
echo "error: firstmate home $home is not a seeded secondmate home" >&2
return 1
fi
marker_id=$(cat "$abs_home/$SUB_HOME_MARKER" 2>/dev/null || true)
if [ "$marker_id" != "$id" ]; then
echo "error: firstmate home $home is marked for secondmate ${marker_id:-unknown}, expected $id" >&2
return 1
fi
if [ ! -f "$abs_home/AGENTS.md" ]; then
echo "error: $home is not a firstmate home (missing AGENTS.md)" >&2
return 1
fi
if [ ! -d "$abs_home/bin" ]; then
echo "error: $home is not a firstmate home (missing bin/)" >&2
return 1
fi
printf '%s\n' "$abs_home"
}
validate_firstmate_operational_dirs() {
local abs_home=$1 abs_active_home=$2 abs_root=$3 name dir abs_dir
for name in data state config projects; do
dir="$abs_home/$name"
if [ -L "$dir" ] && [ ! -e "$dir" ]; then
echo "error: secondmate $name directory must resolve inside the secondmate home: $dir" >&2
return 1
fi
if [ -d "$dir" ]; then
abs_dir=$(cd "$dir" && pwd -P)
elif [ -e "$dir" ]; then
echo "error: secondmate $name path is not a directory: $dir" >&2
return 1
else
abs_dir="$abs_home/$name"
fi
if ! path_is_ancestor_of "$abs_home" "$abs_dir"; then
echo "error: secondmate $name directory must resolve inside the secondmate home: $dir" >&2
return 1
fi
if [ "$abs_dir" = "$abs_active_home" ] || path_is_ancestor_of "$abs_active_home" "$abs_dir"; then
echo "error: secondmate $name directory cannot be inside the active firstmate home: $dir" >&2
return 1
fi
if [ "$abs_dir" = "$abs_root" ] || path_is_ancestor_of "$abs_root" "$abs_dir"; then
echo "error: secondmate $name directory cannot be inside the firstmate repo: $dir" >&2
return 1
fi
done
}
if [ "$KIND" = secondmate ]; then
if [ -z "$FIRSTMATE_HOME" ] && [ -f "$STATE/$ID.meta" ]; then
FIRSTMATE_HOME=$(grep '^home=' "$STATE/$ID.meta" | cut -d= -f2- || true)
fi
if [ -z "$FIRSTMATE_HOME" ]; then
FIRSTMATE_HOME=$(secondmate_registry_value "$ID" home || true)
fi
fi
if [ "$KIND" = secondmate ]; then
[ -n "$FIRSTMATE_HOME" ] || { echo "error: no firstmate home supplied or registered for $ID" >&2; exit 1; }
PROJ_ABS=$(validate_firstmate_home_for_spawn "$ID" "$FIRSTMATE_HOME")
WT="$PROJ_ABS"
# Local-HEAD sync: before launch, fast-forward this secondmate's worktree to the
# PRIMARY checkout's current default-branch commit, so a freshly spawned or
# recovery-respawned secondmate always runs the primary's version (AGENTS.md
# spawn section). Purely local - no fetch: the home is a worktree of this same
# repo and already holds the commit. ff-only and guarded; a dirty, diverged, or
# wrong-branch home is left untouched and launches as-is. The agent re-reads
# AGENTS.md fresh on launch, so no nudge is needed here.
if sm_primary_head=$(primary_head_commit "$FM_ROOT"); then
sm_ff_out=$(ff_target "$PROJ_ABS" "secondmate $ID" "$sm_primary_head" yes yes 2>&1 || true)
case "$sm_ff_out" in
*': skipped:'*)
sm_ff_line=$(first_line "$sm_ff_out")
sm_ff_prefix="secondmate $ID: skipped: "
sm_ff_reason=${sm_ff_line#"$sm_ff_prefix"}
echo "warning: secondmate $ID sync skipped before launch: $sm_ff_reason" >&2
;;
esac
else
echo "warning: secondmate $ID sync skipped before launch: primary default-branch commit cannot be resolved" >&2
fi
mkdir -p "$PROJ_ABS/state" || {
echo "error: could not create secondmate state directory for $PROJ_ABS" >&2
exit 1
}
CONFIG_INHERIT_LOCK=$(fm_config_inherit_lock_path "$PROJ_ABS") || {
echo "error: could not resolve secondmate inheritance lock for $PROJ_ABS" >&2
exit 1
}
if ! fm_lock_acquire_wait "$CONFIG_INHERIT_LOCK"; then
echo "error: could not acquire secondmate inheritance lock for $PROJ_ABS" >&2
exit 1
fi
CONFIG_INHERIT_LOCK_HELD=1
# Inheritance propagation: push the primary-authoritative local inheritance
# surface into this secondmate home (fm-config-inherit-lib.sh).
propagate_secondmate_inheritance "$FM_HOME" "$PROJ_ABS" "$CONFIG" "$DATA" \
|| echo "warning: secondmate $ID inheritance failed for $PROJ_ABS" >&2
if [ -f "$PROJ_ABS/data/charter.md" ]; then
BRIEF="$PROJ_ABS/data/charter.md"
else
BRIEF="$DATA/$ID/brief.md"
fi
else
PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)"
WT=""
BRIEF="$DATA/$ID/brief.md"
fi
[ -f "$BRIEF" ] || { echo "error: no brief at $BRIEF" >&2; exit 1; }
BRIEF_DIR_REAL=$(cd "$(dirname "$BRIEF")" && pwd -P)
BRIEF_REAL="$BRIEF_DIR_REAL/$(basename "$BRIEF")"
# PROJ_ABS can still carry a symlinked path component (e.g. macOS's /tmp ->
# /private/tmp) when it came from the ship/scout branch's logical `pwd` above.
# Every backend's own current-path read (tmux's pane_current_path, herdr's
# foreground_cwd, zellij/cmux's active pwd probe against the live shell) can
# report the OS-level, physically-resolved cwd, so comparing it against a
# still-symlinked PROJ_ABS can misfire both ways: false-negative (the poll
# below never notices the pane left the project) or false-positive (the
# isolation guard refuses a spawn that never actually tangled). Canonicalize
# once here so every downstream comparison uses the same physical form
# (docs/herdr-backend.md "Known gaps").
PROJ_ABS_REAL=$(cd "$PROJ_ABS" 2>/dev/null && pwd -P) || PROJ_ABS_REAL="$PROJ_ABS"
real_path_or_raw() { # <path>
local path=$1 real
if real=$(cd "$path" 2>/dev/null && pwd -P); then
printf '%s\n' "$real"
else
printf '%s\n' "$path"
fi
}
# Session-provider container-ensure + task creation. tmux stays exactly as P1
# left it (same session-name / new-window sequence, see bin/backends/tmux.sh);
# a herdr spawn goes through the version-gated, workspace-per-HOME,
# tab-per-task sequence in bin/backends/herdr.sh instead (D4/D5 as refined by
# docs/herdr-backend.md's "workspace-per-home" pass, AGENTS.md task
# herdr-sm-spaces-k4). Both branches converge on the same $T ("target") string
# that every downstream operation (send/capture/kill) already treats as opaque
# per-backend routing (fm_backend_resolve_selector).
validate_spawn_worktree() { # <source> <inspect-target>
local source=$1 inspect_target=$2 wt_real proj_real wt_top wt_top_real
wt_real=
if ! wt_real=$(cd "$WT" 2>/dev/null && pwd -P); then
wt_real=
fi
proj_real=$PROJ_ABS_REAL
wt_top=$(git -C "$WT" rev-parse --show-toplevel 2>/dev/null || true)
wt_top_real=
if ! wt_top_real=$(cd "$wt_top" 2>/dev/null && pwd -P); then
wt_top_real=
fi
if [ -z "$wt_real" ] || [ -z "$wt_top_real" ] || [ "$wt_real" != "$wt_top_real" ] || [ "$wt_real" = "$proj_real" ]; then
echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${wt_top:-none}'; primary '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2
exit 1
fi
}
herdr_projection_meta_field_exact() { # <meta> <key>
local meta=$1 key=$2 count
[ -f "$meta" ] && [ ! -L "$meta" ] || return 1
count=$(grep -c "^${key}=" "$meta" 2>/dev/null || true)
[ "$count" = 1 ] || return 1
grep "^${key}=" "$meta" 2>/dev/null | cut -d= -f2-
}
# A stale presentation journal never grants launch authority.
# Under the session lock, authoritative metadata must identify one positively
# dead or agent-free endpoint before token inspection may allow flat fallback.
# Exact Herdr fields are retained for the narrower version 2 reclaim path.
herdr_projection_existing_meta_allows_flat() { # <meta>
local meta=$1 old_backend old_target old_session old_pane old_state target_session target_pane
HERDR_RECOVERY_BACKEND=""
HERDR_RECOVERY_WORKSPACE_ID=""
HERDR_RECOVERY_TAB_ID=""
HERDR_RECOVERY_PANE_ID=""
old_backend=$(fm_backend_of_meta "$meta")
old_target=$(fm_backend_target_of_meta "$meta")
[ -n "$old_target" ] || {
echo "error: existing metadata for $ID has no endpoint; refusing duplicate launch while its herdr presentation journal is quarantined" >&2
return 1
}
HERDR_RECOVERY_BACKEND=$old_backend
if [ "$old_backend" = herdr ]; then
fm_backend_herdr_parse_target "$old_target" || {
echo "error: existing herdr endpoint for $ID is malformed; refusing duplicate launch" >&2
return 1
}
target_session=$FM_BACKEND_HERDR_SESSION
target_pane=$FM_BACKEND_HERDR_PANE
old_session=$(herdr_projection_meta_field_exact "$meta" herdr_session) || {
echo "error: existing herdr metadata for $ID has an ambiguous session; refusing duplicate launch" >&2
return 1
}
HERDR_RECOVERY_WORKSPACE_ID=$(herdr_projection_meta_field_exact "$meta" herdr_workspace_id) || {
echo "error: existing herdr metadata for $ID has an ambiguous workspace; refusing duplicate launch" >&2
return 1
}
HERDR_RECOVERY_TAB_ID=$(herdr_projection_meta_field_exact "$meta" herdr_tab_id) || {
echo "error: existing herdr metadata for $ID has an ambiguous tab; refusing duplicate launch" >&2
return 1
}
old_pane=$(herdr_projection_meta_field_exact "$meta" herdr_pane_id) || {
echo "error: existing herdr metadata for $ID has an ambiguous pane; refusing duplicate launch" >&2
return 1
}
[ "$target_session" = "$old_session" ] && [ "$target_pane" = "$old_pane" ] || {
echo "error: existing herdr metadata for $ID has inconsistent endpoint identities; refusing duplicate launch" >&2
return 1
}
HERDR_RECOVERY_PANE_ID=$old_pane
fm_backend_herdr_server_ensure "$old_session" || {
echo "error: existing herdr endpoint for $ID could not be inspected; refusing duplicate launch" >&2
return 1
}
old_state=$(fm_backend_herdr_pane_agent_state "$old_session" "$old_pane")
case "$old_state" in
dead|no-agent) return 0 ;;
live|unknown)
echo "error: existing herdr endpoint for $ID is $old_state; refusing duplicate launch" >&2
return 1
;;
esac
fi
old_state=$(fm_backend_agent_alive "$old_backend" "$old_target")
case "$old_state" in
dead) return 0 ;;
alive|unknown)
echo "error: existing $old_backend endpoint for $ID is $old_state; refusing duplicate launch" >&2
return 1
;;
esac
}
W="fm-$ID"
case "$BACKEND" in
tmux)
SES=$(fm_backend_tmux_container_ensure)
T="$SES:$W"
# #134 robustness (tmux): fm_backend_tmux_create_task captures a stable window
# id and pins the window name (automatic-rename/allow-rename off) so a captain's
# non-default tmux config cannot rename the window away from fm-<id> once
# treehouse cd's into the worktree. WT_TARGET carries that stable id for the
# rename-critical worktree-detection steps below; the persisted window= handle
# stays $T (the name form), which is safe now that rename is disabled.
WID=$(fm_backend_tmux_create_task "$SES" "$W" "$PROJ_ABS") || exit 1
WT_TARGET="$WID"
;;
herdr)
# fm_backend_herdr_workspace_label resolves the target workspace from
# FM_HOME. For every KIND except secondmate, this process's own FM_HOME is
# already the right home (the primary spawning its own crewmate/scout, or
# a secondmate spawning ITS OWN crewmate/scout from its own process's
# FM_HOME - the latter needs no glue at all). A --secondmate spawn is the
# one case that does: it is the PRIMARY's own fm-spawn.sh process
# launching a DIFFERENT home (PROJ_ABS, already validated above as the
# secondmate's home), so FM_HOME here still names the primary. Shadow it
# to PROJ_ABS for just these two calls (bash restores it automatically
# after each prefixed simple-command call) so the secondmate's tab lands
# in the secondmate's own workspace, not the primary's "firstmate" one.
HERDR_LABEL_HOME=$FM_HOME
if [ "$KIND" = secondmate ]; then
HERDR_LABEL_HOME=$PROJ_ABS
fi
HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID")
HERDR_PROJECTED=0
if [ "$KIND" != secondmate ] && [ -f "$CONFIG/herdr-presentation-spaces" ]; then
HERDR_SES=$(fm_backend_herdr_session)
HERDR_PARENT_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label)
if [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; then
fm_backend_herdr_server_ensure "$HERDR_SES" || {
echo "error: herdr presentation recovery could not ensure its exact named session" >&2
exit 1
}
spawn_herdr_presentation_order_lock_acquire "$HERDR_SES" || {
echo "error: herdr presentation recovery could not acquire its session lock; refusing a concurrent resume" >&2
exit 1
}
if [ -e "$STATE/$ID.meta" ] || [ -L "$STATE/$ID.meta" ]; then
herdr_projection_existing_meta_allows_flat "$STATE/$ID.meta" || exit 1
fi
fm_backend_herdr_projection_recovery_allows_flat \
"$HERDR_SES" "$HERDR_PRESENTATION_JOURNAL" "$ID" || exit 1
if [ "${HERDR_RECOVERY_BACKEND:-}" = herdr ]; then
set +e
FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_projection_reclaim_task \
"$HERDR_SES" "$HERDR_PRESENTATION_JOURNAL" "$ID" "$HERDR_LABEL_HOME" \
"$HERDR_RECOVERY_WORKSPACE_ID" "$HERDR_RECOVERY_TAB_ID" "$HERDR_RECOVERY_PANE_ID" \
"$HERDR_PARENT_LABEL" "$W" "$PROJ_ABS"
HERDR_RECLAIM_STATUS=$?
set -e
case "$HERDR_RECLAIM_STATUS" in
0)