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
·1064 lines (1028 loc) · 46.3 KB
/
Copy pathfm-spawn.sh
File metadata and controls
executable file
·1064 lines (1028 loc) · 46.3 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.
# 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|grok)
# 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.
# 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 inheritable config
# into the secondmate home's config/, so the secondmate's OWN crewmates,
# dispatch profiles, and backlog backend inherit the primary's settings
# (fm-config-inherit-lib.sh).
# --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
# Per-harness turn-end hooks are installed automatically; some live outside the worktree.
# 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-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=
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
}
orca_spawn_abort_cleanup() {
local status=$?
[ "$ORCA_ABORT_CLEANUP" = 1 ] || return "$status"
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
return "$status"
}
trap orca_spawn_abort_cleanup EXIT
# 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; }
PROJ=
ARG3=
FIRSTMATE_HOME=
if [ "$KIND" = secondmate ]; then
case "${POS[1]:-}" in
''|claude|codex|opencode|pi|grok)
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__"$(cat __BRIEF__)"' ;;
codex)
if [ "$kind" = secondmate ]; then
printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"'
else
printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(cat __BRIEF__)"'
fi
;;
opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(cat __BRIEF__)"' ;;
pi)
if [ "$kind" = secondmate ]; then
printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(cat __BRIEF__)"'
else
printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(cat __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__"$(cat __BRIEF__)"' ;;
*) 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
# 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 "'"
}
model_flag_for_harness() {
local harness=$1 model=$2
[ -n "$model" ] && [ "$model" != default ] || return 0
case "$harness" in
claude|codex|opencode|pi|grok)
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 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.
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
# Inheritable-config propagation: push the primary's declared LOCAL config into
# this secondmate home's config/, so the secondmate's OWN crewmates and backlog
# backend inherit the primary's settings. config/ is gitignored, so this is a
# separate copy from the local-HEAD fast-forward above;
# primary-authoritative and re-pushed on every convergence. config/secondmate-harness
# is the primary's own knob and is deliberately NOT in the inheritable set
# (fm-config-inherit-lib.sh). A primary with no inheritable config set is a no-op.
propagate_inheritable_config "$CONFIG" "$PROJ_ABS/config" \
|| echo "warning: secondmate $ID config inheritance failed for $PROJ_ABS/config" >&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; }
# 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
}
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_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS") || exit 1
# fm_backend_herdr_container_ensure echoes "<session>:<workspace_id>\t<seeded_default_tab_id>"
# (the second field empty when this call ADOPTED a pre-existing workspace
# rather than creating a fresh one). Split on the guaranteed single tab
# character; the seeded tab id is threaded through to create_task
# untouched, which is the only function permitted to prune it (never
# re-derived from labels - see docs/herdr-backend.md "Default-tab prune").
CONTAINER=${HERDR_CONTAINER_RAW%%$'\t'*}
HERDR_SEEDED_DEFAULT_TAB_ID=${HERDR_CONTAINER_RAW#*$'\t'}
HERDR_SES=${CONTAINER%%:*}
HERDR_WORKSPACE_ID=${CONTAINER#*:}
HERDR_TASK_IDS=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_create_task "$CONTAINER" "$W" "$PROJ_ABS" "$HERDR_SEEDED_DEFAULT_TAB_ID") || exit 1
read -r HERDR_TAB_ID HERDR_PANE_ID <<EOF
$HERDR_TASK_IDS
EOF
if [ -z "$HERDR_TAB_ID" ] || [ -z "$HERDR_PANE_ID" ]; then
echo "error: herdr did not return a tab/pane id for $W" >&2
exit 1
fi
T="$HERDR_SES:$HERDR_PANE_ID"
;;
zellij)
ZELLIJ_SES=$(fm_backend_zellij_container_ensure) || exit 1
ZELLIJ_TASK_IDS=$(fm_backend_zellij_create_task "$ZELLIJ_SES" "$W" "$PROJ_ABS") || exit 1
read -r ZELLIJ_TAB_ID ZELLIJ_PANE_ID <<EOF
$ZELLIJ_TASK_IDS
EOF
if [ -z "$ZELLIJ_TAB_ID" ] || [ -z "$ZELLIJ_PANE_ID" ]; then
echo "error: zellij did not return a tab/pane id for $W" >&2
exit 1
fi
T="$ZELLIJ_SES:$ZELLIJ_PANE_ID"
;;
cmux)
fm_backend_cmux_container_ensure || exit 1
CMUX_TASK_IDS=$(fm_backend_cmux_create_task "$W" "$PROJ_ABS") || exit 1
read -r CMUX_WORKSPACE_ID CMUX_SURFACE_ID <<EOF
$CMUX_TASK_IDS
EOF
if [ -z "$CMUX_WORKSPACE_ID" ] || [ -z "$CMUX_SURFACE_ID" ]; then
echo "error: cmux did not return a workspace/surface id for $W" >&2
exit 1
fi
T="$CMUX_WORKSPACE_ID:$CMUX_SURFACE_ID"
;;
orca)
set +e
ORCA_WT_RAW=$(fm_backend_orca_worktree_create "$PROJ_ABS" "$W")
ORCA_WT_STATUS=$?
set -e
if [ "$ORCA_WT_STATUS" -ne 0 ]; then
if [ "$ORCA_WT_STATUS" -eq 2 ] && [ -n "$ORCA_WT_RAW" ]; then
if parse_orca_worktree_result "$ORCA_WT_RAW" && [ -n "$ORCA_WORKTREE_ID" ]; then
ORCA_ABORT_CLEANUP=1
fi
fi
exit 1
fi
parse_orca_worktree_result "$ORCA_WT_RAW" || true
ORCA_ABORT_CLEANUP=1
if [ -z "$ORCA_WORKTREE_ID" ] || [ -z "$WT" ]; then
echo "error: orca did not return a worktree id/path for $W" >&2
exit 1
fi
validate_spawn_worktree "orca worktree create" "$W"
if [ -z "$ORCA_TERMINAL" ]; then
ORCA_TERMINAL=$(fm_backend_orca_terminal_create "$ORCA_WORKTREE_ID" "$W") || exit 1
fi
T="$ORCA_TERMINAL"
;;
esac
# #134 robustness: only tmux needs a worktree-detection target distinct from $T -
# its rename-safe stable window id, set as WT_TARGET=$WID in the tmux branch above.
# Every other backend addresses its pane/surface by the id already in $T, so default
# WT_TARGET to $T for them (and for any future backend) - the shared treehouse-get +
# worktree-detection steps below must never reference an unbound WT_TARGET under set -u.
: "${WT_TARGET:=$T}"
spawn_send_text_line() { # <target> <text>
case "$BACKEND" in
tmux) fm_backend_tmux_send_text_line "$1" "$2" ;;
herdr) fm_backend_herdr_send_text_line "$1" "$2" ;;
zellij) fm_backend_zellij_send_text_line "$1" "$2" "$W" ;;
orca) fm_backend_orca_send_text_line "$1" "$2" ;;
cmux) fm_backend_cmux_send_text_line "$1" "$2" "$W" ;;
esac
}
spawn_current_path() { # <target>
case "$BACKEND" in
tmux) fm_backend_tmux_current_path "$1" ;;
herdr) fm_backend_herdr_current_path "$1" ;;
zellij) fm_backend_zellij_current_path "$1" "$W" ;;
cmux) fm_backend_cmux_current_path "$1" "$W" ;;
esac
}
spawn_send_literal() { # <target> <text>
case "$BACKEND" in
tmux) fm_backend_tmux_send_literal "$1" "$2" ;;
herdr) fm_backend_herdr_send_literal "$1" "$2" ;;
zellij) fm_backend_zellij_send_literal "$1" "$2" "$W" ;;
orca) fm_backend_orca_send_literal "$1" "$2" ;;
cmux) fm_backend_cmux_send_literal "$1" "$2" "$W" ;;
esac
}
spawn_send_key() { # <target> <key>
case "$BACKEND" in
tmux) fm_backend_tmux_send_key "$1" "$2" ;;
herdr) fm_backend_herdr_send_key "$1" "$2" ;;
zellij) fm_backend_zellij_send_key "$1" "$2" "$W" ;;
orca) fm_backend_orca_send_key "$1" "$2" ;;
cmux) fm_backend_cmux_send_key "$1" "$2" "$W" ;;
esac
}
if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
spawn_send_text_line "$WT_TARGET" 'treehouse get'
# Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree.
# Target the stable window id, not the name: if the name is ever lost (e.g. an
# automatic-rename slips through), display-message -t <bad-name> falls back to the
# active client's window, which would misread firstmate's OWN pane path as the
# worktree and tangle a hook into the primary checkout. The window id never lies.
# Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project
# prefix would otherwise make the pane's OS-level cwd read differ from
# PROJ_ABS on the very first poll, before the pane has actually moved.
for _ in $(seq 1 60); do
p=$(spawn_current_path "$WT_TARGET" || true)
if [ -n "$p" ] && [ "$(real_path_or_raw "$p")" != "$PROJ_ABS_REAL" ]; then
WT="$p"
break
fi
sleep 1
done
if [ -z "$WT" ]; then
echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2
exit 1
fi
validate_spawn_worktree "treehouse get" "$T"
fi
# Per-task temp root: /tmp/fm-<id>/ with Go's build temp nested at gotmp/. Go won't
# create GOTMPDIR, so mkdir before it is used; fm-teardown removes the whole root.
# Nested (not a bare /tmp/fm-<id>/gotmp) so other per-task temp can live alongside
# later, and teardown cleans one deterministic path. GOTMPDIR (not TMPDIR) is the
# targeted knob: TMPDIR is too broad (affects every program's temp, not just Go's).
TASK_TMP="/tmp/fm-$ID"
mkdir -p "$TASK_TMP/gotmp"
# Per-harness turn-end hook: a file that touches state/<id>.turn-ended when the
# agent finishes a turn. Worktree-resident hooks are kept out of git's view so
# they never block teardown's dirty check or leak into a commit.
mkdir -p "$STATE"
STATE_REAL=$(cd "$STATE" && pwd -P)
TURNEND="$STATE_REAL/$ID.turn-ended"
exclude_path() {
local rel=$1 EXCL
EXCL=$(git -C "$WT" rev-parse --git-path info/exclude 2>/dev/null || true)
[ -n "$EXCL" ] || return 0
mkdir -p "$(dirname "$EXCL")"
grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL"
}
if [ "$KIND" != secondmate ]; then
case "$HARNESS" in
claude*)
mkdir -p "$WT/.claude"
cat > "$WT/.claude/settings.local.json" <<EOF
{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"touch '$TURNEND'"}]}]}}
EOF
exclude_path '.claude/settings.local.json'
;;
opencode*)
mkdir -p "$WT/.opencode/plugins"
cat > "$WT/.opencode/plugins/fm-turn-end.js" <<EOF
export const FmTurnEnd = async ({ \$ }) => ({
event: async ({ event }) => {
if (event.type === "session.idle") await \$\`touch $TURNEND\`
},
})
EOF
exclude_path '.opencode/plugins/fm-turn-end.js'
;;
pi*)
# Written OUTSIDE the worktree: pi's project-trust gate fires on any extension
# loaded from inside the project (verified live), but an explicit -e path
# elsewhere loads without a dialog. Lives in state/, cleaned by teardown.
cat > "$STATE/$ID.pi-ext.ts" <<EOF
// Firstmate turn-end signal; written by fm-spawn.
// Use "turn_end" (fires after each turn the agent finishes), not "agent_end"
// (fires once, only when the whole run exits): the watcher needs a signal at
// every turn boundary so an idle crewmate is surfaced, not just at shutdown.
import { execFile } from "node:child_process";
export default function (pi: any) {
pi.on("turn_end", () => execFile("touch", ["$TURNEND"]));
}
EOF
;;
codex*)
# codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__.
;;
grok*)
# grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the
# clean equivalent of codex's notify= and pi's turn_end. But grok only loads
# PROJECT hooks (<worktree>/.grok/hooks/, <worktree>/.claude/settings.local.json)
# after the folder is granted hook-trust, which is not automatic and which
# firstmate cannot establish at launch without editing grok's own managed
# trust store (a high-blast-radius write). GLOBAL hooks in ~/.grok/hooks/ are
# always trusted and load on first launch with no gate. So the turn-end hook
# lives OUTSIDE the worktree as a single firstmate-owned global hook that is a
# guarded no-op for every non-firstmate grok session: it fires only when the
# current workspace holds a .fm-grok-turnend token pointer that matches the
# firstmate-owned hook registry. firstmate then drops that per-task pointer
# (gitignored, like the other harnesses' worktree hook files).
# Result: the hook is outside the worktree, needs no trust grant, and never
# touches grok's managed config - only firstmate-owned files.
GROK_HOOKS_DIR="${GROK_HOME:-$HOME/.grok}/hooks"
GROK_AUTH_DIR="$GROK_HOOKS_DIR/fm-turn-end.d"
mkdir -p "$GROK_AUTH_DIR"
old_umask=$(umask)
umask 077
auth_file=$(mktemp "$GROK_AUTH_DIR/fm.XXXXXXXXXXXX")
umask "$old_umask"
printf '%s\n' "$TURNEND" > "$auth_file"
printf '%s\n' "${auth_file##*/}" > "$STATE/$ID.grok-turnend-token"
sq_grok_auth_dir=$(shell_quote "$GROK_AUTH_DIR")
cat > "$GROK_HOOKS_DIR/fm-turn-end.sh" <<EOF
#!/usr/bin/env bash
set -u
auth_dir=$sq_grok_auth_dir
workspace=\${GROK_WORKSPACE_ROOT:-}
[ -n "\$workspace" ] || exit 0
p="\$workspace/.fm-grok-turnend"
[ -f "\$p" ] || exit 0
first=
IFS= read -r -n 256 first < "\$p" 2>/dev/null || [ -n "\$first" ] || exit 0
case "\$first" in token=*) token=\${first#token=} ;; *) exit 0 ;; esac
case "\$token" in fm.????????????) : ;; *) exit 0 ;; esac
case "\$token" in *[!A-Za-z0-9._-]*) exit 0 ;; esac
t=\$(cat "\$auth_dir/\$token" 2>/dev/null) || exit 0
case "\$t" in /*.turn-ended) : ;; *) exit 0 ;; esac
touch "\$t" 2>/dev/null || true
exit 0
EOF
chmod +x "$GROK_HOOKS_DIR/fm-turn-end.sh"
hook_command=$(json_escape "bash $(shell_quote "$GROK_HOOKS_DIR/fm-turn-end.sh")")
printf '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"%s"}]}]}}\n' "$hook_command" > "$GROK_HOOKS_DIR/fm-turn-end.json"
printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-grok-turnend"
exclude_path '.fm-grok-turnend'
;;
esac
fi
# Per-project delivery mode + yolo flag (bin/fm-project-mode.sh; the project-management skill and AGENTS.md task lifecycle).
# Recorded in meta so fm-teardown's safety check and the validate/merge stages can
# branch on them. Mode governs ship tasks; a scout's deliverable is a report, not a
# merge, so scout teardown ignores mode.
SECONDMATE_PROJECTS=
if [ "$KIND" = secondmate ]; then
MODE=secondmate
YOLO=off
SECONDMATE_PROJECTS=$(secondmate_registry_value "$ID" projects || true)
else
PROJ_NAME=$(basename "$PROJ_ABS")
read -r MODE YOLO <<EOF
$("$FM_ROOT/bin/fm-project-mode.sh" "$PROJ_NAME")
EOF
fi
META_WINDOW=$T
[ "$BACKEND" = orca ] && META_WINDOW=$W
{
echo "window=$META_WINDOW"
echo "worktree=$WT"
echo "project=$PROJ_ABS"
echo "harness=$HARNESS"