Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
```
40 changes: 40 additions & 0 deletions .agents/skills/harness-adapters/references/harness/agy.md
Original file line number Diff line number Diff line change
@@ -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 <model>`, and optional `--effort <low|medium|high>`. |
| 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=<conversation-id>`; 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 <low|medium|high>` 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/<id>.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/<id>.agy-turnend-token`, authenticated through `$HOME/.gemini/config/plugins/firstmate/fm-turn-end.d/<token>`.
Every hook script invocation outputs valid JSON `{}` on stdout and exits zero.
33 changes: 33 additions & 0 deletions .greptile/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ state/ runtime records and signals; gitignored
<id>.turn-ended touched by turn-end hooks
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.agy-turnend-token firstmate-owned Antigravity (agy) hook registry token for the task; removed by teardown
<id>.muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown
<id>.cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown
<id>.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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ fm_backend_tmux_classify_process_name() { # <path> [argv0] -> agent|shell|other
# cannot carry it either: ~/.local/bin/muse-bin-<version> 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
Expand Down
176 changes: 176 additions & 0 deletions bin/fm-agy-turnend-hook.sh
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@ secondmate_liveness_one() { # <meta> <id>
[ -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
;;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions bin/fm-busy-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ fm_busy_sources_for_harness() { # <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"
Expand Down
4 changes: 3 additions & 1 deletion bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading