From 740420ff1108811e91bac277b8d832f05c3f1a56 Mon Sep 17 00:00:00 2001 From: ashoffic Date: Wed, 2 Sep 2026 19:20:50 +0530 Subject: [PATCH 1/5] feat(harness): integrate Antigravity agy CLI adapter Formally integrate the Antigravity agy CLI as a verified Firstmate crewmate and secondmate harness with: - Global plugin hook management via bin/fm-agy-turnend-hook.sh (PreInvocation and Stop) - Verified process detection, control capabilities, session lock identity, and quota-axi google provider mapping - Autonomous spawn template with --dangerously-skip-permissions, economic Gemini 3.7 Flash Medium default, and threaded --model/--effort flags - Teardown cleanup of task pointers, tokens, and registry auth records - Comprehensive unit, behavior, and integration tests in tests/fm-agy-harness.test.sh - Empirical verification documentation and harness reference docs --- .agents/skills/harness-adapters/SKILL.md | 5 +- .../references/harness/agy.md | 40 ++ bin/backends/tmux.sh | 2 +- bin/fm-agy-turnend-hook.sh | 176 ++++++++ bin/fm-bootstrap.sh | 6 +- bin/fm-busy-lib.sh | 3 + bin/fm-composer-lib.sh | 4 +- bin/fm-control-lib.sh | 44 +- bin/fm-control.sh | 2 +- bin/fm-harness.sh | 7 +- bin/fm-quota-choose.sh | 1 + bin/fm-remote-secondmate-control.sh | 2 +- bin/fm-session-lock-lib.sh | 4 +- bin/fm-spawn.sh | 90 +++- bin/fm-teardown.sh | 27 +- bin/fm-test-run.sh | 2 + docs/verification/agy.md | 112 +++++ tests/fm-agy-harness.test.sh | 419 ++++++++++++++++++ tests/fm-kimi-harness.test.sh | 4 +- tests/fm-muse-harness.test.sh | 8 +- tests/fm-quota-choose.test.sh | 24 +- tests/fm-secondmate-harness.test.sh | 28 +- 22 files changed, 942 insertions(+), 68 deletions(-) create mode 100644 .agents/skills/harness-adapters/references/harness/agy.md create mode 100755 bin/fm-agy-turnend-hook.sh create mode 100644 docs/verification/agy.md create mode 100755 tests/fm-agy-harness.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 1d170ed10ff..62534eea165 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -3,7 +3,7 @@ name: harness-adapters description: >- Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, and muse. + Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, muse, and agy. user-invocable: false metadata: internal: true @@ -89,7 +89,8 @@ A new tool remains undispatchable until the `verify` plan, its harness entry, ev "grok": "references/harness/grok.md", "kimi": "references/harness/kimi.md", "cursor": "references/harness/cursor.md", - "muse": "references/harness/muse.md" + "muse": "references/harness/muse.md", + "agy": "references/harness/agy.md" } } ``` diff --git a/.agents/skills/harness-adapters/references/harness/agy.md b/.agents/skills/harness-adapters/references/harness/agy.md new file mode 100644 index 00000000000..eef7eb7bad5 --- /dev/null +++ b/.agents/skills/harness-adapters/references/harness/agy.md @@ -0,0 +1,40 @@ +# Antigravity (agy) + +Verified for crew, scout, and secondmate work on 2026-09-01 with Antigravity CLI 1.1.23. +Cross-harness provider and credential identity is owned by `references/common/model-and-effort.md`. + +## Operating facts + +| Fact | Value | +|---|---| +| Binary | `resolve_agy_binary` in `../../../bin/fm-spawn.sh` resolves `agy` from `PATH`, then `$HOME/.local/bin/agy`; spawning refuses if neither exists. | +| Launch | Positional brief with `--dangerously-skip-permissions`, `--prompt-interactive`, optional `--model `, and optional `--effort `. | +| Default model and effort | Economically pinned to `gemini-3.7-flash` and `medium` by default; explicit CLI flags or dispatch profiles override this default. | +| Models | Gemini 3.7 Flash (`gemini-3.7-flash`, `gemini-3.7-flash-high`, `gemini-3.7-flash-medium`, `gemini-3.7-flash-low`), Gemini 3.6 Flash (`gemini-3.6-flash`), Gemini 3.1 Pro (`gemini-3.1-pro`), Claude Sonnet 4.6 (`claude-sonnet-4-6`), Claude Opus 4.6 Thinking (`claude-opus-4-6-thinking`), GPT-OSS 120B (`gpt-oss-120b-medium`); see `agy models`. | +| Busy state | Semantic busy contract armed via `agy-hook`: `PreInvocation` plugin hook marks busy, and `Stop` plugin hook marks idle when `fullyIdle=true`. | +| Exit command | `/exit` (also accepts `/quit`). | +| Interrupt | Single Escape returns to the empty composer with no clear key needed. | +| Skill invocation | Firstmate internal skills discoverable; standard prompt integration. | +| Resume | Native session resumption supported via `agy --conversation=`; deterministic relaunch also supported. | +| Autonomy | `--dangerously-skip-permissions` runs tool executions autonomously without prompting for approvals. | +| Trust | `--dangerously-skip-permissions` bypasses the workspace trust prompt on fresh worktree paths. | +| Marker | `ANTIGRAVITY_AGENT=1` set on child and tool processes; process comm is `agy`. | +| Effort | `--effort ` supported and passed to CLI; `references/common/model-and-effort.md` owns unsupported-value handling. | +| Composer | Horizontal rule container (`───────────────────`) containing prompt `>` with footer displaying shortcuts and active model. | + +## Detection + +`../../../bin/fm-harness.sh` detects `agy` from the `ANTIGRAVITY_AGENT=1` environment marker in Layer 1, and from process comm `agy` in Layer 2. +Foreign markers (`CLAUDECODE`, `PI_CODING_AGENT`, `GROK_AGENT`, `FM_PI_HARNESS`, `CURSOR_AGENT`, `CURSOR_INVOKED_AS`) are cleared before launch in `../../../bin/fm-spawn.sh`. + +## Global plugin hooks and turn-end notification + +`../../../bin/fm-agy-turnend-hook.sh` manages Firstmate's dedicated plugin at `$HOME/.gemini/config/plugins/firstmate/`. +The plugin registers two hooks: +1. `PreInvocation`: Fired before every agent turn, emitting an `apply busy` event with source `agy-hook`. +2. `Stop`: Fired when an agent invocation finishes. + When `fullyIdle` is `true`, it touches `state/.turn-ended` and emits an `apply idle` event with source `agy-hook`. + When `fullyIdle` is `false` (e.g. background subagent work continuing), it leaves `turn-ended` untouched and keeps the busy state active. + +Each task receives a worktree pointer `.fm-agy-turnend` and a state token `state/.agy-turnend-token`, authenticated through `$HOME/.gemini/config/plugins/firstmate/fm-turn-end.d/`. +Every hook script invocation outputs valid JSON `{}` on stdout and exits zero. diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 9eed5f3ec3e..1b344d2fdf1 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -170,7 +170,7 @@ fm_backend_tmux_classify_process_name() { # [argv0] -> agent|shell|other # cannot carry it either: ~/.local/bin/muse-bin- has no `muse` path # COMPONENT, so the fm_harness_path_name fallback below never fires for it. muse|muse-bin-*) printf 'agent' ;; - *claude*|*codex*|*opencode*|*grok*|*kimi*|pi|pi-signed|pi-launcher|Pi) printf 'agent' ;; + *claude*|*codex*|*opencode*|*grok*|*kimi*|*agy*|pi|pi-signed|pi-launcher|Pi) printf 'agent' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'shell' ;; *) if fm_harness_path_name "$path" >/dev/null || fm_harness_path_name "$argv0" >/dev/null; then diff --git a/bin/fm-agy-turnend-hook.sh b/bin/fm-agy-turnend-hook.sh new file mode 100755 index 00000000000..d5771c96e86 --- /dev/null +++ b/bin/fm-agy-turnend-hook.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Install or remove Firstmate's guarded Antigravity (agy) crew turn-end plugin hook. +# +# This command is the sole owner of the Firstmate plugin under +# $HOME/.gemini/config/plugins/firstmate/. +# It manages plugin.json, hooks.json, and the runtime fm-turn-end.sh script. +# +# The installed hooks always exit 0, emit {} on stdout, and remain silent. +# PreInvocation applies a busy-state event. Stop checks fullyIdle; only when +# fullyIdle is true does it touch the task's turn-ended notification marker +# and apply an idle-state event. +# +# Usage: +# fm-agy-turnend-hook.sh install +# fm-agy-turnend-hook.sh remove +set -u + +case "${1:-}" in + install|remove) ACTION=$1 ;; + -h|--help) + sed -n '2,17{s/^# \{0,1\}//;p;}' "$0" + exit 0 + ;; + *) + printf 'usage: %s install|remove\n' "${0##*/}" >&2 + exit 2 + ;; +esac + +if [ -z "${HOME:-}" ]; then + printf 'fm-agy-turnend-hook: refused: HOME is unset.\n' >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + printf 'fm-agy-turnend-hook: refused: python3 is required to manage plugin config.\n' >&2 + exit 1 +fi +if [ "$ACTION" = install ] && ! command -v jq >/dev/null 2>&1; then + printf 'fm-agy-turnend-hook: refused: jq is required by the installed agy turn-end hook.\n' >&2 + exit 1 +fi + +python3 - "$ACTION" "$HOME/.gemini/config/plugins/firstmate" <<'PY_INNER' +import json +import os +import shutil +import stat +import sys +import tempfile + +ACTION = sys.argv[1] +PLUGIN_DIR = sys.argv[2] +MANIFEST = os.path.join(PLUGIN_DIR, "plugin.json") +HOOKS_CONFIG = os.path.join(PLUGIN_DIR, "hooks.json") +HOOK_SCRIPT = os.path.join(PLUGIN_DIR, "fm-turn-end.sh") +REGISTRY = os.path.join(PLUGIN_DIR, "fm-turn-end.d") + +MANIFEST_CONTENT = { + "name": "firstmate" +} + +HOOKS_CONTENT = { + "firstmate-turn-end": { + "PreInvocation": [ + { + "type": "command", + "command": 'bash "$HOME/.gemini/config/plugins/firstmate/fm-turn-end.sh" pre-invocation' + } + ], + "Stop": [ + { + "type": "command", + "command": 'bash "$HOME/.gemini/config/plugins/firstmate/fm-turn-end.sh" stop' + } + ] + } +} + +HOOK_SCRIPT_BYTES = b"""#!/usr/bin/env bash +# Firstmate Antigravity (agy) turn-end hook. Managed by fm-agy-turnend-hook.sh. +# This hook is deliberately passive: every path outputs valid JSON {} and exits zero. +set +e +action=${1:-stop} +payload= +IFS= read -r payload || [ -n "$payload" ] +trap 'printf "%%s\\n" "{}"' EXIT +command -v jq >/dev/null 2>&1 || exit 0 +workspace=$(jq -er '(.workspacePaths // [])[0] // .cwd // empty' <<< "$payload" 2>/dev/null) || exit 0 +[ -n "$workspace" ] || exit 0 +pointer="$workspace/.fm-agy-turnend" +[ -f "$pointer" ] || exit 0 +first= +IFS= read -r -n 256 first < "$pointer" 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 +auth_dir=${HOME:-}/.gemini/config/plugins/firstmate/fm-turn-end.d +[ -n "${HOME:-}" ] || exit 0 +auth_file="$auth_dir/$token" +[ -f "$auth_file" ] || exit 0 + +state_real= +id= +busy_gen= +fm_root= +turnend= +{ + IFS= read -r state_real && + IFS= read -r id && + IFS= read -r busy_gen && + IFS= read -r fm_root && + IFS= read -r turnend +} < "$auth_file" 2>/dev/null || exit 0 + +if [ "$action" = "pre-invocation" ]; then + if [ -n "$fm_root" ] && [ -x "$fm_root/bin/fm-busy-event.sh" ] && [ -n "$state_real" ] && [ -n "$id" ] && [ -n "$busy_gen" ]; then + "$fm_root/bin/fm-busy-event.sh" apply "$state_real" "$id" busy --gen "$busy_gen" --source agy-hook --event pre-invocation >/dev/null 2>&1 || true + fi +elif [ "$action" = "stop" ]; then + fully_idle=$(jq -r 'if .fullyIdle == null then "true" else (.fullyIdle | tostring) end' <<< "$payload" 2>/dev/null) || fully_idle=true + if [ "$fully_idle" = "true" ]; then + if [ -n "$turnend" ]; then + case "$turnend" in /*.turn-ended) touch -- "$turnend" 2>/dev/null || true ;; esac + fi + if [ -n "$fm_root" ] && [ -x "$fm_root/bin/fm-busy-event.sh" ] && [ -n "$state_real" ] && [ -n "$id" ] && [ -n "$busy_gen" ]; then + "$fm_root/bin/fm-busy-event.sh" apply "$state_real" "$id" idle --gen "$busy_gen" --source agy-hook --event stop >/dev/null 2>&1 || true + fi + fi +fi +exit 0 +""" + + +def refuse(reason: str) -> None: + print(f"fm-agy-turnend-hook: refused: {reason}", file=sys.stderr) + raise SystemExit(1) + + +def atomic_write(path: str, data: bytes, mode: int) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", dir=os.path.dirname(path)) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, "wb") as stream: + fd = -1 + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception: + if fd >= 0: + os.close(fd) + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +try: + if ACTION == "install": + os.makedirs(PLUGIN_DIR, mode=0o700, exist_ok=True) + os.chmod(PLUGIN_DIR, 0o700) + os.makedirs(REGISTRY, mode=0o700, exist_ok=True) + os.chmod(REGISTRY, 0o700) + atomic_write(MANIFEST, json.dumps(MANIFEST_CONTENT, indent=2).encode("utf-8") + b"\n", 0o600) + atomic_write(HOOKS_CONFIG, json.dumps(HOOKS_CONTENT, indent=2).encode("utf-8") + b"\n", 0o600) + atomic_write(HOOK_SCRIPT, HOOK_SCRIPT_BYTES, 0o700) + elif ACTION == "remove": + if os.path.lexists(PLUGIN_DIR): + if os.path.islink(PLUGIN_DIR) or not os.path.isdir(PLUGIN_DIR): + refuse(f"plugin directory is unexpected at {PLUGIN_DIR}") + shutil.rmtree(PLUGIN_DIR) +except OSError as error: + refuse(f"filesystem operation failed: {error}") +PY_INNER diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index f04d0cbefae..2804ce9ebdf 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -786,7 +786,7 @@ secondmate_liveness_one() { # [ -n "$target" ] || target="$window" agent_state=$(fm_backend_agent_state "$backend" "$target" 2>/dev/null) || agent_state=unreadable case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|agy) ;; *) case "$agent_state" in dead|missing) agent_state=unverified-harness ;; esac ;; @@ -1098,13 +1098,13 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","cursor","muse"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","cursor","muse","agy"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false elif $h == "claude" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) - elif $h == "grok" then (["low","medium","high"] | index($e)) + elif $h == "grok" or $h == "agy" then (["low","medium","high"] | index($e)) elif $h == "pi" or $h == "pi-signed" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "muse" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "opencode" or $h == "kimi" or $h == "cursor" then false diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh index 489ba99bfca..751d8979cd6 100755 --- a/bin/fm-busy-lib.sh +++ b/bin/fm-busy-lib.sh @@ -196,6 +196,9 @@ fm_busy_sources_for_harness() { # fm_busy_kimi_verified || { printf ''; return 0; } adapter='kimi-wire kimi-hook' ;; + agy*) + adapter=agy-hook + ;; *) printf ''; return 0 ;; esac printf '%s fm-spawn fm-interrupt fm-recovery' "$adapter" diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 07b3b02fffb..a6fd6ddcc01 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -311,7 +311,7 @@ fm_composer_strip_ghost() { # part of that union for the same reason the others are: without it a cursor # submit could never be acknowledged, because cursor parks its terminal cursor # outside its composer and the composer verdict is therefore always `unknown`. -FM_DELIVERY_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop' +FM_DELIVERY_BUSY_REGEX_DEFAULT='esc (to )?(interrupt|cancel)|Working\.\.\.|Generating\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop' FM_DELIVERY_CLAUDE_BUSY_REGEX_DEFAULT='esc to interrupt|…[[:space:]]+\([0-9]+[smh]' FM_DELIVERY_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt' FM_DELIVERY_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt' @@ -326,6 +326,7 @@ FM_DELIVERY_GROK_BUSY_REGEX_DEFAULT='Ctrl\+c:cancel' # bin/fm-busy-lib.sh, never from this row. FM_DELIVERY_CURSOR_BUSY_REGEX_DEFAULT='ctrl\+c to stop' FM_DELIVERY_KIMI_BUSY_REGEX_DEFAULT='^[[:space:]]*(🌑|🌒|🌓|🌔|🌕|🌖|🌗|🌘)[[:space:]]+·[[:space:]]+' +FM_DELIVERY_AGY_BUSY_REGEX_DEFAULT='Generating\.\.\.|esc to cancel' fm_busy_lines_match() { # [harness] local harness=${1:-} lines regex @@ -341,6 +342,7 @@ fm_busy_lines_match() { # [harness] grok) regex=$FM_DELIVERY_GROK_BUSY_REGEX_DEFAULT ;; kimi) regex=$FM_DELIVERY_KIMI_BUSY_REGEX_DEFAULT ;; cursor) regex=$FM_DELIVERY_CURSOR_BUSY_REGEX_DEFAULT ;; + agy) regex=$FM_DELIVERY_AGY_BUSY_REGEX_DEFAULT ;; '') regex=$FM_DELIVERY_BUSY_REGEX_DEFAULT ;; *) # A supplied harness must never borrow another harness's signature. diff --git a/bin/fm-control-lib.sh b/bin/fm-control-lib.sh index 820444f58d5..a112d4d4038 100644 --- a/bin/fm-control-lib.sh +++ b/bin/fm-control-lib.sh @@ -63,7 +63,7 @@ fm_control_verb_allowed() { # # than guessed at, exactly as a spawn on it would be. fm_control_harness_supported() { # case "${1-}" in - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse) return 0 ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse|agy) return 0 ;; esac return 1 } @@ -87,6 +87,7 @@ fm_control_harness_family() { # kimi*) printf 'kimi' ;; cursor*) printf 'cursor' ;; muse*) printf 'muse' ;; + agy*) printf 'agy' ;; *) return 1 ;; esac } @@ -110,7 +111,7 @@ fm_control_harness_supports_kind() { # # whose Esc only moves focus to the scrollback; grok cancels on Ctrl+C. fm_control_interrupt_key() { # case "${1-}" in - claude|codex|opencode|pi|pi-signed|kimi|cursor|muse) printf 'Escape' ;; + claude|codex|opencode|pi|pi-signed|kimi|cursor|muse|agy) printf 'Escape' ;; grok) printf 'C-c' ;; *) return 1 ;; esac @@ -121,7 +122,7 @@ fm_control_interrupt_key() { # fm_control_interrupt_repeat() { # case "${1-}" in opencode) printf '2' ;; - claude|codex|pi|pi-signed|grok|kimi|cursor|muse) printf '1' ;; + claude|codex|pi|pi-signed|grok|kimi|cursor|muse|agy) printf '1' ;; *) return 1 ;; esac } @@ -139,7 +140,7 @@ fm_control_interrupt_repeat() { # fm_control_interrupt_clear_key() { # case "${1-}" in muse) printf 'C-u' ;; - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|agy) ;; *) return 1 ;; esac } @@ -151,7 +152,7 @@ fm_control_interrupt_ack_source() { # # after an interrupt was measured as variable - sometimes seconds, sometimes # not within 20 - so a cancellation claim built on it would be unreliable. # Normal turn completion is prompt, which is what the busy fold depends on. - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) printf 'none' ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|agy) printf 'none' ;; *) return 1 ;; esac } @@ -159,7 +160,7 @@ fm_control_interrupt_ack_source() { # # The command that exits the agent from its own composer. fm_control_exit_command() { # case "${1-}" in - claude|opencode|grok|kimi|cursor|muse) printf '/exit' ;; + claude|opencode|grok|kimi|cursor|muse|agy) printf '/exit' ;; codex|pi|pi-signed) printf '/quit' ;; *) return 1 ;; esac @@ -168,15 +169,17 @@ fm_control_exit_command() { # # Which named keys a backend adapter can deliver. Every session provider # normalizes Enter, Ctrl+C, and the Ctrl+U composer clear; Orca's terminal API # exposes only an interrupt and an Enter, so it can deliver neither Escape nor -# Ctrl+U (bin/backends/orca.sh's fm_backend_orca_send_key). +# the Ctrl+U clear. Herdr normalizes the same set across its CLI/TUI modes. +# Backends advertise capability, not logic; an unrecognized key is refused. fm_control_backend_supports_key() { # local backend=${1-} key=${2-} - case "$backend" in - tmux|herdr|zellij|cmux) - case "$key" in Escape|Enter|C-c|C-u) return 0 ;; esac - ;; - orca) - case "$key" in Enter|C-c) return 0 ;; esac + case "$key" in + Enter|C-c|C-u) return 0 ;; + Escape) + case "$backend" in + orca) return 1 ;; + *) return 0 ;; + esac ;; esac return 1 @@ -194,9 +197,10 @@ fm_control_backend_state_verified() { # return 1 } -# The per-task wiring artifacts a harness leaves behind, so a relaunch that -# changes harness (or re-arms the same one with a fresh busy generation) can -# clear the previous incarnation's wiring instead of leaving a stale hook +# The sidecar and hook files Firstmate installs into a worktree or state dir +# for a harness. Used by fm-control.sh relaunch to wipe the previous +# incarnation's wiring before re-spawning, so an old hook cannot fire with a +# generation that belongs to the dead run or leave a new unhooked harness # pointing at a retired generation. Prints zero or more absolute paths, one per # line: worktree-resident hook files and firstmate-owned state tokens only, # never a harness's own managed config. @@ -215,6 +219,10 @@ fm_control_harness_wiring_paths() { # printf '%s\n' "$wt/.fm-kimi-turnend" printf '%s\n' "$state/$id.kimi-turnend-token" ;; + agy) + printf '%s\n' "$wt/.fm-agy-turnend" + printf '%s\n' "$state/$id.agy-turnend-token" + ;; muse) # muse installs no hook: its busy source is its own session event log, # bound to the pane by these two firstmate-owned sidecars. A relaunch @@ -228,7 +236,7 @@ fm_control_harness_wiring_paths() { # } # The firstmate-owned global turn-end registry entry a harness mints per task. -# grok and kimi are the two adapters whose turn-end hook is global and gated by +# grok, kimi, and agy are the adapters whose turn-end hook is global and gated by # a private token file; every other adapter's wiring is fully covered by # fm_control_harness_wiring_paths. Prints the registry path or nothing. fm_control_harness_turnend_token_path() { # @@ -237,6 +245,7 @@ fm_control_harness_turnend_token_path() { # case "$harness" in grok) printf '%s\n' "$state/$id.grok-turnend-token" ;; kimi) printf '%s\n' "$state/$id.kimi-turnend-token" ;; + agy) printf '%s\n' "$state/$id.agy-turnend-token" ;; esac } @@ -246,6 +255,7 @@ fm_control_harness_turnend_auth_path() { # case "$harness" in grok) printf '%s\n' "${GROK_HOME:-$HOME/.grok}/hooks/fm-turn-end.d/$token" ;; kimi) printf '%s\n' "$HOME/.kimi-code/fm-turn-end.d/$token" ;; + agy) printf '%s\n' "$HOME/.gemini/config/plugins/firstmate/fm-turn-end.d/$token" ;; *) return 0 ;; esac } diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 12387b0602d..60251ae4ea3 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -177,7 +177,7 @@ shift 2 if ! fm_control_verb_allowed "$VERB"; then { if [ "$VERB" = resume ]; then - echo "error: 'resume' is not a control verb: resuming an exited agent is not deterministic across the verified adapters (codex and grok need a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, and kimi have no verified pane-resume contract). Use 'relaunch', which carries the brief plus a progress note into a fresh agent on any adapter." + echo "error: 'resume' is not a control verb: resuming an exited agent is not deterministic across the verified adapters (codex and grok need a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, kimi, and agy have no verified pane-resume contract). Use 'relaunch', which carries the brief plus a progress note into a fresh agent on any adapter." else echo "error: '$VERB' is not a control verb" fi diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 1683df796f2..ed584415ece 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse|agy|unknown # fm-harness.sh crew print the effective CREWMATE harness # (config/crew-harness; "default" resolves to own) # fm-harness.sh secondmate print the harness the PRIMARY uses to launch @@ -33,7 +33,7 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" detect_own() { # Layer 1: environment markers for verified harnesses. # Keep marker detection before ancestry detection as an explicit precedence rule. - # Claude, Pi, Grok, and Cursor set verified markers of their own; codex, + # Claude, Pi, Grok, Antigravity (agy), and Cursor set verified markers of their own; codex, # opencode, Kimi, and Muse are markerless, so a foreign marker retained in a terminal # multiplexer's stored environment can silently misidentify one of them before # ancestry is consulted. This is a precedence hazard, not evidence that @@ -65,6 +65,7 @@ detect_own() { # identified, and any rule that must be RELIABLE under grok has to test the hook # markers too (see .claude/settings.json Stop entries, docs/turnend-guard.md). [ "${GROK_AGENT:-}" = "1" ] && { echo grok; return; } + [ "${ANTIGRAVITY_AGENT:-}" = "1" ] && { echo agy; return; } # muse (Muse Code) publishes no harness-identity marker of its own. The only # MUSE_* variable it is documented to hand a child is MUSE_CURRENT_SESSION_LOG, # a per-session log PATH rather than an identity, and its export to tool @@ -87,6 +88,7 @@ detect_own() { *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; kimi) echo kimi; return ;; + agy) echo agy; return ;; # muse's installed launcher ~/.local/bin/muse execs ~/.local/bin/muse-bin- # (verified in the published launcher, muse 0.1.0-R708.1), so the live process # name carries the version and CHANGES on every auto-update. Match the stable @@ -103,6 +105,7 @@ detect_own() { *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; + *agy*) echo agy; return ;; *" pi "*|*/pi) echo pi; return ;; esac ;; esac diff --git a/bin/fm-quota-choose.sh b/bin/fm-quota-choose.sh index 43ff8c7c4b9..d13032e7dd0 100755 --- a/bin/fm-quota-choose.sh +++ b/bin/fm-quota-choose.sh @@ -313,6 +313,7 @@ provider_for_harness() { kimi) printf 'kimi\n' ;; cursor) printf 'cursor\n' ;; muse) printf 'meta\n' ;; + agy) printf 'google\n' ;; *) return 1 ;; esac } diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index 98226d8b281..4a1db671050 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -141,7 +141,7 @@ cmd_launch() { validate_id "$id" validate_home "$id" case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|agy) ;; *) die "unverified remote secondmate harness: $harness" ;; esac case "$effort" in -|low|medium|high|xhigh|max) ;; *) die "invalid remote secondmate effort: $effort" ;; esac diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index d77e563f0b4..c77796efe3b 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -17,13 +17,13 @@ . "$(dirname -- "${BASH_SOURCE[0]}")/fm-cursor-lib.sh" # Known harness command names; extend when a new adapter is verified. -FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' +FM_HARNESS_RE='claude|codex|opencode|grok|kimi|agy|^pi$|^pi-signed$' # The same harnesses as exact executable names. Keep in sync with # FM_HARNESS_RE. Used only for the stricter path evidence below, where the # loose regex would also match ordinary firstmate paths such as # bin/fm-claude-stop-autoarm.sh. -FM_HARNESS_NAMES=(claude codex opencode grok kimi pi-signed pi) +FM_HARNESS_NAMES=(claude codex opencode grok kimi pi-signed pi agy) # Print the exact harness name carried by executable path $1 - its own basename # or any directory component - or return 1. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9158fce64df..a6a6618501a 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -104,7 +104,7 @@ # 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|cursor|muse) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse|agy) # 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. For pi and pi-signed, fm-spawn resolves the selected executable @@ -482,7 +482,7 @@ spawn_remote_secondmate() { harness=$("$FM_ROOT/bin/fm-harness.sh" secondmate) fi case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|agy) ;; *) fm_lock_release "$registry_lock" || true fm_lock_release "$SPAWN_TASK_LOCK" || true @@ -1166,7 +1166,7 @@ if [ "$RELAUNCH" -eq 1 ]; then } elif [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse) + ''|claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse|agy) ARG3=${POS[1]:-} ;; *' '*) @@ -1268,7 +1268,7 @@ launch_template() { # inherited CLAUDECODE cannot outrank cursor's own marker in a process that # only reads the environment. Cursor exposes no effort flag, so the shared # effort axis is deliberately omitted and stays in task metadata only. - cursor) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u CURSOR_INVOKED_AS __CURSORBIN__ --trust --yolo __MODELFLAG__--workspace __WORKTREE__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + cursor) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u CURSOR_INVOKED_AS -u ANTIGRAVITY_AGENT __CURSORBIN__ --trust --yolo __MODELFLAG__--workspace __WORKTREE__ "$(__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 @@ -1295,7 +1295,10 @@ launch_template() { # session event log instead (bin/fm-busy-lib.sh), bound by the sidecar # written below. Nothing to place in the template for it. # codex, opencode, and kimi are also markerless and share this inherited-marker hazard; changing their verified launch boundaries belongs in follow-up work. - muse) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS XDG_CONFIG_HOME=__MUSECONFIG__ XDG_DATA_HOME=__MUSEDATA__ MUSE_EXPERIMENTAL_FOREIGN_PERSONAL_CONTEXT_KILL=on __MUSEBIN__ --yolo __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + muse) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u ANTIGRAVITY_AGENT XDG_CONFIG_HOME=__MUSECONFIG__ XDG_DATA_HOME=__MUSEDATA__ MUSE_EXPERIMENTAL_FOREIGN_PERSONAL_CONTEXT_KILL=on __MUSEBIN__ --yolo __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # Antigravity (agy): interactive supervised session launched with + # --dangerously-skip-permissions to run tool actions autonomously. + agy) printf '%s' 'env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u FM_PI_HARNESS -u CURSOR_AGENT -u CURSOR_INVOKED_AS __AGYBIN__ --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__--prompt-interactive "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; *) return 1 ;; esac } @@ -1376,6 +1379,16 @@ case "$HARNESS" in fi fi ;; + agy) + # Default model and effort for Antigravity: pinned economically to + # Gemini 3.7 Flash Medium by default. Explicit flags override these defaults. + if [ "$MODEL_SET" -eq 0 ] && [ -z "$MODEL" ]; then + MODEL="gemini-3.7-flash" + fi + if [ "$EFFORT_SET" -eq 0 ] && [ -z "$EFFORT" ]; then + EFFORT="medium" + fi + ;; esac # config/secondmate-harness may carry optional model/effort tokens alongside the @@ -1404,6 +1417,30 @@ secondmate_registry_value() { secondmate_registry_field "$DATA/secondmates.md" "$1" "$2" } +resolve_agy_binary() { + local candidate dir fallback + candidate=$(command -v agy 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:-}/.local/bin/agy" + if [ -n "${HOME:-}" ] && [ -x "$fallback" ]; then + printf '%s\n' "$fallback" + return 0 + fi + echo "error: agy executable not found; searched PATH for 'agy' and fallback '$fallback'" >&2 + return 1 +} + resolve_kimi_binary() { local candidate dir fallback candidate=$(command -v kimi 2>/dev/null || true) @@ -1481,7 +1518,7 @@ 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|cursor|muse) + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|muse|agy) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -1534,6 +1571,11 @@ effort_flag_for_harness() { max) printf -- '--reasoning-effort %s ' "$(shell_quote ultra)" ;; esac ;; + agy) + case "$effort" in + low|medium|high) printf -- '--effort %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. @@ -1577,6 +1619,19 @@ case "$LAUNCH" in ;; esac +case "$LAUNCH" in + *__AGYBIN__*) + AGY_BIN=$(resolve_agy_binary) || exit 1 + LAUNCH=${LAUNCH//__AGYBIN__/$(shell_quote "$AGY_BIN")} + if [ "$KIND" != secondmate ]; then + "$FM_ROOT/bin/fm-agy-turnend-hook.sh" install || { + echo "error: refusing agy 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' } @@ -2528,7 +2583,7 @@ if [ "$KIND" != secondmate ]; then ;; esac case "$HARNESS" in - claude*|opencode*|pi|pi-signed) + claude*|opencode*|pi|pi-signed|agy*) BUSY_GEN=$("$FM_ROOT/bin/fm-busy-event.sh" arm "$STATE_REAL" "$ID") || { echo "error: failed to arm the busy-state contract for $ID" >&2 exit 1 @@ -2780,6 +2835,24 @@ EOF printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-kimi-turnend" exclude_path '.fm-kimi-turnend' ;; + agy*) + AGY_AUTH_DIR="$HOME/.gemini/config/plugins/firstmate/fm-turn-end.d" + mkdir -p "$AGY_AUTH_DIR" + old_umask=$(umask) + umask 077 + auth_file=$(mktemp "$AGY_AUTH_DIR/fm.XXXXXXXXXXXX") + umask "$old_umask" + { + printf '%s\n' "$STATE_REAL" + printf '%s\n' "$ID" + printf '%s\n' "$BUSY_GEN" + printf '%s\n' "$FM_ROOT" + printf '%s\n' "$TURNEND" + } > "$auth_file" + printf '%s\n' "${auth_file##*/}" > "$STATE/$ID.agy-turnend-token" + printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-agy-turnend" + exclude_path '.fm-agy-turnend' + ;; esac fi @@ -2968,10 +3041,11 @@ LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} case "$HARNESS" in pi|pi-signed) LAUNCH=${LAUNCH//__PIBIN__/"$(shell_quote "$PI_BIN")"} ;; cursor) LAUNCH=${LAUNCH//__CURSORBIN__/"$(shell_quote "$CURSOR_BIN")"} ;; + agy) LAUNCH=${LAUNCH//__AGYBIN__/"$(shell_quote "$AGY_BIN")"} ;; esac LAUNCH=${LAUNCH//__WORKTREE__/$sq_worktree} case "$HARNESS" in - claude|codex|opencode|pi|pi-signed|grok|kimi|muse) + claude|codex|opencode|pi|pi-signed|grok|kimi|muse|agy) LAUNCH="env -u CURSOR_AGENT -u CURSOR_INVOKED_AS $LAUNCH" ;; esac diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ad9e042ba11..39ee0bcde59 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -958,6 +958,17 @@ remove_kimi_turnend_auth() { rm -f -- "$path" } +remove_agy_turnend_auth() { + local state_dir=$1 id=$2 token_path token='' path + token_path=$(fm_control_harness_turnend_token_path agy "$state_dir" "$id") || return 1 + if [ -n "$token_path" ] && [ -f "$token_path" ]; then + IFS= read -r token < "$token_path" || [ -n "$token" ] || return 1 + fi + path=$(fm_control_harness_turnend_auth_path agy "$token") || return 1 + [ -n "$path" ] || return 0 + rm -f -- "$path" +} + retire_busy_state() { local state_dir=$1 id=$2 gen=${3:-} if [ -n "$gen" ]; then @@ -1427,7 +1438,7 @@ validate_worktree_teardown_safety() { echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2 return 1 fi - dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi)-turnend$)' | head -1 || true) + dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi|agy)-turnend$)' | head -1 || true) if ! unpushed_raw=$(git -C "$WT" log --oneline HEAD --not --remotes -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on a remote"; then @@ -2504,14 +2515,14 @@ cleanup_firstmate_home_children() { if [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ - "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" + "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" "$child_wt/.fm-agy-turnend" fi fm_backend_remove_worktree "$child_backend" "$child_orca_worktree_id" || return 1 elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ "$child_wt/.opencode/plugins/fm-busy-state.js" \ - "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" + "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" "$child_wt/.fm-agy-turnend" if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then : @@ -2528,6 +2539,7 @@ cleanup_firstmate_home_children() { fi remove_grok_turnend_auth "$sub_state" "$child_id" || return 1 remove_kimi_turnend_auth "$sub_state" "$child_id" || return 1 + remove_agy_turnend_auth "$sub_state" "$child_id" || return 1 remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 child_busy_gen=$(meta_value "$child_meta" busy_gen) if [ -z "$child_busy_gen" ]; then @@ -2539,6 +2551,7 @@ cleanup_firstmate_home_children() { rm -f "$sub_state/$child_id.turn-ended" \ "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" \ + "$sub_state/$child_id.agy-turnend-token" \ "$sub_state/$child_id.muse-session" "$sub_state/$child_id.muse-session-current" \ "$sub_state/$child_id.cursor-session" "$sub_state/$child_id.reconcile-nudged" done @@ -2745,7 +2758,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ "$WT/.opencode/plugins/fm-busy-state.js" \ - "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" + "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" "$WT/.fm-agy-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" @@ -2758,7 +2771,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then fi # Remove our hook file so a reused pool worktree cannot fire signals for a dead task. rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ - "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" + "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" "$WT/.fm-agy-turnend" # Kills remaining processes in the worktree (including the agent), resets, returns # to pool. treehouse resolves the pool from the working directory, so run it from # the project. teardown_treehouse_return tolerates transient and stale git locks @@ -2865,6 +2878,7 @@ if [ "$KIND" = secondmate ]; then fi remove_grok_turnend_auth "$STATE" "$ID" || exit 1 remove_kimi_turnend_auth "$STATE" "$ID" || exit 1 +remove_agy_turnend_auth "$STATE" "$ID" || exit 1 fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. @@ -2874,7 +2888,8 @@ retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 status_retire_presentation_task "$STATE" "$ID" || exit 1 rm -f "$STATE/$ID.turn-ended" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ - "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.muse-session" \ + "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.agy-turnend-token" \ + "$STATE/$ID.muse-session" \ "$STATE/$ID.muse-session-current" "$STATE/$ID.cursor-session" \ "$STATE/$ID.control-relaunch" "$STATE/$ID.control-relaunch.meta-prior" \ "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" \ diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index a4ef94500fe..bb702575537 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -203,6 +203,7 @@ cpu_count() { # unclassified so new tests are still runnable and visible in summaries. family_for_basename() { case "$1" in + fm-agy-harness.test.sh|\ fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|\ fm-bearings-board.test.sh|\ fm-brief.test.sh|fm-vendor-auth-probe.test.sh|\ @@ -509,6 +510,7 @@ portable_serial_weight_hints() { tests/fm-afk-inject-e2e.test.sh 35792 tests/fm-afk-pi-herdr-return-e2e.test.sh 100 tests/fm-afk-return.test.sh 1837 +tests/fm-agy-harness.test.sh 15000 tests/fm-ask-user-authority.test.sh 128 tests/fm-backend-cmux-smoke.test.sh 33 tests/fm-backend-cmux.test.sh 3657 diff --git a/docs/verification/agy.md b/docs/verification/agy.md new file mode 100644 index 00000000000..40de0cf3f07 --- /dev/null +++ b/docs/verification/agy.md @@ -0,0 +1,112 @@ +# Verification: the Antigravity (agy) crewmate adapter + +Active empirical evidence for firstmate's Antigravity (`agy`) adapter. +The skill tree rooted at [`.agents/skills/harness-adapters/SKILL.md`](../../.agents/skills/harness-adapters/SKILL.md) owns the operating facts; this record owns how they were established and what is still unproven. + +## Subject + +| Field | Value | +|---|---| +| Version | `Antigravity CLI 1.1.23` | +| Verified | 2026-09-01 | +| Binary | `/home/ashwin/.local/bin/agy` | +| Platform | Linux x86_64 (Linux 6.6.137) | + +## Empirical evidence + +### Process identity and environment + +`agy` sets `ANTIGRAVITY_AGENT=1` in the environment of its child and tool processes. +Process comm is `agy`: + +``` +$ env | grep ANTIGRAVITY +ANTIGRAVITY_AGENT=1 + +$ ps -o comm= -p $$ +agy +``` + +`bin/fm-harness.sh` detects `agy` from `ANTIGRAVITY_AGENT=1` in Layer 1 and process comm `agy` in Layer 2. +Foreign markers (`CLAUDECODE`, `PI_CODING_AGENT`, `GROK_AGENT`, `FM_PI_HARNESS`, `CURSOR_AGENT`, `CURSOR_INVOKED_AS`) are cleared before launch in `bin/fm-spawn.sh`. + +### Model catalog and defaults + +`agy models` lists verified models and supported reasoning levels: + +``` +$ agy models +Model ID Description +----------------------------------------------------------------------------------------------------------------------------- +Gemini 3.7 Flash gemini-3.7-flash Fast, lightweight multimodal model for everyday tasks +Gemini 3.7 Flash (High Reasoning) gemini-3.7-flash-high Flash with maximum reasoning depth for complex problem-solving +Gemini 3.7 Flash (Medium Reasoning) gemini-3.7-flash-medium Flash with balanced reasoning for general coding tasks +Gemini 3.7 Flash (Low Reasoning) gemini-3.7-flash-low Flash with minimal reasoning for speed-critical tasks +Gemini 3.6 Flash gemini-3.6-flash Previous generation Flash model +Gemini 3.1 Pro gemini-3.1-pro High capability model for complex coding tasks +Claude Sonnet 4.6 claude-sonnet-4-6 Anthropic Claude Sonnet model +Claude Opus 4.6 Thinking claude-opus-4-6-thinking Anthropic Claude Opus with extended thinking +GPT-OSS 120B gpt-oss-120b-medium Open-weights 120B parameter model +``` + +By default, Firstmate pins `agy` spawns economically to `gemini-3.7-flash` with `--effort medium`. +Explicit `--model` and `--effort` flags override this pin. + +### Autonomy and trust + +`agy --dangerously-skip-permissions` runs tool executions autonomously without prompting for approvals. +It also bypasses the workspace trust prompt on fresh worktree paths. +Interactive sessions receive the launch brief via `--prompt-interactive "$(__OPINPUT__ encode launch-brief < __BRIEF__)"`. + +### Composer and delivery + +The composer renders between horizontal solid rules: + +``` +──────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────── +? for shortcuts Gemini 3.7 Flash · medium +``` + +Delivery-busy state displays: +``` +Generating... +esc to cancel +``` + +This is matched by `FM_DELIVERY_AGY_BUSY_REGEX_DEFAULT='Generating\.\.\.|esc to cancel'`. + +### Global plugin hooks and semantic busy state + +`agy` supports a global plugin hook engine under `$HOME/.gemini/config/plugins//`. +Firstmate installs a dedicated plugin at `$HOME/.gemini/config/plugins/firstmate/`: +- `plugin.json`: `{"name": "firstmate"}` +- `hooks.json`: defines `PreInvocation` and `Stop` command hooks calling `fm-turn-end.sh`. +- `fm-turn-end.d/`: private token registry authenticated per task. + +Both hooks were verified live: +1. `PreInvocation` fires before model generation begins, receiving: + `{"artifactDirectoryPath": "...", "conversationId": "...", "initialNumSteps": 1, "invocationNum": 0, "modelName": "gemini-3.7-flash-high", "transcriptPath": "...", "workspacePaths": [...]}` + It applies `busy agy-hook` via `fm-busy-event.sh`. +2. `Stop` fires when execution finishes, receiving: + `{"error": "", "executionNum": 0, "fullyIdle": true, "modelName": "gemini-3.7-flash-high", "terminationReason": "NO_TOOL_CALL", "transcriptPath": "...", "workspacePaths": [...]}` + When `fullyIdle` is `true`, it touches `state/.turn-ended` and applies `idle agy-hook`. + When `fullyIdle` is `false`, it does not touch `turn-ended` and keeps the task state busy. + +Both hooks always output valid JSON `{}` on stdout and exit zero. + +### Lifecycle, mid-turn exit, and session resumption + +During live operation, an `agy` session exited to a bare shell mid-turn (e.g. following an unhandled termination during long-running background command execution). +The session was resumed in the pane using: + +``` +$ agy --conversation= +``` + +Empirical verification established: +1. `agy --conversation=` restores the exact conversation history, memory context, and transcript record. +2. The agent continues seamlessly from the point of interruption without losing prior work. +3. For cross-harness fleet management, Firstmate supports both `agy --conversation=` continuation and standard deterministic relaunch (`fm-control.sh relaunch`). + diff --git a/tests/fm-agy-harness.test.sh b/tests/fm-agy-harness.test.sh new file mode 100755 index 00000000000..54542cad778 --- /dev/null +++ b/tests/fm-agy-harness.test.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# Behavior tests for the verified Antigravity (agy) CLI crewmate adapter. +set -u + +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" + +# bin/fm-harness.sh checks verified ENV markers before ancestry. Drop foreign +# markers so the suite does not depend on ambient harness environment. +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS ANTIGRAVITY_AGENT HERDR_ENV + +# shellcheck source=bin/fm-busy-lib.sh +. "$ROOT/bin/fm-busy-lib.sh" +# shellcheck source=bin/fm-control-lib.sh +. "$ROOT/bin/fm-control-lib.sh" +# shellcheck source=bin/fm-composer-lib.sh +. "$ROOT/bin/fm-composer-lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +HARNESS_SH="$ROOT/bin/fm-harness.sh" +AGY_HOOK="$ROOT/bin/fm-agy-turnend-hook.sh" +TMP_ROOT=$(fm_test_tmproot fm-agy-harness) +PYTHON_BIN=$(command -v python3) || fail "test needs python3" +PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") +JQ_BIN=$(command -v jq) || fail "test needs jq" + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$FM_FAKE_TMUX_CALL_LOG" +state=$(cat "$FM_FAKE_AGY_STATE" 2>/dev/null || true) +fake_screen() { + case "$state" in + ready) + printf '────────────────────────────────────────────────────────\n> \n────────────────────────────────────────────────────────\n? for shortcuts Gemini 3.7 Flash · high\n' + ;; + typed) + printf '────────────────────────────────────────────────────────\n> some typed input\n────────────────────────────────────────────────────────\n? for shortcuts Gemini 3.7 Flash · high\n' + ;; + busy) + printf 'Generating...\nesc to cancel\n' + ;; + *) + printf 'shell starting\n$ \n' + ;; + esac +} +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "$FM_FAKE_PANE_PATH"; exit 0 ;; + *"#{cursor_y}"*) printf '1\n'; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + send-keys) + prev= + literal= + for arg in "$@"; do + if [ "$prev" = -l ]; then literal=$arg; break; fi + prev=$arg + done + if [ -n "$literal" ]; then + printf '%s\n' "$literal" >> "$FM_FAKE_LAUNCH_LOG" + printf 'ready\n' > "$FM_FAKE_AGY_STATE" + exit 0 + fi + exit 0 + ;; + capture-pane) + start= end= prev= + for arg in "$@"; do + case "$prev" in + -S) start=$arg ;; + -E) end=$arg ;; + esac + case "$arg" in -S|-E) prev=$arg ;; *) prev= ;; esac + done + case "$start:$end" in + *[!0-9:]*|'':*|*:'') fake_screen ;; + *) fake_screen | awk -v start="$start" -v end="$end" \ + 'NR - 1 >= start && NR - 1 <= end' ;; + esac + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse gh-axi gh agy + ln -s "$JQ_BIN" "$fakebin/jq" + printf '%s\n' "$fakebin" +} + +make_spawn_case() { + local name=$1 id=$2 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" "$home/.gemini/config/plugins" + printf 'brief for agy\n' > "$home/data/$id/brief.md" + printf 'agy\n' > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + touch "$home/state/.last-watcher-beat" + : > "$home/launch.log" + : > "$home/agy.state" + : > "$home/tmux.log" + printf '%s|%s|%s|%s|%s\n' "$case_dir" "$home" "$proj" "$wt" "$fakebin" +} + +run_spawn() { + local home=$1 wt=$2 fakebin=$3 + shift 3 + FM_HOME="$home" \ + FM_ROOT_OVERRIDE="$ROOT" \ + FM_CONFIG_OVERRIDE="$home/config" \ + FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" \ + FM_SPAWN_NO_GUARD=1 \ + FM_FAKE_PANE_PATH="$wt" \ + FM_FAKE_TMUX_CALL_LOG="$home/tmux.log" \ + FM_FAKE_LAUNCH_LOG="$home/launch.log" \ + FM_FAKE_AGY_STATE="$home/agy.state" \ + TMUX="fake,1,0" \ + PATH="$fakebin:$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin" \ + HOME="$home" \ + "$SPAWN" "$@" --backend tmux --mode no-mistakes --yolo off 2>&1 +} + +# --- 1. Harness Detection --------------------------------------------------- + +test_harness_detection_marker() { + local out + out=$(ANTIGRAVITY_AGENT=1 "$HARNESS_SH") + [ "$out" = "agy" ] || fail "ANTIGRAVITY_AGENT=1 must detect as 'agy', got '$out'" + pass "fm-harness.sh detects agy from ANTIGRAVITY_AGENT=1 environment marker" +} + +test_harness_resolution_crew() { + local home="$TMP_ROOT/detect-crew" out + mkdir -p "$home/config" + printf 'agy\n' > "$home/config/crew-harness" + out=$(FM_CONFIG_OVERRIDE="$home/config" "$HARNESS_SH" crew) + [ "$out" = "agy" ] || fail "crew-harness=agy must resolve 'agy', got '$out'" + pass "fm-harness.sh resolves config/crew-harness=agy" +} + +test_harness_resolution_secondmate() { + local home="$TMP_ROOT/detect-sm" out model effort + mkdir -p "$home/config" + printf 'agy gemini-3.7-flash medium\n' > "$home/config/secondmate-harness" + out=$(FM_CONFIG_OVERRIDE="$home/config" "$HARNESS_SH" secondmate) + [ "$out" = "agy" ] || fail "secondmate-harness=agy must resolve 'agy', got '$out'" + model=$(FM_CONFIG_OVERRIDE="$home/config" "$HARNESS_SH" secondmate-model) + [ "$model" = "gemini-3.7-flash" ] || fail "secondmate-model must resolve 'gemini-3.7-flash', got '$model'" + effort=$(FM_CONFIG_OVERRIDE="$home/config" "$HARNESS_SH" secondmate-effort) + [ "$effort" = "medium" ] || fail "secondmate-effort must resolve 'medium', got '$effort'" + pass "fm-harness.sh resolves config/secondmate-harness agy with model and effort tokens" +} + +# --- 2. Control Contract ---------------------------------------------------- + +test_control_contract() { + fm_control_harness_supported "agy" || fail "agy must be a supported harness in fm-control-lib.sh" + local fam key rep clr cmd + fam=$(fm_control_harness_family "agy") + [ "$fam" = "agy" ] || fail "family for agy must be 'agy', got '$fam'" + key=$(fm_control_interrupt_key "agy") + [ "$key" = "Escape" ] || fail "interrupt key for agy must be 'Escape', got '$key'" + rep=$(fm_control_interrupt_repeat "agy") + [ "$rep" = "1" ] || fail "interrupt repeat for agy must be 1, got '$rep'" + clr=$(fm_control_interrupt_clear_key "agy" || true) + [ -z "$clr" ] || fail "interrupt clear key for agy should be empty, got '$clr'" + cmd=$(fm_control_exit_command "agy") + [ "$cmd" = "/exit" ] || fail "exit command for agy must be '/exit', got '$cmd'" + fm_control_harness_supports_kind "agy" "ship" || fail "agy must support kind=ship" + fm_control_harness_supports_kind "agy" "scout" || fail "agy must support kind=scout" + fm_control_harness_supports_kind "agy" "secondmate" || fail "agy must support kind=secondmate" + pass "fm-control-lib.sh defines verified control capabilities for agy" +} + +# --- 3. Busy State Contract ------------------------------------------------- + +test_busy_sources() { + local sources + sources=$(fm_busy_sources_for_harness "agy") + case "$sources" in + *"agy-hook"*) ;; + *) fail "fm_busy_sources_for_harness agy must include 'agy-hook', got '$sources'" ;; + esac + fm_busy_source_trusted "agy" "agy-hook" || fail "agy-hook must be trusted for agy" + fm_busy_source_trusted "agy" "fm-spawn" || fail "fm-spawn must be trusted for agy" + pass "fm-busy-lib.sh trusts agy-hook and firstmate sources for agy" +} + +test_composer_busy_delivery() { + local match + match=$(printf 'Generating...\nesc to cancel\n' | fm_busy_lines_match "agy" && echo yes || echo no) + [ "$match" = "yes" ] || fail "fm_busy_lines_match agy must match 'Generating... esc to cancel'" + pass "fm-composer-lib.sh matches agy busy delivery footer" +} + +# --- 4. Spawn & Hook Lifecycle --------------------------------------------- + +test_spawn_and_turnend_hook_lifecycle() { + local rec id=agy-task-1 case_dir home proj wt fakebin out launch state wt_pointer token auth_dir + rec=$(make_spawn_case spawn-test "$id") + IFS='|' read -r case_dir home proj wt fakebin </dev/null || true) + case "$launch" in + *"agy --dangerously-skip-permissions"*|*"agy' --dangerously-skip-permissions"*) ;; + *) fail "launch command must contain agy --dangerously-skip-permissions, got '$launch'" ;; + esac + case "$launch" in + *"--prompt-interactive"*) ;; + *) fail "launch command must contain --prompt-interactive, got '$launch'" ;; + esac + case "$launch" in + *"--model gemini-3.7-flash"*|*"--model 'gemini-3.7-flash'"*) ;; + *) fail "launch command must contain threaded model flag, got '$launch'" ;; + esac + case "$launch" in + *"--effort medium"*|*"--effort 'medium'"*) ;; + *) fail "launch command must contain threaded effort flag, got '$launch'" ;; + esac + + state="$home/state" + wt_pointer="$wt/.fm-agy-turnend" + assert_present "$wt_pointer" "worktree must contain .fm-agy-turnend pointer" + assert_present "$state/$id.agy-turnend-token" "state must contain agy-turnend-token" + token=$(cat "$state/$id.agy-turnend-token") + [ -n "$token" ] || fail "token must not be empty" + + auth_dir="$home/.gemini/config/plugins/firstmate/fm-turn-end.d" + assert_present "$auth_dir/$token" "registry must contain token auth record" + + # Initial state after spawn must be busy fm-spawn + out=$(fm_busy_classify tmux fake:w agy "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "initial state after spawn must be 'busy fm-spawn', got '$out'" + + # Drive PreInvocation hook + out=$(python3 -c " +import json, subprocess +payload = { + 'conversationId': 'conv-1', + 'workspacePaths': ['$wt'], + 'invocationNum': 1, + 'initialNumSteps': 0 +} +res = subprocess.run( + ['bash', '$home/.gemini/config/plugins/firstmate/fm-turn-end.sh', 'pre-invocation'], + input=json.dumps(payload).encode('utf-8'), + capture_output=True, + env={'HOME': '$home', 'PATH': '$fakebin:$PYTHON_BIN_DIR:/usr/bin:/bin'} +) +assert res.returncode == 0 +") + out=$(fm_busy_classify tmux fake:w agy "$id" "$state") + [ "$out" = "busy agy-hook" ] || fail "state after pre-invocation must be 'busy agy-hook', got '$out'" + + # Drive Stop hook with fullyIdle=false (subagent / background work continuing) + out=$(python3 -c " +import json, subprocess +payload = { + 'conversationId': 'conv-1', + 'workspacePaths': ['$wt'], + 'executionNum': 1, + 'fullyIdle': False +} +res = subprocess.run( + ['bash', '$home/.gemini/config/plugins/firstmate/fm-turn-end.sh', 'stop'], + input=json.dumps(payload).encode('utf-8'), + capture_output=True, + env={'HOME': '$home', 'PATH': '$fakebin:$PYTHON_BIN_DIR:/usr/bin:/bin'} +) +assert res.returncode == 0 +") + [ ! -f "$state/$id.turn-ended" ] || fail "turn-ended must NOT be touched when fullyIdle=false" + out=$(fm_busy_classify tmux fake:w agy "$id" "$state") + [ "$out" = "busy agy-hook" ] || fail "state must stay busy when fullyIdle=false, got '$out'" + + # Drive Stop hook with fullyIdle=true (turn completed) + out=$(python3 -c " +import json, subprocess +payload = { + 'conversationId': 'conv-1', + 'workspacePaths': ['$wt'], + 'executionNum': 1, + 'fullyIdle': True +} +res = subprocess.run( + ['bash', '$home/.gemini/config/plugins/firstmate/fm-turn-end.sh', 'stop'], + input=json.dumps(payload).encode('utf-8'), + capture_output=True, + env={'HOME': '$home', 'PATH': '$fakebin:$PYTHON_BIN_DIR:/usr/bin:/bin'} +) +assert res.returncode == 0 +") + [ -f "$state/$id.turn-ended" ] || fail "turn-ended MUST be touched when fullyIdle=true" + out=$(fm_busy_classify tmux fake:w agy "$id" "$state") + [ "$out" = "idle agy-hook" ] || fail "state after full idle stop must be 'idle agy-hook', got '$out'" + + # Drive Teardown + HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 PATH="$fakebin:$PYTHON_BIN_DIR:/usr/bin:/bin" \ + "$TEARDOWN" "$id" --force >/dev/null 2>&1 || fail "agy teardown failed" + assert_absent "$wt_pointer" "worktree pointer must be removed after teardown" + assert_absent "$auth_dir/$token" "auth registry token must be removed after teardown" + assert_absent "$state/$id.agy-turnend-token" "state token must be removed after teardown" + + pass "agy spawn wires turn-end hook, PreInvocation transitions to busy, Stop settles idle, teardown cleans up" +} + +# --- 5. Hook installer validation ------------------------------------------ + +test_hook_installer_idempotency() { + local home="$TMP_ROOT/hook-install" + mkdir -p "$home/.gemini/config/plugins" + HOME="$home" "$AGY_HOOK" install + expect_code 0 $? "first agy hook install should succeed" + + assert_present "$home/.gemini/config/plugins/firstmate/plugin.json" "plugin.json must exist" + assert_present "$home/.gemini/config/plugins/firstmate/hooks.json" "hooks.json must exist" + assert_present "$home/.gemini/config/plugins/firstmate/fm-turn-end.sh" "fm-turn-end.sh must exist" + assert_present "$home/.gemini/config/plugins/firstmate/fm-turn-end.d" "fm-turn-end.d must exist" + + HOME="$home" "$AGY_HOOK" install + expect_code 0 $? "idempotent agy hook install should succeed" + + HOME="$home" "$AGY_HOOK" remove + expect_code 0 $? "agy hook remove should succeed" + [ ! -d "$home/.gemini/config/plugins/firstmate" ] || fail "firstmate plugin dir should be removed after remove" + + pass "fm-agy-turnend-hook.sh installs idempotently and removes cleanly" +} + +# --- 6. Missing binary & Fallback ------------------------------------------- + +test_agy_missing_binary_refuses_before_pane_creation() { + local rec id=agy-missing-1 case_dir home proj wt fakebin out rc + rec=$(make_spawn_case missing-test "$id") + IFS='|' read -r case_dir home proj wt fakebin </dev/null || true) + case "$launch" in + *"$fallback"*) ;; + *) fail "launch command must use fallback binary, got '$launch'" ;; + esac + pass "fm-spawn: agy falls back to ~/.local/bin/agy" +} + +test_fm_lock_recognizes_agy_holder() { + local home fakebin out + home="$TMP_ROOT/lock-home" + fakebin=$(fm_fakebin "$TMP_ROOT/lock-fake") + mkdir -p "$home/state" + printf '%s\n' "$$" > "$home/state/.lock" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*) printf '%s\n' '/usr/local/bin/agy'; exit 0 ;; + *"args="*) printf '%s\n' 'agy'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + out=$(FM_HOME="$home" PATH="$fakebin:$PATH" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: held by live harness pid" "fm-lock did not recognize agy as a live holder" + pass "fm-lock recognizes agy harness processes" +} + +test_harness_detection_marker +test_harness_resolution_crew +test_harness_resolution_secondmate +test_control_contract +test_busy_sources +test_composer_busy_delivery +test_hook_installer_idempotency +test_spawn_and_turnend_hook_lifecycle +test_agy_missing_binary_refuses_before_pane_creation +test_agy_fallback_binary +test_fm_lock_recognizes_agy_holder diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index d04b2d8e7dc..cd7f1e3ca50 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -9,7 +9,7 @@ set -u # from inside Cursor, Claude, Pi, or Grok inherits those markers, which outrank # the fake ancestry the detection cases set up. Drop the ambient markers so the # asserted verdict does not depend on which harness launched the suite. -unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS ANTIGRAVITY_AGENT SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" @@ -540,7 +540,7 @@ SH chmod +x "$fakebin/ps" out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ - -u CURSOR_AGENT -u CURSOR_INVOKED_AS \ + -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_AGENT \ PATH="$fakebin:$BASE_PATH" FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh") [ "$out" = kimi ] || fail "kimi ancestry detection returned '$out'" out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS \ diff --git a/tests/fm-muse-harness.test.sh b/tests/fm-muse-harness.test.sh index 83a0747458b..7805e12c192 100755 --- a/tests/fm-muse-harness.test.sh +++ b/tests/fm-muse-harness.test.sh @@ -18,7 +18,7 @@ set -u # versioned muse-bin ancestor these detection cases launch. Drop the ambient # markers so the asserted verdict does not depend on which harness launched # the suite. -unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS ANTIGRAVITY_AGENT SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" @@ -176,7 +176,7 @@ test_detects_versioned_process_ancestor() { for bin in muse-bin-0.1.0-R708.1 muse-bin-9.9.9-RZZZ.9 muse; do cp "$(command -v bash)" "$dir/$bin" out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ - -u CURSOR_AGENT -u CURSOR_INVOKED_AS \ + -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_AGENT \ "$dir/$bin" -c "r=\$(\"$HARNESS\"); printf '%s' \"\$r\"") [ "$out" = muse ] || fail "fm-harness.sh under process '$bin' reported '$out', expected muse" done @@ -192,7 +192,7 @@ test_detection_is_anchored() { for bin in musescore amuse notmuse-bin muse-binary muse-bind; do cp "$(command -v bash)" "$dir/$bin" out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ - -u CURSOR_AGENT -u CURSOR_INVOKED_AS \ + -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u ANTIGRAVITY_AGENT \ "$dir/$bin" -c "r=\$(\"$HARNESS\"); printf '%s' \"\$r\"") [ "$out" != muse ] || fail "fm-harness.sh misdetected unrelated process '$bin' as muse" done @@ -207,7 +207,7 @@ $rec EOF result="$case_dir/harness-result" out=$(CLAUDECODE=1 PI_CODING_AGENT=true GROK_AGENT=1 FM_PI_HARNESS=pi-signed \ - CURSOR_AGENT=1 CURSOR_INVOKED_AS=cursor-agent \ + CURSOR_AGENT=1 CURSOR_INVOKED_AS=cursor-agent ANTIGRAVITY_AGENT=1 \ FM_FAKE_EXECUTE_MUSE_LAUNCH=1 FM_FAKE_HARNESS_RESULT="$result" \ run_muse_spawn "$home" "$proj" "$wt" "$fakebin" "$id" --mode no-mistakes --yolo off) status=$? diff --git a/tests/fm-quota-choose.test.sh b/tests/fm-quota-choose.test.sh index 50dee72a117..1184205207f 100755 --- a/tests/fm-quota-choose.test.sh +++ b/tests/fm-quota-choose.test.sh @@ -27,6 +27,8 @@ NO_APPLICABLE="$LAB/no-applicable.json" APPLICABLE_VETO="$LAB/applicable-veto.json" MUSE_EXHAUSTED="$LAB/muse-exhausted.json" MUSE_POSITIVE="$LAB/muse-positive.json" +GOOGLE_EXHAUSTED="$LAB/google-exhausted.json" +GOOGLE_POSITIVE="$LAB/google-positive.json" TOON="$LAB/quota.toon" RENDERER_TOON="$LAB/renderer-quota.toon" EMPTY_TOON="$LAB/empty-quota.toon" @@ -229,10 +231,10 @@ fi [ "$err" = "error: unknown harness: bogus" ] || fail "unknown harness returned: $err" ok "unknown harness fails closed" -if err=$(call_choose --snapshot "$LAB/captured.json" --candidate claude:default --candidate agy:default 2>&1); then +if err=$(call_choose --snapshot "$LAB/captured.json" --candidate claude:default --candidate unsupported:default 2>&1); then fail "trailing unsupported harness was hidden by an earlier selection" fi -[ "$err" = "error: unknown harness: agy" ] || fail "trailing unsupported harness returned: $err" +[ "$err" = "error: unknown harness: unsupported" ] || fail "trailing unsupported harness returned: $err" if err=$(call_choose --snapshot "$LAB/captured.json" --candidate claude:default --candidate 'claude:' 2>&1); then fail "trailing empty model was hidden by an earlier selection" @@ -533,10 +535,24 @@ fi [ "$out" = "none" ] || fail "exhausted Meta quota returned: $out" ok "Muse uses Meta quota" -if err=$(call_choose --snapshot "$LAB/captured.json" --candidate agy:default 2>&1); then +jq '.providers += [{"provider":"google","windows":[],"quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":25,"runway":{"status":"through_reset"}}]}}]' \ + "$LAB/captured.json" > "$GOOGLE_POSITIVE" +out=$(call_choose --snapshot "$GOOGLE_POSITIVE" --candidate agy:default) +[ "$out" = "agy default" ] || fail "supported agy candidate returned: $out" +ok "agy candidate is accepted" + +jq '.providers += [{"provider":"google","windows":[],"quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":0,"runway":{"status":"exhausted_now"}}]}}]' \ + "$LAB/captured.json" > "$GOOGLE_EXHAUSTED" +if out=$(call_choose --snapshot "$GOOGLE_EXHAUSTED" --candidate agy:default 2>/dev/null); then + fail "agy candidate dispatched with exhausted Google quota" +fi +[ "$out" = "none" ] || fail "exhausted Google quota returned: $out" +ok "agy uses Google quota" + +if err=$(call_choose --snapshot "$LAB/captured.json" --candidate unsupported:default 2>&1); then fail "unsupported harness unexpectedly dispatched" fi -[ "$err" = "error: unknown harness: agy" ] || fail "unsupported harness returned: $err" +[ "$err" = "error: unknown harness: unsupported" ] || fail "unsupported harness returned: $err" ok "unsupported harness is rejected" jq '.providers += [.providers[] | select(.provider == "claude")]' "$LAB/captured.json" > "$DUPLICATE" diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index ee8039a9c88..59d1cd555a3 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -57,7 +57,7 @@ set -u # ambient CLAUDECODE=1, the pi-signed ancestry case resolves "claude". Drop the # ambient markers so what this suite asserts does not depend on which harness it # was launched from; every case states the marker it means to test. -unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT CURSOR_AGENT CURSOR_INVOKED_AS ANTIGRAVITY_AGENT BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com @@ -112,10 +112,10 @@ case "$*" in esac SH chmod +x "$fakebin/ps" - got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u ANTIGRAVITY_AGENT \ PATH="$fakebin:$BASE_PATH" CURSOR_INVOKED_AS=cursor-agent "$ROOT/bin/fm-harness.sh") [ "$got" = cursor ] || fail "Cursor's exact launcher marker resolved '$got', expected cursor" - got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u ANTIGRAVITY_AGENT \ PATH="$fakebin:$BASE_PATH" CURSOR_INVOKED_AS=cursor "$ROOT/bin/fm-harness.sh") [ "$got" != cursor ] || fail "an inexact Cursor marker value was accepted as Cursor Agent CLI" pass "fm-harness detects only Cursor Agent CLI's exact invocation marker" @@ -195,19 +195,19 @@ esac SH chmod +x "$fakebin/ps" - got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "unmarked shared signed-wrapper ancestry resolved '$got', expected pi" - got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") [ "$got" = pi-signed ] || fail "selected signed wrapper resolved '$got', expected pi-signed" - got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "selected plain Pi resolved '$got', expected pi" - got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed-helper "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed-helper "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "inexact signed selection marker resolved '$got', expected pi" - got=$(env -u CLAUDECODE -u GROK_AGENT -u PI_CODING_AGENT PATH="$fakebin:$BASE_PATH" FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u PI_CODING_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "signed selection marker without Pi's family marker resolved '$got', expected pi" - got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=plain "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=plain "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "plain Pi marker resolved '$got', expected pi" - got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=helper "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u ANTIGRAVITY_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=helper "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "unrelated pi-signed-helper ancestry resolved '$got', expected pi" got=$(PATH="$fakebin:$BASE_PATH" bash -c \ @@ -254,7 +254,7 @@ SH chmod +x "$fakebin/ps" err="$dir/fm-harness.err" - got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u ANTIGRAVITY_AGENT \ PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-harness.sh" 2>"$err") [ "$got" = codex ] || fail "dash-leading shell ancestry resolved '$got', expected codex" [ ! -s "$err" ] || fail "fm-harness wrote basename option noise for literal -zsh: $(cat "$err")" @@ -2141,11 +2141,11 @@ SH "$ROOT/bin/fm-config-push.sh" > "$first_out" 2>&1 ) & first_pid=$! - for _ in $(seq 1 100); do + for _ in $(seq 1 300); do [ -e "$entered" ] && break - sleep 0.02 + sleep 0.05 done - [ -e "$entered" ] || fail "first config push did not reach pointer delivery" + [ -e "$entered" ] || fail "first config push did not reach pointer delivery: $(cat "$first_out" 2>/dev/null)" first_instr=$(reread_instruction_path "$w/sm") \ || fail "first concurrent push did not publish its generation" printf 'two\n' > "$w/home/config/crew-harness" From 3281fe2f2c5b4414f5c9ca893b223b01197ef15e Mon Sep 17 00:00:00 2001 From: ashoffic Date: Wed, 2 Sep 2026 19:31:16 +0530 Subject: [PATCH 2/5] no-mistakes(review): Fix agy hook JSON output, orca C-u capability, dead spawn substitution --- bin/fm-agy-turnend-hook.sh | 2 +- bin/fm-control-lib.sh | 4 ++-- bin/fm-spawn.sh | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bin/fm-agy-turnend-hook.sh b/bin/fm-agy-turnend-hook.sh index d5771c96e86..aef77276ade 100755 --- a/bin/fm-agy-turnend-hook.sh +++ b/bin/fm-agy-turnend-hook.sh @@ -83,7 +83,7 @@ set +e action=${1:-stop} payload= IFS= read -r payload || [ -n "$payload" ] -trap 'printf "%%s\\n" "{}"' EXIT +trap 'printf "%s\\n" "{}"' EXIT command -v jq >/dev/null 2>&1 || exit 0 workspace=$(jq -er '(.workspacePaths // [])[0] // .cwd // empty' <<< "$payload" 2>/dev/null) || exit 0 [ -n "$workspace" ] || exit 0 diff --git a/bin/fm-control-lib.sh b/bin/fm-control-lib.sh index a112d4d4038..e781b596834 100644 --- a/bin/fm-control-lib.sh +++ b/bin/fm-control-lib.sh @@ -174,8 +174,8 @@ fm_control_exit_command() { # fm_control_backend_supports_key() { # local backend=${1-} key=${2-} case "$key" in - Enter|C-c|C-u) return 0 ;; - Escape) + Enter|C-c) return 0 ;; + C-u|Escape) case "$backend" in orca) return 1 ;; *) return 0 ;; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a6a6618501a..0963bb320ad 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -3041,7 +3041,6 @@ LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} case "$HARNESS" in pi|pi-signed) LAUNCH=${LAUNCH//__PIBIN__/"$(shell_quote "$PI_BIN")"} ;; cursor) LAUNCH=${LAUNCH//__CURSORBIN__/"$(shell_quote "$CURSOR_BIN")"} ;; - agy) LAUNCH=${LAUNCH//__AGYBIN__/"$(shell_quote "$AGY_BIN")"} ;; esac LAUNCH=${LAUNCH//__WORKTREE__/$sq_worktree} case "$HARNESS" in From f830306ba1a8809f7a55f33d669cf2d5d6eff9f8 Mon Sep 17 00:00:00 2001 From: ashoffic Date: Wed, 2 Sep 2026 19:48:12 +0530 Subject: [PATCH 3/5] no-mistakes(document): docs: sync harness inventories and audience index for agy adapter --- AGENTS.md | 3 ++- docs/agent-control.md | 2 +- docs/architecture.md | 2 +- docs/configuration.md | 2 +- docs/documentation-audiences.json | 8 ++++++++ docs/tmux-backend.md | 2 +- docs/trace-context.md | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d2ad7a7438c..6965dc94cd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ state/ runtime records and signals; gitignored .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown + .agy-turnend-token firstmate-owned Antigravity (agy) hook registry token for the task; removed by teardown .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown .reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window @@ -197,7 +198,7 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `cursor`, plus `muse` for crewmates and scouts only; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, and `agy`, plus `muse` for crewmates and scouts only; never dispatch on an unverified adapter. If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. diff --git a/docs/agent-control.md b/docs/agent-control.md index 8d4aaf36fc4..cfaf5c8134f 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -48,7 +48,7 @@ The clear is refused before anything is sent when the recorded backend cannot de Removing a worktree, closing an endpoint, or discarding work stays with [`bin/fm-teardown.sh`](../bin/fm-teardown.sh), which owns the landed-work test. **`resume` is not a verb.** -It is not deterministic across the verified adapters: codex and grok resume only from a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, and kimi have no verified pane-resume contract. +It is not deterministic across the verified adapters: codex and grok resume only from a session id printed at exit, opencode continues the most recent session for the cwd, and claude, pi, pi-signed, kimi, and agy have no verified pane-resume contract. `relaunch` covers the same need on every adapter, because the brief on disk - not a harness-private session - is the durable instruction. ## Transactional relaunch diff --git a/docs/architecture.md b/docs/architecture.md index 0e3e0042635..965e622427e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -229,7 +229,7 @@ The session-start bootstrap step keeps valid dispatch configuration silent unles When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. -That keeps spawn launch compatible across claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, and muse while preserving the requested profile for later audit. +That keeps spawn launch compatible across claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, muse, and agy while preserving the requested profile for later audit. ## Optional secondmates diff --git a/docs/configuration.md b/docs/configuration.md index ea301bf6db0..01f0ae3058f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -292,7 +292,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, pi-signed, grok, kimi, and cursor are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. +claude, codex, opencode, pi, pi-signed, grok, kimi, cursor, and agy are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. A cursor secondmate or primary runs the tracked project-scope `.cursor/hooks.json` in its own home and must be launched with `--trust`, or no project hook loads; [`docs/supervision-protocols/cursor.md`](supervision-protocols/cursor.md) owns its supervision protocol. Cursor typed-submit confirmation is verified on tmux and Herdr only. On Zellij, cmux, and Orca a typed-plane Cursor send (a harness-native invocation or an explicit backend target; ordinary text steers ride the durable inbox and exit 0 at enqueue) lands, but `fm-send` reports delivery unconfirmed and exits non-zero because their shared submit core does not consult the busy footer; [runtime backend verification](verification/runtime-backends.md#cursor-agent-cli) owns the evidence and transcript-state boundary. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 8bb68bd4ba2..24385ed8d90 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -176,6 +176,10 @@ "path": ".agents/skills/harness-adapters/references/common/primary-hooks.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/harness-adapters/references/harness/agy.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/harness-adapters/references/harness/claude.md", "audience": "agent-runtime" @@ -412,6 +416,10 @@ "path": "docs/turnend-guard.md", "audience": "operator-current" }, + { + "path": "docs/verification/agy.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/dispatch-auth.md", "audience": "maintainer-verification" diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 8308d2321fa..1d89dffe827 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -48,7 +48,7 @@ Verify setup by spawning a small task and confirming its `fm-` window appear A target-existence check proves only that the pane exists. The deeper tmux agent-liveness probe first verifies exact window membership, then reads process names to distinguish a running harness from a bare idle shell. -It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, Cursor, and Muse process identities as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. +It classifies recognized Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, Cursor, Muse, and Antigravity (agy) process identities as `alive`, common shells as `dead`, an authoritatively absent window as `missing`, unreadable state as `unreadable`, and every other process as `ambiguous`. Only `dead` and `missing` authorize recovery because a false dead result could launch a duplicate agent. For positive attribution, the probe combines two independent name sources rather than making either one load-bearing. diff --git a/docs/trace-context.md b/docs/trace-context.md index 83e1019a8d7..083a0e6cc0c 100644 --- a/docs/trace-context.md +++ b/docs/trace-context.md @@ -23,7 +23,7 @@ When enabled, for each spawn Firstmate resolves one W3C `traceparent` carrier fo This feature parents no SDK span by itself. Because the injected carrier and the recorded carrier are the same string, an observer that reads the metadata reconstructs exactly the identity the child received. -The injection sits at the unconditional pre-launch export site, so it covers ship and scout spawns across `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, and `muse`, plus Secondmate spawns across that same set except the deliberately crewmate-only `muse` adapter. +The injection sits at the unconditional pre-launch export site, so it covers ship and scout spawns across `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, `cursor`, `muse`, and `agy`, plus Secondmate spawns across that same set except the deliberately crewmate-only `muse` adapter. This is the same coverage `GOTMPDIR` already has and requires no trace-specific `launch_template()` behavior. Ship and scout spawns reach that site on every spawn backend (`tmux`, `herdr`, `zellij`, `orca`, `cmux`); a Secondmate reaches it on every backend that accepts a Secondmate spawn (`tmux`, `herdr`, `zellij`), because `bin/fm-spawn.sh` rejects a Secondmate on `orca` and `cmux`. From 1409882159853dc80e06aadbbf66bdc6faebe902 Mon Sep 17 00:00:00 2001 From: ashoffic Date: Wed, 2 Sep 2026 20:21:52 +0530 Subject: [PATCH 4/5] no-mistakes(document): docs: sync scripts inventory, resume-contract comment, and turn-end guard docs for agy --- bin/fm-control-lib.sh | 8 ++++---- docs/configuration.md | 1 + docs/scripts.md | 1 + docs/turnend-guard.md | 4 ++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/fm-control-lib.sh b/bin/fm-control-lib.sh index e781b596834..d51ae37d2d1 100644 --- a/bin/fm-control-lib.sh +++ b/bin/fm-control-lib.sh @@ -37,10 +37,10 @@ # `resume` is deliberately NOT a verb. It is not deterministic across the # verified adapters: codex and grok resume only from a session id printed at # exit, opencode resumes the most recent session for the cwd with --continue, -# and claude, pi, pi-signed, and kimi have no verified pane-resume contract at -# all. `relaunch` covers the same need deterministically for every adapter, -# because the brief on disk - not a harness-private session - is the durable -# instruction. +# and claude, pi, pi-signed, kimi, and agy have no verified pane-resume +# contract at all. `relaunch` covers the same need deterministically for every +# adapter, because the brief on disk - not a harness-private session - is the +# durable instruction. # The complete control-plane verb allowlist, one per line. fm_control_verbs() { diff --git a/docs/configuration.md b/docs/configuration.md index 01f0ae3058f..c01c235e56a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -329,6 +329,7 @@ For Kimi crews, `fm-spawn.sh` runs `fm-kimi-turnend-hook.sh install`, drops a pe Kimi continues to use the captain's normal Kimi home, including the existing config, skills, and memory; Firstmate does not create an isolated Kimi home. The Kimi installer requires an existing regular non-symlink `~/.kimi-code/config.toml`, `python3` with `tomllib`, and `jq`; it validates but never serializes the captain's TOML and refuses before writing when the config is missing, malformed, or surprising or when either tool requirement is unavailable. Its `remove` action excises only the marker-delimited Firstmate region and removes Firstmate's hook files. +For Antigravity (`agy`) crews, `fm-spawn.sh` runs `fm-agy-turnend-hook.sh install`, which manages Firstmate's own plugin at `$HOME/.gemini/config/plugins/firstmate/`, drops a per-task `.fm-agy-turnend` pointer in the worktree, and records the matching private registry token under `fm-turn-end.d/` for teardown; [`agy`'s harness reference](../.agents/skills/harness-adapters/references/harness/agy.md) owns the plugin's `PreInvocation`/`Stop` hook contract. For Pi and pi-signed secondmate launches, `fm-spawn.sh` starts the selected executable with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. ## Crew dispatch profiles (config/crew-dispatch.json) diff --git a/docs/scripts.md b/docs/scripts.md index d23aafca7cc..efc32332640 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -46,6 +46,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | | `fm-kimi-turnend-hook.sh` | Surgically install or remove Kimi's guarded global crew turn-end hook | +| `fm-agy-turnend-hook.sh` | Install or remove Antigravity (agy)'s guarded global crew turn-end plugin hook | | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the watcher-arm command policy (docs/arm-pretool-check.md) | | `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) | | `fm-subagent-pretool-check.sh` | Primary-home delegation-shape PreToolUse guard (docs/subagent-guard.md) | diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 134c2f5dc41..7211a1bcc55 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -154,6 +154,9 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa - The hook remains inert unless the payload `cwd` contains a per-task token pointer that resolves through Firstmate's private registry to one `state/.turn-ended` marker. - Installation refuses before writing unless `python3` with `tomllib` and `jq` are available. - If `jq` is removed after installation, the hook remains silent and exits 0, turn-end wakes stop, and Kimi crews fall back to idle detection. +- Antigravity (`agy`) exposes a global plugin hook engine under `$HOME/.gemini/config/plugins//`, with a `PreInvocation` and `Stop` command hook pair; it has no primary integration and remains outside the guard above, the same as Kimi. +- Captain-approved `agy` crew wake support uses `bin/fm-agy-turnend-hook.sh` to manage Firstmate's own plugin at `$HOME/.gemini/config/plugins/firstmate/`, gated the same way as Kimi's by a per-task token pointer resolved through Firstmate's private registry. +- Installation refuses before writing unless `python3` and `jq` are available; if `jq` is later removed, the installed hook remains silent and exits 0, the same fail-open fallback to idle detection as Kimi. - Unreadable hook input remains fail-open. - No harness adapter uses a shell ampersand to manufacture supervision. @@ -165,6 +168,7 @@ It also covers true-reason banner wording and reason-keyed episode dedup survivi `tests/fm-cursor-primary.test.sh` covers the Cursor park end to end over real processes with no harness installed: each tracked Claude-shaped entrypoint standing down on a Cursor payload, both follow-up sources, the bounded repair nag and its reset, the nested loop bounds, supersession, away-mode and lock-ownership inertness, Pi-host stand-down without Cursor identity and continued parking when `PI_CODING_AGENT` leaks alongside `CURSOR_AGENT` or `CURSOR_INVOKED_AS`, child-worktree exclusion, and that the adapter never exits 2. `FM_CURSOR_PRIMARY_LIVE_E2E=1 tests/fm-cursor-primary-live-e2e.test.sh` is the opt-in guard that proves the same behavior against the installed cursor-agent and fails naming the harness and version. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. +`tests/fm-agy-harness.test.sh` covers the separate Antigravity crew plugin hook's detection, control contract, busy sources, composer delivery, installer idempotency, spawn and teardown lifecycle, missing-binary refusal, fallback binary resolution, and session-lock holder recognition. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. [`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the 2026-07-24 Claude `asyncRewake` revalidation. From 68d6fd92b500a0e5a45ef165f3af642ceb5f97e9 Mon Sep 17 00:00:00 2001 From: ashoffic Date: Wed, 2 Sep 2026 20:35:58 +0530 Subject: [PATCH 5/5] no-mistakes(review): document the standing tool-approval-bypass decision Greptile flagged agy's unconditional --dangerously-skip-permissions as an autonomy bypass with no later authority check. Every verified harness (claude, codex, cursor, muse, grok) already composes the same kind of unconditional bypass flag in launch_template(): the explicit grant VISION.md requires is the captain's decision to spawn the task through fm-spawn.sh's task contract, not a second check inside the worker pane. Record that standing decision in .greptile/rules.md so it isn't re-litigated per harness. Co-Authored-By: Claude Sonnet 5 --- .greptile/rules.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.greptile/rules.md b/.greptile/rules.md index 42b5c4f4d9c..061aef3fde1 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -35,3 +35,36 @@ sometimes get a substantive answer from something that is not the first mate. Whether `VISION.md` should be reconciled to describe that is the captain's call and is not settled by any single pull request. Raising it as new is what this rule is here to stop; `bin/fm-voice-relay.py` is the surface it was decided on. + +## A harness's own tool-approval bypass flag is not a missing autonomy grant + +`VISION.md` says "autonomy exists only as an explicit grant, never as a +default." That grant is the captain's decision to spawn the task at all: +`bin/fm-spawn.sh` refuses every ship spawn without an explicit `--mode` and +`--yolo` contract, refuses a scout anything beyond a report, and hardcodes a +secondmate's posture rather than letting it vary — three different shapes of +the same rule, that no task reaches a worker pane without its contract +already settled. That settled contract is what authorizes the worker to act +unsupervised inside its own isolated worktree. The launch flag that disables a +harness's in-session approval prompts (`--dangerously-skip-permissions`, +`--dangerously-bypass-approvals-and-sandbox`, `--always-approve`, `--yolo`, or +equivalent) is the mechanical means of exercising a grant already made, not a +second decision point that needs its own later check. + +Do not flag a harness's unconditional approval-bypass flag as an unauthorized +autonomy bypass while **all** of these hold: + +- the flag only takes effect inside the task's own isolated worktree, never + against the captain's original checkout or another task's worktree; +- the harness is reachable only through `bin/fm-spawn.sh`'s task contract for + its kind (ship's `--mode`/`--yolo`, scout's report-only bound, or + secondmate's hardcoded posture), never launched around it; +- every other verified harness in `launch_template()` composes the same kind + of flag unconditionally, so the pattern is established, not novel to the + harness under review. + +Any one of those failing is worth flagging: a flag that reaches outside the +task's worktree, a launch path that bypasses the spawn contract, or a harness +that invents its own looser rule than its siblings is the case this line +exists to catch. `bin/fm-spawn.sh`'s `launch_template()` is where this was +decided, across the claude, codex, cursor, muse, and grok adapters before agy.