From 475dc74f21db123608b545f3f5f350b537a692e8 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Mon, 6 Jul 2026 14:30:40 +0800 Subject: [PATCH 01/11] docs(seo): refresh antigravity transition positioning --- .claude-plugin/marketplace.json | 4 +- README.md | 53 ++++++++++++++----- README.zh-TW.md | 53 ++++++++++++++----- docs/COMPARISON.md | 35 ++++++++++++ docs/MODEL_COMPARISON.md | 21 ++++---- package.json | 33 +++++++++++- plugins/gemini/scripts/lib/gemini.mjs | 18 +++---- plugins/gemini/scripts/lib/model-map.mjs | 18 +++---- .../gemini/skills/gemini-prompting/SKILL.md | 4 +- .../references/gemini-prompt-antipatterns.md | 10 ++-- .../references/gemini-prompt-blocks.md | 4 +- .../references/gemini-prompt-recipes.md | 2 +- 12 files changed, 186 insertions(+), 69 deletions(-) create mode 100644 docs/COMPARISON.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4c18980..b853a47 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ "login": "arcobaleno64" }, "metadata": { - "description": "Gemini/AGY companion plugin for Claude Code — delegate tasks, adversarial reviews, and rescue. Mirrors openai/codex-plugin-cc with Google Gemini.", + "description": "Use Gemini CLI or Antigravity CLI (agy) inside Claude Code for task delegation, code review, and adversarial review.", "version": "0.6.6" }, "plugins": [ { "name": "gemini", - "description": "Gemini/AGY companion plugin — delegation, adversarial review, rescue agent", + "description": "Gemini CLI and Antigravity CLI bridge for Claude Code task delegation and adversarial review", "version": "0.6.6", "author": { "name": "arcobaleno64" diff --git a/README.md b/README.md index a71ea96..7fb5d1a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ -# gemini — Claude Code Plugin +# Gemini / Antigravity Companion for Claude Code -> Delegate tasks and adversarial code reviews to Google Gemini / AGY directly from Claude Code. +> Use Gemini CLI or Antigravity CLI (`agy`) inside Claude Code for task delegation, pragmatic code review, and adversarial review. + +**Transition-ready for Google's Gemini CLI to Antigravity CLI migration.** +`gemini-plugin-cc` keeps the familiar Claude Code slash-command workflow while letting you route work to Gemini CLI where available, or to Antigravity CLI (`agy`) during the post-June-2026 transition. [繁體中文說明 →](README.zh-TW.md) @@ -8,6 +11,28 @@ Ported from [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) --- +## Why this plugin? + +`gemini-plugin-cc` is a Claude Code-native companion bridge for users who want both Gemini CLI and Antigravity CLI (`agy`) support during Google's Gemini CLI transition. + +Compared with AGY-only, multi-host plugins, this project keeps the Gemini CLI path where available while providing an explicit `--engine agy` route for users migrating to Antigravity CLI. + +- Claude Code-native `/gemini:*` slash commands. +- Pragmatic and adversarial code review over the current diff or branch. +- Background task delegation for longer-running companion-agent work. +- Gemini model aliases, graceful model fallback, and transient review retry. +- AGY transcript recovery for `agy --print` non-pipe behavior. +- Safer stdin prompt delivery on the Gemini engine. + +| Need | Use this plugin when... | +|---|---| +| Gemini CLI still works for you | You want model selection, JSON output, and stdin prompt delivery. | +| You are migrating to AGY | Use `--engine agy` for Antigravity CLI fallback. | +| You want adversarial review | Use `/gemini:adversarial-review` with optional focus text. | +| You need AGY-only multi-host support | Consider an AGY-only plugin instead. | + +--- + ## Features - **`/gemini:rescue`** — Delegate investigation, debugging, or implementation tasks to Gemini. Runs in the foreground or detached in the background. @@ -36,8 +61,8 @@ Ported from [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) **Authentication**: Run `gemini` once to complete OAuth. No API key is required. > **Heads-up (reality check):** -> - **2026-06-18**: free/personal gemini CLI access ends. After that the **gemini** engine needs a paid Gemini Code Assist Standard/Enterprise tier; **AGY** (`--engine agy`) becomes the free path. -> - **Gemini 3.5** (Flash/Pro) is GA on the API but **not served by the gemini CLI 0.44.1** (latest) — it returns 404. Use it via the **AGY** engine (fixed model). The plugin gracefully falls back to a GA model if a requested id is unavailable. See [Model Aliases](#model-aliases) and [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md). +> - **2026-06-18 consumer transition**: Google announced that free/personal, Google AI Pro, and Google AI Ultra Gemini CLI requests stop being served after this date; Standard/Enterprise access remains. See Google's [Gemini CLI to Antigravity CLI announcement](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/). +> - **Model availability drifts by CLI version.** In the 2026-06-02 probe against gemini CLI 0.44.1, `gemini-3.5-*` returned `404 ModelNotFound`; newer CLI releases may differ. The plugin gracefully falls back to a GA model if a requested id is unavailable. See [Model Aliases](#model-aliases) and [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md). --- @@ -58,7 +83,7 @@ Ported from [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) ### Pinned release (a specific published version) -Pin the marketplace to a release tag — e.g. the latest, `v0.6.6`: +Pin the marketplace to a release tag — e.g. `v0.6.6`: ``` /plugin marketplace add arcobaleno64/gemini-plugin-cc@v0.6.6 @@ -153,7 +178,7 @@ Lists active and recent background jobs. Pass a job ID to inspect a single job. ### `/gemini:result [job-id]` -Retrieves the output of a completed job. If the job has a Gemini session ID, the output includes `Resume in Gemini: gemini resume ` — paste that into a terminal to continue the session in Gemini CLI directly. +Retrieves the output of a completed job. If the job has a Gemini session ID, the output includes `Resume in Gemini: gemini --resume ` — paste that into a terminal to continue the session in Gemini CLI directly. ### `/gemini:cancel [job-id]` @@ -183,7 +208,7 @@ When enabled and the review returns `needs-attention`, Claude Code is blocked fr | Alias | Resolved Model | Notes | |---|---|---| -| `flash` / `flash3` | `gemini-3-flash-preview` | Latest Gemini 3 Flash (preview) | +| `flash` / `flash3` | `gemini-3-flash-preview` | Gemini 3 Flash (preview) | | `pro` / `pro3` | `gemini-3.1-pro-preview` | Gemini 3.1 Pro (preview) | | `flash25` | `gemini-2.5-flash` | Stable 2.5 Flash (GA) | | `pro25` | `gemini-2.5-pro` | Stable 2.5 Pro (GA) | @@ -194,10 +219,10 @@ When enabled and the review returns `needs-attention`, Claude Code is blocked fr - Aliases and effort tiers live in a single source of truth — `plugins/gemini/scripts/lib/model-map.mjs` — and `npm test` verifies the table above against it, so the two cannot drift. - **Effort mapping** (applied when `--effort` is given without `--model`): `none`/`minimal` → `gemini-2.5-flash-lite`; `low`/`medium` → `gemini-3-flash-preview`; `high`/`xhigh` → `gemini-3.1-pro-preview`. -- **Preview IDs may change.** Model IDs ending in `-preview` track Google's preview channel (last verified against gemini CLI 0.44.1). If an alias stops resolving, override it with `--model ` — any value that is not a known alias is passed through to the CLI unchanged. -- **Gemini 3.5 is not on the CLI yet.** `gemini-3.5-flash` and `gemini-3.5-pro` are GA on the Gemini API but are **not served by the gemini CLI 0.44.1** (latest at time of writing) — they return `404 ModelNotFound`. To use Gemini 3.5 Flash, route through the **AGY engine** (`--engine agy`), which runs it (fixed model; no `--model`/`--effort` selection). +- **CLI probe snapshot.** The alias table reflects the model-map probe from 2026-06-02 against gemini CLI 0.44.1. Newer Gemini CLI releases may serve different model IDs. If an alias stops resolving, override it with `--model ` — any value that is not a known alias is passed through to the CLI unchanged. +- **Gemini 3.5 availability can drift.** The 2026-06-02 gemini CLI 0.44.1 probe returned `404 ModelNotFound` for `gemini-3.5-flash` and `gemini-3.5-pro`; newer CLI releases may differ. Unknown or unavailable model IDs degrade gracefully to the GA fallback. - **Graceful model fallback.** If a requested model id is not found on your gemini CLI (preview/retired id, or a CLI-version mismatch), the plugin retries the run **once on the GA fallback `gemini-2.5-flash`** and prints a clear note — so a stale id degrades gracefully instead of hard-failing. -- **AGY ignores `--model` and `--effort`.** `agy --print` is locked to Gemini 3.5 Flash (High) and exposes no model/effort selection; the plugin prints a note and ignores both flags when `--engine agy` is active. +- **AGY model selection is not managed by this plugin yet.** Some AGY versions expose their own `--model` surface, but `--engine agy` currently runs through AGY's configured/default model and the plugin does not translate `--model` or `--effort` to AGY arguments. Use `--engine gemini` for plugin-managed model selection. --- @@ -210,7 +235,7 @@ In `auto` mode the plugin selects the first available engine in this order: Override via `--engine` flag or the `GEMINI_ENGINE` environment variable. -> `--model` and `--effort` apply to the **gemini** engine only. `agy --print` is locked to Gemini 3.5 Flash (High) and has no model/effort flag, so the plugin ignores `--model`/`--effort` when `--engine agy` is active. +> `--model` and `--effort` are managed by the **gemini** engine only. `--engine agy` currently leaves model choice to AGY's configured/default behavior; the plugin does not translate Gemini aliases or effort tiers to AGY arguments. > **AGY transcript recovery is platform-verified on Windows and macOS; Linux is reported working.** Because `agy --print` does not pipe its output (upstream [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466) — reproduced on macOS agy 1.0.7: 0 bytes reach stdout through a pipe), the plugin recovers AGY responses from the on-disk "brain" transcript under `~/.gemini/antigravity-cli/brain` (Windows 1.0.3 and macOS 1.0.7, both verified — same root) or `~/.antigravity-cli/brain` (Linux 1.0.2, reported). If `--engine agy` reports no brain root on your machine, run `agy` once so it creates the directory, or open an issue with its actual location. @@ -274,7 +299,7 @@ This plugin is a high-fidelity port of [openai/codex-plugin-cc](https://github.c | `/codex:adversarial-review` | `/gemini:adversarial-review` | **best-effort equivalent** — adversarial prompt over the same diff target | | `/codex:rescue` | `/gemini:rescue` | **1:1 parity** — same forwarder/subagent contract and flags | | `/codex:status` | `/gemini:status` | **1:1 parity** — same job model; `--all` crosses Claude sessions | -| `/codex:result` | `/gemini:result` | **Gemini-specific divergence** — surfaces the Gemini session id + `gemini resume` | +| `/codex:result` | `/gemini:result` | **Gemini-specific divergence** — surfaces the Gemini session id + `gemini --resume` | | `/codex:cancel` | `/gemini:cancel` | **1:1 parity** — same process-tree termination (POSIX + Windows) | ### Codex app server vs Gemini CLI adapter @@ -282,7 +307,7 @@ This plugin is a high-fidelity port of [openai/codex-plugin-cc](https://github.c - **Runtime**: Codex uses a persistent app-server with native review and persistent threads. This plugin invokes the Gemini CLI directly *per command* (no shared runtime); AGY is an optional fallback. - **Standard review**: In the Codex plugin, `/codex:review` is a *native* reviewer. Here, `/gemini:review` is a **prompt-based / CLI-adapter equivalent** — it sends the diff to Gemini with a pragmatic-review prompt and parses structured JSON back. It is not a native Gemini reviewer. - **Sandbox**: Codex exposes `read-only` / `workspace-write` sandboxes. Gemini has no equivalent; write access is gated by `--write` (`--yolo`), and otherwise the prompt enforces read-only discipline. (`--approval-mode plan` is intentionally not used: it requires a TTY and conflicts with stdin prompt delivery.) -- **Thread/session resume**: Codex persists threads on the app server. Here, resume relies on the Gemini CLI **session id** captured from the JSON envelope; `/gemini:result` prints `gemini resume `, and `--resume-last` continues the latest thread *for the current Claude session*. +- **Thread/session resume**: Codex persists threads on the app server. Here, resume relies on the Gemini CLI **session id** captured from the JSON envelope; `/gemini:result` prints `gemini --resume `, and `--resume-last` continues the latest thread *for the current Claude session*. --- @@ -302,7 +327,7 @@ Three skills are bundled for Claude Code to consume: Documented, non-blocking constraints — see the linked sections for detail: -- **Gemini 3.5 is not served by the gemini CLI, and the free CLI sunsets 2026-06-18.** `gemini-3.5-*` returns 404 on CLI 0.44.1 (reach it via AGY); a requested id that is not served degrades gracefully to the GA fallback `gemini-2.5-flash`. After 2026-06-18 the free/personal CLI tier ends. See [Model Alias Notes](#model-alias-notes) and [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md). +- **Model and access availability drift.** Google announced the 2026-06-18 consumer Gemini CLI transition; Gemini model IDs served by the CLI also change over time. This plugin keeps a GA fallback for unavailable Gemini model IDs. See [Model Alias Notes](#model-alias-notes) and [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md). - **`/gemini:review` is a prompt/CLI adapter, not a native reviewer.** It sends the diff with a review prompt and parses the structured JSON, rather than using an app-server reviewer, so its feedback depth differs from a native one. See [Codex app server vs Gemini CLI adapter](#codex-app-server-vs-gemini-cli-adapter). --- diff --git a/README.zh-TW.md b/README.zh-TW.md index adabcfa..635720d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,11 +1,36 @@ -# gemini — Claude Code 外掛 +# Gemini / Antigravity Companion for Claude Code -> 直接從 Claude Code 將任務與對抗性程式碼審查委派給 Google Gemini / AGY。 +> 在 Claude Code 內使用 Gemini CLI 或 Antigravity CLI (`agy`) 進行 task delegation、pragmatic code review 與 adversarial review。 + +**為 Google Gemini CLI 到 Antigravity CLI 的遷移期而準備。** +`gemini-plugin-cc` 保留熟悉的 Claude Code slash-command workflow;Gemini CLI 可用時可走 Gemini CLI,遷移到 Antigravity CLI (`agy`) 的使用者則可改走 AGY engine。 本外掛移植自 [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc)(Apache-2.0)的技能架構——相同的斜線命令 UX、相同的背景工作模型、相同的技能合約——由 Gemini 生態系統驅動。 --- +## 為什麼選這個外掛? + +`gemini-plugin-cc` 是 Claude Code-native 的 companion bridge,適合在 Google Gemini CLI transition 期間同時保留 Gemini CLI 與 Antigravity CLI (`agy`) 路徑的使用者。 + +相較於 AGY-only、多宿主外掛,本專案保留 Gemini CLI 可用時的路徑,同時提供明確的 `--engine agy` 給正在遷移到 Antigravity CLI 的使用者。 + +- Claude Code-native 的 `/gemini:*` slash commands。 +- 對目前 diff 或 branch 執行 pragmatic code review 與 adversarial review。 +- 用 background task delegation 處理較長時間的 companion-agent 工作。 +- Gemini model aliases、graceful model fallback 與 transient review retry。 +- 針對 `agy --print` non-pipe behavior 的 AGY transcript recovery。 +- Gemini engine 採用較安全的 stdin prompt delivery。 + +| 需求 | 適合使用本外掛的情境 | +|---|---| +| Gemini CLI 仍可用 | 你需要 model selection、JSON output 與 stdin prompt delivery。 | +| 正在遷移到 AGY | 使用 `--engine agy` 作為 Antigravity CLI fallback。 | +| 需要 adversarial review | 使用 `/gemini:adversarial-review`,可加 focus text。 | +| 需要 AGY-only 多宿主支援 | 可考慮 AGY-only plugin。 | + +--- + ## 功能特色 - **`/gemini:rescue`** — 將調查、除錯或實作任務委派給 Gemini。可在前景執行或以背景工作方式分離執行。 @@ -34,8 +59,8 @@ **認證**:執行一次 `gemini` 完成 OAuth 登入。不需要 API 金鑰。 > **重要提示(貼近現實):** -> - **2026-06-18**:免費/個人版 gemini CLI 存取終止。之後 **gemini** 引擎需付費 Gemini Code Assist Standard/Enterprise tier;**AGY**(`--engine agy`)成為免費途徑。 -> - **Gemini 3.5**(Flash/Pro)已在 API GA,但 **gemini CLI 0.44.1(最新)並不提供**——會回 404。請改走 **AGY** 引擎(固定模型)。若所請求 id 不可用,外掛會優雅降級至 GA 模型。詳見 [模型別名](#模型別名) 與 [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md)。 +> - **2026-06-18 consumer transition**:Google 宣布免費/個人版、Google AI Pro、Google AI Ultra 的 Gemini CLI requests 於此日期後停止服務;Standard/Enterprise access 維持。詳見 Google 的 [Gemini CLI to Antigravity CLI announcement](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/)。 +> - **模型可用性會隨 CLI 版本漂移。** 2026-06-02 以 gemini CLI 0.44.1 實測時,`gemini-3.5-*` 回 `404 ModelNotFound`;新版 CLI 可能不同。若所請求 id 不可用,外掛會優雅降級至 GA 模型。詳見 [模型別名](#模型別名) 與 [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md)。 --- @@ -56,7 +81,7 @@ ### 釘選發布版(指定某個已發布版本) -將 marketplace 釘到某個 release 標籤——例如最新的 `v0.6.6`: +將 marketplace 釘到某個 release 標籤——例如 `v0.6.6`: ``` /plugin marketplace add arcobaleno64/gemini-plugin-cc@v0.6.6 @@ -151,7 +176,7 @@ ### `/gemini:result [工作-ID]` -取得已完成工作的輸出內容。若工作帶有 Gemini session ID,輸出中會包含 `Resume in Gemini: gemini resume `——將該命令貼到終端機即可直接在 Gemini CLI 中接續工作階段。 +取得已完成工作的輸出內容。若工作帶有 Gemini session ID,輸出中會包含 `Resume in Gemini: gemini --resume `——將該命令貼到終端機即可直接在 Gemini CLI 中接續工作階段。 ### `/gemini:cancel [工作-ID]` @@ -181,7 +206,7 @@ | 別名 | 對應模型 | 說明 | |---|---|---| -| `flash` / `flash3` | `gemini-3-flash-preview` | 最新 Gemini 3 Flash(preview) | +| `flash` / `flash3` | `gemini-3-flash-preview` | Gemini 3 Flash(preview) | | `pro` / `pro3` | `gemini-3.1-pro-preview` | Gemini 3.1 Pro(preview) | | `flash25` | `gemini-2.5-flash` | 穩定 2.5 Flash(GA) | | `pro25` | `gemini-2.5-pro` | 穩定 2.5 Pro(GA) | @@ -192,10 +217,10 @@ - 別名與努力等級集中於單一來源——`plugins/gemini/scripts/lib/model-map.mjs`——且 `npm test` 會以其驗證上表,二者不致漂移。 - **努力對映**(於提供 `--effort` 但未給 `--model` 時套用):`none`/`minimal` → `gemini-2.5-flash-lite`;`low`/`medium` → `gemini-3-flash-preview`;`high`/`xhigh` → `gemini-3.1-pro-preview`。 -- **Preview ID 可能變動。** 以 `-preview` 結尾之 model ID 追隨 Google 的 preview channel(最後驗證於 gemini CLI 0.44.1)。若某別名無法解析,以 `--model <精確 ID>` 覆蓋——任何非已知別名之值將原樣透傳給 CLI。 -- **Gemini 3.5 尚未上 CLI。** `gemini-3.5-flash` 與 `gemini-3.5-pro` 已在 Gemini API GA,但 **gemini CLI 0.44.1(目前最新)並不提供**——會回 `404 ModelNotFound`。欲用 Gemini 3.5 Flash,請改走 **AGY 引擎**(`--engine agy`,由其固定執行;無 `--model`/`--effort` 選擇)。 +- **CLI probe snapshot。** 上表反映 2026-06-02 以 gemini CLI 0.44.1 進行的 model-map 實測;新版 Gemini CLI 可能提供不同 model ID。若某別名無法解析,以 `--model <精確 ID>` 覆蓋——任何非已知別名之值將原樣透傳給 CLI。 +- **Gemini 3.5 可用性會漂移。** 2026-06-02 以 gemini CLI 0.44.1 實測時,`gemini-3.5-flash` 與 `gemini-3.5-pro` 回 `404 ModelNotFound`;新版 CLI 可能不同。未知或不可用 model ID 會優雅降級至 GA fallback。 - **模型優雅降級。** 若所請求之 model id 在你的 gemini CLI 上找不到(preview/已退役 id,或 CLI 版本落差),外掛會**以 GA fallback `gemini-2.5-flash` 重試一次**並印出明確提示——讓過時 id 優雅降級,而非硬性失敗。 -- **AGY 忽略 `--model` 與 `--effort`。** `agy --print` 鎖定 Gemini 3.5 Flash(High)、無模型/努力選擇;`--engine agy` 時外掛會印出提示並忽略此二旗標。 +- **AGY model selection 尚未由本外掛管理。** 部分 AGY 版本提供自己的 `--model` 介面,但 `--engine agy` 目前走 AGY 的 configured/default model;本外掛不會把 `--model` 或 `--effort` 翻譯成 AGY 參數。若要由外掛管理 model selection,請用 `--engine gemini`。 --- @@ -208,7 +233,7 @@ 可透過 `--engine` 旗標或 `GEMINI_ENGINE` 環境變數覆蓋。 -> `--model` 與 `--effort` 僅適用於 **gemini** 引擎。`agy --print` 鎖定 Gemini 3.5 Flash(High)、無 model/effort 旗標,故 `--engine agy` 時外掛會忽略 `--model`/`--effort`。 +> `--model` 與 `--effort` 只由 **gemini** 引擎管理。`--engine agy` 目前讓 AGY 使用其 configured/default model;本外掛不會把 Gemini aliases 或 effort tiers 翻譯成 AGY 參數。 > **AGY 紀錄恢復已於 Windows 與 macOS 驗證通過;Linux 為回報可用。** 因 `agy --print` 不透過 pipe 輸出(上游 [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466)——已於 macOS agy 1.0.7 重現:pipe 下 stdout 為 0 bytes),外掛改從磁碟上的「brain」紀錄恢復回應——`~/.gemini/antigravity-cli/brain`(Windows 1.0.3 與 macOS 1.0.7,皆已驗證、同一路徑)或 `~/.antigravity-cli/brain`(Linux 1.0.2,回報)。若 `--engine agy` 回報找不到 brain 根目錄,請先執行一次 `agy` 讓其建立該目錄,或開 issue 回報實際位置。 @@ -272,7 +297,7 @@ Claude Code | `/codex:adversarial-review` | `/gemini:adversarial-review` | **最佳等效** — 對同一 diff target 施以對抗性 prompt | | `/codex:rescue` | `/gemini:rescue` | **1:1 對等** — 相同的 forwarder/subagent 合約與旗標 | | `/codex:status` | `/gemini:status` | **1:1 對等** — 相同工作模型;`--all` 跨 Claude session | -| `/codex:result` | `/gemini:result` | **Gemini 專屬差異** — 顯示 Gemini session id 與 `gemini resume` | +| `/codex:result` | `/gemini:result` | **Gemini 專屬差異** — 顯示 Gemini session id 與 `gemini --resume` | | `/codex:cancel` | `/gemini:cancel` | **1:1 對等** — 相同的 process-tree 終止(POSIX 與 Windows) | ### Codex app server 與 Gemini CLI adapter @@ -280,7 +305,7 @@ Claude Code - **執行時**:Codex 使用常駐 app-server,具原生審查與持久 thread。本外掛則於*每次命令*直接呼叫 Gemini CLI(無共享執行時);AGY 為選用備援。 - **標準審查**:Codex 外掛之 `/codex:review` 為*原生*審查器;本外掛之 `/gemini:review` 為 **prompt/CLI adapter 等效實作**——將 diff 連同務實審查 prompt 送交 Gemini 並解析回傳之結構化 JSON,並非原生 Gemini 審查器。 - **沙箱**:Codex 提供 `read-only`/`workspace-write` 沙箱。Gemini 無對應沙箱;寫入權由 `--write`(`--yolo`)把關,否則以 prompt 強制唯讀紀律。(不採 `--approval-mode plan`:其需 TTY,與 stdin 提示傳遞衝突。) -- **Thread/session 接續**:Codex 於 app-server 持久化 thread。本外掛之接續依賴自 JSON 信封擷取之 Gemini CLI **session id**;`/gemini:result` 會印出 `gemini resume `,而 `--resume-last` 接續*當前 Claude session* 之最新 thread。 +- **Thread/session 接續**:Codex 於 app-server 持久化 thread。本外掛之接續依賴自 JSON 信封擷取之 Gemini CLI **session id**;`/gemini:result` 會印出 `gemini --resume `,而 `--resume-last` 接續*當前 Claude session* 之最新 thread。 --- @@ -300,7 +325,7 @@ Claude Code 以下為已記錄之非阻塞限制——詳見所連結之章節: -- **gemini CLI 不提供 Gemini 3.5,且免費 CLI 於 2026-06-18 終止。** `gemini-3.5-*` 於 CLI 0.44.1 回 404(改走 AGY);不被提供之 model id 會優雅降級至 GA fallback `gemini-2.5-flash`。2026-06-18 後免費/個人版 CLI 層級終止。詳見 [模型別名說明](#模型別名說明) 與 [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md)。 +- **模型與存取可用性會漂移。** Google 已宣布 2026-06-18 consumer Gemini CLI transition;Gemini CLI 提供的 model IDs 也會隨版本變動。對不可用的 Gemini model ID,本外掛保留 GA fallback。詳見 [模型別名說明](#模型別名說明) 與 [docs/MODEL_COMPARISON.md](docs/MODEL_COMPARISON.md)。 - **`/gemini:review` 為 prompt/CLI adapter,非原生審查器。** 其將 diff 連同審查 prompt 送出並解析結構化 JSON,而非透過 app-server 審查器,故反饋深度有別於原生。詳見 [Codex app server 與 Gemini CLI adapter](#codex-app-server-與-gemini-cli-adapter)。 --- diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md new file mode 100644 index 0000000..f8ab52a --- /dev/null +++ b/docs/COMPARISON.md @@ -0,0 +1,35 @@ +# Gemini / Antigravity Plugin Comparison + +`gemini-plugin-cc` is a Claude Code-native companion bridge for developers who want Gemini CLI where it is still available, plus an explicit Antigravity CLI (`agy`) path during Google's Gemini CLI transition. + +This project is not an AGY-only replacement or a multi-host plugin. It focuses on Claude Code workflows with richer review behavior and defensive handling around real CLI failure modes. + +## Positioning + +| Need | Best fit | +|---|---| +| Claude Code-native Gemini / AGY bridge | Use this plugin. | +| Pragmatic and adversarial review inside Claude Code | Use `/gemini:review` or `/gemini:adversarial-review`. | +| Gemini CLI model aliases, JSON output, and stdin prompt delivery | Use the Gemini engine where your account still supports Gemini CLI. | +| Antigravity CLI fallback during migration | Use `--engine agy`. | +| AGY-only, multi-host, or standalone `npx` workflows | Use an AGY-only multi-host plugin instead. | + +## What This Plugin Emphasizes + +- Claude Code-native `/gemini:*` slash commands. +- Standard and adversarial code review over the current diff or branch. +- Background jobs with status, result, and cancel flows. +- Gemini model aliases, graceful model fallback, and transient review retry. +- AGY transcript recovery for `agy --print` non-pipe behavior. +- Safer stdin prompt delivery on the Gemini engine. + +## What This Plugin Does Not Claim + +- It is not a universal multi-host Antigravity plugin. +- It does not claim full feature parity with Antigravity CLI. +- It does not implement or claim ACP support for AGY. +- It does not publish an npm / `npx` install path. + +## Recommended GitHub Topics + +`claude-code`, `claude-code-plugin`, `gemini-cli`, `antigravity-cli`, `agy`, `google-gemini`, `google-antigravity`, `code-review`, `adversarial-review`, `ai-code-review`, `task-delegation`, `ai-coding-agent`, `agentic-coding`, `developer-tools`, `cli`, `nodejs`, `javascript`, `llm`, `plugins` diff --git a/docs/MODEL_COMPARISON.md b/docs/MODEL_COMPARISON.md index c6f1f30..67f64ac 100644 --- a/docs/MODEL_COMPARISON.md +++ b/docs/MODEL_COMPARISON.md @@ -1,17 +1,18 @@ # Model vs Harness — what actually drives the gap > Scope: the models each plugin drives — **Gemini** (this plugin) vs **OpenAI Codex/GPT** (`codex-plugin-cc`). -> Date: 2026-06-02 | Method: live single-shot runs on identical prompt + diff (model isolation), contrasted with each tool's native harness, plus web-confirmed model facts. Setting the plugin features aside. +> Date: 2026-06-02 | Method: live single-shot runs on identical prompt + diff (model isolation), contrasted with each tool's native harness, plus model facts available at that time. Setting the plugin features aside. +> Currentness note: this is a dated snapshot, not a rolling model leaderboard. On 2026-07-06, npm reports `@google/gemini-cli` latest as 0.49.0 and local `agy` 1.0.16 exposes its own model surface, so do not treat the 0.44.1 / AGY 1.0.4 observations below as current global facts. > Honesty note: proprietary model "strength" is not precisely measurable; vendor pages were partly fetch-blocked (403) so some benchmark numbers come from independent leaderboards / reputable secondary reporting. Treat single-digit gaps as noise-adjacent. --- ## TL;DR -1. **Raw single-shot review quality is close and mixed** between the latest Gemini and OpenAI coding models — all caught the headline bugs; they differ only in the long tail. +1. **Raw single-shot review quality was close and mixed in this 2026-06-02 snapshot** — all tested models caught the headline bugs; they differed only in the long tail. 2. **Most of the gap people attribute to "model" is actually harness.** Codex's native reviewer is *agentic* (it explores the repo); this plugin's review is *single-shot prompt over a diff*. Give Gemini the same agentic loop and most of the observed difference closes. -3. **On multi-step agentic benchmarks (Terminal-Bench), OpenAI currently leads by a real margin; on competitive/algorithmic coding (LiveCodeBench), Gemini leads.** Split, not one-sided. -4. **Model availability is a hard local constraint:** Gemini 3.5 (the current GA flagship on the API) is **not served by the gemini CLI 0.44.1** (404); it is reachable only through the **AGY** engine (fixed model, no selection). +3. **The benchmark snapshot was split:** OpenAI led on the multi-step agentic Terminal-Bench numbers available then; Gemini led the competitive/algorithmic LiveCodeBench numbers cited then. +4. **Model availability is a hard local constraint:** the 2026-06-02 gemini CLI 0.44.1 probe returned `404 ModelNotFound` for `gemini-3.5-*`. Newer CLI releases may differ, so availability claims must be re-probed. --- @@ -57,7 +58,7 @@ Reading: the big gap is on **multi-step agentic execution** (Terminal-Bench), wh ## D. Model availability — the local reality (transparency) -Probed on this machine (gemini CLI **0.44.1**, the latest on npm), 2026-06-02: +Probed on this machine (gemini CLI **0.44.1**, then-current on npm), 2026-06-02: | Model id | gemini CLI 0.44.1 | Note | |---|:-:|---| @@ -65,12 +66,12 @@ Probed on this machine (gemini CLI **0.44.1**, the latest on npm), 2026-06-02: | `gemini-3-flash-preview` | ✅ served | preview | | `gemini-3.1-pro-preview` | ✅ served | preview; gemini CLI's configured default | | `gemini-3.1-flash-lite` | ✅ served | GA | -| **`gemini-3.5-flash`** | ❌ **404 ModelNotFound** | GA on the API, **not on the CLI**; reachable via **AGY** (fixed model) | -| **`gemini-3.5-pro`** | ❌ **404** | GA imminent; not on the CLI | +| **`gemini-3.5-flash`** | ❌ **404 ModelNotFound** | Not served by this CLI version in this probe | +| **`gemini-3.5-pro`** | ❌ **404** | Not served by this CLI version in this probe | -- **AGY (antigravity 1.0.4)** exposes **no `--model`/`--effort`** flag (verified via `agy --help`); its model is backend-fixed (documented as Gemini 3.5 Flash, High). So Gemini 3.5 Flash is reachable only via AGY, and not tunable. +- **AGY (antigravity 1.0.4)** exposed **no `--model`/`--effort`** flag in the original 2026-06-02 probe. By 2026-07-06, local AGY 1.0.16 exposes `--model` and `agy models`; this plugin still does not translate Gemini aliases / effort tiers to AGY arguments. - The plugin therefore points `flash` at `gemini-3-flash-preview` (served) and **gracefully degrades** to the GA `gemini-2.5-flash` if a requested id 404s — see the model-not-found fallback in `lib/gemini.mjs`. -- Heads-up: free/personal gemini CLI access ends **2026-06-18**; after that the gemini engine requires a paid tier, and AGY becomes the free path. +- Heads-up: Google announced the consumer Gemini CLI transition for **2026-06-18**; after that date, access depends on the user's tier and current Google CLI policy. --- @@ -78,4 +79,4 @@ Probed on this machine (gemini CLI **0.44.1**, the latest on npm), 2026-06-02: - **Don't chase raw model parity** — it is close, and the model tier is Google/OpenAI's to move (choose it with `--effort`/`--model`, not code). - **Do close the harness gap** — giving the Gemini review path agentic repo exploration (opt-in) recovers most of the observed difference. This is the highest-leverage, in-our-control improvement. -- **Be honest about availability** — 3.5 via AGY only; graceful fallback when ids drift. Documented in the README so user expectations match reality. +- **Be honest about availability** — treat model availability as version-specific and re-probe before making current claims. The runtime keeps graceful fallback when Gemini IDs drift. diff --git a/package.json b/package.json index 609ac77..66429ae 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,38 @@ "version": "0.6.6", "private": true, "type": "module", - "description": "Use Gemini/AGY from Claude Code to review code or delegate tasks.", + "description": "Use Gemini CLI or Antigravity CLI (agy) inside Claude Code for task delegation, code review, and adversarial review.", + "repository": { + "type": "git", + "url": "git+https://github.com/arcobaleno64/gemini-plugin-cc.git" + }, + "homepage": "https://github.com/arcobaleno64/gemini-plugin-cc#readme", + "bugs": { + "url": "https://github.com/arcobaleno64/gemini-plugin-cc/issues" + }, + "keywords": [ + "claude-code", + "claude-code-plugin", + "gemini-cli", + "antigravity-cli", + "agy", + "google-gemini", + "google-antigravity", + "ai-coding-agent", + "coding-agent", + "agentic-coding", + "code-review", + "adversarial-review", + "developer-tools", + "cli", + "javascript", + "nodejs", + "llm", + "plugin", + "task-delegation", + "ai-code-review", + "open-source" + ], "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/plugins/gemini/scripts/lib/gemini.mjs b/plugins/gemini/scripts/lib/gemini.mjs index 9566490..c720506 100644 --- a/plugins/gemini/scripts/lib/gemini.mjs +++ b/plugins/gemini/scripts/lib/gemini.mjs @@ -23,8 +23,8 @@ const GA_FALLBACK_MODEL = "gemini-2.5-flash"; // back instead of hard-failing. The CLI surfaces this as `ModelNotFoundError` / // `code: 404` on stderr and, with --output-format json, an envelope carrying // `{ error: { message: "Requested entity was not found." } }` instead of -// `response`. Preview/retired ids and CLI-version skew (e.g. gemini-3.5-flash is -// not served by CLI 0.44.1) are the common triggers. Scoped narrowly to +// `response`. Preview/retired ids and CLI-version skew are the common triggers. +// Scoped narrowly to // model-not-found so auth/quota errors are NOT silently retried. function isModelNotFoundError(rawStdout, rawStderr, parsedEnvelope = null) { const text = `${rawStdout ?? ""}\n${rawStderr ?? ""}`; @@ -134,11 +134,11 @@ export async function runGeminiTurn(cwd, options = {}) { const engineInfo = detectEngine(requestedEngine ?? null); if (engineInfo.engine === "agy") { - // `agy --print` is hardcoded to Gemini 3.5 Flash (High) and exposes no - // --model/--effort flag (env / settings.json cannot override it), so both are - // ignored here. Other/higher tiers are only reachable via the gemini engine. + // AGY versions have their own model surface, but Gemini aliases / effort + // tiers are gemini-engine concepts. Until this plugin has an explicit AGY + // mapping contract, leave model choice to AGY's configured/default behavior. if (model || effort) { - process.stderr.write(`[gemini-companion] Note: AGY's --print is locked to Gemini 3.5 Flash (High) and has no model/effort flag; ignoring --model/--effort. Use --engine gemini for other models.\n`); + process.stderr.write(`[gemini-companion] Note: --engine agy currently uses AGY's configured/default model; this plugin does not translate --model/--effort to AGY arguments. Use --engine gemini for plugin-managed model selection.\n`); } model = null; } else { @@ -323,9 +323,9 @@ export async function runGeminiReview(cwd, options = {}) { let modelFallbackNote = null; // Graceful degradation (gemini engine): if the requested model id is not found - // (preview/retired, or absent on this CLI version — e.g. gemini-3.5-flash on - // 0.44.1), retry ONCE on the GA fallback so the user still gets a review - // instead of a hard failure — and surface the substitution loudly. + // (preview/retired, or absent on this CLI version), retry ONCE on the GA + // fallback so the user still gets a review instead of a hard failure — and + // surface the substitution loudly. if (engineInfo.engine === "gemini" && model && model !== GA_FALLBACK_MODEL && isModelNotFoundError(rawStdout, rawStderr, tryParseJsonFromText(rawStdout))) { process.stderr.write(`[gemini-companion] Model '${model}' is unavailable on this gemini CLI (model-not-found); retrying review on GA fallback '${GA_FALLBACK_MODEL}'.\n`); const fbArgs = buildCliArgs("gemini", { diff --git a/plugins/gemini/scripts/lib/model-map.mjs b/plugins/gemini/scripts/lib/model-map.mjs index ea68f35..e88fed8 100644 --- a/plugins/gemini/scripts/lib/model-map.mjs +++ b/plugins/gemini/scripts/lib/model-map.mjs @@ -6,18 +6,18 @@ // Preview model IDs (those ending in `-preview`) track Google's Gemini preview // channel and can change without notice; override any alias with `--model `. // -// lastVerified: model IDs last checked against gemini CLI 0.44.1. +// lastVerified: model IDs last checked against gemini CLI 0.44.1 on 2026-06-02. // source: gemini CLI model listing / Google Gemini API model names. export const MODEL_MAP_METADATA = { lastVerified: "2026-06", - source: "gemini CLI 0.44.1 (latest) live model probe / Google Gemini API model names", - note: "Preview model IDs (…-preview) may change; override with --model . NOTE: gemini-3.5-flash/-pro are GA on the API but NOT served by gemini CLI 0.44.1 (404) — reach 3.5 Flash via the AGY engine. Unknown/unavailable model ids degrade gracefully to the GA fallback at runtime." + source: "gemini CLI 0.44.1 live model probe on 2026-06-02 / Google Gemini API model names", + note: "Preview model IDs (…-preview) may change; override with --model . The 2026-06-02 probe returned 404 for gemini-3.5-flash/-pro on gemini CLI 0.44.1, but newer CLI releases may differ. Unknown/unavailable model ids degrade gracefully to the GA fallback at runtime." }; // Ordered alias entries. `preview: true` marks IDs that can change. export const MODEL_ALIAS_ENTRIES = [ - { alias: "flash", model: "gemini-3-flash-preview", label: "Latest Gemini 3 Flash (preview)", preview: true }, - { alias: "flash3", model: "gemini-3-flash-preview", label: "Latest Gemini 3 Flash (preview)", preview: true }, + { alias: "flash", model: "gemini-3-flash-preview", label: "Gemini 3 Flash (preview)", preview: true }, + { alias: "flash3", model: "gemini-3-flash-preview", label: "Gemini 3 Flash (preview)", preview: true }, { alias: "pro", model: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro (preview)", preview: true }, { alias: "pro3", model: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro (preview)", preview: true }, { alias: "lite3", model: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash-Lite (GA, cost-efficient)", preview: false }, @@ -30,10 +30,10 @@ export const MODEL_ALIAS_ENTRIES = [ export const MODEL_ALIASES = new Map(MODEL_ALIAS_ENTRIES.map((entry) => [entry.alias, entry.model])); // Reasoning-effort tier -> resolved model, used when --effort is supplied -// without an explicit --model. These apply to the GEMINI engine only: AGY's -// `agy --print` is hardcoded to Gemini 3.5 Flash (High) and exposes no -// --model/--effort flag (env / settings.json cannot override it), so both are -// ignored on the AGY path — higher/other tiers are reachable only via gemini. +// without an explicit --model. These apply to the GEMINI engine only. The AGY +// CLI has its own model surface in newer versions, but this plugin does not +// translate Gemini aliases / effort tiers to AGY arguments yet, so plugin-managed +// model selection remains a gemini-engine feature. // See lib/gemini.mjs (the agy branch nulls model and emits a note). export const EFFORT_MODEL_MAP = new Map([ ["none", "gemini-2.5-flash-lite"], diff --git a/plugins/gemini/skills/gemini-prompting/SKILL.md b/plugins/gemini/skills/gemini-prompting/SKILL.md index 48678de..afa2828 100644 --- a/plugins/gemini/skills/gemini-prompting/SKILL.md +++ b/plugins/gemini/skills/gemini-prompting/SKILL.md @@ -28,7 +28,7 @@ Model selection guidance (`` block): - Debug or fix tasks → `--effort high` (→ gemini-3.1-pro-preview) - Research or review tasks → `--effort medium` (→ gemini-3-flash-preview) - Quick formatting or structure tasks → `--effort low` (→ gemini-3-flash-preview) -- AGY engine: locked to Gemini 3.5 Flash (High); `--model` and `--effort` are both ignored. Use AGY for speed when a fixed, high-capability model is acceptable. +- AGY engine: this plugin currently leaves model choice to AGY's configured/default behavior. `--model` and `--effort` are gemini-engine controls here and are not translated to AGY arguments. When to add blocks: - Coding or debugging: add `completeness_contract`, `verification_loop`, and `missing_context_gating`. @@ -53,4 +53,4 @@ Prompt assembly checklist: - [references/gemini-prompt-blocks.md](references/gemini-prompt-blocks.md) — the reusable XML block catalogue (including `json_output_contract` and `model_selection`). - [references/gemini-prompt-recipes.md](references/gemini-prompt-recipes.md) — end-to-end templates for diagnosis, narrow fix, review, research, and prompt-patching. -- [references/gemini-prompt-antipatterns.md](references/gemini-prompt-antipatterns.md) — failure modes to avoid, including Gemini/AGY-specific ones (AGY ignores `--model`/`--effort`; AGY has no pipeable stdout). +- [references/gemini-prompt-antipatterns.md](references/gemini-prompt-antipatterns.md) — failure modes to avoid, including Gemini/AGY-specific ones (plugin-managed model selection is gemini-only; AGY has no pipeable stdout). diff --git a/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-antipatterns.md b/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-antipatterns.md index fb5d37d..334c65d 100644 --- a/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-antipatterns.md +++ b/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-antipatterns.md @@ -100,19 +100,19 @@ If a point is an inference, label it clearly. ``` -## Expecting `--model` / `--effort` to change AGY (AGY-specific) +## Expecting plugin `--model` / `--effort` to control AGY (AGY-specific) Bad: ```text -Use --engine agy --model gemini-3.1-pro-preview --effort high to force the strongest model. +Use --engine agy --model gemini-3.1-pro-preview --effort high to force AGY through the plugin's Gemini aliases. ``` Better: -- Use the **gemini** engine when you need a specific model or effort tier — `--effort high` → `gemini-3.1-pro-preview`. -- For AGY, drop `--model`/`--effort`: `agy --print` is locked to Gemini 3.5 Flash (High) and the plugin ignores both flags (it prints a note when you pass them). +- Use the **gemini** engine when you need this plugin to pick a specific model or effort tier — `--effort high` → `gemini-3.1-pro-preview`. +- For AGY, treat `--model`/`--effort` as unmanaged by this plugin: it leaves model choice to AGY's configured/default behavior and prints a note when you pass those flags. -Explanation: AGY exposes no model/effort selection at invocation time. Choosing capability is a gemini-engine concern only. +Explanation: AGY's own model surface can change by AGY version, and it is not the same contract as this plugin's Gemini alias / effort map. Plugin-managed capability selection is a gemini-engine concern only. ## Expecting AGY to return output on stdout (AGY-specific) diff --git a/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-blocks.md b/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-blocks.md index 5699573..6b6f4aa 100644 --- a/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-blocks.md +++ b/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-blocks.md @@ -188,7 +188,7 @@ Mention only major phase changes or blockers. ### `model_selection` -Drives `--effort` / `--model` for the **gemini** engine. AGY ignores both — see the +Drives `--effort` / `--model` for the **gemini** engine. The plugin does not translate these controls to AGY — see the anti-patterns file. ```xml @@ -197,7 +197,7 @@ anti-patterns file. --effort medium → gemini-3-flash-preview (balanced review, research) --effort low → gemini-3-flash-preview (formatting, structure) --effort none → gemini-2.5-flash-lite (cheapest, trivial transforms) -AGY: --model and --effort are ignored — `agy --print` is locked to Gemini 3.5 Flash (High). +AGY: plugin-managed --model and --effort do not apply; AGY uses its configured/default model behavior. Aliases (override anytime with --model ): flash/pro/lite, plus *25/*3 variants. Preview ids ending in -preview can drift; see model-map.mjs. diff --git a/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-recipes.md b/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-recipes.md index 4175e24..f9ddc55 100644 --- a/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-recipes.md +++ b/plugins/gemini/skills/gemini-prompting/references/gemini-prompt-recipes.md @@ -4,7 +4,7 @@ Use these as starting templates for Gemini task prompts or other Gemini/AGY prom Copy the smallest recipe that fits the task, then trim anything you do not need. In `gemini:gemini-rescue`, run diagnosis and fix-oriented recipes in write mode by default unless the user explicitly asked for read-only behavior. -For the **gemini** engine, pick capability with `--effort` (or an explicit `--model`); for **AGY**, both are ignored (it is locked to Gemini 3.5 Flash, High), so lean on a tight prompt contract instead of more model power. +For the **gemini** engine, pick capability with `--effort` (or an explicit `--model`). For **AGY**, this plugin currently leaves model choice to AGY's configured/default behavior and does not translate Gemini aliases or effort tiers, so lean on a tight prompt contract instead of plugin-managed model power. ## Diagnosis From 04a643548ed30ccd84817d1af42d096c934be04d Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Wed, 8 Jul 2026 18:20:04 +0800 Subject: [PATCH 02/11] feat(gemini): structured failure classification + stale/corrupt job reconciliation Add failures.mjs to classify CLI/transcript/job failures (rate-limit, invalid-json, stale-job, etc.) with retryable + next-step guidance, threaded through review/task execution, AGY transcript recovery, and rendered output. Job status/result queries now reconcile active jobs whose worker pid died or whose job file is corrupt, guarding against a completion race so a job that finished on disk while a stale snapshot was in flight is never clobbered back to "failed". Also fix buildEnvUnavailable inheriting the calling shell's own GEMINI_ENGINE, which made the AGY-fallback setup-label test environment-dependent. --- plugins/gemini/scripts/gemini-companion.mjs | 21 +- plugins/gemini/scripts/lib/agy-transcript.mjs | 34 ++- plugins/gemini/scripts/lib/engine.mjs | 23 ++ plugins/gemini/scripts/lib/failures.mjs | 224 ++++++++++++++++++ plugins/gemini/scripts/lib/gemini.mjs | 39 +++ plugins/gemini/scripts/lib/job-control.mjs | 127 +++++++++- plugins/gemini/scripts/lib/render.mjs | 49 +++- plugins/gemini/scripts/lib/state.mjs | 39 ++- plugins/gemini/scripts/lib/tracked-jobs.mjs | 22 +- tests/agy-transcript.test.mjs | 23 ++ tests/engine.test.mjs | 16 +- tests/failures.test.mjs | 77 ++++++ tests/fake-gemini-fixture.mjs | 7 +- tests/job-control.test.mjs | 80 ++++++- tests/render.test.mjs | 36 +++ tests/runtime.test.mjs | 8 + tests/state.test.mjs | 32 ++- 17 files changed, 824 insertions(+), 33 deletions(-) create mode 100644 plugins/gemini/scripts/lib/failures.mjs create mode 100644 tests/failures.test.mjs diff --git a/plugins/gemini/scripts/gemini-companion.mjs b/plugins/gemini/scripts/gemini-companion.mjs index d50c4c9..ad3f620 100644 --- a/plugins/gemini/scripts/gemini-companion.mjs +++ b/plugins/gemini/scripts/gemini-companion.mjs @@ -407,14 +407,15 @@ async function executeReviewRun(request) { }); const parsed = result.reviewJson - ? { parsed: result.reviewJson, rawOutput: result.reviewText, parseError: null } - : { parsed: null, rawOutput: result.reviewText, parseError: "Could not parse structured JSON from review output." }; + ? { parsed: result.reviewJson, rawOutput: result.reviewText, parseError: null, failure: result.failure ?? null } + : { parsed: null, rawOutput: result.reviewText, parseError: "Could not parse structured JSON from review output.", failure: result.failure ?? null }; const payload = { review: reviewName, target: context.target, - gemini: { status: result.status, stdout: result.reviewText }, - result: parsed.parsed + gemini: { status: result.status, stdout: result.reviewText, stderr: result.stderr ?? "" }, + result: parsed.parsed, + ...(result.failure ? { failure: result.failure } : {}) }; const fallbackBanner = result.modelFallback ? `> ⚠️ ${result.modelFallback}\n\n` : ""; @@ -433,7 +434,8 @@ async function executeReviewRun(request) { summary: parsed.parsed?.summary ?? parsed.parseError ?? "Review completed.", jobTitle: `Gemini ${reviewName}`, jobClass: "review", - targetLabel: context.target?.label ?? "" + targetLabel: context.target?.label ?? "", + ...(result.status !== 0 && result.failure ? { failure: result.failure } : {}) }; } @@ -465,11 +467,12 @@ async function executeTaskRun(request) { const rawOutput = result.finalMessage ?? ""; const failureMessage = result.stderr ?? ""; + const failure = result.failure ?? null; const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, resumeLast: request.resumeLast }); const fallbackBanner = result.modelFallback ? `${result.modelFallback}\n\n` : ""; const rendered = fallbackBanner + renderTaskResult( - { rawOutput, failureMessage, reasoningSummary: result.reasoningSummary }, + { rawOutput, failureMessage, reasoningSummary: result.reasoningSummary, failure }, { title: taskMetadata.title, jobId: request.jobId ?? null, write: Boolean(request.write) } ); const payload = { @@ -477,7 +480,8 @@ async function executeTaskRun(request) { threadId: result.threadId ?? null, rawOutput, touchedFiles: result.touchedFiles, - reasoningSummary: result.reasoningSummary + reasoningSummary: result.reasoningSummary, + ...(failure ? { failure } : {}) }; return { @@ -490,7 +494,8 @@ async function executeTaskRun(request) { summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), jobTitle: taskMetadata.title, jobClass: "task", - write: Boolean(request.write) + write: Boolean(request.write), + ...(result.status !== 0 && failure ? { failure } : {}) }; } diff --git a/plugins/gemini/scripts/lib/agy-transcript.mjs b/plugins/gemini/scripts/lib/agy-transcript.mjs index 8ee3a12..0ff322f 100644 --- a/plugins/gemini/scripts/lib/agy-transcript.mjs +++ b/plugins/gemini/scripts/lib/agy-transcript.mjs @@ -36,6 +36,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { classifyCliFailure } from "./failures.mjs"; + // Candidate brain roots, newest-platform first. Verified: 1.0.3 Windows and // 1.0.7 macOS (same root). Reported: 1.0.2 Linux. export function agyBrainRoots() { @@ -123,13 +125,17 @@ export function readAgyTranscript(brainRoot, convDir) { // try next candidate } } - if (!file) return { response: null, thinking: null, done: false, reason: "no transcript file found" }; + if (!file) { + const reason = "no transcript file found"; + return { response: null, thinking: null, done: false, reason, failure: classifyCliFailure({ transcriptReason: reason }) }; + } let raw; try { raw = fs.readFileSync(file, "utf8"); } catch (e) { - return { response: null, thinking: null, done: false, reason: `transcript read failed: ${e.message}` }; + const reason = `transcript read failed: ${e.message}`; + return { response: null, thinking: null, done: false, reason, failure: classifyCliFailure({ transcriptReason: reason }) }; } // JSONL: one object per line. Tolerate partial/garbled lines (a SIGKILL during @@ -149,14 +155,19 @@ export function readAgyTranscript(brainRoot, convDir) { } } - if (!last) return { response: null, thinking: null, done: false, reason: "no PLANNER_RESPONSE row in transcript" }; + if (!last) { + const reason = "no PLANNER_RESPONSE row in transcript"; + return { response: null, thinking: null, done: false, reason, failure: classifyCliFailure({ transcriptReason: reason }) }; + } const done = last.status === "DONE"; + const reason = done ? "ok" : `final PLANNER_RESPONSE status=${last.status ?? "unknown"} — possible truncation from spawn timeout`; return { response: last.content ?? null, thinking: last.thinking ?? null, done, // false => agy was likely SIGKILLed before finishing (see timeout grace, TODO-3 integration note) - reason: done ? "ok" : `final PLANNER_RESPONSE status=${last.status ?? "unknown"} — possible truncation from spawn timeout`, + reason, + ...(!done ? { failure: classifyCliFailure({ transcriptReason: reason }) } : {}) }; } @@ -164,15 +175,24 @@ export function readAgyTranscript(brainRoot, convDir) { // result here AFTER the spawn returns. export function recoverAgyResponse(brainRoot, beforeSnapshot) { if (!brainRoot) { - return { response: null, thinking: null, done: false, confident: false, convDir: null, reason: "no agy brain root found on this platform (run agy once to create it; see agyBrainRoots() for the known roots)" }; + const reason = "no agy brain root found on this platform (run agy once to create it; see agyBrainRoots() for the known roots)"; + return { response: null, thinking: null, done: false, confident: false, convDir: null, reason, failure: classifyCliFailure({ transcriptReason: reason }) }; } const after = listConvDirs(brainRoot); const picked = pickNewConvDir(beforeSnapshot, after); if (!picked.dir) { - return { response: null, thinking: null, done: false, confident: false, convDir: null, reason: picked.reason }; + return { response: null, thinking: null, done: false, confident: false, convDir: null, reason: picked.reason, failure: classifyCliFailure({ transcriptReason: picked.reason }) }; } const t = readAgyTranscript(brainRoot, picked.dir); - return { ...t, confident: picked.confident && t.done, convDir: picked.dir, reason: picked.confident ? t.reason : `${picked.reason}; ${t.reason}` }; + const reason = picked.confident ? t.reason : `${picked.reason}; ${t.reason}`; + const failure = t.failure ?? (!picked.confident ? classifyCliFailure({ transcriptReason: reason }) : null); + return { + ...t, + confident: picked.confident && t.done, + convDir: picked.dir, + reason, + ...(failure ? { failure } : {}) + }; } /* ============================================================================ diff --git a/plugins/gemini/scripts/lib/engine.mjs b/plugins/gemini/scripts/lib/engine.mjs index fe87897..7e731c3 100644 --- a/plugins/gemini/scripts/lib/engine.mjs +++ b/plugins/gemini/scripts/lib/engine.mjs @@ -1,10 +1,12 @@ import process from "node:process"; +import { createFailureError } from "./failures.mjs"; import { binaryAvailable, resolveBinaryPath } from "./process.mjs"; import { EFFORT_MODEL_MAP, MODEL_ALIASES, VALID_EFFORT_LEVELS } from "./model-map.mjs"; import { resolveAgyBrainRoot } from "./agy-transcript.mjs"; export const ENGINE_ENV = "GEMINI_ENGINE"; +export const AGY_POSITIONAL_PROMPT_SAFE_LIMIT = 24_000; // Model aliases and effort tiers live in model-map.mjs (single source of truth, // verified against the README table). Re-exported here for existing importers. @@ -95,11 +97,32 @@ function formatAgyTimeout(timeoutMs) { return `${minutes}m`; } +function assertAgyPromptSafe(prompt) { + const value = String(prompt ?? ""); + if (value.includes("\0")) { + throw createFailureError({ + promptNul: true, + engine: "agy", + summary: "AGY prompt contains a NUL byte and cannot be passed as a positional argument.", + nextStep: "Remove NUL bytes from the prompt or use `--engine gemini`, which sends prompts over stdin." + }); + } + if (value.length > AGY_POSITIONAL_PROMPT_SAFE_LIMIT) { + throw createFailureError({ + promptTooLong: true, + engine: "agy", + summary: `AGY positional prompt is ${value.length} characters, above the ${AGY_POSITIONAL_PROMPT_SAFE_LIMIT.toLocaleString("en-US")} character safe limit.`, + nextStep: "Shorten the prompt or use `--engine gemini`, which sends prompts over stdin." + }); + } +} + export function buildCliArgs(engine, options = {}) { const { prompt = "", model, write = false, resumeLast = false, outputJson = false, approvalModePlan = false, timeoutMs, useStdin = false } = options; if (engine === "agy") { // AGY does not support stdin; prompt must be passed as a positional argument + assertAgyPromptSafe(prompt); const args = ["--print", prompt]; if (write) args.push("--dangerously-skip-permissions"); if (resumeLast) args.push("--continue"); diff --git a/plugins/gemini/scripts/lib/failures.mjs b/plugins/gemini/scripts/lib/failures.mjs new file mode 100644 index 0000000..5a242cd --- /dev/null +++ b/plugins/gemini/scripts/lib/failures.mjs @@ -0,0 +1,224 @@ +export const FAILURE_CATEGORIES = new Set([ + "binary-missing", + "auth", + "quota", + "rate-limit", + "model-unavailable", + "timeout", + "prompt-too-long", + "no-output", + "transcript-missing", + "transcript-ambiguous", + "invalid-json", + "cancelled", + "stale-job", + "unknown" +]); + +const DEFAULTS = { + "binary-missing": { + retryable: false, + summary: "Required CLI binary is not available.", + nextStep: "Install Gemini CLI, or use `--engine agy` only after AGY is installed and initialized." + }, + auth: { + retryable: false, + summary: "CLI authentication failed or is missing.", + nextStep: "Run `gemini` once to authenticate, then retry the command." + }, + quota: { + retryable: false, + summary: "Gemini quota or billing limits blocked the request.", + nextStep: "Wait for quota reset, adjust billing or account limits, or retry with a different available engine." + }, + "rate-limit": { + retryable: true, + summary: "The request was rate limited.", + nextStep: "Retry later, reduce concurrency, or narrow the request." + }, + "model-unavailable": { + retryable: false, + summary: "The requested model is unavailable to this CLI.", + nextStep: "Use a supported model, omit `--model`, or use the default Gemini engine mapping." + }, + timeout: { + retryable: true, + summary: "The CLI command timed out.", + nextStep: "Retry later, reduce prompt size or review scope, or use `--engine gemini` for AGY timeouts." + }, + "prompt-too-long": { + retryable: false, + summary: "The prompt cannot be sent safely to the selected engine.", + nextStep: "Shorten the prompt or use `--engine gemini`, which sends prompts over stdin." + }, + "no-output": { + retryable: true, + summary: "The CLI returned no usable output.", + nextStep: "Retry the command; for AGY, initialize it once interactively or use `--engine gemini`." + }, + "transcript-missing": { + retryable: true, + summary: "AGY transcript recovery did not produce a completed response.", + nextStep: "Run `agy` once to initialize its brain directory, retry, or use `--engine gemini`." + }, + "transcript-ambiguous": { + retryable: true, + summary: "AGY transcript recovery found an ambiguous conversation match.", + nextStep: "Retry when no other AGY runs are starting, or use `--engine gemini`." + }, + "invalid-json": { + retryable: true, + summary: "The CLI returned output that was not valid structured JSON.", + nextStep: "Retry the command; if it repeats, inspect the job log and run `/gemini:setup`." + }, + cancelled: { + retryable: true, + summary: "The job was cancelled.", + nextStep: "Run the command again if the work is still needed." + }, + "stale-job": { + retryable: true, + summary: "The job was still marked active, but its worker is gone or stale.", + nextStep: "Inspect `/gemini:result ` if output exists, otherwise retry the command." + }, + unknown: { + retryable: true, + summary: "The CLI failed with an unclassified error.", + nextStep: "Inspect the job log, run `/gemini:setup`, then retry with a narrower prompt if needed." + } +}; + +function compactText(value) { + if (value == null) { + return ""; + } + if (value instanceof Error) { + return value.message; + } + if (typeof value === "object") { + return String(value.message ?? value.detail ?? value.reason ?? ""); + } + return String(value); +} + +function firstLine(text) { + return String(text ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? ""; +} + +function combinedTrustedText(input) { + return [ + input.stderr, + input.error, + input.errorMessage, + input.message, + input.reason, + input.transcriptReason, + input.structuredError + ] + .map(compactText) + .filter(Boolean) + .join("\n"); +} + +function normalizeFailure(category, input = {}) { + const defaults = DEFAULTS[category] ?? DEFAULTS.unknown; + const summary = input.summary ?? firstLine(input.errorMessage); + return { + category, + retryable: Boolean(input.retryable ?? defaults.retryable), + summary: String(summary || defaults.summary), + nextStep: String(input.nextStep ?? defaults.nextStep) + }; +} + +function explicitFailure(input) { + const source = input?.failure ?? input; + const category = source?.category; + if (!FAILURE_CATEGORIES.has(category)) { + return null; + } + return normalizeFailure(category, source); +} + +function errorCode(input) { + return input?.error?.code ?? input?.code ?? null; +} + +function transcriptCategory(reason) { + if (!reason) { + return null; + } + if (/multiple|ambiguous|\b\d+\s+new dirs?|not certain|picked newest/i.test(reason)) { + return "transcript-ambiguous"; + } + if (/brain root|no new conversation dir|no transcript file|no PLANNER_RESPONSE|status=.*possible truncation|status=|transcript read failed/i.test(reason)) { + return "transcript-missing"; + } + return null; +} + +export function classifyCliFailure(input = {}) { + const data = typeof input === "string" ? { message: input } : (input ?? {}); + const already = explicitFailure(data); + if (already) { + return already; + } + + const trusted = combinedTrustedText(data); + const stdout = compactText(data.stdout); + const structuredText = data.structured === true ? `${trusted}\n${stdout}` : trusted; + const code = errorCode(data); + const signal = compactText(data.signal); + + if (data.cancelled || /cancel(l)?ed|aborted|SIGINT/i.test(structuredText) || signal === "SIGINT") { + return normalizeFailure("cancelled", data); + } + if (code === "ENOENT" || /command not found|not recognized as .*command|binary .*not (found|available)|No Gemini or AGY engine found|engine requested but .*binary is not available/i.test(structuredText)) { + return normalizeFailure("binary-missing", data); + } + if (data.promptTooLong || data.promptNul || /prompt .*too long|context length|token limit|NUL byte|positional prompt/i.test(structuredText)) { + return normalizeFailure("prompt-too-long", data); + } + if (/oauth|unauth|authenticat|login required|invalid api key|permission denied|\b401\b|\b403\b/i.test(structuredText)) { + return normalizeFailure("auth", data); + } + if (/quota|billing|RESOURCE_EXHAUSTED/i.test(structuredText)) { + return normalizeFailure("quota", data); + } + if (/\b429\b|too many requests|rate.?limit/i.test(structuredText)) { + return normalizeFailure("rate-limit", data); + } + if (/ModelNotFoundError|Requested entity was not found|model .*not found|model.*unavailable|not_found|\b404\b/i.test(structuredText)) { + return normalizeFailure("model-unavailable", data); + } + + const transcriptReason = compactText(data.transcriptReason ?? data.reason) || structuredText; + const transcript = transcriptCategory(transcriptReason); + if (transcript) { + const retryable = /brain root/i.test(transcriptReason) ? false : undefined; + return normalizeFailure(transcript, { ...data, retryable }); + } + + if (code === "ETIMEDOUT" || data.timedOut || /timed? out|timeout|deadline exceeded|SIGTERM|SIGKILL/i.test(structuredText) || signal === "SIGTERM" || signal === "SIGKILL") { + return normalizeFailure("timeout", data); + } + + if (data.invalidJson || /invalid json|JSON\.parse|Could not parse structured JSON|unexpected token/i.test(structuredText)) { + return normalizeFailure("invalid-json", data); + } + if (data.noOutput || (!String(stdout).trim() && !String(trusted).trim() && (data.status == null || data.status === 0))) { + return normalizeFailure("no-output", data); + } + + return normalizeFailure("unknown", data); +} + +export function createFailureError(input = {}) { + const failure = classifyCliFailure(input); + const error = new Error(`${failure.summary} Next step: ${failure.nextStep}`); + error.failure = failure; + return error; +} diff --git a/plugins/gemini/scripts/lib/gemini.mjs b/plugins/gemini/scripts/lib/gemini.mjs index c720506..c4408d2 100644 --- a/plugins/gemini/scripts/lib/gemini.mjs +++ b/plugins/gemini/scripts/lib/gemini.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { buildCliArgs, detectEngine, mapEffortToModel, normalizeRequestedModel } from "./engine.mjs"; +import { classifyCliFailure } from "./failures.mjs"; import { binaryAvailable, runCommand } from "./process.mjs"; import { resolveAgyBrainRoot, listConvDirs, recoverAgyResponse } from "./agy-transcript.mjs"; @@ -217,6 +218,7 @@ export async function runGeminiTurn(cwd, options = {}) { let finalMessage = rawStdout.trim(); let threadId = null; let reasoningSummary = extractReasoningSummary(rawStderr) ?? null; + let recoveryFailure = null; if (engineInfo.engine === "agy") { // agy wrote the response to its transcript, not stdout (#27466). Recover it @@ -227,6 +229,7 @@ export async function runGeminiTurn(cwd, options = {}) { `AGY produced no recoverable response (${rec.reason}). agy --print does not pipe output (google-gemini/gemini-cli#27466); transcript recovery failed.` ); } + recoveryFailure = rec.failure ?? null; if (!rec.confident) { process.stderr.write(`[gemini-companion] Warning: AGY transcript match is not certain (${rec.reason}). Verify the response corresponds to this run.\n`); } @@ -247,7 +250,22 @@ export async function runGeminiTurn(cwd, options = {}) { } } + if (!finalMessage && exitCode === 0) { + exitCode = 1; + } + const touchedFiles = extractTouchedFiles(finalMessage); + const failure = recoveryFailure ?? (exitCode !== 0 || !finalMessage + ? classifyCliFailure({ + engine: engineInfo.engine, + status: exitCode, + signal: result.signal, + error: result.error, + stdout: finalMessage || rawStdout, + stderr: rawStderr, + noOutput: !finalMessage + }) + : null); onProgress?.({ message: exitCode === 0 ? "Turn completed." : "Turn failed.", phase: exitCode === 0 ? "done" : "failed" }); @@ -260,6 +278,7 @@ export async function runGeminiTurn(cwd, options = {}) { engine: engineInfo.engine, stderr: rawStderr, modelFallback: modelFallbackNote, + ...(failure ? { failure } : {}) }; } @@ -348,6 +367,7 @@ export async function runGeminiReview(cwd, options = {}) { let reviewJson = null; let reviewText = rawStdout.trim(); + let recoveryFailure = null; if (engineInfo.engine === "agy") { // agy wrote the review to its transcript, not stdout (#27466). Recover it and @@ -358,6 +378,7 @@ export async function runGeminiReview(cwd, options = {}) { `AGY produced no recoverable review (${rec.reason}). agy --print does not pipe output (google-gemini/gemini-cli#27466); transcript recovery failed.` ); } + recoveryFailure = rec.failure ?? null; if (!rec.confident) { process.stderr.write(`[gemini-companion] Warning: AGY transcript match is not certain (${rec.reason}). Verify the review corresponds to this run.\n`); } @@ -389,6 +410,23 @@ export async function runGeminiReview(cwd, options = {}) { reviewJson = tryParseJsonFromText(rawStdout); } + if (!reviewText && exitCode === 0) { + exitCode = 1; + } + + const failure = recoveryFailure ?? (exitCode !== 0 || !reviewText || !reviewJson + ? classifyCliFailure({ + engine: engineInfo.engine, + status: exitCode, + signal: result.signal, + error: result.error, + stdout: reviewText || rawStdout, + stderr: rawStderr, + noOutput: !reviewText, + invalidJson: Boolean(reviewText && !reviewJson) + }) + : null); + onProgress?.({ message: exitCode === 0 ? "Review completed." : "Review failed.", phase: exitCode === 0 ? "done" : "failed" }); return { @@ -399,6 +437,7 @@ export async function runGeminiReview(cwd, options = {}) { engine: engineInfo.engine, stderr: rawStderr, modelFallback: modelFallbackNote, + ...(failure ? { failure } : {}) }; } diff --git a/plugins/gemini/scripts/lib/job-control.mjs b/plugins/gemini/scripts/lib/job-control.mjs index 0653d6b..5975806 100644 --- a/plugins/gemini/scripts/lib/job-control.mjs +++ b/plugins/gemini/scripts/lib/job-control.mjs @@ -1,12 +1,14 @@ import fs from "node:fs"; +import { classifyCliFailure } from "./failures.mjs"; import { getSessionRuntimeStatus } from "./gemini.mjs"; -import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs"; +import { getConfig, listJobs, readJobFile, resolveJobFile, upsertJob, writeJobFile } from "./state.mjs"; import { SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; export const DEFAULT_MAX_STATUS_JOBS = 8; export const DEFAULT_MAX_PROGRESS_LINES = 4; +export const DEFAULT_STALE_JOB_MS = 6 * 60 * 60 * 1000; export function sortJobsNewestFirst(jobs) { return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); @@ -195,6 +197,125 @@ export function readStoredJob(workspaceRoot, jobId) { return readJobFile(jobFile); } +function isActiveStatus(status) { + return status === "queued" || status === "running"; +} + +function defaultIsPidAlive(pid) { + if (!Number.isFinite(pid)) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function activeJobAgeMs(job, nowMs) { + const started = Date.parse(job.startedAt ?? job.createdAt ?? job.updatedAt ?? ""); + if (!Number.isFinite(started)) { + return 0; + } + return Math.max(0, nowMs - started); +} + +function staleFailureForJob(job, options = {}) { + if (!isActiveStatus(job.status) || !Object.prototype.hasOwnProperty.call(job, "pid")) { + return null; + } + + const staleJobMs = options.staleJobMs ?? DEFAULT_STALE_JOB_MS; + const nowMs = options.now ? new Date(options.now).getTime() : Date.now(); + const isPidAlive = options.isPidAlive ?? defaultIsPidAlive; + if (!Number.isFinite(job.pid)) { + return classifyCliFailure({ + category: "stale-job", + summary: `Job ${job.id} was marked ${job.status}, but it has no live worker pid.` + }); + } + if (!isPidAlive(job.pid)) { + return classifyCliFailure({ + category: "stale-job", + summary: `Job ${job.id} was marked ${job.status}, but worker pid ${job.pid} is no longer running.` + }); + } + if (activeJobAgeMs(job, nowMs) > staleJobMs) { + return classifyCliFailure({ + category: "stale-job", + summary: `Job ${job.id} exceeded the active-job stale threshold.` + }); + } + return null; +} + +function jobFileFailureForActiveJob(workspaceRoot, job) { + if (!isActiveStatus(job.status)) { + return null; + } + const jobFile = resolveJobFile(workspaceRoot, job.id); + if (!fs.existsSync(jobFile)) { + return null; + } + const stored = readJobFile(jobFile); + return stored?.failure?.category === "invalid-json" ? stored.failure : null; +} + +function failActiveJob(workspaceRoot, job, failure) { + const jobFile = resolveJobFile(workspaceRoot, job.id); + const existing = fs.existsSync(jobFile) ? readJobFile(jobFile) : {}; + // readJobFile synthesizes a status:"failed" placeholder when the job file + // itself is corrupt (invalid-json) — that's not a real terminal state, so + // it must still go through the write below. A genuine terminal status here + // means the worker finished on disk since this reconcile pass captured its + // now-stale snapshot (e.g. it just completed); don't clobber that result. + if (existing.status && !isActiveStatus(existing.status) && existing.failure?.category !== "invalid-json") { + return null; + } + + const completedAt = new Date().toISOString(); + const failed = { + ...job, + ...existing, + id: job.id, + status: "failed", + phase: "failed", + pid: null, + completedAt, + errorMessage: failure.summary, + failure + }; + writeJobFile(workspaceRoot, job.id, failed); + upsertJob(workspaceRoot, { + id: job.id, + status: "failed", + phase: "failed", + pid: null, + completedAt, + errorMessage: failure.summary, + failure + }); + return failed; +} + +export function reconcileActiveJobs(workspaceRoot, jobs, options = {}) { + let changed = false; + const reconciled = jobs.map((job) => { + const failure = jobFileFailureForActiveJob(workspaceRoot, job) ?? staleFailureForJob(job, options); + if (!failure) { + return job; + } + const failed = failActiveJob(workspaceRoot, job, failure); + if (!failed) { + return job; + } + changed = true; + return failed; + }); + return changed ? listJobs(workspaceRoot) : reconciled; +} + function matchJobReference(jobs, reference, predicate = () => true) { const filtered = jobs.filter(predicate); if (!reference) { @@ -221,7 +342,7 @@ export function buildStatusSnapshot(cwd, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const config = getConfig(workspaceRoot); // Default to the current Claude session; --all crosses every session. - const allJobs = listJobs(workspaceRoot); + const allJobs = reconcileActiveJobs(workspaceRoot, listJobs(workspaceRoot), options); const scopedJobs = options.all ? allJobs : filterJobsForCurrentSession(allJobs, options); const jobs = sortJobsNewestFirst(scopedJobs); const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS; @@ -251,7 +372,7 @@ export function buildStatusSnapshot(cwd, options = {}) { export function buildSingleJobSnapshot(cwd, reference, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); - const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); + const jobs = sortJobsNewestFirst(reconcileActiveJobs(workspaceRoot, listJobs(workspaceRoot), options)); const selected = matchJobReference(jobs, reference); if (!selected) { throw new Error(`No job found for "${reference}". Run /gemini:status to inspect known jobs.`); diff --git a/plugins/gemini/scripts/lib/render.mjs b/plugins/gemini/scripts/lib/render.mjs index aa6c370..cbd179c 100644 --- a/plugins/gemini/scripts/lib/render.mjs +++ b/plugins/gemini/scripts/lib/render.mjs @@ -91,6 +91,21 @@ function formatJobLine(job) { return parts.join(" | "); } +function pushFailureDetails(lines, failure, indent = "") { + if (!failure || typeof failure !== "object") { + return; + } + const category = failure.category ?? "unknown"; + const retryLabel = failure.retryable ? "retryable" : "not retryable"; + lines.push(`${indent}Failure: ${category} (${retryLabel})`); + if (failure.summary) { + lines.push(`${indent}Summary: ${failure.summary}`); + } + if (failure.nextStep) { + lines.push(`${indent}Next step: ${failure.nextStep}`); + } +} + function escapeMarkdownCell(value) { return String(value ?? "") .replace(/\|/g, "\\|") @@ -131,6 +146,7 @@ function pushJobDetails(lines, job, options = {}) { if (job.summary) { lines.push(` Summary: ${job.summary}`); } + pushFailureDetails(lines, job.failure, " "); if (job.phase) { lines.push(` Phase: ${job.phase}`); } @@ -238,6 +254,11 @@ export function renderReviewResult(parsedResult, meta) { lines.push("", "Raw final message:", "", "```text", parsedResult.rawOutput, "```"); } + if (parsedResult.failure) { + lines.push(""); + pushFailureDetails(lines, parsedResult.failure); + } + appendReasoningSection(lines, meta.reasoningSummary ?? parsedResult.reasoningSummary); return `${lines.join("\n").trimEnd()}\n`; @@ -304,11 +325,22 @@ export function renderReviewResult(parsedResult, meta) { export function renderTaskResult(parsedResult, meta) { const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : ""; if (rawOutput) { - return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + if (!parsedResult?.failure) { + return output; + } + const lines = [output.trimEnd(), ""]; + pushFailureDetails(lines, parsedResult.failure); + return `${lines.join("\n").trimEnd()}\n`; } const message = String(parsedResult?.failureMessage ?? "").trim() || "Gemini did not return a final message."; - return `${message}\n`; + if (!parsedResult?.failure) { + return `${message}\n`; + } + const lines = [message, ""]; + pushFailureDetails(lines, parsedResult.failure); + return `${lines.join("\n").trimEnd()}\n`; } export function renderStatusReport(report) { @@ -379,6 +411,7 @@ export function renderStoredJobResult(job, storedJob) { const threadId = storedJob?.threadId ?? job.threadId ?? null; const engine = storedJob?.engine ?? job?.engine ?? null; const resume = resumeInfo(engine, threadId); + const failure = storedJob?.failure ?? storedJob?.result?.failure ?? job?.failure ?? null; if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; if (!resume) { @@ -393,10 +426,13 @@ export function renderStoredJobResult(job, storedJob) { ""; if (rawOutput) { const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; + const failureLines = []; + pushFailureDetails(failureLines, failure); + const failureOutput = failureLines.length ? `\n${failureLines.join("\n")}\n` : ""; if (!resume) { - return output; + return `${output}${failureOutput}`; } - return `${output}\n${resume.idLabel}: ${threadId}\n${resume.resumeLabel}: ${resume.command}\n`; + return `${output}${failureOutput}\n${resume.idLabel}: ${threadId}\n${resume.resumeLabel}: ${resume.command}\n`; } if (storedJob?.rendered) { @@ -423,6 +459,11 @@ export function renderStoredJobResult(job, storedJob) { lines.push(`Summary: ${job.summary}`); } + if (failure) { + lines.push(""); + pushFailureDetails(lines, failure); + } + if (job.errorMessage) { lines.push("", job.errorMessage); } else if (storedJob?.errorMessage) { diff --git a/plugins/gemini/scripts/lib/state.mjs b/plugins/gemini/scripts/lib/state.mjs index 557cc51..842ad8a 100644 --- a/plugins/gemini/scripts/lib/state.mjs +++ b/plugins/gemini/scripts/lib/state.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { classifyCliFailure } from "./failures.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; const STATE_VERSION = 1; @@ -87,6 +88,21 @@ function removeFileIfExists(filePath) { } } +function atomicWriteJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempFile = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp` + ); + try { + fs.writeFileSync(tempFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + fs.renameSync(tempFile, filePath); + } catch (error) { + removeFileIfExists(tempFile); + throw error; + } +} + export function saveState(cwd, state) { const previousJobs = loadState(cwd).jobs; ensureStateDir(cwd); @@ -109,7 +125,7 @@ export function saveState(cwd, state) { removeFileIfExists(job.logFile); } - fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); + atomicWriteJson(resolveStateFile(cwd), nextState); return nextState; } @@ -164,12 +180,29 @@ export function getConfig(cwd) { export function writeJobFile(cwd, jobId, payload) { ensureStateDir(cwd); const jobFile = resolveJobFile(cwd, jobId); - fs.writeFileSync(jobFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + atomicWriteJson(jobFile, payload); return jobFile; } export function readJobFile(jobFile) { - return JSON.parse(fs.readFileSync(jobFile, "utf8")); + try { + return JSON.parse(fs.readFileSync(jobFile, "utf8")); + } catch (error) { + const id = path.basename(jobFile, path.extname(jobFile)); + const errorMessage = `Unreadable job file ${jobFile}: ${error instanceof Error ? error.message : String(error)}`; + return { + id, + status: "failed", + phase: "failed", + errorMessage, + failure: classifyCliFailure({ + category: "invalid-json", + errorMessage, + summary: "Stored job payload is not valid JSON.", + nextStep: "Run `/gemini:status` to reconcile the failed job, then retry the command if needed." + }) + }; + } } function removeJobFile(jobFile) { diff --git a/plugins/gemini/scripts/lib/tracked-jobs.mjs b/plugins/gemini/scripts/lib/tracked-jobs.mjs index 804e484..6c3a15b 100644 --- a/plugins/gemini/scripts/lib/tracked-jobs.mjs +++ b/plugins/gemini/scripts/lib/tracked-jobs.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import process from "node:process"; +import { classifyCliFailure } from "./failures.mjs"; import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; export const SESSION_ID_ENV = "GEMINI_COMPANION_SESSION_ID"; @@ -155,6 +156,14 @@ export async function runTrackedJob(job, runner, options = {}) { const execution = await runner(); const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; const completedAt = nowIso(); + const failure = completionStatus === "failed" + ? (execution.failure ?? classifyCliFailure({ + status: execution.exitStatus, + stderr: execution.payload?.gemini?.stderr ?? execution.payload?.stderr ?? "", + stdout: execution.payload?.rawOutput ?? execution.payload?.gemini?.stdout ?? "", + errorMessage: execution.summary + })) + : null; writeJobFile(job.workspaceRoot, job.id, { ...runningRecord, status: completionStatus, @@ -165,7 +174,8 @@ export async function runTrackedJob(job, runner, options = {}) { phase: completionStatus === "completed" ? "done" : "failed", completedAt, result: execution.payload, - rendered: execution.rendered + rendered: execution.rendered, + ...(failure ? { failure } : {}) }); upsertJob(job.workspaceRoot, { id: job.id, @@ -176,12 +186,14 @@ export async function runTrackedJob(job, runner, options = {}) { summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, - completedAt + completedAt, + ...(failure ? { failure } : {}) }); appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); return execution; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + const failure = classifyCliFailure(error?.failure ?? { error, errorMessage }); const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; const completedAt = nowIso(); writeJobFile(job.workspaceRoot, job.id, { @@ -191,7 +203,8 @@ export async function runTrackedJob(job, runner, options = {}) { errorMessage, pid: null, completedAt, - logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null + logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null, + failure }); upsertJob(job.workspaceRoot, { id: job.id, @@ -199,7 +212,8 @@ export async function runTrackedJob(job, runner, options = {}) { phase: "failed", pid: null, errorMessage, - completedAt + completedAt, + failure }); throw error; } diff --git a/tests/agy-transcript.test.mjs b/tests/agy-transcript.test.mjs index 2c736a3..31fd508 100644 --- a/tests/agy-transcript.test.mjs +++ b/tests/agy-transcript.test.mjs @@ -131,6 +131,8 @@ test("recoverAgyResponse fails loud-able when no brain root is available", () => assert.equal(rec.response, null); assert.equal(rec.confident, false); assert.match(rec.reason, /brain root/i); + assert.equal(rec.failure.category, "transcript-missing"); + assert.equal(rec.failure.retryable, false); }); test("recoverAgyResponse reports when no new conversation dir appears", () => { @@ -140,6 +142,27 @@ test("recoverAgyResponse reports when no new conversation dir appears", () => { const rec = recoverAgyResponse(root, before); // nothing new added assert.equal(rec.response, null); assert.match(rec.reason, /no new conversation dir/i); + assert.equal(rec.failure.category, "transcript-missing"); +}); + +test("recoverAgyResponse marks ambiguous transcript recovery", () => { + const root = tmpRoot(); + const before = listConvDirs(root); + writeTranscript(root, "older", realRows("OLDER", "DONE")); + writeTranscript(root, "newer", realRows("NEWER", "DONE")); + const rec = recoverAgyResponse(root, before); + assert.equal(rec.confident, false); + assert.equal(rec.failure.category, "transcript-ambiguous"); +}); + +test("recoverAgyResponse marks non-DONE transcripts as missing/incomplete", () => { + const root = tmpRoot(); + const before = listConvDirs(root); + writeTranscript(root, "fresh", realRows("PARTIAL", "IN_PROGRESS")); + const rec = recoverAgyResponse(root, before); + assert.equal(rec.done, false); + assert.equal(rec.failure.category, "transcript-missing"); + assert.match(rec.failure.summary, /transcript/i); }); test("listConvDirs maps conversation dirs and ignores files", () => { diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 54aa807..46f70d0 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { MODEL_ALIASES, normalizeRequestedModel, mapEffortToModel } from "../plugins/gemini/scripts/lib/engine.mjs"; +import { MODEL_ALIASES, normalizeRequestedModel, mapEffortToModel, buildCliArgs } from "../plugins/gemini/scripts/lib/engine.mjs"; // These two IDs return 404 ModelNotFound on the gemini CLI (verified 0.44.1). // No alias or effort tier may resolve to them. @@ -45,3 +45,17 @@ test("unknown / explicit model strings pass through unchanged", () => { assert.equal(normalizeRequestedModel(null), null); assert.equal(normalizeRequestedModel(""), null); }); + +test("agy positional prompt rejects NUL bytes before argv construction", () => { + assert.throws( + () => buildCliArgs("agy", { prompt: "hello\0world" }), + (error) => error.failure?.category === "prompt-too-long" && /NUL/i.test(error.message) + ); +}); + +test("agy positional prompt rejects prompts above the safe Windows argv limit", () => { + assert.throws( + () => buildCliArgs("agy", { prompt: "x".repeat(24_001) }), + (error) => error.failure?.category === "prompt-too-long" && /24,000|24000/.test(error.message) + ); +}); diff --git a/tests/failures.test.mjs b/tests/failures.test.mjs new file mode 100644 index 0000000..44709a1 --- /dev/null +++ b/tests/failures.test.mjs @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { classifyCliFailure } from "../plugins/gemini/scripts/lib/failures.mjs"; + +test("classifyCliFailure identifies auth failures", () => { + const failure = classifyCliFailure({ stderr: "OAuth token expired. Run gemini to authenticate." }); + assert.equal(failure.category, "auth"); + assert.equal(failure.retryable, false); + assert.match(failure.nextStep, /authenticate/i); +}); + +test("classifyCliFailure identifies quota failures", () => { + const failure = classifyCliFailure({ stderr: "RESOURCE_EXHAUSTED: quota exceeded for project" }); + assert.equal(failure.category, "quota"); + assert.equal(failure.retryable, false); + assert.match(failure.nextStep, /quota|billing|later/i); +}); + +test("classifyCliFailure identifies 429 rate limits as retryable", () => { + const failure = classifyCliFailure({ stderr: "429 Too Many Requests: rate limit exceeded" }); + assert.equal(failure.category, "rate-limit"); + assert.equal(failure.retryable, true); + assert.match(failure.nextStep, /retry/i); +}); + +test("classifyCliFailure identifies timeout failures", () => { + const failure = classifyCliFailure({ error: Object.assign(new Error("spawn timed out"), { code: "ETIMEDOUT" }) }); + assert.equal(failure.category, "timeout"); + assert.equal(failure.retryable, true); +}); + +test("classifyCliFailure identifies model-unavailable failures", () => { + const failure = classifyCliFailure({ stderr: "ModelNotFoundError: Requested entity was not found. code: 404" }); + assert.equal(failure.category, "model-unavailable"); + assert.equal(failure.retryable, false); + assert.match(failure.nextStep, /model/i); +}); + +test("classifyCliFailure identifies empty output", () => { + const failure = classifyCliFailure({ noOutput: true, status: 0, stdout: "", stderr: "" }); + assert.equal(failure.category, "no-output"); + assert.equal(failure.retryable, true); +}); + +test("classifyCliFailure identifies invalid JSON", () => { + const failure = classifyCliFailure({ invalidJson: true, stdout: "not json" }); + assert.equal(failure.category, "invalid-json"); + assert.equal(failure.retryable, true); +}); + +test("classifyCliFailure identifies transcript recovery failures", () => { + assert.equal(classifyCliFailure({ transcriptReason: "no transcript file found" }).category, "transcript-missing"); + assert.equal(classifyCliFailure({ transcriptReason: "2 new dirs appeared; picked newest by mtime" }).category, "transcript-ambiguous"); +}); + +test("classifyCliFailure identifies prompt-too-long preflight failures", () => { + const failure = classifyCliFailure({ promptTooLong: true, engine: "agy" }); + assert.equal(failure.category, "prompt-too-long"); + assert.equal(failure.retryable, false); + assert.match(failure.nextStep, /shorten|gemini/i); +}); + +test("classifyCliFailure falls back to unknown", () => { + const failure = classifyCliFailure({ stderr: "unexpected failure shape" }); + assert.equal(failure.category, "unknown"); + assert.equal(failure.retryable, true); +}); + +test("classifyCliFailure does not treat transport words in model prose as transport failures", () => { + const failure = classifyCliFailure({ + status: 1, + stdout: "The code returns a 429 and hits a rate limit under load.", + stderr: "" + }); + assert.notEqual(failure.category, "rate-limit"); +}); diff --git a/tests/fake-gemini-fixture.mjs b/tests/fake-gemini-fixture.mjs index 6aeb480..9091500 100644 --- a/tests/fake-gemini-fixture.mjs +++ b/tests/fake-gemini-fixture.mjs @@ -126,8 +126,13 @@ export function buildEnv(binDir) { // directory; pair with installUnavailableEngines for "not ready" assertions. export function buildEnvUnavailable(binDir) { const sep = process.platform === "win32" ? ";" : ":"; + const env = { ...process.env }; + // Must resolve to "auto" regardless of the calling shell's own engine + // preference (e.g. a developer's GEMINI_ENGINE=agy), so delete it rather + // than inherit it. + delete env.GEMINI_ENGINE; return { - ...process.env, + ...env, PATH: `${binDir}${sep}${process.env.PATH}`, GEMINI_HOME: path.join(binDir, "gemini-home") }; diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 296ad0d..efb6cb2 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -1,9 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; import { makeTempDir } from "./helpers.mjs"; -import { setConfig } from "../plugins/gemini/scripts/lib/state.mjs"; +import { readJobFile, resolveJobFile, saveState, setConfig, writeJobFile } from "../plugins/gemini/scripts/lib/state.mjs"; import { buildStatusSnapshot } from "../plugins/gemini/scripts/lib/job-control.mjs"; +import { runTrackedJob } from "../plugins/gemini/scripts/lib/tracked-jobs.mjs"; test("buildStatusSnapshot surfaces the stop-review-gate flag", () => { const cwd = makeTempDir(); @@ -19,3 +21,79 @@ test("buildStatusSnapshot reports an empty job list for a fresh workspace", () = assert.deepEqual(snapshot.running, []); assert.equal(snapshot.latestFinished, null); }); + +test("buildStatusSnapshot marks unreadable active job files as failed", () => { + const cwd = makeTempDir(); + saveState(cwd, { + version: 1, + config: {}, + jobs: [ + { + id: "task-corrupt", + status: "running", + pid: 123, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + } + ] + }); + fs.writeFileSync(resolveJobFile(cwd, "task-corrupt"), "{ not-json", "utf8"); + + const snapshot = buildStatusSnapshot(cwd, { isPidAlive: () => true, all: true }); + + assert.deepEqual(snapshot.running, []); + assert.equal(snapshot.latestFinished.id, "task-corrupt"); + assert.equal(snapshot.latestFinished.status, "failed"); + assert.equal(snapshot.latestFinished.failure.category, "invalid-json"); +}); + +test("buildStatusSnapshot marks active jobs failed when their pid is gone", () => { + const cwd = makeTempDir(); + writeJobFile(cwd, "task-stale", { id: "task-stale", status: "running", pid: 123 }); + saveState(cwd, { + version: 1, + config: {}, + jobs: [ + { + id: "task-stale", + status: "running", + pid: 123, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + } + ] + }); + + const snapshot = buildStatusSnapshot(cwd, { isPidAlive: () => false, all: true }); + + assert.deepEqual(snapshot.running, []); + assert.equal(snapshot.latestFinished.id, "task-stale"); + assert.equal(snapshot.latestFinished.status, "failed"); + assert.equal(snapshot.latestFinished.failure.category, "stale-job"); + assert.match(snapshot.latestFinished.failure.nextStep, /retry|result/i); +}); + +test("runTrackedJob persists structured failure metadata when the runner throws", async () => { + const cwd = makeTempDir(); + + await assert.rejects( + runTrackedJob( + { + id: "task-failed", + title: "Gemini Task", + workspaceRoot: cwd, + jobClass: "task" + }, + () => { + throw new Error("429 Too Many Requests: rate limit exceeded"); + } + ) + ); + + const snapshot = buildStatusSnapshot(cwd, { all: true }); + const stored = readJobFile(resolveJobFile(cwd, "task-failed")); + + assert.equal(snapshot.latestFinished.failure.category, "rate-limit"); + assert.equal(snapshot.latestFinished.failure.retryable, true); + assert.equal(stored.failure.category, "rate-limit"); +}); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 561843b..e866c9f 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -183,3 +183,39 @@ test("renderStoredJobResult reports when no payload was stored", () => { const out = renderStoredJobResult({ id: "task-9", status: "failed", title: "X" }, {}); assert.match(out, /No captured result payload was stored/); }); + +test("renderJobStatusReport includes structured failure metadata", () => { + const out = renderJobStatusReport({ + id: "task-failed", + status: "failed", + title: "Gemini Task", + failure: { + category: "rate-limit", + retryable: true, + summary: "Rate limit exceeded.", + nextStep: "Retry later." + } + }); + + assert.match(out, /Failure: rate-limit \(retryable\)/); + assert.match(out, /Rate limit exceeded\./); + assert.match(out, /Next step: Retry later\./); +}); + +test("renderStoredJobResult includes stored failure metadata when there is no output", () => { + const out = renderStoredJobResult( + { id: "task-failed", status: "failed", title: "Gemini Task" }, + { + failure: { + category: "auth", + retryable: false, + summary: "Gemini authentication failed.", + nextStep: "Run `gemini` once to authenticate." + } + } + ); + + assert.match(out, /Failure: auth \(not retryable\)/); + assert.match(out, /Gemini authentication failed\./); + assert.match(out, /Run `gemini` once to authenticate\./); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index d2affd4..518c78c 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -632,6 +632,14 @@ test("task surfaces a failed gemini turn as a non-zero exit", () => { const state = JSON.parse(fs.readFileSync(path.join(resolveStateDir(repo), "state.json"), "utf8")); assert.equal(state.jobs[0].status, "failed"); + assert.equal(state.jobs[0].failure.category, "no-output"); + assert.equal(typeof state.jobs[0].failure.nextStep, "string"); + + const status = JSON.parse(run("node", [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }).stdout); + assert.equal(status.latestFinished.failure.category, "no-output"); + + const stored = JSON.parse(run("node", [SCRIPT, "result", state.jobs[0].id, "--json"], { cwd: repo, env: buildEnv(binDir) }).stdout); + assert.equal(stored.storedJob.failure.category, "no-output"); }); test("task rejects an unknown engine", () => { diff --git a/tests/state.test.mjs b/tests/state.test.mjs index bbd237c..f3f7e2b 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -11,7 +11,9 @@ import { resolveStateDir, resolveStateFile, saveState, - generateJobId + generateJobId, + writeJobFile, + readJobFile } from "../plugins/gemini/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { @@ -84,3 +86,31 @@ test("generateJobId produces a prefixed id with a crypto-random suffix", () => { assert.match(id, /^task-[0-9a-z]+-[0-9a-f]{10}$/); assert.notEqual(generateJobId("task"), generateJobId("task")); }); + +test("state and job writes leave parseable JSON", () => { + const workspace = makeTempDir(); + const stateFile = resolveStateFile(workspace); + + saveState(workspace, { + version: 1, + config: { stopReviewGateEnabled: true }, + jobs: [{ id: "task-1", status: "completed", updatedAt: "2026-01-01T00:00:00.000Z" }] + }); + const jobFile = writeJobFile(workspace, "task-1", { id: "task-1", status: "completed" }); + + assert.equal(JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs[0].id, "task-1"); + assert.equal(JSON.parse(fs.readFileSync(jobFile, "utf8")).status, "completed"); +}); + +test("readJobFile fails closed for corrupted job JSON", () => { + const workspace = makeTempDir(); + const jobFile = resolveJobFile(workspace, "task-corrupt"); + fs.writeFileSync(jobFile, "{ not-json", "utf8"); + + const job = readJobFile(jobFile); + + assert.equal(job.id, "task-corrupt"); + assert.equal(job.status, "failed"); + assert.equal(job.failure.category, "invalid-json"); + assert.match(job.errorMessage, /Unreadable job file/i); +}); From 0c23b9930d57d0b59609dfb197557808f8b046d5 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Thu, 9 Jul 2026 14:45:14 +0800 Subject: [PATCH 03/11] fix(gemini): AGY 1.1.0 write turns no longer land in agy's scratch dir Machine-verified on AGY 1.1.0/Windows: a fresh --engine agy --write turn with no prior workspace/project association silently writes files under ~/.gemini/antigravity-cli/scratch/ instead of cwd (status 0, no error). buildCliArgs now appends --new-project on a non-continuation write turn to bind the session's workspace to cwd; a --continue resume is left alone. Also verified AGY 1.1.0's new default request-review mode does not stall headless writes (--dangerously-skip-permissions still bypasses it, no --mode workaround needed), and documented that AGY now runs its own consumerOAuth login independent of the shared gemini CLI credential file, which getAgyLoginStatus() does not yet account for. Co-Authored-By: Claude Sonnet 5 --- plugins/gemini/CHANGELOG.md | 5 +++++ plugins/gemini/scripts/lib/agy-transcript.mjs | 12 ++++++++++++ plugins/gemini/scripts/lib/engine.mjs | 11 ++++++++++- tests/engine.test.mjs | 19 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/plugins/gemini/CHANGELOG.md b/plugins/gemini/CHANGELOG.md index 6220286..d3beac0 100644 --- a/plugins/gemini/CHANGELOG.md +++ b/plugins/gemini/CHANGELOG.md @@ -2,7 +2,12 @@ ## Unreleased +### Fixed +- **`--engine agy --write` no longer silently writes to AGY's scratch dir instead of the target directory.** Machine-verified on AGY 1.1.0/Windows 2026-07-09: a fresh (non-continuation) `agy --print --dangerously-skip-permissions` write turn with no prior workspace/project association creates files under `~/.gemini/antigravity-cli/scratch/` rather than the spawned `cwd` — silently, with `status: 0` and no error, so a caller only notices by checking the file landed in the wrong place. `buildCliArgs` now appends `--new-project` on a write turn that is not a `--continue` resume, binding the session's workspace to `cwd`; a resumed conversation is left alone since it already has its original project association. New `tests/engine.test.mjs` coverage for the three `buildCliArgs("agy", ...)` flag-composition cases (write, resumed write, read-only). (`lib/engine.mjs`) + ### Documentation +- **AGY 1.1.0 impact assessment.** AGY 1.1.0 (released 2026-07-08) makes `request-review` the default execution mode: it pauses before file writes to show an interactive line-level diff preview. Machine-verified 2026-07-09 that `--dangerously-skip-permissions` still fully suppresses this pause for a headless `--engine agy --write` turn — no `--mode accept-edits` workaround needed. Also confirmed via `agy --help` on 1.1.0 that the four other flags this plugin depends on (`--print`, `--continue`, `--print-timeout`) are unchanged, and via the upstream tracker that [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466) (`agy --print` empty stdout) remains open as of 2026-06-23, so `agy-transcript.mjs`'s transcript-recovery path is still required. The `/agents` panel global-config-dir fix in 1.1.0 (`~/.gemini/antigravity-cli/` → `~/.gemini/config/`) targets subagent definitions, not the transcript "brain" root this plugin reads — no path change there. + - **Known limitation found, not yet fixed: `getAgyLoginStatus()` is stale for AGY 1.1.0.** It infers AGY's login state from the shared gemini CLI credential file (`~/.gemini/oauth_creds.json`), per a comment asserting "AGY stores no credential of its own." That is no longer true: AGY 1.1.0's `cli.log` shows a distinct `consumerOAuth` flow ("You are not logged into Antigravity" / "authenticated successfully as ...") that is independent of the gemini CLI's OAuth state and is established by running `agy` interactively, not `gemini`. Machine-verified 2026-07-09: `~/.gemini/oauth_creds.json` stayed untouched (and reported "expired") through two `gemini`-driven logins, while `agy --print` failed with `authentication failed or timed out` until the user logged in via `agy` directly — after which the shared-credential-based status function would still have reported AGY as logged out. Where AGY 1.1.0 persists its own token was not located (not a plaintext file under `~/.gemini` or `%APPDATA%`/`%LOCALAPPDATA%`; likely OS credential storage), so `getAgyLoginStatus()` was left as-is rather than patched with an unverified detection heuristic. - **macOS AGY is now platform-verified.** On macOS (agy 1.0.7) the AGY brain root is `~/.gemini/antigravity-cli/brain` — the same path already first in `agyBrainRoots()` — so `--engine agy` works out of the box: `gemini-companion.mjs task --engine agy` was run end-to-end on macOS and recovered the response from the transcript (`/.system_generated/logs/transcript{,_full}.jsonl`, matching the expected layout), and the upstream no-pipe behavior of `agy --print` ([google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466)) was reproduced on macOS (0 bytes reach stdout through a pipe), confirming transcript recovery is required there too. Updated README (EN + zh-TW) Engine Routing / Troubleshooting / Known limitations, the `gemini-prompting` antipatterns reference, and the `agy-transcript.mjs` platform notes; TODO-3 (platform paths) is resolved, and the "no brain root" reason string now tells the user to run `agy` once instead of pointing at an internal TODO. No behavior change — comments, docs, and one user-facing message only. - **README install snippets and dependency table refreshed to the current release.** Pinned-install examples now reference the latest tag `v0.6.6` (was the stale `v0.6.0`), the "newer tag" example bumped to `v0.6.7`, and the AGY dependency row reads `≥ 1.0.3` (1.0.7 verified on macOS). Docs only, no behavior change. diff --git a/plugins/gemini/scripts/lib/agy-transcript.mjs b/plugins/gemini/scripts/lib/agy-transcript.mjs index 0ff322f..9fe902b 100644 --- a/plugins/gemini/scripts/lib/agy-transcript.mjs +++ b/plugins/gemini/scripts/lib/agy-transcript.mjs @@ -31,6 +31,18 @@ // Linux 1.0.2 reported at ~/.antigravity-cli/brain. Timeout grace IS // applied in runGeminiTurn (agy --print-timeout window < the hard spawn // kill). +// +// AGY 1.1.0 (2026-07-08): `agy --help` confirms this brain-root path and the +// four flags this file/engine.mjs depend on are unchanged; 1.1.0's global +// config-dir fix (~/.gemini/antigravity-cli/ -> ~/.gemini/config/) is about +// the /agents subagent-definition dir, a different path from the brain root +// below. Machine-verified 2026-07-09 (Windows): 1.1.0's new default +// `request-review` mode does NOT stall a headless `--engine agy --write` +// spawn — --dangerously-skip-permissions still bypasses it. Separately found +// (and fixed in buildCliArgs via --new-project, see engine.mjs and +// CHANGELOG): without an active workspace, that same write turn used to +// silently land its file under this brain root's sibling `scratch/` dir +// instead of `cwd`. import fs from "node:fs"; import os from "node:os"; diff --git a/plugins/gemini/scripts/lib/engine.mjs b/plugins/gemini/scripts/lib/engine.mjs index 7e731c3..b0367d8 100644 --- a/plugins/gemini/scripts/lib/engine.mjs +++ b/plugins/gemini/scripts/lib/engine.mjs @@ -125,7 +125,16 @@ export function buildCliArgs(engine, options = {}) { assertAgyPromptSafe(prompt); const args = ["--print", prompt]; if (write) args.push("--dangerously-skip-permissions"); - if (resumeLast) args.push("--continue"); + if (resumeLast) { + args.push("--continue"); + } else if (write) { + // Without an active workspace/project, agy 1.1.0 silently writes to its + // scratch dir (~/.gemini/antigravity-cli/scratch) instead of `cwd` + // (machine-verified 2026-07-09). --new-project binds the session's + // workspace to `cwd`. Only on a fresh (non-continuation) write turn — + // a resumed conversation already has its original project association. + args.push("--new-project"); + } const timeout = formatAgyTimeout(timeoutMs); if (timeout) args.push("--print-timeout", timeout); return args; diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 46f70d0..a4b497b 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -59,3 +59,22 @@ test("agy positional prompt rejects prompts above the safe Windows argv limit", (error) => error.failure?.category === "prompt-too-long" && /24,000|24000/.test(error.message) ); }); + +test("agy write turn adds --new-project so files land in cwd, not agy's scratch dir", () => { + const args = buildCliArgs("agy", { prompt: "hello", write: true }); + assert.ok(args.includes("--dangerously-skip-permissions")); + assert.ok(args.includes("--new-project")); + assert.ok(!args.includes("--continue")); +}); + +test("agy resumed write turn uses --continue instead of --new-project", () => { + const args = buildCliArgs("agy", { prompt: "hello", write: true, resumeLast: true }); + assert.ok(args.includes("--continue")); + assert.ok(!args.includes("--new-project")); +}); + +test("agy read-only turn adds neither --dangerously-skip-permissions nor --new-project", () => { + const args = buildCliArgs("agy", { prompt: "hello" }); + assert.ok(!args.includes("--dangerously-skip-permissions")); + assert.ok(!args.includes("--new-project")); +}); From eb954440384336f95913ddf04689f06175722aa7 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Fri, 10 Jul 2026 08:47:46 +0800 Subject: [PATCH 04/11] =?UTF-8?q?docs:=20AGY=201.1.0=20verified=20end-to-e?= =?UTF-8?q?nd=20=E2=80=94=20version=20notes=20+=20brain-root=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-commit re-verification of 0c23b99 on AGY 1.1.0/Windows: a fresh headless write turn completed in 13s (no request-review stall) with the file landing in cwd. README (EN + zh-TW) dependency row and brain-root platform notes, agyBrainRoots() inline comment, and one CHANGELOG evidence sentence. Docs/comments only, no behavior change; 216 tests pass. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- README.zh-TW.md | 4 ++-- plugins/gemini/CHANGELOG.md | 2 +- plugins/gemini/scripts/lib/agy-transcript.mjs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7fb5d1a..b0ceea2 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Compared with AGY-only, multi-host plugins, this project keeps the Gemini CLI pa |---|---|---| | Node.js | ≥ 18 | [nodejs.org](https://nodejs.org) | | Gemini CLI | ≥ 0.40 | `npm install -g @google/gemini-cli` | -| AGY _(optional)_ | ≥ 1.0.3 | _(see install note below)_ | +| AGY _(optional)_ | ≥ 1.0.3 (1.1.0 verified) | _(see install note below)_ | | Claude Code | any | [claude.ai/code](https://claude.ai/code) | **Install AGY** (optional fallback): `curl -fsSL https://antigravity.google/cli/install.sh | bash` @@ -237,7 +237,7 @@ Override via `--engine` flag or the `GEMINI_ENGINE` environment variable. > `--model` and `--effort` are managed by the **gemini** engine only. `--engine agy` currently leaves model choice to AGY's configured/default behavior; the plugin does not translate Gemini aliases or effort tiers to AGY arguments. -> **AGY transcript recovery is platform-verified on Windows and macOS; Linux is reported working.** Because `agy --print` does not pipe its output (upstream [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466) — reproduced on macOS agy 1.0.7: 0 bytes reach stdout through a pipe), the plugin recovers AGY responses from the on-disk "brain" transcript under `~/.gemini/antigravity-cli/brain` (Windows 1.0.3 and macOS 1.0.7, both verified — same root) or `~/.antigravity-cli/brain` (Linux 1.0.2, reported). If `--engine agy` reports no brain root on your machine, run `agy` once so it creates the directory, or open an issue with its actual location. +> **AGY transcript recovery is platform-verified on Windows and macOS; Linux is reported working.** Because `agy --print` does not pipe its output (upstream [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466) — reproduced on macOS agy 1.0.7: 0 bytes reach stdout through a pipe), the plugin recovers AGY responses from the on-disk "brain" transcript under `~/.gemini/antigravity-cli/brain` (Windows 1.0.3 and 1.1.0, macOS 1.0.7 — all verified, same root) or `~/.antigravity-cli/brain` (Linux 1.0.2, reported). If `--engine agy` reports no brain root on your machine, run `agy` once so it creates the directory, or open an issue with its actual location. --- diff --git a/README.zh-TW.md b/README.zh-TW.md index 635720d..d131e92 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -51,7 +51,7 @@ |---|---|---| | Node.js | ≥ 18 | [nodejs.org](https://nodejs.org) | | Gemini CLI | ≥ 0.40 | `npm install -g @google/gemini-cli` | -| AGY _(選用)_ | ≥ 1.0.3 | _(安裝指令見下)_ | +| AGY _(選用)_ | ≥ 1.0.3(1.1.0 已驗證) | _(安裝指令見下)_ | | Claude Code | 任意版本 | [claude.ai/code](https://claude.ai/code) | **安裝 AGY**(選用備援):`curl -fsSL https://antigravity.google/cli/install.sh | bash` @@ -235,7 +235,7 @@ > `--model` 與 `--effort` 只由 **gemini** 引擎管理。`--engine agy` 目前讓 AGY 使用其 configured/default model;本外掛不會把 Gemini aliases 或 effort tiers 翻譯成 AGY 參數。 -> **AGY 紀錄恢復已於 Windows 與 macOS 驗證通過;Linux 為回報可用。** 因 `agy --print` 不透過 pipe 輸出(上游 [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466)——已於 macOS agy 1.0.7 重現:pipe 下 stdout 為 0 bytes),外掛改從磁碟上的「brain」紀錄恢復回應——`~/.gemini/antigravity-cli/brain`(Windows 1.0.3 與 macOS 1.0.7,皆已驗證、同一路徑)或 `~/.antigravity-cli/brain`(Linux 1.0.2,回報)。若 `--engine agy` 回報找不到 brain 根目錄,請先執行一次 `agy` 讓其建立該目錄,或開 issue 回報實際位置。 +> **AGY 紀錄恢復已於 Windows 與 macOS 驗證通過;Linux 為回報可用。** 因 `agy --print` 不透過 pipe 輸出(上游 [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466)——已於 macOS agy 1.0.7 重現:pipe 下 stdout 為 0 bytes),外掛改從磁碟上的「brain」紀錄恢復回應——`~/.gemini/antigravity-cli/brain`(Windows 1.0.3 與 1.1.0、macOS 1.0.7,皆已驗證、同一路徑)或 `~/.antigravity-cli/brain`(Linux 1.0.2,回報)。若 `--engine agy` 回報找不到 brain 根目錄,請先執行一次 `agy` 讓其建立該目錄,或開 issue 回報實際位置。 --- diff --git a/plugins/gemini/CHANGELOG.md b/plugins/gemini/CHANGELOG.md index d3beac0..0805da3 100644 --- a/plugins/gemini/CHANGELOG.md +++ b/plugins/gemini/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### Fixed -- **`--engine agy --write` no longer silently writes to AGY's scratch dir instead of the target directory.** Machine-verified on AGY 1.1.0/Windows 2026-07-09: a fresh (non-continuation) `agy --print --dangerously-skip-permissions` write turn with no prior workspace/project association creates files under `~/.gemini/antigravity-cli/scratch/` rather than the spawned `cwd` — silently, with `status: 0` and no error, so a caller only notices by checking the file landed in the wrong place. `buildCliArgs` now appends `--new-project` on a write turn that is not a `--continue` resume, binding the session's workspace to `cwd`; a resumed conversation is left alone since it already has its original project association. New `tests/engine.test.mjs` coverage for the three `buildCliArgs("agy", ...)` flag-composition cases (write, resumed write, read-only). (`lib/engine.mjs`) +- **`--engine agy --write` no longer silently writes to AGY's scratch dir instead of the target directory.** Machine-verified on AGY 1.1.0/Windows 2026-07-09: a fresh (non-continuation) `agy --print --dangerously-skip-permissions` write turn with no prior workspace/project association creates files under `~/.gemini/antigravity-cli/scratch/` rather than the spawned `cwd` — silently, with `status: 0` and no error, so a caller only notices by checking the file landed in the wrong place. `buildCliArgs` now appends `--new-project` on a write turn that is not a `--continue` resume, binding the session's workspace to `cwd`; a resumed conversation is left alone since it already has its original project association. New `tests/engine.test.mjs` coverage for the three `buildCliArgs("agy", ...)` flag-composition cases (write, resumed write, read-only). (`lib/engine.mjs`) Re-verified end-to-end post-commit on AGY 1.1.0/Windows 2026-07-10: a fresh `task --engine agy --write` turn completed in 13s (job `completed`, no `request-review` stall) with the probe file landing in `cwd`, not scratch. ### Documentation - **AGY 1.1.0 impact assessment.** AGY 1.1.0 (released 2026-07-08) makes `request-review` the default execution mode: it pauses before file writes to show an interactive line-level diff preview. Machine-verified 2026-07-09 that `--dangerously-skip-permissions` still fully suppresses this pause for a headless `--engine agy --write` turn — no `--mode accept-edits` workaround needed. Also confirmed via `agy --help` on 1.1.0 that the four other flags this plugin depends on (`--print`, `--continue`, `--print-timeout`) are unchanged, and via the upstream tracker that [google-gemini/gemini-cli#27466](https://github.com/google-gemini/gemini-cli/issues/27466) (`agy --print` empty stdout) remains open as of 2026-06-23, so `agy-transcript.mjs`'s transcript-recovery path is still required. The `/agents` panel global-config-dir fix in 1.1.0 (`~/.gemini/antigravity-cli/` → `~/.gemini/config/`) targets subagent definitions, not the transcript "brain" root this plugin reads — no path change there. diff --git a/plugins/gemini/scripts/lib/agy-transcript.mjs b/plugins/gemini/scripts/lib/agy-transcript.mjs index 9fe902b..0e4846b 100644 --- a/plugins/gemini/scripts/lib/agy-transcript.mjs +++ b/plugins/gemini/scripts/lib/agy-transcript.mjs @@ -50,12 +50,12 @@ import path from "node:path"; import { classifyCliFailure } from "./failures.mjs"; -// Candidate brain roots, newest-platform first. Verified: 1.0.3 Windows and -// 1.0.7 macOS (same root). Reported: 1.0.2 Linux. +// Candidate brain roots, newest-platform first. Verified: 1.0.3 and 1.1.0 +// Windows, 1.0.7 macOS (same root). Reported: 1.0.2 Linux. export function agyBrainRoots() { const home = os.homedir(); return [ - path.join(home, ".gemini", "antigravity-cli", "brain"), // 1.0.3 Windows + 1.0.7 macOS (verified) + path.join(home, ".gemini", "antigravity-cli", "brain"), // 1.0.3/1.1.0 Windows + 1.0.7 macOS (verified) path.join(home, ".antigravity-cli", "brain"), // 1.0.2 Linux (reported) ]; } From 0353a16d69606b5f24252406e53dd36f6c16573e Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Mon, 13 Jul 2026 13:38:46 +0800 Subject: [PATCH 05/11] fix(process): quote shell argv (S-CC1) --- plugins/gemini/scripts/lib/process.mjs | 24 ++++++++++++++++++++++-- tests/process.test.mjs | 21 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/plugins/gemini/scripts/lib/process.mjs b/plugins/gemini/scripts/lib/process.mjs index 2a36ca9..e7b5856 100644 --- a/plugins/gemini/scripts/lib/process.mjs +++ b/plugins/gemini/scripts/lib/process.mjs @@ -2,8 +2,28 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import process from "node:process"; +// Windows shell:true builds one cmd.exe command-line string by naively +// concatenating argv with spaces (Node's own DEP0190 warning: "arguments +// are not escaped, only concatenated") -- so an argv element (or the +// binary itself) containing a space or a shell metacharacter must be +// quoted here ourselves, or cmd.exe re-splits/misinterprets it before the +// real binary ever sees it. NOT a complete cmd.exe escaper (misses %, !, +// backtick, comma, semicolon and doesn't handle embedded quotes per cmd's +// real two-phase parse) -- safe here because every argv element is a +// short fixed constant, a resolved binary path, or a validated model id; +// prompts travel via stdin for gemini and are length/NUL-validated before +// reaching agy's positional argv. +const WINDOWS_SHELL_UNSAFE = /[\s|<>&()^"]/; +function quoteForWindowsShell(arg) { + if (typeof arg !== "string" || !WINDOWS_SHELL_UNSAFE.test(arg)) return arg; + return `"${arg.replace(/"/g, '\\"')}"`; +} + export function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const shell = options.shell ?? (process.platform === "win32" && !path.isAbsolute(command)); + const safeCommand = shell ? quoteForWindowsShell(command) : command; + const safeArgs = shell ? args.map(quoteForWindowsShell) : args; + const result = spawnSync(safeCommand, safeArgs, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -12,7 +32,7 @@ export function runCommand(command, args = [], options = {}) { // Absolute paths are spawned directly (shell:false) so arguments are passed // literally and never re-parsed by the shell. Bare command names still use the // shell on Windows to resolve .cmd/.ps1 wrappers (npm global bins). - shell: options.shell ?? (process.platform === "win32" && !path.isAbsolute(command)), + shell, windowsHide: true, ...(options.maxBuffer != null && { maxBuffer: options.maxBuffer }), ...(options.timeout != null && { timeout: options.timeout }), diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 3fb2d77..ae979f6 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -6,7 +6,8 @@ import { terminateProcessTree, binaryAvailable, formatCommandFailure, - resolveBinaryPath + resolveBinaryPath, + runCommand } from "../plugins/gemini/scripts/lib/process.mjs"; test("terminateProcessTree uses taskkill on Windows", () => { @@ -88,6 +89,24 @@ test("resolveBinaryPath finds a PATH command and passes absolute paths through", assert.equal(resolveBinaryPath("/already/absolute/tool"), "/already/absolute/tool"); }); +test("runCommand preserves a spaced argv element when shell is enabled", () => { + const result = runCommand(process.execPath, ["-p", "JSON.stringify(process.argv.slice(1))", "argument with spaces"], { + shell: true + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), '["argument with spaces"]'); +}); + +test("runCommand leaves argv unchanged when shell is disabled", () => { + const result = runCommand(process.execPath, ["-p", "JSON.stringify(process.argv.slice(1))", "argument with spaces"], { + shell: false + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), '["argument with spaces"]'); +}); + test("formatCommandFailure formats exit-code and signal failures", () => { assert.equal( formatCommandFailure({ command: "git", args: ["status"], status: 1, signal: null, stderr: "boom", stdout: "" }), From 2a09e6d33528a58a7ab824a39a359b50ee94c198 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Mon, 13 Jul 2026 13:42:49 +0800 Subject: [PATCH 06/11] docs(adapter): align contract reference (A-CC1) --- docs/adapter-contract.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/adapter-contract.md diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md new file mode 100644 index 0000000..7371f17 --- /dev/null +++ b/docs/adapter-contract.md @@ -0,0 +1,18 @@ +# Adapter Contract + +```text +ENGINE_NAME: string +DELIVERS_PROMPT_VIA: "stdin" | "argv" // argv 者必須文件化輸入驗證前提 +detect() → { engine, binary /*resolved path 優先*/, version } // 不可用時 throw +buildArgs({ prompt?, jobId? }) → string[] +buildSpawnOptions({ cwd, timeoutMs? }) → { cwd, env, timeoutMs?, shell } +parseOutput(rawText, exitCode) → { ok, error?, raw, ... } +cancel(pid) → { signaled, confirmedTerminated?, reason? } +``` + +## gemini-plugin-cc 對應表 + +- `binaryAvailable`/`resolveBinaryPath`(`process.mjs`)對應契約 `detect()` 的組成件。 +- `terminateProcessTree` 對應契約 `cancel(pid)`:語意等價為樹殺與結果物件。 +- Prompt 遞送:gemini 使用 stdin;agy 使用 argv(上游限制)。輸入驗證前提為 model id 字元集白名單與長度控制。 +- 不改動 CC 既有函式命名(已發布 v0.6.6,Hyrum 面);本檔為對照文件,宣告 CC 以語意等價符合契約。 From 6903fafc63bd3d22272485ef4df084ffc0a30053 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Mon, 13 Jul 2026 18:36:25 +0800 Subject: [PATCH 07/11] fix(security): fail-closed agy resolution, honest quoter scope (WP-3 r2) quoteForWindowsShell cannot reliably escape cmd.exe (embedded quotes toggle its parse state; backslash-escaping is the child's MSVCRT convention). Instead of strengthening the quoter, eliminate the shell:true + free-text-argv combo: agy now must resolve to an absolute .exe path (requireExe, mirroring companion-cx) or detectEngine throws fail-closed, so its positional prompt always spawns shell:false and never traverses cmd.exe. Also closes the .cmd-on-unpatched-Node CVE-2024-27980 angle. Quoter kept only as a no-op net for fixed-constant bare-name argv; comment/doc corrected to not claim it protects free text. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018uDUc5kSwMWqWrgsGsyYdh --- docs/adapter-contract.md | 2 +- plugins/gemini/scripts/lib/engine.mjs | 26 +++++++++++++++++----- plugins/gemini/scripts/lib/process.mjs | 30 ++++++++++++-------------- tests/engine.test.mjs | 18 +++++++++++++++- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md index 7371f17..0fb30e2 100644 --- a/docs/adapter-contract.md +++ b/docs/adapter-contract.md @@ -14,5 +14,5 @@ cancel(pid) → { signaled, confirmedTerminated?, reason? } - `binaryAvailable`/`resolveBinaryPath`(`process.mjs`)對應契約 `detect()` 的組成件。 - `terminateProcessTree` 對應契約 `cancel(pid)`:語意等價為樹殺與結果物件。 -- Prompt 遞送:gemini 使用 stdin;agy 使用 argv(上游限制)。輸入驗證前提為 model id 字元集白名單與長度控制。 +- Prompt 遞送:gemini 使用 stdin;agy 使用 argv(上游限制)。agy 的 free-text prompt 安全性來自解析為絕對 `.exe` 路徑後以 `shell:false` 啟動,不依賴 argv quoting;model id 字元集白名單僅適用於 gemini 的 `--model`。 - 不改動 CC 既有函式命名(已發布 v0.6.6,Hyrum 面);本檔為對照文件,宣告 CC 以語意等價符合契約。 diff --git a/plugins/gemini/scripts/lib/engine.mjs b/plugins/gemini/scripts/lib/engine.mjs index b0367d8..12c0d31 100644 --- a/plugins/gemini/scripts/lib/engine.mjs +++ b/plugins/gemini/scripts/lib/engine.mjs @@ -1,4 +1,5 @@ import process from "node:process"; +import path from "node:path"; import { createFailureError } from "./failures.mjs"; import { binaryAvailable, resolveBinaryPath } from "./process.mjs"; @@ -8,6 +9,9 @@ import { resolveAgyBrainRoot } from "./agy-transcript.mjs"; export const ENGINE_ENV = "GEMINI_ENGINE"; export const AGY_POSITIONAL_PROMPT_SAFE_LIMIT = 24_000; +const AGY_EXECUTABLE_PATH_ERROR = + "AGY could not be resolved to an executable .exe path; the plugin refuses to spawn it via the shell to avoid argv injection on Windows. Ensure agy is on PATH or use --engine gemini."; + // Model aliases and effort tiers live in model-map.mjs (single source of truth, // verified against the README table). Re-exported here for existing importers. export { MODEL_ALIASES, VALID_EFFORT_LEVELS }; @@ -40,7 +44,17 @@ export function normalizeRequestedModel(model) { return resolved; } -export function detectEngine(requestedEngine = null) { +function resolveAgyExecutablePath({ resolveBinaryPathImpl = resolveBinaryPath } = {}) { + const resolved = resolveBinaryPathImpl("agy", { requireExe: process.platform === "win32" }); + const isAbsolute = typeof resolved === "string" && path.isAbsolute(resolved); + const isExecutable = process.platform !== "win32" || path.extname(resolved ?? "").toLowerCase() === ".exe"; + if (!isAbsolute || !isExecutable) { + throw new Error(AGY_EXECUTABLE_PATH_ERROR); + } + return resolved; +} + +export function detectEngine(requestedEngine = null, options = {}) { const envEngine = process.env[ENGINE_ENV]; const target = requestedEngine ?? envEngine ?? "auto"; const normalized = String(target).trim().toLowerCase(); @@ -50,7 +64,8 @@ export function detectEngine(requestedEngine = null) { } if (normalized === "agy") { - const status = binaryAvailable("agy", ["--version"]); + const binary = resolveAgyExecutablePath(options); + const status = binaryAvailable(binary, ["--version"]); if (!status.available) throw new Error("AGY engine requested but agy binary is not available."); // `agy --print` does not emit its response over a pipe in non-TTY use // (upstream bug google-gemini/gemini-cli#27466); the plugin recovers the @@ -62,7 +77,7 @@ export function detectEngine(requestedEngine = null) { "AGY engine requested but `agy --print` cannot return output over a pipe (upstream bug google-gemini/gemini-cli#27466) and no transcript brain dir was found to recover from on this platform. Run `agy` once interactively to initialize it, or use `--engine gemini`." ); } - return { engine: "agy", binary: resolveBinaryPath("agy") ?? "agy", version: status.detail ?? "unknown" }; + return { engine: "agy", binary, version: status.detail ?? "unknown" }; } if (normalized === "gemini") { @@ -83,9 +98,10 @@ export function detectEngine(requestedEngine = null) { return { engine: "gemini", binary: "gemini", version: geminiStatus.detail ?? "unknown" }; } - const agyStatus = binaryAvailable("agy", ["--version"]); + const agyBinary = resolveAgyExecutablePath(options); + const agyStatus = binaryAvailable(agyBinary, ["--version"]); if (agyStatus.available) { - return { engine: "agy", binary: resolveBinaryPath("agy") ?? "agy", version: agyStatus.detail ?? "unknown" }; + return { engine: "agy", binary: agyBinary, version: agyStatus.detail ?? "unknown" }; } throw new Error("No Gemini or AGY engine found. Install agy or gemini CLI and retry."); diff --git a/plugins/gemini/scripts/lib/process.mjs b/plugins/gemini/scripts/lib/process.mjs index e7b5856..3c07e1c 100644 --- a/plugins/gemini/scripts/lib/process.mjs +++ b/plugins/gemini/scripts/lib/process.mjs @@ -2,17 +2,12 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import process from "node:process"; -// Windows shell:true builds one cmd.exe command-line string by naively -// concatenating argv with spaces (Node's own DEP0190 warning: "arguments -// are not escaped, only concatenated") -- so an argv element (or the -// binary itself) containing a space or a shell metacharacter must be -// quoted here ourselves, or cmd.exe re-splits/misinterprets it before the -// real binary ever sees it. NOT a complete cmd.exe escaper (misses %, !, -// backtick, comma, semicolon and doesn't handle embedded quotes per cmd's -// real two-phase parse) -- safe here because every argv element is a -// short fixed constant, a resolved binary path, or a validated model id; -// prompts travel via stdin for gemini and are length/NUL-validated before -// reaching agy's positional argv. +// This is not a reliable cmd.exe escaper: embedded quotes toggle cmd's parse +// state, and backslashes escape quotes for MSVCRT children, not for cmd.exe. +// Keep it only as a belt-and-suspenders safety net for the fixed-constant argv +// used by remaining bare-name command paths (git/where/gemini/taskkill), never +// as protection for free text. Gemini prompts use stdin; agy's free-text prompt +// is protected by absolute-path resolution and therefore shell:false instead. const WINDOWS_SHELL_UNSAFE = /[\s|<>&()^"]/; function quoteForWindowsShell(arg) { if (typeof arg !== "string" || !WINDOWS_SHELL_UNSAFE.test(arg)) return arg; @@ -76,20 +71,23 @@ export function binaryAvailable(command, versionArgs = ["--version"], options = return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" }; } -export function resolveBinaryPath(command) { +export function resolveBinaryPath(command, { requireExe = false } = {}) { if (path.isAbsolute(command)) { - return command; + return !requireExe || path.extname(command).toLowerCase() === ".exe" ? command : null; } const finder = process.platform === "win32" ? "where" : "which"; const result = runCommand(finder, [command]); if (result.status !== 0) { return null; } - const first = result.stdout + const candidates = result.stdout .split(/\r?\n/) .map((line) => line.trim()) - .filter(Boolean)[0]; - return first || null; + .filter((line) => line && path.isAbsolute(line)); + const resolved = requireExe + ? candidates.find((candidate) => path.extname(candidate).toLowerCase() === ".exe") + : candidates[0]; + return resolved ?? null; } function looksLikeMissingProcessMessage(text) { diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index a4b497b..6105942 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { MODEL_ALIASES, normalizeRequestedModel, mapEffortToModel, buildCliArgs } from "../plugins/gemini/scripts/lib/engine.mjs"; +import { MODEL_ALIASES, normalizeRequestedModel, mapEffortToModel, buildCliArgs, detectEngine } from "../plugins/gemini/scripts/lib/engine.mjs"; // These two IDs return 404 ModelNotFound on the gemini CLI (verified 0.44.1). // No alias or effort tier may resolve to them. @@ -46,6 +46,22 @@ test("unknown / explicit model strings pass through unchanged", () => { assert.equal(normalizeRequestedModel(""), null); }); +test("detectEngine fails closed when agy resolves only to a bare non-.exe path", () => { + assert.throws( + () => detectEngine("agy", { resolveBinaryPathImpl: () => "agy.cmd" }), + /AGY could not be resolved to an executable \.exe path; the plugin refuses to spawn it via the shell to avoid argv injection on Windows\./ + ); +}); + +test("detectEngine fails closed when agy resolves only to an absolute .cmd shim (CVE-2024-27980 angle)", () => { + // An absolute .cmd path would still re-enter cmd.exe on pre-patch Node even + // under shell:false, so requireExe must reject it, not just bare names. + assert.throws( + () => detectEngine("agy", { resolveBinaryPathImpl: () => (process.platform === "win32" ? "C:\\tools\\agy.cmd" : null) }), + /AGY could not be resolved to an executable \.exe path/ + ); +}); + test("agy positional prompt rejects NUL bytes before argv construction", () => { assert.throws( () => buildCliArgs("agy", { prompt: "hello\0world" }), From 3ac566c1c461c02dfbe9b3f81262116ab44ad94a Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Tue, 14 Jul 2026 07:09:06 +0800 Subject: [PATCH 08/11] feat(mcp): add Gemini runtime bridge (F-CC1) Expose the existing background dispatch and job-control paths behind a hand-rolled stdio MCP server, with hermetic request tests and CLI prompt parity coverage. Constraint: No new dependencies and no duplicated prompt assembly Confidence: high Scope-risk: moderate --- plugins/gemini/.mcp.json | 9 + plugins/gemini/CHANGELOG.md | 3 + plugins/gemini/scripts/gemini-companion.mjs | 142 +++++++++++---- plugins/gemini/scripts/gemini-mcp.mjs | 190 ++++++++++++++++++++ tests/gemini-mcp.test.mjs | 162 +++++++++++++++++ 5 files changed, 468 insertions(+), 38 deletions(-) create mode 100644 plugins/gemini/.mcp.json create mode 100644 plugins/gemini/scripts/gemini-mcp.mjs create mode 100644 tests/gemini-mcp.test.mjs diff --git a/plugins/gemini/.mcp.json b/plugins/gemini/.mcp.json new file mode 100644 index 0000000..22603dd --- /dev/null +++ b/plugins/gemini/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "gemini": { + "command": "node", + "args": ["./scripts/gemini-mcp.mjs"], + "cwd": "." + } + } +} diff --git a/plugins/gemini/CHANGELOG.md b/plugins/gemini/CHANGELOG.md index 0805da3..9bc6e46 100644 --- a/plugins/gemini/CHANGELOG.md +++ b/plugins/gemini/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **F-CC1: hand-rolled stdio MCP server.** Added `gemini_rescue`, `gemini_review`, `gemini_job_status`, `gemini_job_result`, and `gemini_job_cancel` as thin JSON-RPC wrappers over the existing companion dispatch, job-control, and state paths. The plugin now declares the server through `.mcp.json`; MCP and CLI background dispatch share the same persisted request construction so prompt assembly cannot drift. + ### Fixed - **`--engine agy --write` no longer silently writes to AGY's scratch dir instead of the target directory.** Machine-verified on AGY 1.1.0/Windows 2026-07-09: a fresh (non-continuation) `agy --print --dangerously-skip-permissions` write turn with no prior workspace/project association creates files under `~/.gemini/antigravity-cli/scratch/` rather than the spawned `cwd` — silently, with `status: 0` and no error, so a caller only notices by checking the file landed in the wrong place. `buildCliArgs` now appends `--new-project` on a write turn that is not a `--continue` resume, binding the session's workspace to `cwd`; a resumed conversation is left alone since it already has its original project association. New `tests/engine.test.mjs` coverage for the three `buildCliArgs("agy", ...)` flag-composition cases (write, resumed write, read-only). (`lib/engine.mjs`) Re-verified end-to-end post-commit on AGY 1.1.0/Windows 2026-07-10: a fresh `task --engine agy --write` turn completed in 13s (job `completed`, no `request-review` stall) with the probe file landing in `cwd`, not scratch. diff --git a/plugins/gemini/scripts/gemini-companion.mjs b/plugins/gemini/scripts/gemini-companion.mjs index ad3f620..14d5af8 100644 --- a/plugins/gemini/scripts/gemini-companion.mjs +++ b/plugins/gemini/scripts/gemini-companion.mjs @@ -63,6 +63,7 @@ import { import { MODEL_MAP_METADATA, MODEL_ALIAS_ENTRIES } from "./lib/model-map.mjs"; const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const SELF_PATH = fileURLToPath(import.meta.url); const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"); const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; @@ -615,9 +616,9 @@ async function runForegroundCommand(job, runner, options = {}) { return execution; } -function spawnDetachedWorker(cwd, jobId, workerCommand) { +function spawnDetachedWorker(cwd, jobId, workerCommand, spawnFn = spawn) { const scriptPath = path.join(ROOT_DIR, "scripts", "gemini-companion.mjs"); - const child = spawn(process.execPath, [scriptPath, workerCommand, "--cwd", cwd, "--job-id", jobId], { + const child = spawnFn(process.execPath, [scriptPath, workerCommand, "--cwd", cwd, "--job-id", jobId], { cwd, env: process.env, detached: true, @@ -631,11 +632,11 @@ function spawnDetachedWorker(cwd, jobId, workerCommand) { // Persist a job and hand it to a detached worker (`task-worker` or // `review-worker`). Both job classes share the same enqueue/state machinery; the // worker subcommand only decides which executor deserializes the request. -function enqueueBackgroundJob(cwd, job, request, workerCommand) { +function enqueueBackgroundJob(cwd, job, request, workerCommand, { spawnFn = spawn } = {}) { const { logFile } = createTrackedProgress(job); appendLogLine(logFile, "Queued for background execution."); - const child = spawnDetachedWorker(cwd, job.id, workerCommand); + const child = spawnDetachedWorker(cwd, job.id, workerCommand, spawnFn); const queuedRecord = { ...job, status: "queued", @@ -659,19 +660,13 @@ function enqueueBackgroundJob(cwd, job, request, workerCommand) { }; } -async function handleReviewCommand(argv, { reviewName, templateName, supportsFocus }) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["base", "scope", "model", "engine", "cwd"], - booleanOptions: ["json", "wait", "background", "deep"], - aliasMap: { m: "model" } - }); - - const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const focusText = supportsFocus ? positionals.join(" ").trim() : ""; - +export function dispatchBackgroundReview(request, { spawnFn = spawn } = {}) { + const cwd = path.resolve(request.cwd ?? process.cwd()); + const workspaceRoot = resolveWorkspaceRoot(cwd); + const reviewName = request.reviewName ?? "Review"; + const templateName = request.templateName ?? "review"; ensureGitRepository(cwd); - const target = resolveReviewTarget(cwd, { base: options.base, scope: options.scope }); + const target = resolveReviewTarget(cwd, { base: request.base, scope: request.scope }); const kind = templateName === "adversarial-review" ? "adversarial-review" : "review"; const job = createCompanionJob({ prefix: "review", @@ -681,12 +676,62 @@ async function handleReviewCommand(argv, { reviewName, templateName, supportsFoc jobClass: "review", summary: `${reviewName} ${target.label}` }); + const storedRequest = buildReviewRequest({ + cwd, + base: request.base, + scope: request.scope, + model: request.model, + engine: request.engine, + focusText: request.focusText ?? "", + reviewName, + templateName, + deep: request.deep + }); + return enqueueBackgroundJob(cwd, job, storedRequest, "review-worker", { spawnFn }).payload; +} + +export function dispatchBackgroundTask(request, { spawnFn = spawn } = {}) { + const cwd = path.resolve(request.cwd ?? process.cwd()); + const workspaceRoot = resolveWorkspaceRoot(cwd); + const model = normalizeRequestedModel(request.model); + const effort = request.effort ?? null; + if (effort != null && !VALID_EFFORT_LEVELS.has(String(effort).trim().toLowerCase())) { + throw new Error(`Invalid --effort "${effort}". Valid values: ${[...VALID_EFFORT_LEVELS].join(", ")}.`); + } + const prompt = request.prompt ?? ""; + const resumeLast = Boolean(request.resumeLast); + requireTaskRequest(prompt, resumeLast); + const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast }); + const job = buildTaskJob(workspaceRoot, taskMetadata, Boolean(request.write)); + const storedRequest = buildTaskRequest({ + cwd, + model, + effort, + engine: request.engine ?? null, + prompt, + write: Boolean(request.write), + resumeLast, + jobId: job.id + }); + return enqueueBackgroundJob(cwd, job, storedRequest, "task-worker", { spawnFn }).payload; +} + +async function handleReviewCommand(argv, { reviewName, templateName, supportsFocus }) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["base", "scope", "model", "engine", "cwd"], + booleanOptions: ["json", "wait", "background", "deep"], + aliasMap: { m: "model" } + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const focusText = supportsFocus ? positionals.join(" ").trim() : ""; if (options.background) { // Persist the review to a detached review-worker so the result survives an // interrupted Claude session (parity with background tasks). Returns a job id // immediately; track via /gemini:status and /gemini:result. - const request = buildReviewRequest({ + const payload = dispatchBackgroundReview({ cwd, base: options.base, scope: options.scope, @@ -697,11 +742,22 @@ async function handleReviewCommand(argv, { reviewName, templateName, supportsFoc templateName, deep: options.deep }); - const { payload } = enqueueBackgroundJob(cwd, job, request, "review-worker"); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); return; } + ensureGitRepository(cwd); + const target = resolveReviewTarget(cwd, { base: options.base, scope: options.scope }); + const kind = templateName === "adversarial-review" ? "adversarial-review" : "review"; + const job = createCompanionJob({ + prefix: "review", + kind, + title: `Gemini ${reviewName}`, + workspaceRoot, + jobClass: "review", + summary: `${reviewName} ${target.label}` + }); + await runForegroundCommand( job, (progress) => @@ -755,20 +811,15 @@ async function handleTask(argv) { const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast }); if (options.background) { - requireTaskRequest(prompt, resumeLast); - - const job = buildTaskJob(workspaceRoot, taskMetadata, write); - const request = buildTaskRequest({ + const payload = dispatchBackgroundTask({ cwd, - model, + model: options.model, effort: options.effort ?? null, engine, prompt, write, - resumeLast, - jobId: job.id + resumeLast }); - const { payload } = enqueueBackgroundJob(cwd, job, request, "task-worker"); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); return; } @@ -874,6 +925,15 @@ async function handleStatus(argv) { outputResult(renderStatusPayload(report, options.json), options.json); } +export function getJobStatus({ cwd = process.cwd(), jobId }) { + return buildSingleJobSnapshot(path.resolve(cwd), jobId); +} + +export function getJobResult({ cwd = process.cwd(), jobId, all = false }) { + const { workspaceRoot, job } = resolveResultJob(path.resolve(cwd), jobId, { all }); + return { job, storedJob: readStoredJob(workspaceRoot, job.id) }; +} + function handleResult(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd"], @@ -882,11 +942,9 @@ function handleResult(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; - const { workspaceRoot, job } = resolveResultJob(cwd, reference, { all: options.all }); - const storedJob = readStoredJob(workspaceRoot, job.id); - const payload = { job, storedJob }; + const payload = getJobResult({ cwd, jobId: reference, all: options.all }); - outputCommandResult(payload, renderStoredJobResult(job, storedJob), options.json); + outputCommandResult(payload, renderStoredJobResult(payload.job, payload.storedJob), options.json); } async function handleTaskResumeCandidate(argv) { @@ -927,13 +985,19 @@ async function handleCancel(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; - const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { all: options.all }); + const { payload, job, termination } = cancelJob({ cwd, jobId: reference, all: options.all }); + + outputCommandResult(payload, renderCancelReport(job, termination), options.json); +} + +export function cancelJob({ cwd = process.cwd(), jobId = "", all = false }, { terminateProcessTreeFn = terminateProcessTree } = {}) { + const { workspaceRoot, job } = resolveCancelableJob(path.resolve(cwd), jobId, { all }); const existing = readStoredJob(workspaceRoot, job.id) ?? {}; // Be honest about whether a live process was actually killed: the detached // worker is unref()-ed, so by cancel time its PID may already be gone. The job // is still marked cancelled (the user's intent is recorded) in every case. - const termination = terminateProcessTree(job.pid ?? Number.NaN); + const termination = terminateProcessTreeFn(job.pid ?? Number.NaN); appendLogLine(job.logFile, `Cancelled by user — ${describeTermination(termination)}.`); const completedAt = nowIso(); @@ -967,7 +1031,7 @@ async function handleCancel(argv) { processTerminated: termination.delivered }; - outputCommandResult(payload, renderCancelReport(nextJob, termination), options.json); + return { payload, job: nextJob, termination }; } async function main() { @@ -1013,8 +1077,10 @@ async function main() { } } -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); +if (process.argv[1] === SELF_PATH) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} diff --git a/plugins/gemini/scripts/gemini-mcp.mjs b/plugins/gemini/scripts/gemini-mcp.mjs new file mode 100644 index 0000000..d962519 --- /dev/null +++ b/plugins/gemini/scripts/gemini-mcp.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +import { statSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import readline from "node:readline"; +import { fileURLToPath } from "node:url"; + +import { + cancelJob, + dispatchBackgroundReview, + dispatchBackgroundTask, + getJobResult, + getJobStatus +} from "./gemini-companion.mjs"; + +const MCP_PROTOCOL_VERSION = "2025-03-26"; +const SELF_PATH = fileURLToPath(import.meta.url); +const { version: SERVER_VERSION } = JSON.parse( + readFileSync(new URL("../.claude-plugin/plugin.json", import.meta.url), "utf8") +); + +function tool(name, description, required, properties) { + return { name, description, inputSchema: { type: "object", additionalProperties: false, required, properties } }; +} + +function enumSchema(values, defaultValue) { + return { type: "string", enum: values, ...(defaultValue ? { default: defaultValue } : {}) }; +} + +export const TOOLS = [ + tool("gemini_rescue", "Queue a Gemini/AGY rescue task through the existing companion runtime.", ["workspace", "prompt"], { + workspace: { type: "string", description: "Absolute path to the target workspace." }, + prompt: { type: "string", minLength: 1 }, + write: { type: "boolean", default: false }, + model: { type: "string" }, + effort: enumSchema(["none", "minimal", "low", "medium", "high", "xhigh"]), + engine: enumSchema(["auto", "gemini", "agy"]) + }), + tool("gemini_review", "Queue a read-only code review through the existing companion runtime.", ["workspace"], { + workspace: { type: "string", description: "Absolute path to the target workspace." }, + base: { type: "string" }, + scope: enumSchema(["auto", "working-tree", "branch"], "auto"), + model: { type: "string" }, + engine: enumSchema(["auto", "gemini", "agy"]), + deep: { type: "boolean", default: false } + }), + tool("gemini_job_status", "Return the current state of a Gemini companion job.", ["workspace", "jobId"], { + workspace: { type: "string", description: "Absolute path to the job workspace." }, + jobId: { type: "string", minLength: 1 } + }), + tool("gemini_job_result", "Return the stored output of a finished Gemini companion job.", ["workspace", "jobId"], { + workspace: { type: "string", description: "Absolute path to the job workspace." }, + jobId: { type: "string", minLength: 1 } + }), + tool("gemini_job_cancel", "Cancel a queued or running Gemini companion job.", ["workspace", "jobId"], { + workspace: { type: "string", description: "Absolute path to the job workspace." }, + jobId: { type: "string", minLength: 1 } + }) +]; + +const DEFAULT_RUNTIME = { + cancelJob, + dispatchBackgroundReview, + dispatchBackgroundTask, + getJobResult, + getJobStatus +}; + +function workspacePath(value) { + if (typeof value !== "string" || !path.isAbsolute(value)) { + throw new Error("workspace must be an absolute path."); + } + const resolved = path.resolve(value); + try { + if (!statSync(resolved).isDirectory()) throw new Error(); + } catch { + throw new Error("workspace must identify an existing directory."); + } + return resolved; +} + +function requiredString(value, name) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required.`); + return value; +} + +function optionalString(value, name) { + if (value == null) return undefined; + if (typeof value !== "string") throw new Error(`${name} must be a string.`); + return value; +} + +function optionalBoolean(value, name) { + if (value == null) return undefined; + if (typeof value !== "boolean") throw new Error(`${name} must be a boolean.`); + return value; +} + +function optionalEnum(value, name, values) { + if (value == null) return undefined; + if (typeof value !== "string" || !values.includes(value)) { + throw new Error(`${name} must be one of: ${values.join(", ")}.`); + } + return value; +} + +export async function callTool(name, args = {}, { runtime = DEFAULT_RUNTIME } = {}) { + const workspace = workspacePath(args.workspace); + + if (name === "gemini_rescue") { + return runtime.dispatchBackgroundTask({ + cwd: workspace, + prompt: requiredString(args.prompt, "prompt"), + write: optionalBoolean(args.write, "write") ?? false, + model: optionalString(args.model, "model"), + effort: optionalEnum(args.effort, "effort", ["none", "minimal", "low", "medium", "high", "xhigh"]), + engine: optionalEnum(args.engine, "engine", ["auto", "gemini", "agy"]) + }); + } + if (name === "gemini_review") { + return runtime.dispatchBackgroundReview({ + cwd: workspace, + base: optionalString(args.base, "base"), + scope: optionalEnum(args.scope, "scope", ["auto", "working-tree", "branch"]), + model: optionalString(args.model, "model"), + engine: optionalEnum(args.engine, "engine", ["auto", "gemini", "agy"]), + deep: optionalBoolean(args.deep, "deep") ?? false, + reviewName: "Review", + templateName: "review" + }); + } + + const jobId = requiredString(args.jobId, "jobId"); + if (name === "gemini_job_status") return runtime.getJobStatus({ cwd: workspace, jobId }); + if (name === "gemini_job_result") return runtime.getJobResult({ cwd: workspace, jobId }); + if (name === "gemini_job_cancel") { + const cancelled = await runtime.cancelJob({ cwd: workspace, jobId }); + return cancelled.payload ?? cancelled; + } + throw new Error(`Unknown tool: ${name}`); +} + +export async function handleRequest(request, dependencies = {}) { + if (request.method === "notifications/initialized") return undefined; + if (request.method === "ping") return {}; + if (request.method === "initialize") { + return { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "gemini", version: SERVER_VERSION } + }; + } + if (request.method === "tools/list") return { tools: TOOLS }; + if (request.method === "tools/call") { + try { + const result = await callTool(request.params?.name, request.params?.arguments, dependencies); + return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result }; + } catch (error) { + return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true }; + } + } + throw new Error(`Unsupported method: ${request.method}`); +} + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +async function main() { + const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + for await (const line of input) { + if (!line.trim()) continue; + let request; + try { + request = JSON.parse(line); + const result = await handleRequest(request); + if (request.id !== undefined && result !== undefined) send({ jsonrpc: "2.0", id: request.id, result }); + } catch (error) { + if (request === undefined) send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); + else if (request.id !== undefined) { + send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: error instanceof Error ? error.message : String(error) } }); + } + } + } +} + +if (process.argv[1] === SELF_PATH) { + main(); +} diff --git a/tests/gemini-mcp.test.mjs b/tests/gemini-mcp.test.mjs new file mode 100644 index 0000000..a9e3f3c --- /dev/null +++ b/tests/gemini-mcp.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { handleRequest } from "../plugins/gemini/scripts/gemini-mcp.mjs"; +import { dispatchBackgroundTask } from "../plugins/gemini/scripts/gemini-companion.mjs"; +import { readStoredJob } from "../plugins/gemini/scripts/lib/job-control.mjs"; +import { initGitRepo, makeTempDir } from "./helpers.mjs"; + +function toolRequest(name, args) { + return { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }; +} + +test("gemini MCP advertises its identity and five tools", async () => { + const initialized = await handleRequest({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + assert.equal(initialized.serverInfo.name, "gemini"); + assert.equal(initialized.serverInfo.version, "0.6.6"); + + const listed = await handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + assert.deepEqual(listed.tools.map((tool) => tool.name), [ + "gemini_rescue", + "gemini_review", + "gemini_job_status", + "gemini_job_result", + "gemini_job_cancel" + ]); +}); + +test("handleRequest delegates rescue and review to injected runtime dispatchers", async () => { + const workspace = makeTempDir(); + const calls = []; + const runtime = { + dispatchBackgroundTask(input) { + calls.push(["task", input]); + return { jobId: "task-1", status: "queued" }; + }, + dispatchBackgroundReview(input) { + calls.push(["review", input]); + return { jobId: "review-1", status: "queued" }; + } + }; + + const rescue = await handleRequest(toolRequest("gemini_rescue", { + workspace, + prompt: "investigate the timeout", + engine: "gemini", + effort: "high" + }), { runtime }); + assert.equal(rescue.structuredContent.jobId, "task-1"); + + const review = await handleRequest(toolRequest("gemini_review", { + workspace, + scope: "working-tree", + engine: "agy", + deep: true + }), { runtime }); + assert.equal(review.structuredContent.jobId, "review-1"); + assert.deepEqual(calls, [ + ["task", { + cwd: path.resolve(workspace), + prompt: "investigate the timeout", + write: false, + model: undefined, + effort: "high", + engine: "gemini" + }], + ["review", { + cwd: path.resolve(workspace), + base: undefined, + scope: "working-tree", + model: undefined, + engine: "agy", + deep: true, + reviewName: "Review", + templateName: "review" + }] + ]); +}); + +test("handleRequest delegates job status, result, and cancel without reading state itself", async () => { + const workspace = makeTempDir(); + const calls = []; + const runtime = { + getJobStatus(input) { + calls.push(["status", input]); + return { job: { id: input.jobId, status: "running" } }; + }, + getJobResult(input) { + calls.push(["result", input]); + return { job: { id: input.jobId, status: "completed" }, storedJob: { result: "done" } }; + }, + cancelJob(input) { + calls.push(["cancel", input]); + return { payload: { jobId: input.jobId, status: "cancelled" } }; + } + }; + + for (const [name, expectedStatus] of [ + ["gemini_job_status", "running"], + ["gemini_job_result", "completed"], + ["gemini_job_cancel", "cancelled"] + ]) { + const response = await handleRequest(toolRequest(name, { workspace, jobId: "job-1" }), { runtime }); + assert.equal(response.isError, undefined); + assert.equal(response.structuredContent.job?.status ?? response.structuredContent.status, expectedStatus); + } + assert.deepEqual(calls.map(([kind]) => kind), ["status", "result", "cancel"]); + assert.ok(calls.every(([, input]) => input.cwd === path.resolve(workspace) && input.jobId === "job-1")); +}); + +test("handleRequest returns MCP tool errors for invalid arguments", async () => { + const response = await handleRequest(toolRequest("gemini_rescue", { + workspace: "relative/path", + prompt: "inspect" + }), { runtime: {} }); + assert.equal(response.isError, true); + assert.match(response.content[0].text, /absolute path/i); +}); + +test("CLI runtime and MCP rescue dispatch persist byte-identical job prompts", async () => { + const workspace = makeTempDir(); + const dataDir = makeTempDir(); + initGitRepo(workspace); + const previousData = process.env.GEMINI_COMPANION_DATA; + process.env.GEMINI_COMPANION_DATA = dataDir; + const spawnFn = () => ({ pid: 12345, unref() {} }); + const prompt = "Inspect src/auth.js exactly; do not edit.\nPreserve this second line."; + + try { + const cliDispatch = dispatchBackgroundTask({ + cwd: workspace, + prompt, + engine: "gemini", + model: "flash", + effort: "high" + }, { spawnFn }); + const mcpDispatch = await handleRequest(toolRequest("gemini_rescue", { + workspace, + prompt, + engine: "gemini", + model: "flash", + effort: "high" + }), { + runtime: { + dispatchBackgroundTask(input) { + return dispatchBackgroundTask(input, { spawnFn }); + } + } + }); + + const cliJob = readStoredJob(workspace, cliDispatch.jobId); + const mcpJob = readStoredJob(workspace, mcpDispatch.structuredContent.jobId); + assert.equal(cliJob.request.prompt, prompt); + assert.equal(mcpJob.request.prompt, cliJob.request.prompt); + } finally { + if (previousData === undefined) delete process.env.GEMINI_COMPANION_DATA; + else process.env.GEMINI_COMPANION_DATA = previousData; + fs.rmSync(workspace, { recursive: true, force: true }); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +}); From e908488e9b8d032c8305b82f191ada9869b8f2e0 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Tue, 14 Jul 2026 07:24:50 +0800 Subject: [PATCH 09/11] feat(review): add blind multi-engine groups (F-CC2) Dispatch prompt-identical Gemini and AGY adversarial reviews under one group id, aggregate status/results, and degrade visibly when only one requested engine is available. Constraint: Preserve v0.6.6 exports and single-engine behavior Confidence: high Scope-risk: moderate --- plugins/gemini/CHANGELOG.md | 1 + plugins/gemini/commands/adversarial-review.md | 6 +- plugins/gemini/scripts/gemini-companion.mjs | 194 +++++++++++++++--- plugins/gemini/scripts/lib/job-control.mjs | 29 ++- plugins/gemini/scripts/lib/render.mjs | 42 ++++ tests/runtime.test.mjs | 135 ++++++++++++ 6 files changed, 371 insertions(+), 36 deletions(-) diff --git a/plugins/gemini/CHANGELOG.md b/plugins/gemini/CHANGELOG.md index 9bc6e46..5467d13 100644 --- a/plugins/gemini/CHANGELOG.md +++ b/plugins/gemini/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - **F-CC1: hand-rolled stdio MCP server.** Added `gemini_rescue`, `gemini_review`, `gemini_job_status`, `gemini_job_result`, and `gemini_job_cancel` as thin JSON-RPC wrappers over the existing companion dispatch, job-control, and state paths. The plugin now declares the server through `.mcp.json`; MCP and CLI background dispatch share the same persisted request construction so prompt assembly cannot drift. +- **F-CC2: parallel blind adversarial review.** `/gemini:adversarial-review --engines gemini,agy` now queues prompt-identical background jobs with one shared group ID, aggregates both engines in `/gemini:status` and `/gemini:result`, and degrades to the available engine with an explicit stderr warning when only one requested CLI is usable. ### Fixed - **`--engine agy --write` no longer silently writes to AGY's scratch dir instead of the target directory.** Machine-verified on AGY 1.1.0/Windows 2026-07-09: a fresh (non-continuation) `agy --print --dangerously-skip-permissions` write turn with no prior workspace/project association creates files under `~/.gemini/antigravity-cli/scratch/` rather than the spawned `cwd` — silently, with `status: 0` and no error, so a caller only notices by checking the file landed in the wrong place. `buildCliArgs` now appends `--new-project` on a write turn that is not a `--continue` resume, binding the session's workspace to `cwd`; a resumed conversation is left alone since it already has its original project association. New `tests/engine.test.mjs` coverage for the three `buildCliArgs("agy", ...)` flag-composition cases (write, resumed write, read-only). (`lib/engine.mjs`) Re-verified end-to-end post-commit on AGY 1.1.0/Windows 2026-07-10: a fresh `task --engine agy --write` turn completed in 13s (job `completed`, no `request-review` stall) with the probe file landing in `cwd`, not scratch. diff --git a/plugins/gemini/commands/adversarial-review.md b/plugins/gemini/commands/adversarial-review.md index 0d2a73e..f7ab2c2 100644 --- a/plugins/gemini/commands/adversarial-review.md +++ b/plugins/gemini/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run an adversarial Gemini code review that challenges the implementation approach and design choices -argument-hint: '[--wait|--background] [--deep] [--base ] [--scope auto|working-tree|branch] [--engine ] [--model ] [focus ...]' +argument-hint: '[--wait|--background] [--deep] [--base ] [--scope auto|working-tree|branch] [--engine | --engines gemini,agy] [--model ] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- @@ -19,6 +19,7 @@ Core constraint: - Keep the framing focused on whether the current approach is the right one, what assumptions it depends on, and where the design could fail under real-world conditions. Execution mode rules: +- If the raw arguments include `--engines`, do not ask. Run the companion in the foreground with those arguments; the runtime queues the grouped jobs in the background and returns the group ID immediately. - If the raw arguments include `--wait`, do not ask. Run in the foreground. - If the raw arguments include `--background`, do not ask. Run in a Claude background task. - Otherwise, estimate the review size before asking: @@ -41,6 +42,8 @@ Argument handling: - The companion script handles `--background` itself: it enqueues the review and spawns a detached `review-worker`, so the result persists even if this session ends. Do not use Claude's `run_in_background: true` for it. - `/gemini:adversarial-review` uses the same review target selection as `/gemini:review` (including `--base ` and `--scope`). - Unlike `/gemini:review`, it can take extra focus text after the flags. +- `--engines gemini,agy` queues the same blind prompt on both available engines as a background group. The jobs share a group ID but do not receive each other's identity or output. Do not combine `--engines` with `--engine` or `--wait`. +- If one requested engine is unavailable, the runtime prints a degradation warning to stderr and queues the remaining engine as a normal single job. If neither is available, it fails without creating a job. - `--deep` runs an **agentic** review: Gemini uses its read-only tools to inspect repo context beyond the diff (dependency manifests, untracked files, callers) before producing the same JSON findings. Slower and higher-token; omit it for the fast, diff-scoped default. Pair `--deep` with `--background` for larger changes. Foreground flow: @@ -61,3 +64,4 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/gemini-companion.mjs" adversarial-review --b - This enqueues the review, spawns a detached `review-worker`, and returns a job id right away. The result persists even if this Claude session is interrupted. - Do not use `run_in_background: true` and do not call `BashOutput` — the companion already detached; this call returns immediately. - Relay the returned job id: "Gemini adversarial review started in the background as ``. Check `/gemini:status ` for progress and `/gemini:result ` when it finishes." +- For `--engines`, relay the returned group ID instead: "Gemini adversarial review group started as ``. Check `/gemini:status ` for both engines and `/gemini:result ` for their verdicts." diff --git a/plugins/gemini/scripts/gemini-companion.mjs b/plugins/gemini/scripts/gemini-companion.mjs index 14d5af8..7c79351 100644 --- a/plugins/gemini/scripts/gemini-companion.mjs +++ b/plugins/gemini/scripts/gemini-companion.mjs @@ -26,7 +26,7 @@ import { filterJobsForCurrentSession, readStoredJob, resolveCancelableJob, - resolveResultJob, + resolveResultJobs, sortJobsNewestFirst } from "./lib/job-control.mjs"; import { @@ -44,9 +44,11 @@ import { renderReviewResult, renderTaskResult, renderStoredJobResult, + renderStoredJobGroupResult, renderCancelReport, describeTermination, renderJobStatusReport, + renderJobGroupStatusReport, renderSetupReport, renderStatusReport } from "./lib/render.mjs"; @@ -89,7 +91,7 @@ function printUsage() { [ "Usage:", " node scripts/gemini-companion.mjs setup [--json] [--enable-review-gate|--disable-review-gate]", - " node scripts/gemini-companion.mjs adversarial-review [--wait|--background] [--deep] [--base ] [--scope ] [--engine agy|gemini|auto] [focus text]", + " node scripts/gemini-companion.mjs adversarial-review [--wait|--background] [--deep] [--base ] [--scope ] [--engine agy|gemini|auto] [--engines gemini,agy] [focus text]", " node scripts/gemini-companion.mjs review [--wait|--background] [--deep] [--base ] [--scope ] [--engine agy|gemini|auto]", " node scripts/gemini-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--engine agy|gemini|auto] [prompt]", " node scripts/gemini-companion.mjs status [job-id] [--all] [--json]", @@ -312,20 +314,24 @@ function isActiveJobStatus(status) { return status === "queued" || status === "running"; } +function isActiveJobSnapshot(snapshot) { + return (snapshot.jobs ?? [snapshot.job]).some((job) => isActiveJobStatus(job.status)); +} + async function waitForSingleJobSnapshot(cwd, reference, options = {}) { const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS); const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS); const deadline = Date.now() + timeoutMs; let snapshot = buildSingleJobSnapshot(cwd, reference); - while (isActiveJobStatus(snapshot.job.status) && Date.now() < deadline) { + while (isActiveJobSnapshot(snapshot) && Date.now() < deadline) { await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))); snapshot = buildSingleJobSnapshot(cwd, reference); } return { ...snapshot, - waitTimedOut: isActiveJobStatus(snapshot.job.status), + waitTimedOut: isActiveJobSnapshot(snapshot), timeoutMs }; } @@ -350,8 +356,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { return null; } -async function executeReviewRun(request) { - const reviewName = request.reviewName ?? "Adversarial Review"; +export function buildReviewPrompt(request) { const templateName = request.templateName ?? "adversarial-review"; // Resolve the review target from the caller's --base/--scope so the user's // selection actually reaches the diff collection. (Re-resolving with empty @@ -361,6 +366,24 @@ async function executeReviewRun(request) { scope: request.scope }); const context = collectReviewContext(request.cwd, target); + if (context.isEmpty) return { context, prompt: null }; + + const template = loadPromptTemplate(ROOT_DIR, templateName); + const basePrompt = interpolateTemplate(template, { + TARGET_LABEL: context.target.label, + USER_FOCUS: request.focusText || "No extra focus provided.", + REVIEW_INPUT: context.content + }); + return { + context, + prompt: request.deep ? `${basePrompt}\n${DEEP_REVIEW_GUIDANCE}` : basePrompt + }; +} + +async function executeReviewRun(request) { + const reviewName = request.reviewName ?? "Adversarial Review"; + const templateName = request.templateName ?? "adversarial-review"; + const { context, prompt } = request.preparedReview ?? buildReviewPrompt(request); // Nothing-to-review guard: if the resolved target has no changes, short-circuit // instead of asking Gemini to review an empty diff (which it rubber-stamps as @@ -390,15 +413,6 @@ async function executeReviewRun(request) { }; } - const template = loadPromptTemplate(ROOT_DIR, templateName); - const basePrompt = interpolateTemplate(template, { - TARGET_LABEL: context.target.label, - USER_FOCUS: request.focusText || "No extra focus provided.", - REVIEW_INPUT: context.content - }); - // --deep: invite agentic repo exploration beyond the diff (read-only). Default - // stays the fast, diff-scoped single-shot review (zero behavior change). - const prompt = request.deep ? `${basePrompt}\n${DEEP_REVIEW_GUIDANCE}` : basePrompt; const result = await runGeminiReviewResilient(request.cwd, { prompt, model: request.model, @@ -520,7 +534,7 @@ function getJobKindLabel(kind, jobClass) { return jobClass === "review" ? "review" : "rescue"; } -function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) { +function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false, engine = null, groupId = null }) { return createJobRecord({ id: generateJobId(prefix), kind, @@ -529,7 +543,9 @@ function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summ workspaceRoot, jobClass, summary, - write + write, + ...(engine ? { engine } : {}), + ...(groupId ? { groupId } : {}) }); } @@ -570,11 +586,11 @@ function buildTaskRequest({ cwd, model, effort, engine, prompt, write, resumeLas }; } -// Serializable review request for the detached review-worker. The diff target is -// NOT stored — the worker re-resolves it from base/scope at run time (the same -// re-resolution executeReviewRun already does), so the review reflects the tree -// when the worker runs, mirroring how a background task bakes in its prompt. -function buildReviewRequest({ cwd, base, scope, model, engine, focusText, reviewName, templateName, deep = false }) { +// Serializable review request for the detached review-worker. A normal single +// review re-resolves base/scope when the worker runs. A grouped blind review may +// supply one preparedReview snapshot so every engine receives byte-identical +// input even if the working tree changes between worker starts. +function buildReviewRequest({ cwd, base, scope, model, engine, focusText, reviewName, templateName, deep = false, preparedReview = null }) { return { cwd, base, @@ -584,7 +600,8 @@ function buildReviewRequest({ cwd, base, scope, model, engine, focusText, review focusText, reviewName, templateName, - deep + deep, + ...(preparedReview ? { preparedReview } : {}) }; } @@ -654,7 +671,9 @@ function enqueueBackgroundJob(cwd, job, request, workerCommand, { spawnFn = spaw status: "queued", title: job.title, summary: job.summary, - logFile + logFile, + ...(job.engine ? { engine: job.engine } : {}), + ...(job.groupId ? { groupId: job.groupId } : {}) }, logFile }; @@ -674,7 +693,9 @@ export function dispatchBackgroundReview(request, { spawnFn = spawn } = {}) { title: `Gemini ${reviewName}`, workspaceRoot, jobClass: "review", - summary: `${reviewName} ${target.label}` + summary: `${reviewName} ${target.label}`, + engine: request.engine, + groupId: request.groupId }); const storedRequest = buildReviewRequest({ cwd, @@ -685,11 +706,72 @@ export function dispatchBackgroundReview(request, { spawnFn = spawn } = {}) { focusText: request.focusText ?? "", reviewName, templateName, - deep: request.deep + deep: request.deep, + preparedReview: request.preparedReview }); return enqueueBackgroundJob(cwd, job, storedRequest, "review-worker", { spawnFn }).payload; } +export function dispatchAdversarialReview(request, { + spawnFn = spawn, + detectEngineFn = detectEngine, + stderr = process.stderr +} = {}) { + const engineValues = Array.isArray(request.engines) ? request.engines : String(request.engines ?? "").split(","); + const requestedEngines = [...new Set(engineValues.map((engine) => String(engine).trim().toLowerCase()).filter(Boolean))]; + if (requestedEngines.length === 0) throw new Error("--engines requires at least one engine."); + const invalid = requestedEngines.filter((engine) => engine !== "gemini" && engine !== "agy"); + if (invalid.length > 0) throw new Error(`Unknown review engine: ${invalid.join(", ")}. Valid engines: gemini, agy.`); + + const available = []; + const unavailable = []; + for (const engine of requestedEngines) { + try { + detectEngineFn(engine); + available.push(engine); + } catch (error) { + unavailable.push({ engine, error: error instanceof Error ? error.message : String(error) }); + } + } + if (available.length === 0) { + throw new Error(`None of the requested review engines are available: ${unavailable.map(({ engine, error }) => `${engine} (${error})`).join("; ")}.`); + } + + const warning = unavailable.length > 0 + ? `Adversarial review degraded to ${available.join(", ")}; unavailable: ${unavailable.map(({ engine }) => engine).join(", ")}.` + : null; + if (warning) stderr.write(`${warning}\n`); + + const groupId = available.length > 1 ? generateJobId("review-group") : null; + const preparedReview = groupId ? buildReviewPrompt({ + ...request, + cwd: path.resolve(request.cwd ?? process.cwd()), + reviewName: "Adversarial Review", + templateName: "adversarial-review" + }) : null; + const jobs = available.map((engine) => dispatchBackgroundReview({ + ...request, + engine, + groupId, + preparedReview, + reviewName: "Adversarial Review", + templateName: "adversarial-review" + }, { spawnFn })); + return { + groupId, + jobIds: jobs.map((job) => job.jobId), + jobs, + engines: available, + unavailableEngines: unavailable.map(({ engine }) => engine), + degraded: unavailable.length > 0, + warning + }; +} + +function renderQueuedReviewGroupLaunch(payload) { + return `Gemini adversarial review group ${payload.groupId} started in the background (${payload.engines.join(", ")}). Check /gemini:status ${payload.groupId} for progress.\n`; +} + export function dispatchBackgroundTask(request, { spawnFn = spawn } = {}) { const cwd = path.resolve(request.cwd ?? process.cwd()); const workspaceRoot = resolveWorkspaceRoot(cwd); @@ -716,9 +798,9 @@ export function dispatchBackgroundTask(request, { spawnFn = spawn } = {}) { return enqueueBackgroundJob(cwd, job, storedRequest, "task-worker", { spawnFn }).payload; } -async function handleReviewCommand(argv, { reviewName, templateName, supportsFocus }) { +async function handleReviewCommand(argv, { reviewName, templateName, supportsFocus, supportsEngines = false }) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["base", "scope", "model", "engine", "cwd"], + valueOptions: ["base", "scope", "model", "engine", "cwd", ...(supportsEngines ? ["engines"] : [])], booleanOptions: ["json", "wait", "background", "deep"], aliasMap: { m: "model" } }); @@ -727,6 +809,33 @@ async function handleReviewCommand(argv, { reviewName, templateName, supportsFoc const workspaceRoot = resolveCommandWorkspace(options); const focusText = supportsFocus ? positionals.join(" ").trim() : ""; + if (supportsEngines && options.engines != null) { + if (options.engine != null) throw new Error("Choose either --engine or --engines, not both."); + if (options.wait) throw new Error("--engines dispatches background jobs and cannot be combined with --wait."); + const dispatch = dispatchAdversarialReview({ + cwd, + base: options.base, + scope: options.scope, + model: options.model, + engines: String(options.engines).split(","), + focusText, + deep: options.deep + }); + if (dispatch.groupId) { + const payload = { + groupId: dispatch.groupId, + jobIds: dispatch.jobIds, + status: "queued", + engines: dispatch.engines + }; + outputCommandResult(payload, renderQueuedReviewGroupLaunch(payload), options.json); + } else { + const payload = dispatch.jobs[0]; + outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); + } + return; + } + if (options.background) { // Persist the review to a detached review-worker so the result survives an // interrupted Claude session (parity with background tasks). Returns a job id @@ -783,7 +892,12 @@ async function handleReview(argv) { } async function handleAdversarialReview(argv) { - return handleReviewCommand(argv, { reviewName: "Adversarial Review", templateName: "adversarial-review", supportsFocus: true }); + return handleReviewCommand(argv, { + reviewName: "Adversarial Review", + templateName: "adversarial-review", + supportsFocus: true, + supportsEngines: true + }); } async function handleTask(argv) { @@ -913,7 +1027,11 @@ async function handleStatus(argv) { pollIntervalMs: options["poll-interval-ms"] }) : buildSingleJobSnapshot(cwd, reference); - outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json); + outputCommandResult( + snapshot, + snapshot.groupId ? renderJobGroupStatusReport(snapshot) : renderJobStatusReport(snapshot.job), + options.json + ); return; } @@ -930,8 +1048,14 @@ export function getJobStatus({ cwd = process.cwd(), jobId }) { } export function getJobResult({ cwd = process.cwd(), jobId, all = false }) { - const { workspaceRoot, job } = resolveResultJob(path.resolve(cwd), jobId, { all }); - return { job, storedJob: readStoredJob(workspaceRoot, job.id) }; + const resolved = resolveResultJobs(path.resolve(cwd), jobId, { all }); + if (resolved.groupId) { + return { + groupId: resolved.groupId, + results: resolved.jobs.map((job) => ({ job, storedJob: readStoredJob(resolved.workspaceRoot, job.id) })) + }; + } + return { job: resolved.job, storedJob: readStoredJob(resolved.workspaceRoot, resolved.job.id) }; } function handleResult(argv) { @@ -944,7 +1068,11 @@ function handleResult(argv) { const reference = positionals[0] ?? ""; const payload = getJobResult({ cwd, jobId: reference, all: options.all }); - outputCommandResult(payload, renderStoredJobResult(payload.job, payload.storedJob), options.json); + outputCommandResult( + payload, + payload.groupId ? renderStoredJobGroupResult(payload) : renderStoredJobResult(payload.job, payload.storedJob), + options.json + ); } async function handleTaskResumeCandidate(argv) { diff --git a/plugins/gemini/scripts/lib/job-control.mjs b/plugins/gemini/scripts/lib/job-control.mjs index 5975806..9a23d18 100644 --- a/plugins/gemini/scripts/lib/job-control.mjs +++ b/plugins/gemini/scripts/lib/job-control.mjs @@ -373,14 +373,20 @@ export function buildStatusSnapshot(cwd, options = {}) { export function buildSingleJobSnapshot(cwd, reference, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const jobs = sortJobsNewestFirst(reconcileActiveJobs(workspaceRoot, listJobs(workspaceRoot), options)); - const selected = matchJobReference(jobs, reference); + const exactGroup = reference ? jobs.filter((job) => job.groupId === reference) : []; + const selected = exactGroup[0] ?? matchJobReference(jobs, reference); if (!selected) { throw new Error(`No job found for "${reference}". Run /gemini:status to inspect known jobs.`); } + const grouped = selected.groupId + ? jobs.filter((job) => job.groupId === selected.groupId).map((job) => enrichJob(job, { maxProgressLines: options.maxProgressLines })) + : []; + return { workspaceRoot, - job: enrichJob(selected, { maxProgressLines: options.maxProgressLines }) + job: enrichJob(selected, { maxProgressLines: options.maxProgressLines }), + ...(selected.groupId ? { groupId: selected.groupId, jobs: grouped } : {}) }; } @@ -413,6 +419,25 @@ export function resolveResultJob(cwd, reference, options = {}) { throw new Error("No finished Gemini jobs found for this repository yet."); } +export function resolveResultJobs(cwd, reference, options = {}) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + const candidates = options.all ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)); + const jobs = sortJobsNewestFirst(candidates); + const exactGroup = reference ? jobs.filter((job) => job.groupId === reference) : []; + const selected = exactGroup[0] ?? resolveResultJob(cwd, reference, options).job; + + if (!selected.groupId) { + return { workspaceRoot, job: selected }; + } + + const grouped = jobs.filter((job) => job.groupId === selected.groupId); + const active = grouped.find((job) => isActiveStatus(job.status)); + if (active) { + throw new Error(`Review group ${selected.groupId} is still running (${active.id}: ${active.status}). Check /gemini:status and try again once it finishes.`); + } + return { workspaceRoot, groupId: selected.groupId, jobs: grouped }; +} + export function resolveCancelableJob(cwd, reference, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); // Default scope is the current Claude session so /gemini:cancel can never diff --git a/plugins/gemini/scripts/lib/render.mjs b/plugins/gemini/scripts/lib/render.mjs index cbd179c..cf40ba6 100644 --- a/plugins/gemini/scripts/lib/render.mjs +++ b/plugins/gemini/scripts/lib/render.mjs @@ -407,6 +407,24 @@ export function renderJobStatusReport(job) { return `${lines.join("\n").trimEnd()}\n`; } +export function renderJobGroupStatusReport({ groupId, jobs = [] }) { + const lines = [ + "# Gemini Adversarial Review Group Status", + "", + `Group: ${groupId}`, + "", + "| Engine | Job | Status | Phase | Elapsed / Duration |", + "| --- | --- | --- | --- | --- |" + ]; + for (const job of jobs) { + lines.push( + `| ${escapeMarkdownCell(job.engine ?? "unknown")} | ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(job.elapsed ?? job.duration ?? "")} |` + ); + } + lines.push("", `Result: /gemini:result ${groupId}`); + return `${lines.join("\n").trimEnd()}\n`; +} + export function renderStoredJobResult(job, storedJob) { const threadId = storedJob?.threadId ?? job.threadId ?? null; const engine = storedJob?.engine ?? job?.engine ?? null; @@ -475,6 +493,30 @@ export function renderStoredJobResult(job, storedJob) { return `${lines.join("\n").trimEnd()}\n`; } +export function renderStoredJobGroupResult({ groupId, results = [] }) { + const lines = [ + "# Gemini Adversarial Review Group Result", + "", + `Group: ${groupId}`, + "", + "| Engine | Job | Status | Verdict | Summary |", + "| --- | --- | --- | --- | --- |" + ]; + + for (const { job, storedJob } of results) { + const review = storedJob?.result?.result; + lines.push( + `| ${escapeMarkdownCell(storedJob?.engine ?? job.engine ?? "unknown")} | ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(review?.verdict ?? "unavailable")} | ${escapeMarkdownCell(review?.summary ?? job.summary ?? "")} |` + ); + } + + for (const { job, storedJob } of results) { + const engine = String(storedJob?.engine ?? job.engine ?? "unknown").toUpperCase(); + lines.push("", `## ${engine} — ${job.id}`, "", renderStoredJobResult(job, storedJob).trimEnd()); + } + return `${lines.join("\n").trimEnd()}\n`; +} + // Human-readable outcome of terminateProcessTree(), so /gemini:cancel can be // honest about whether a live process was actually killed. The detached worker // is unref()-ed, so by cancel time its PID is often already gone. diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 518c78c..b7bbfb1 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -20,10 +20,16 @@ import { } from "./fake-gemini-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; import { + readJobFile, + resolveJobFile, resolveStateDir, resolveStateFile, writeJobFile } from "../plugins/gemini/scripts/lib/state.mjs"; +import { + buildReviewPrompt, + dispatchAdversarialReview +} from "../plugins/gemini/scripts/gemini-companion.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SCRIPT = path.join(ROOT, "plugins", "gemini", "scripts", "gemini-companion.mjs"); @@ -652,6 +658,87 @@ test("task rejects an unknown engine", () => { assert.match(result.stderr, /Unknown engine/i); }); +test("dispatchAdversarialReview queues prompt-identical blind jobs with a shared groupId", () => { + const { repo } = setupRepo("review-findings"); + const dataDir = makeTempDir(); + commit(repo, "src/app.js", "export const value = items[0];\n"); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); + const previousData = process.env.GEMINI_COMPANION_DATA; + process.env.GEMINI_COMPANION_DATA = dataDir; + const spawns = []; + const spawnFn = (...args) => { + spawns.push(args); + return { pid: 2000 + spawns.length, unref() {} }; + }; + + try { + const dispatched = dispatchAdversarialReview({ + cwd: repo, + scope: "working-tree", + engines: ["gemini", "agy"], + focusText: "challenge the retry design" + }, { + spawnFn, + detectEngineFn: (engine) => ({ engine }) + }); + + assert.match(dispatched.groupId, /^review-group-/); + assert.equal(dispatched.jobIds.length, 2); + assert.equal(spawns.length, 2); + const jobs = dispatched.jobIds.map((jobId) => readJobFile(resolveJobFile(repo, jobId))); + assert.deepEqual(new Set(jobs.map((job) => job.groupId)), new Set([dispatched.groupId])); + assert.deepEqual(new Set(jobs.map((job) => job.engine)), new Set(["gemini", "agy"])); + const prompts = jobs.map((job) => job.request.preparedReview.prompt); + assert.equal(new Set(prompts).size, 1); + assert.equal(prompts[0], buildReviewPrompt(jobs[0].request).prompt); + assert.ok(!prompts[0].includes(dispatched.groupId), "blind prompts must not expose orchestration metadata"); + } finally { + if (previousData === undefined) delete process.env.GEMINI_COMPANION_DATA; + else process.env.GEMINI_COMPANION_DATA = previousData; + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("dispatchAdversarialReview degrades to one available engine with a stderr warning", () => { + const { repo } = setupRepo("review-findings"); + const dataDir = makeTempDir(); + commit(repo, "src/app.js", "export const value = 1;\n"); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + const previousData = process.env.GEMINI_COMPANION_DATA; + process.env.GEMINI_COMPANION_DATA = dataDir; + let stderr = ""; + + try { + const dispatched = dispatchAdversarialReview({ + cwd: repo, + scope: "working-tree", + engines: ["gemini", "agy"] + }, { + spawnFn: () => ({ pid: 3001, unref() {} }), + detectEngineFn(engine) { + if (engine === "agy") throw new Error("agy is unavailable"); + return { engine }; + }, + stderr: { write(chunk) { stderr += chunk; } } + }); + + assert.equal(dispatched.groupId, null); + assert.deepEqual(dispatched.engines, ["gemini"]); + assert.deepEqual(dispatched.unavailableEngines, ["agy"]); + assert.equal(dispatched.jobIds.length, 1); + assert.match(stderr, /degraded to gemini.*unavailable: agy/i); + const job = readJobFile(resolveJobFile(repo, dispatched.jobIds[0])); + assert.equal(job.engine, "gemini"); + assert.equal(job.groupId, undefined); + } finally { + if (previousData === undefined) delete process.env.GEMINI_COMPANION_DATA; + else process.env.GEMINI_COMPANION_DATA = previousData; + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +}); + test("task --background enqueues a detached worker and reports completion", async () => { const { repo, binDir } = setupRepo("task"); commit(repo, "README.md", "hello\n"); @@ -916,6 +1003,54 @@ test("result returns the stored output and resume hint for the latest finished j assert.match(result.stdout, /Resume in Gemini: gemini --resume thr_finished/); }); +test("status and result aggregate adversarial-review jobs by groupId", () => { + const workspace = makeTempDir(); + const groupId = "review-group-shared"; + const jobs = [ + { id: "review-gemini", engine: "gemini", verdict: "approve", summary: "Gemini approves." }, + { id: "review-agy", engine: "agy", verdict: "needs-attention", summary: "AGY found a blocker." } + ]; + seedState(workspace, jobs.map((job, index) => ({ + id: job.id, + groupId, + engine: job.engine, + kind: "adversarial-review", + jobClass: "review", + title: "Gemini Adversarial Review", + status: "completed", + summary: job.summary, + createdAt: `2026-07-14T00:00:0${index}.000Z`, + updatedAt: `2026-07-14T00:00:0${index}.000Z` + }))); + for (const job of jobs) { + writeJobFile(workspace, job.id, { + id: job.id, + groupId, + engine: job.engine, + status: "completed", + result: { result: { verdict: job.verdict, summary: job.summary, findings: [], next_steps: [] } }, + rendered: `# Gemini Adversarial Review\n\nVerdict: ${job.verdict}\n\n${job.summary}\n` + }); + } + + const status = run("node", [SCRIPT, "status", groupId, "--json"], { cwd: workspace, env: envWithoutSession() }); + assert.equal(status.status, 0, status.stderr); + const statusPayload = JSON.parse(status.stdout); + assert.equal(statusPayload.groupId, groupId); + assert.equal(statusPayload.jobs.length, 2); + + const result = run("node", [SCRIPT, "result", groupId, "--json"], { cwd: workspace, env: envWithoutSession() }); + assert.equal(result.status, 0, result.stderr); + const resultPayload = JSON.parse(result.stdout); + assert.equal(resultPayload.groupId, groupId); + assert.equal(resultPayload.results.length, 2); + + const rendered = run("node", [SCRIPT, "result", groupId], { cwd: workspace, env: envWithoutSession() }); + assert.equal(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /\| gemini \| review-gemini \| completed \| approve \|/i); + assert.match(rendered.stdout, /\| agy \| review-agy \| completed \| needs-attention \|/i); +}); + test("cancel stops an active job and marks it cancelled", async (t) => { const workspace = makeTempDir(); From 9e268040812f53000ad1eedd1a1930e0b2897cc6 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Tue, 14 Jul 2026 07:45:05 +0800 Subject: [PATCH 10/11] fix(mcp): resolve server path via CLAUDE_PLUGIN_ROOT + launch e2e test; CC ledger (WP-6 r1 / D-CC1) Blind review blocking: .mcp.json used a relative script path with cwd ".", which resolves against the host's cwd, not the plugin dir -- so the MCP server would fail to launch whenever the user's cwd differs from the plugin directory (the normal case), silently un-delivering F-CC1. Every other file in this plugin (hooks.json, commands/*.md) already uses ${CLAUDE_PLUGIN_ROOT}; .mcp.json now does too. Added tests/mcp-launch.test.mjs that spawns exactly what .mcp.json declares from a temp cwd and requires an initialize reply -- verified it fails on the old relative form and passes on the fixed one (the major finding: nothing previously exercised the declared launch command). Also added docs/known-diffs.md (D-CC1) recording the two minor group-cancel / partial-dispatch follow-ups and CC's deliberate differences. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018uDUc5kSwMWqWrgsGsyYdh --- docs/known-diffs.md | 49 +++++++++++++++++++++++++++++++++ plugins/gemini/.mcp.json | 4 +-- tests/mcp-launch.test.mjs | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 docs/known-diffs.md create mode 100644 tests/mcp-launch.test.mjs diff --git a/docs/known-diffs.md b/docs/known-diffs.md new file mode 100644 index 0000000..cdf6a55 --- /dev/null +++ b/docs/known-diffs.md @@ -0,0 +1,49 @@ +# Known differences — gemini-plugin-cc + +Deliberate, documented divergences from its sibling companion plugins +(companion-cx, companion-agy). gemini-plugin-cc is the mature, published +(v0.6.6) member and the origin of several capabilities the campaign ported to +the siblings; this file records where it intentionally differs. See the +campaign matrix for the full three-way comparison. + +## Deliberate design differences + +- **Job state lives in the project-local `.omc/state/` dir**, not a user-home + dir like the siblings' `~/.companion-*/jobs`. This survives session + interruption in-tree. Converging the two would be a breaking change to where + existing jobs are found; kept as-is. +- **Bench harness is retained.** The Codex-vs-Gemini benchmark suite (`bench/`, + cassettes, scoring) lives only here; the siblings deliberately omit it (owner + decision), so it is a one-way difference, not a gap. +- **Existing exported function names are unchanged.** Because this plugin is + published (v0.6.6), the shared adapter contract is satisfied by *semantic* + equivalence documented in `docs/adapter-contract.md` (e.g. `terminateProcessTree` + ↔ contract `cancel`), not by renaming to match the siblings (Hyrum's law). + +## Security posture (shared with siblings) + +- **agy free-text prompt travels via argv** (AGY has no stdin mode — upstream + limitation). Safety comes from resolving `agy` to an absolute `.exe` path so + it spawns `shell:false` and never traverses cmd.exe; if it cannot resolve to + an executable, `detectEngine` fails closed rather than falling back to a bare + name (WP-3 r2). The `quoteForWindowsShell` helper is a no-op safety net for + fixed-constant argv only and is explicitly NOT relied on for free text. + +## Follow-ups (adversarial-review groups, low priority) + +- **`/gemini:cancel ` is not group-aware.** `status` and `result` + accept a groupId and aggregate, but cancel matches only a job id, so an + adversarial-review group must be cancelled one engine job at a time. The + command docs never promised group cancel, so this is an asymmetry, not a + broken contract. +- **Partial dispatch has no rollback.** If a later engine in an adversarial + dispatch fails after an earlier one already spawned, the earlier job is + orphaned (still queryable by its own id, but never gets its group peer). Low + probability; no cleanup today. + +## Upstream-blocked + +- **gemini engine end-to-end** depends on Gemini API auth; the CLI OAuth path is + disabled upstream (observed `API_KEY_INVALID`, 2026-07-13). The plugin + prefers the gemini engine (stdin, pipe-safe) when credentials work and falls + back to agy transcript recovery otherwise. diff --git a/plugins/gemini/.mcp.json b/plugins/gemini/.mcp.json index 22603dd..cdd4bd9 100644 --- a/plugins/gemini/.mcp.json +++ b/plugins/gemini/.mcp.json @@ -2,8 +2,8 @@ "mcpServers": { "gemini": { "command": "node", - "args": ["./scripts/gemini-mcp.mjs"], - "cwd": "." + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/gemini-mcp.mjs"], + "cwd": "${CLAUDE_PLUGIN_ROOT}" } } } diff --git a/tests/mcp-launch.test.mjs b/tests/mcp-launch.test.mjs new file mode 100644 index 0000000..fe256fe --- /dev/null +++ b/tests/mcp-launch.test.mjs @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_ROOT = path.resolve(fileURLToPath(new URL("../plugins/gemini", import.meta.url))); + +// Regression guard for the .mcp.json launch path: the declared command/args +// MUST resolve relative to the plugin root (${CLAUDE_PLUGIN_ROOT}), NOT the +// host's cwd. A relative "./scripts/..." + cwd "." would fail to launch +// whenever the user's cwd differs from the plugin dir -- which is the normal +// case. So we launch EXACTLY what .mcp.json declares, from a temp cwd that is +// deliberately not the plugin dir, and require it to answer initialize. +test("the .mcp.json server command launches from a cwd that is not the plugin dir", async () => { + const config = JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, ".mcp.json"), "utf8")); + const server = config.mcpServers.gemini; + + const expand = (s) => s.replaceAll("${CLAUDE_PLUGIN_ROOT}", PLUGIN_ROOT); + const command = expand(server.command); + const args = server.args.map(expand); + const cwd = server.cwd ? expand(server.cwd) : undefined; + + // Sanity: the resolved script path must be absolute and exist, so a + // relative-path regression (./scripts/...) is caught before spawning. + const scriptArg = args.find((a) => a.endsWith("gemini-mcp.mjs")); + assert.ok(scriptArg && path.isAbsolute(scriptArg) && fs.existsSync(scriptArg), `.mcp.json must resolve gemini-mcp.mjs to an existing absolute path, got: ${scriptArg}`); + + const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "gemini-mcp-launch-")); + const child = spawn(command, args, { cwd: cwd ?? tempCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" && !path.isAbsolute(command) }); + + try { + const firstLine = new Promise((resolve, reject) => { + let buf = ""; + const timer = setTimeout(() => reject(new Error("MCP server did not respond to initialize within 15s")), 15000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + buf += chunk; + const nl = buf.indexOf("\n"); + if (nl !== -1) { clearTimeout(timer); resolve(buf.slice(0, nl)); } + }); + child.once("error", (e) => { clearTimeout(timer); reject(e); }); + child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`MCP server exited early (code ${code}) -- it likely could not find its script from this cwd`)); }); + }); + + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`); + + const reply = JSON.parse(await firstLine); + assert.equal(reply.id, 1); + assert.equal(reply.result.serverInfo.name, "gemini"); + assert.equal(typeof reply.result.protocolVersion, "string"); + } finally { + child.kill(); + fs.rmSync(tempCwd, { recursive: true, force: true }); + } +}); From c74e0b7245838f12cccc824b6d74da1078a20225 Mon Sep 17 00:00:00 2001 From: Arcobaleno Date: Tue, 14 Jul 2026 08:23:59 +0800 Subject: [PATCH 11/11] docs: gemini engine e2e is a permanent limitation, not pending (owner-confirmed OAuth retired) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018uDUc5kSwMWqWrgsGsyYdh --- docs/known-diffs.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/known-diffs.md b/docs/known-diffs.md index cdf6a55..343d700 100644 --- a/docs/known-diffs.md +++ b/docs/known-diffs.md @@ -44,6 +44,7 @@ campaign matrix for the full three-way comparison. ## Upstream-blocked - **gemini engine end-to-end** depends on Gemini API auth; the CLI OAuth path is - disabled upstream (observed `API_KEY_INVALID`, 2026-07-13). The plugin - prefers the gemini engine (stdin, pipe-safe) when credentials work and falls - back to agy transcript recovery otherwise. + retired upstream and will not be restored (owner-confirmed 2026-07-14; + observed `API_KEY_INVALID`). With the gemini engine effectively unavailable, + the plugin's agy path (transcript recovery) is the practical route; the gemini + path remains for environments where a working key exists.