From 2aea7af128e56fce98f4fa6edc1af7e108027bf5 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:53:54 +0300 Subject: [PATCH 1/4] feat: harden agy harness spawn, send, and no-mistakes shim --- .agents/skills/harness-adapters/SKILL.md | 33 +++++- AGENTS.md | 2 +- README.md | 2 +- bin/fm-bootstrap.sh | 4 +- bin/fm-harness.sh | 4 +- bin/fm-lock.sh | 2 +- bin/fm-send.sh | 7 +- bin/fm-spawn.sh | 21 +++- bin/no-mistakes-agy-shim | 139 +++++++++++++++++++++++ docs/examples/crew-dispatch.json | 6 +- tests/fm-spawn-agy.test.sh | 78 +++++++++++++ tests/fm-spawn-dispatch-profile.test.sh | 18 +++ tests/no-mistakes-agy-shim.test.sh | 29 +++++ 13 files changed, 330 insertions(+), 15 deletions(-) create mode 100755 bin/no-mistakes-agy-shim create mode 100644 tests/fm-spawn-agy.test.sh create mode 100644 tests/no-mistakes-agy-shim.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 42d0ea4e..98ca1e6a 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- 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, and grok. +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, grok, and agy. user-invocable: false metadata: internal: true @@ -58,6 +58,7 @@ The supported launch-profile flags below were verified locally on 2026-06-30 wit | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.73. `--effort` parses too, but firstmate's profile axis is reasoning effort. `--reasoning-effort max` is rejected, so `max` is omitted. | | pi | `--model ` | `--thinking ` | Verified on pi 0.80.2. `max` prints an invalid-thinking warning, so firstmate omits Pi effort when the requested effort is `max`. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +| agy | `--model ` | none for firstmate's interactive launch | Verified on agy 1.0.16. The installed CLI exposes `--model` only; no verified effort or reasoning knob on the interactive launch path. | When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -72,6 +73,7 @@ Natural language is acceptable if uncertain. - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. - pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. - grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) already handles this correctly by reading the cursor row; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. +- agy: `/` (e.g. `/no-mistakes`), same as claude; use natural language if uncertain. ## claude (VERIFIED) @@ -182,6 +184,35 @@ Pin `[hints] project_picker_disabled = true` in `~/.grok/config.toml` if a non-p **Known gap, unfixed (found 2026-07-03, not yet in scope of any fix):** a freshly-dismissed, never-typed-into grok composer shows a placeholder ("Type a message...") styled with a dark 24-bit TRUECOLOR foreground, not the SGR-2 dim/faint attribute `fm_tmux_strip_ghost` detects, so it is NOT stripped and reads as real pending text - `FM_COMPOSER_IDLE_RE` is NOT already set to cover it. Worse, live-verified: in that exact pristine placeholder-only state, tmux's own `#{cursor_y}` points at the composer box's BOTTOM BORDER row, one row below the actual text row (the box appears to render one row lower before any real typing starts); once real text is typed the cursor correctly aligns with the text row again. A correct fix needs a row-window read near `cursor_y` (or a structural scan like the herdr adapter's composer-row finder, `bin/backends/herdr.sh`), not just a wider idle regex. In practice `fm-spawn` launches grok with the brief as its initial prompt, so a live task's composer is never observed in this pristine pre-typing state - but this is unverified for every path (e.g. a steer sent before grok's first real turn settles) and needs dedicated investigation before relying on it. +## agy (VERIFIED 2026-07-05, agy 1.0.16; spawn nudge 2026-07-05) + +Antigravity CLI (`agy`), a Claude-Code-compatible TUI from Google Cloud Code. +Launch with a positional prompt: `agy --dangerously-skip-permissions "$(cat )"`. + +| Fact | Value | +|---|---| +| Busy-pane signature | `esc to interrupt` (Claude-compatible footer; covered by the default `FM_BUSY_REGEX`) | +| Exit command | `/exit` (slash popup; `fm-send`'s 1.2s `/` settle applies) | +| Interrupt | single Escape | +| Skill invocation | `/` is rejected as unknown in agy 1.0.16; use natural language or shell `no-mistakes axi run` | +| Autonomy | `--dangerously-skip-permissions` | +| Resume | `agy --continue` or `agy --conversation ` | +| `fm-send` settle | 1.2s on all plain-text steers (agy often leaves text in the composer on the first Enter) | +| Post-spawn nudge | `fm-spawn` waits `FM_AGY_SPAWN_SETTLE` (default 10s), then sends a Begin-now steer with a second Enter backup | + +**Positional brief does not auto-start.** +Verified: `agy ... "$(cat brief)"` opens the TUI with an empty composer; the brief is not processed until firstmate steers. +`fm-spawn` now sends the standard Begin-now nudge automatically after launch settle. +Without it, crewmates often search the filesystem instead of using the worktree cwd. + +**no-mistakes headless path.** +Pipeline steps invoke `bin/no-mistakes-agy-shim` (or `~/.local/bin/no-mistakes-agy-shim`), which runs `agy -p` once and emits Claude stream-json. +Headless `agy -p` can hang indefinitely; the shim honors `FM_AGY_SHIM_TIMEOUT` (default 600s) when `timeout` is on PATH. + +First launch on a machine may require Google sign-in. +First launch per directory shows a directory-trust dialog ("Do you trust the contents of this project?"); accept with Enter on "Yes, I trust this folder". +`fm-spawn` installs the same per-worktree `.claude/settings.local.json` Stop hook as claude so turn-end touches `state/.turn-ended`. + Turn-end hook: grok fires a `Stop` hook at every turn boundary, giving firstmate a precise per-turn wake instead of only stale-pane detection. grok loads PROJECT hooks (`/.grok/hooks/`, `/.claude/settings.local.json`) only after the folder is granted hook-trust in `~/.grok/trusted_folders.toml`, which is not automatic and which firstmate will not establish by editing grok's own managed trust store. GLOBAL hooks in `~/.grok/hooks/` are always trusted and load on first launch. diff --git a/AGENTS.md b/AGENTS.md index 6fdaa675..1ca699f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,7 @@ If the captain expresses a standing dispatch preference such as "use grok for ne Crewmates default to the same harness you are running on. The captain may override the static default at any time, typically at bootstrap: record the choice in `config/crew-harness` (a single adapter name; absent or `default` means mirror your own harness). Resolve `default` with `bin/fm-harness.sh`; resolve the active static crewmate harness with `bin/fm-harness.sh crew`. -Verified adapter names are `claude`, `codex`, `opencode`, `pi`, and `grok`. +Verified adapter names are `claude`, `codex`, `opencode`, `pi`, `grok`, and `agy`. ### Crew dispatch profiles diff --git a/README.md b/README.md index d6d31cd0..342a2399 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ## Quick Start -**Requirements:** a verified agent harness (claude, codex, opencode, pi, or grok), git with GitHub auth, and tmux for the reference session backend. +**Requirements:** a verified agent harness (claude, codex, opencode, pi, grok, or agy), git with GitHub auth, and tmux for the reference session backend. The first mate detects and offers to install everything else. ```sh diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c28814dd..4f713fd2 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -332,13 +332,13 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","grok"] | index($h); + def verified($h): ["claude","codex","opencode","pi","grok","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" or $h == "grok" or $h == "pi") then (["low","medium","high","xhigh"] | index($e)) - elif $h == "opencode" then false + elif ($h == "opencode" or $h == "agy") then false else true end; def bad_efforts: diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 3267e922..3e1b110c 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|grok|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|grok|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 @@ -44,6 +44,7 @@ detect_own() { *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; + agy) echo agy; return ;; pi) echo pi; return ;; node*|python*) # Bare interpreter: match the harness name in its script path. @@ -53,6 +54,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-lock.sh b/bin/fm-lock.sh index 33e4b0d2..4c498b2d 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -15,7 +15,7 @@ LOCK="$STATE/.lock" mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|^pi$' +HARNESS_RE='claude|codex|opencode|grok|agy|^pi$' harness_pid() { local pid=$$ comm args diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 70e21529..feb5630b 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -98,7 +98,12 @@ else \$*) if [ "$TARGET_HARNESS" = codex ]; then settle=1.2; else settle=0.3; fi ;; - *) settle=0.3 ;; + *) + case "$TARGET_HARNESS" in + agy) settle=1.2 ;; + *) settle=0.3 ;; + esac + ;; esac retries=${FM_SEND_RETRIES:-3} sleep_s=${FM_SEND_SLEEP:-0.4} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index bd760060..2701dd5f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -32,7 +32,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|grok) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok|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. @@ -267,7 +267,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|grok) + ''|claude|codex|opencode|pi|grok|agy) ARG3=${POS[1]:-} ;; *' '*) @@ -328,6 +328,8 @@ launch_template() { # launch command - it is a Stop-event hook installed below (global hook + # per-task pointer), so the template is identical for ship/scout/secondmate. grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; + # agy (Antigravity CLI): Claude-Code-compatible TUI; verified 2026-07-05, agy 1.0.16. + agy) printf '%s' 'agy --dangerously-skip-permissions __MODELFLAG__"$(cat __BRIEF__)"' ;; *) return 1 ;; esac } @@ -415,7 +417,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|grok) + claude|codex|opencode|pi|grok|agy) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -828,7 +830,7 @@ exclude_path() { } if [ "$KIND" != secondmate ]; then case "$HARNESS" in - claude*) + claude*|agy*) mkdir -p "$WT/.claude" cat > "$WT/.claude/settings.local.json" </dev/null 2>&1; then + echo "no-mistakes-agy-shim: agy not found (set NO_MISTAKES_AGY_BIN)" >&2 + exit 127 +fi + +prompt="" +schema="" +declare -a agy_args=() + +args=("$@") +i=0 +while [ "$i" -lt "${#args[@]}" ]; do + arg="${args[$i]}" + case "$arg" in + -p|--print|--prompt) + i=$((i + 1)) + prompt="${args[$i]:-}" + ;; + --json-schema) + i=$((i + 1)) + schema="${args[$i]:-}" + ;; + --verbose|--dangerously-skip-permissions) ;; + --output-format) + i=$((i + 1)) + ;; + --output-format=*|--permission-mode|--permission-mode=*) ;; + --model|--project|--sandbox|--add-dir) + agy_args+=("$arg") + i=$((i + 1)) + agy_args+=("${args[$i]:-}") + ;; + --model=*|--project=*|--sandbox=*|--add-dir=*) + agy_args+=("$arg") + ;; + *) + if [ -z "$prompt" ] && [[ "$arg" != -* ]]; then + prompt="$arg" + fi + ;; + esac + i=$((i + 1)) +done + +if [ -z "$prompt" ]; then + echo "no-mistakes-agy-shim: missing prompt" >&2 + exit 2 +fi + +full_prompt="$prompt" +if [ -n "$schema" ]; then + full_prompt="${prompt} + +Respond with one valid JSON object only (no markdown fences) matching this schema: +${schema}" +fi + +stderr_file=$(mktemp) +agy_output="" +agy_ec=0 +set +e +if command -v timeout >/dev/null 2>&1; then + agy_output=$(timeout --signal=TERM "$AGY_TIMEOUT" \ + "$AGY_BIN" --dangerously-skip-permissions "${agy_args[@]}" -p "$full_prompt" 2>"$stderr_file") +else + agy_output=$("$AGY_BIN" --dangerously-skip-permissions "${agy_args[@]}" -p "$full_prompt" 2>"$stderr_file") +fi +agy_ec=$? +set -e + +if [ "$agy_ec" -ne 0 ]; then + err=$(tr '\n' ' ' <"$stderr_file" | sed 's/ */ /g') + rm -f "$stderr_file" + printf '{"type":"result","subtype":"error","is_error":true}\n' >&2 + if [ "$agy_ec" -eq 124 ]; then + echo "agy timed out after ${AGY_TIMEOUT}s (set FM_AGY_SHIM_TIMEOUT to override)" >&2 + else + echo "agy exited: ${err:-unknown error}" >&2 + fi + exit "$agy_ec" +fi +rm -f "$stderr_file" + +SCHEMA_JSON="$schema" AGY_TEXT="$agy_output" python3 - <<'PY' +import json +import os +import re +import sys + +text = os.environ.get("AGY_TEXT", "") +schema_raw = os.environ.get("SCHEMA_JSON", "") +structured = None + +if schema_raw: + candidate = text.strip() + candidate = re.sub(r"^```(?:json)?\s*", "", candidate) + candidate = re.sub(r"\s*```$", "", candidate) + match = re.search(r"\{.*\}", candidate, re.DOTALL) + if match: + try: + structured = json.loads(match.group()) + except json.JSONDecodeError: + pass + +assistant = { + "type": "assistant", + "message": { + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + }, + "content": [{"type": "text", "text": text}], + }, +} +print(json.dumps(assistant), flush=True) + +result = { + "type": "result", + "subtype": "success", + "is_error": False, +} +if structured is not None: + result["structured_output"] = structured +print(json.dumps(result), flush=True) + +if schema_raw and structured is None: + sys.exit(1) +PY \ No newline at end of file diff --git a/docs/examples/crew-dispatch.json b/docs/examples/crew-dispatch.json index e08a2fdf..bb477267 100644 --- a/docs/examples/crew-dispatch.json +++ b/docs/examples/crew-dispatch.json @@ -12,9 +12,9 @@ }, { "when": "The task is a big or ambiguous multi-file feature, a risky refactor, or work that requires holding many moving parts in mind.", - "use": { "harness": "codex", "model": "gpt-5.5", "effort": "high" }, - "why": "Use a stronger coding profile for broad design and implementation work." + "use": { "harness": "agy", "model": "Gemini 3.5 Flash (High)" }, + "why": "Use Agy for broad design and implementation work when Codex is unavailable." } ], - "default": { "harness": "codex", "model": "gpt-5.5", "effort": "medium" } + "default": { "harness": "grok" } } diff --git a/tests/fm-spawn-agy.test.sh b/tests/fm-spawn-agy.test.sh new file mode 100644 index 00000000..f58db855 --- /dev/null +++ b/tests/fm-spawn-agy.test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# agy spawn posts a Begin-now nudge after the launch Enter. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-agy) + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; 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) + if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then + prev= + for a in "$@"; do + if [ "$prev" = "-l" ]; then + printf '%s\n' "$a" >> "$FM_FAKE_LAUNCH_LOG" + elif [ "$prev" = "-l" ] || [ "$a" = "Enter" ]; then + : + fi + if [ "$a" = "Enter" ]; then + printf 'Enter\n' >> "$FM_FAKE_LAUNCH_LOG" + fi + prev=$a + done + fi + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +case_dir="$TMP_ROOT/agy-nudge" +home="$case_dir/home" +proj="$case_dir/project" +wt="$case_dir/wt" +launchlog="$case_dir/launch.log" +fakebin=$(make_spawn_fakebin "$case_dir/fake") +id=agy-nudge-z1 + +mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" +printf 'agy\n' > "$home/config/crew-harness" +fm_git_worktree "$proj" "$wt" "wt-agy" +mkdir -p "$home/data/$id" +printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" +touch "$home/state/.last-watcher-beat" + +: > "$launchlog" +FM_AGY_SPAWN_SETTLE=0 \ + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_LAUNCH_LOG="$launchlog" PATH="$fakebin:$PATH" \ + "$SPAWN" "$id" "$proj" agy >/dev/null 2>&1 +status=$? +expect_code 0 "$status" "agy spawn should succeed" + +launch=$(cat "$launchlog") +assert_contains "$launch" 'agy --dangerously-skip-permissions' "agy launch missing" +assert_contains "$launch" 'Begin now per brief' "agy spawn missing Begin-now nudge" +pass "agy spawn sends Begin-now nudge after launch" \ No newline at end of file diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 5b0bad7a..4bf690d4 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -327,6 +327,23 @@ test_pi_omits_invalid_max_effort() { pass "pi threads model and omits unsupported max effort" } +test_agy_threads_model_and_ignores_effort_axis() { + local rec id out status launch + id=profile-agy-z8b + rec=$(make_spawn_case profile-agy agy "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --harness agy --model 'Claude Sonnet 4.6 (Thinking)' --effort high) + status=$? + expect_code 0 "$status" "agy spawn with model and ignored effort should succeed" + assert_meta_profile "$HOME_DIR/state/$id.meta" agy 'Claude Sonnet 4.6 (Thinking)' high + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "agy --dangerously-skip-permissions --model 'Claude Sonnet 4.6 (Thinking)'" \ + "agy launch did not thread model" + assert_not_contains "$launch" "--effort" "agy launch must not pass unsupported --effort" + pass "agy receives --model and omits the unsupported effort axis" +} + test_batch_forwards_shared_profile_flags() { local rec id1 id2 out status id1=profile-batch-a-z9 @@ -377,6 +394,7 @@ test_grok_threads_model_and_reasoning_effort test_grok_omits_invalid_max_reasoning_effort test_opencode_threads_model_and_ignores_effort_axis test_pi_omits_invalid_max_effort +test_agy_threads_model_and_ignores_effort_axis test_batch_forwards_shared_profile_flags test_active_dispatch_profile_does_not_block_secondmate_launch diff --git a/tests/no-mistakes-agy-shim.test.sh b/tests/no-mistakes-agy-shim.test.sh new file mode 100644 index 00000000..c8633807 --- /dev/null +++ b/tests/no-mistakes-agy-shim.test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# no-mistakes-agy-shim times out hung headless agy -p calls. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SHIM="$ROOT/bin/no-mistakes-agy-shim" +TMP_ROOT=$(fm_test_tmproot no-mistakes-agy-shim) +fakebin=$(fm_fakebin "$TMP_ROOT/fake") + +cat > "$fakebin/agy" <<'SH' +#!/usr/bin/env bash +sleep 30 +printf 'should not finish\n' +SH +chmod +x "$fakebin/agy" + +if ! command -v timeout >/dev/null 2>&1; then + pass "skip timeout test: timeout(1) not on PATH" + exit 0 +fi + +out=$(FM_AGY_SHIM_TIMEOUT=1 NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ + "$SHIM" -p 'say hi' 2>&1) || status=$? +status=${status:-0} +expect_code 124 "$status" "shim should exit 124 on agy timeout" +assert_contains "$out" 'timed out' "shim timeout stderr missing" +pass "no-mistakes-agy-shim honors FM_AGY_SHIM_TIMEOUT" \ No newline at end of file From 0f4c69862626e9a63b9941e96a11c2be3feb58d6 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:59:53 +0300 Subject: [PATCH 2/4] no-mistakes(review): Harden agy harness shim extraction and spawn nudge --- bin/fm-spawn.sh | 2 +- bin/no-mistakes-agy-shim | 20 +++++------ tests/no-mistakes-agy-shim.test.sh | 53 ++++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 2701dd5f..87195d71 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1002,7 +1002,7 @@ spawn_send_key "$T" Enter # agy loads a positional brief into the session but does not auto-start the turn # (verified 2026-07-05, agy 1.0.16). Nudge after trust-dialog/TUI settle. -if [ "$HARNESS" = agy ]; then +if [[ "$HARNESS" == agy* ]]; then sleep "${FM_AGY_SPAWN_SETTLE:-10}" spawn_send_literal "$T" 'Begin now per brief: verify worktree isolation, create your task branch, and execute the full brief contract.' sleep 1.2 diff --git a/bin/no-mistakes-agy-shim b/bin/no-mistakes-agy-shim index 2655b4a2..c3ba35f0 100755 --- a/bin/no-mistakes-agy-shim +++ b/bin/no-mistakes-agy-shim @@ -80,7 +80,7 @@ set -e if [ "$agy_ec" -ne 0 ]; then err=$(tr '\n' ' ' <"$stderr_file" | sed 's/ */ /g') rm -f "$stderr_file" - printf '{"type":"result","subtype":"error","is_error":true}\n' >&2 + printf '{"type":"result","subtype":"error","is_error":true}\n' if [ "$agy_ec" -eq 124 ]; then echo "agy timed out after ${AGY_TIMEOUT}s (set FM_AGY_SHIM_TIMEOUT to override)" >&2 else @@ -101,15 +101,15 @@ schema_raw = os.environ.get("SCHEMA_JSON", "") structured = None if schema_raw: - candidate = text.strip() - candidate = re.sub(r"^```(?:json)?\s*", "", candidate) - candidate = re.sub(r"\s*```$", "", candidate) - match = re.search(r"\{.*\}", candidate, re.DOTALL) - if match: - try: - structured = json.loads(match.group()) - except json.JSONDecodeError: - pass + decoder = json.JSONDecoder() + for i in range(len(text)): + if text[i] in ('{', '['): + try: + obj, end_idx = decoder.raw_decode(text[i:]) + structured = obj + break + except json.JSONDecodeError: + pass assistant = { "type": "assistant", diff --git a/tests/no-mistakes-agy-shim.test.sh b/tests/no-mistakes-agy-shim.test.sh index c8633807..47dafcfc 100644 --- a/tests/no-mistakes-agy-shim.test.sh +++ b/tests/no-mistakes-agy-shim.test.sh @@ -18,12 +18,53 @@ chmod +x "$fakebin/agy" if ! command -v timeout >/dev/null 2>&1; then pass "skip timeout test: timeout(1) not on PATH" - exit 0 +else + out=$(FM_AGY_SHIM_TIMEOUT=1 NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ + "$SHIM" -p 'say hi' 2>&1) || status=$? + status=${status:-0} + expect_code 124 "$status" "shim should exit 124 on agy timeout" + assert_contains "$out" 'timed out' "shim timeout stderr missing" + pass "no-mistakes-agy-shim honors FM_AGY_SHIM_TIMEOUT" fi -out=$(FM_AGY_SHIM_TIMEOUT=1 NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ - "$SHIM" -p 'say hi' 2>&1) || status=$? +# Test robust JSON extraction with braces outside +cat > "$fakebin/agy" <<'SH' +#!/usr/bin/env bash +echo "Some {unrelated} braces here and there." +echo "Here is the valid JSON:" +echo '```json' +echo '{"summary": "success", "nested": {"key": "val {with brace}"}}' +echo '```' +echo "And some trailing {braces} too." +SH +chmod +x "$fakebin/agy" + +out=$(NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ + "$SHIM" --json-schema '{"type": "object"}' -p 'say hi') +status=$? +expect_code 0 "$status" "shim should succeed with valid JSON extract" +assert_contains "$out" '"structured_output": {"summary": "success", "nested": {"key": "val {with brace}"}}' "JSON structured output missing or incorrect" +pass "no-mistakes-agy-shim extracts JSON robustly with external braces" + +# Test error stream redirection: JSON error event is on stdout, error message on stderr +cat > "$fakebin/agy" <<'SH' +#!/usr/bin/env bash +echo "some stderr logs" >&2 +exit 5 +SH +chmod +x "$fakebin/agy" + +stdout_file="$TMP_ROOT/stdout.log" +stderr_file="$TMP_ROOT/stderr.log" + +NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ + "$SHIM" -p 'say hi' >"$stdout_file" 2>"$stderr_file" || status=$? status=${status:-0} -expect_code 124 "$status" "shim should exit 124 on agy timeout" -assert_contains "$out" 'timed out' "shim timeout stderr missing" -pass "no-mistakes-agy-shim honors FM_AGY_SHIM_TIMEOUT" \ No newline at end of file +expect_code 5 "$status" "shim should exit with agy exit code" + +out_stdout=$(cat "$stdout_file") +out_stderr=$(cat "$stderr_file") + +assert_contains "$out_stdout" '{"type":"result","subtype":"error","is_error":true}' "JSON error event should be on stdout" +assert_contains "$out_stderr" "agy exited: some stderr logs" "error message should be on stderr" +pass "no-mistakes-agy-shim outputs JSON error event to stdout and log message to stderr" \ No newline at end of file From dc2959c40e7be58416f2e68ba40c341a531b229a Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:07:11 +0300 Subject: [PATCH 3/4] no-mistakes(review): Harden agy harness lock patterns and shim extraction --- bin/fm-lock.sh | 2 +- bin/no-mistakes-agy-shim | 44 ++++++++++++++++++++---------- tests/no-mistakes-agy-shim.test.sh | 34 ++++++++++++++++++++++- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 4c498b2d..203c4c07 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -15,7 +15,7 @@ LOCK="$STATE/.lock" mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|agy|^pi$' +HARNESS_RE='(^|[^a-zA-Z0-9_-])(claude|codex|opencode|grok|agy|pi)([^a-zA-Z0-9_-]|$)' harness_pid() { local pid=$$ comm args diff --git a/bin/no-mistakes-agy-shim b/bin/no-mistakes-agy-shim index c3ba35f0..5b644315 100755 --- a/bin/no-mistakes-agy-shim +++ b/bin/no-mistakes-agy-shim @@ -30,10 +30,10 @@ while [ "$i" -lt "${#args[@]}" ]; do schema="${args[$i]:-}" ;; --verbose|--dangerously-skip-permissions) ;; - --output-format) + --output-format|--permission-mode) i=$((i + 1)) ;; - --output-format=*|--permission-mode|--permission-mode=*) ;; + --output-format=*|--permission-mode=*) ;; --model|--project|--sandbox|--add-dir) agy_args+=("$arg") i=$((i + 1)) @@ -65,21 +65,25 @@ ${schema}" fi stderr_file=$(mktemp) -agy_output="" +output_file=$(mktemp) +cleanup() { + rm -f "$stderr_file" "$output_file" +} +trap cleanup EXIT + agy_ec=0 set +e if command -v timeout >/dev/null 2>&1; then - agy_output=$(timeout --signal=TERM "$AGY_TIMEOUT" \ - "$AGY_BIN" --dangerously-skip-permissions "${agy_args[@]}" -p "$full_prompt" 2>"$stderr_file") + timeout --signal=TERM "$AGY_TIMEOUT" \ + "$AGY_BIN" --dangerously-skip-permissions "${agy_args[@]}" -p "$full_prompt" >"$output_file" 2>"$stderr_file" else - agy_output=$("$AGY_BIN" --dangerously-skip-permissions "${agy_args[@]}" -p "$full_prompt" 2>"$stderr_file") + "$AGY_BIN" --dangerously-skip-permissions "${agy_args[@]}" -p "$full_prompt" >"$output_file" 2>"$stderr_file" fi agy_ec=$? set -e if [ "$agy_ec" -ne 0 ]; then err=$(tr '\n' ' ' <"$stderr_file" | sed 's/ */ /g') - rm -f "$stderr_file" printf '{"type":"result","subtype":"error","is_error":true}\n' if [ "$agy_ec" -eq 124 ]; then echo "agy timed out after ${AGY_TIMEOUT}s (set FM_AGY_SHIM_TIMEOUT to override)" >&2 @@ -88,18 +92,24 @@ if [ "$agy_ec" -ne 0 ]; then fi exit "$agy_ec" fi -rm -f "$stderr_file" -SCHEMA_JSON="$schema" AGY_TEXT="$agy_output" python3 - <<'PY' +SCHEMA_JSON="$schema" AGY_OUTPUT_FILE="$output_file" python3 - <<'PY' import json import os -import re import sys -text = os.environ.get("AGY_TEXT", "") +output_file = os.environ.get("AGY_OUTPUT_FILE", "") schema_raw = os.environ.get("SCHEMA_JSON", "") structured = None +text = "" +if output_file and os.path.exists(output_file): + try: + with open(output_file, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + except Exception as e: + sys.stderr.write(f"Error reading output file: {e}\n") + if schema_raw: decoder = json.JSONDecoder() for i in range(len(text)): @@ -125,6 +135,15 @@ assistant = { } print(json.dumps(assistant), flush=True) +if schema_raw and structured is None: + result = { + "type": "result", + "subtype": "error", + "is_error": True, + } + print(json.dumps(result), flush=True) + sys.exit(1) + result = { "type": "result", "subtype": "success", @@ -133,7 +152,4 @@ result = { if structured is not None: result["structured_output"] = structured print(json.dumps(result), flush=True) - -if schema_raw and structured is None: - sys.exit(1) PY \ No newline at end of file diff --git a/tests/no-mistakes-agy-shim.test.sh b/tests/no-mistakes-agy-shim.test.sh index 47dafcfc..f06c6ef4 100644 --- a/tests/no-mistakes-agy-shim.test.sh +++ b/tests/no-mistakes-agy-shim.test.sh @@ -67,4 +67,36 @@ out_stderr=$(cat "$stderr_file") assert_contains "$out_stdout" '{"type":"result","subtype":"error","is_error":true}' "JSON error event should be on stdout" assert_contains "$out_stderr" "agy exited: some stderr logs" "error message should be on stderr" -pass "no-mistakes-agy-shim outputs JSON error event to stdout and log message to stderr" \ No newline at end of file +pass "no-mistakes-agy-shim outputs JSON error event to stdout and log message to stderr" + +# Test schema extraction failure +cat > "$fakebin/agy" <<'SH' +#!/usr/bin/env bash +echo "no json here" +SH +chmod +x "$fakebin/agy" + +stdout_file="$TMP_ROOT/stdout.log" +stderr_file="$TMP_ROOT/stderr.log" + +status=0 +NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ + "$SHIM" --json-schema '{"type": "object"}' -p 'say hi' >"$stdout_file" 2>"$stderr_file" || status=$? +expect_code 1 "$status" "shim should exit 1 on schema extraction failure" + +out_stdout=$(cat "$stdout_file") +assert_contains "$out_stdout" '"content": [{"type": "text", "text": "no json here\n"}]' "assistant message on stdout missing" +assert_contains "$out_stdout" '{"type": "result", "subtype": "error", "is_error": true}' "JSON error event on stdout missing" +pass "no-mistakes-agy-shim outputs JSON error event and exits 1 when JSON extraction fails" + +# Test --permission-mode option parsing +cat > "$fakebin/agy" <<'SH' +#!/usr/bin/env bash +echo "dummy output" +SH +chmod +x "$fakebin/agy" + +out=$(NO_MISTAKES_AGY_BIN="$fakebin/agy" PATH="$fakebin:$PATH" \ + "$SHIM" --permission-mode yolo -p 'correct prompt') +assert_contains "$out" '"text": "dummy output\n"' "permission mode parsing failed" +pass "no-mistakes-agy-shim parses --permission-mode option and retains correct prompt" \ No newline at end of file From 6b6256c54f7edab61c3e5818fa6449816c8e33d7 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:10:54 +0300 Subject: [PATCH 4/4] fix: match versioned agy harness in fm-harness and fm-send settle --- bin/fm-harness.sh | 2 +- bin/fm-send.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 3e1b110c..278caa44 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -44,7 +44,7 @@ detect_own() { *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; - agy) echo agy; return ;; + *agy*) echo agy; return ;; pi) echo pi; return ;; node*|python*) # Bare interpreter: match the harness name in its script path. diff --git a/bin/fm-send.sh b/bin/fm-send.sh index feb5630b..05a3fa20 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -100,7 +100,7 @@ else ;; *) case "$TARGET_HARNESS" in - agy) settle=1.2 ;; + agy*) settle=1.2 ;; *) settle=0.3 ;; esac ;;