@estebanforge/pi-antigravity-bridge 1.7.8 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.10] - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - **`readOnly` config key (fail-closed provider kill switch).** `AGY_READONLY` env and `/agy readonly on|off` set it. On: every provider turn is refused with a visible error instead of running, because the agy CLI has no review-only enforcement on any path (probed 2026-10-07, [google-antigravity/antigravity-cli#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181): plan mode stages a plan and executes the write in the same turn on both the `-p` and stream-json paths, with `permission_mode: "request-review"` reported and no permission event for the write; with `--disable-slash-commands` plan is a warned no-op). Built for tool-less callers (`pi -p --no-tools --model antigravity/...`) that must never mutate a workspace. While the knob is on, the `AskAntigravity` tool is refused as well; nothing in this package runs read-only, because the CLI cannot enforce it.
10
+ - **`AskAntigravity` mode default now follows the config.** Omitting `mode` used to hardcode `accept-edits`, so a `mode: "plan"` config still left the delegation tool write-capable by default (issue #3 request 3). The default is now plan when the configured mode is plan; an explicit `mode` param still wins.
11
+
12
+ ### Fixed
13
+
14
+ - **Plan-shaped provider turns refuse visibly instead of running write-capable.** `/agy mode plan` on the stream-json engine was silently a no-op (the engine warns plan has no effect with slash expansion disabled), and the `-p` path is no better: probed 2026-10-07, plan stages a plan, asks for approval, and executes the write in the same turn on both paths ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)). Provider turns with `mode: "plan"` or `readOnly` on now fail with an error pointing at host-side enforcement and the knob to lift. The README no longer claims provider plan mode "genuinely executes nothing" or that the one-shot path enforces plan.
15
+ - **`readOnly` refusals cover the `AskAntigravity` tool too, and plan runs never carry the skip flag.** `AskAntigravity` is refused while `readOnly` is on (a `mode` param cannot bypass the kill switch), and the tool's plan runs no longer pass `--dangerously-skip-permissions` even when the reviewer agent stages: the CLI does not gate writes under plan mode ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)), so an auto-approved plan run is a write-capable run. Analysis commands in plan runs now fail visibly (headless auto-deny) instead of running approved; `accept-edits` runs keep the flag.
16
+ - **Delegation tool refuses `background: true` (fail-closed).** The param is advertised and always refused with an explanation: a wake from a nested delegation would land in the pi session, and long delegations already have the bridge's own early-ack plus poll flow. No behavior change for existing calls.
17
+
18
+
19
+ ## [1.7.9] - 2026-10-06
20
+
21
+ ### Changed
22
+
23
+ - **Breaking: Claude models are effort-tiered now, and the ids moved to the Claude 5.5 family.** Google retired Claude 4.6 on Antigravity and ships Claude Opus 5.5 (2026-09-22) and Claude Sonnet 5.5 (2026-09-28) in its place, each with low/medium/high thinking tiers - the same shape Gemini already had. Claude joins the verified effort-capable families (probed live 2026-10: base slug + `--effort` accepted; a bare base is rejected with "requires --effort"), so `claude-opus-5-5` and `claude-sonnet-5-5` register as single toggleable models whose turns always send `--effort`, instead of per-tier fixed ids. The catalog fixtures mirror the live listing and the no-`agy models` fallback carries its head (3.8 flash, 3.1 pro, sonnet 5.5). A pattern pinned to `antigravity/claude-sonnet-4-6` (settings enabledModels, `--model`, or a scoped-model config) matches nothing after agy's upgrade and must move to `antigravity/claude-sonnet-5-5`; the bare base id itself resolves through the delegation tool to the nearest tier variant.
24
+ - **The `gpt-oss-120b-medium` family is dropped now, not next month.** Upstream announced its removal; nobody used the fixed-thinking single slug and it loses to the cheaper effort-tiered options, so the provider catalog and the delegation tool catalog both filter it before that date lands. The `gpt-oss` alias stops resolving: unrecognized input passes raw to agy and fails loudly there, instead of the tool offering a dying model.
25
+
5
26
  ## [1.7.8] - 2026-09-30
6
27
 
7
28
  ### Fixed
package/README.md CHANGED
@@ -135,7 +135,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
135
135
  /agy status summary, or open the full settings picker (TUI)
136
136
  /agy doctor single status surface: bridge state, driver counters, versions, acp state, lifecycle, settings, log dir
137
137
  /agy auth run the antigravity-acp sign-in now (engine acp): opens the Google login in your browser, shows the URL when no browser opens
138
- /agy mode plan review-only: agy plans but writes nothing
138
+ /agy mode plan provider turns are REFUSED (the CLI cannot enforce review-only, #1181)
139
139
  /agy mode accept-edits agy applies edits directly (default)
140
140
  /agy permissions on|off auto-approve / prompt for tool calls (see warning)
141
141
  /agy ask on|off register the AskAntigravity delegation tool (default on; off keeps only the provider and models, even with pi-ask-antigravity installed)
@@ -158,7 +158,13 @@ pi itself has no built-in approval gate. Unlike codex, claude, or agy running in
158
158
 
159
159
  Because agy runs non-interactively under this provider (nothing can answer a `y/n` prompt), this extension passes `--dangerously-skip-permissions` by default on `accept-edits` runs. It is technically necessary there: `accept-edits` auto-approves file edits but not shell commands, so a `run_command` would otherwise hang forever waiting for a prompt nothing can answer (upstream [google-antigravity/antigravity-cli#318](https://github.com/google-antigravity/antigravity-cli/issues/318)). The net effect is that agy executes commands the same way pi already executes your other tools: without per-action review.
160
160
 
161
- Plan mode never receives the flag: the flag auto-approves every permission request, including plan mode's own approval gate, which would silently turn review-only into full write access inside the `--add-dir` grant (probed 2026-09-25: with the flag a plan run wrote files; without it, file and command attempts end the run quickly with a "confirm plan" message). So `/agy mode plan` (and `AskAntigravity` with `mode: "plan"`) genuinely executes nothing. Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
161
+ Plan mode never receives the flag: the flag auto-approves every permission request, so an auto-approved plan run is a write-capable run (the CLI does not gate writes under plan mode, see next paragraph). Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
162
+
163
+ **Provider turns cannot run review-only.** Probed 2026-10-07 ([google-antigravity/antigravity-cli#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)): on every tested path (one-shot `-p` and the stream-json I/O format, with and without `--add-dir`), `--mode plan` stages a plan, asks for review and approval, and executes the requested file write in the same turn (`permission_mode: "request-review"` is reported; no permission event appears for the write). With `--disable-slash-commands` the CLI additionally warns that `--mode plan` has no effect. The `readOnly` knob below exists because of this: plan-shaped provider turns refuse visibly instead of pretending. `AskAntigravity` with `mode: "plan"` adds a staged restricted agent whose toolset omits file-editing tools (probed 2026-09-28 on agy 1.2.12; re-verify on 1.3.x), but plan mode itself does not gate writes anywhere. Treat host-side pi tool restrictions as the only hard read-only wall.
164
+
165
+ ### readOnly
166
+
167
+ `"readOnly": true` in the config (or `AGY_READONLY=1`, or `/agy readonly on`) is a fail-closed kill switch: every provider turn AND the `AskAntigravity` tool are refused with a visible error instead of running write-capable under a read-only label. Built for callers like `pi -p --no-tools --model antigravity/...` health checks that must never mutate a workspace: with no review-only enforcement available in the CLI (see Permissions), a visible refusal is the only honest behavior. The knob covers provider turns and the ask tool; the web tools still spawn agy for searches. Default false.
162
168
 
163
169
  For per-action review of agy's mutating native tools (`run_command`, `create_file`, `edit_file`, ...), see the [Approval gate](docs/APPROVAL-GATE.md): with it on, the call must pass a pi-side approval (your permission extension, or the built-in ask/allow/deny fallback) before agy executes it.
164
170
 
@@ -175,7 +181,8 @@ For isolation when running any agent that executes commands without a confirmati
175
181
  | `AGY_ACP_BIN` | Path to the ACP server binary (`agy_acp_server.par`). Defaults to `agy_acp_server.par` on PATH. Wins over `config.acp.bin`. When neither points at a binary, auto-setup installs one. |
176
182
  | `AGY_EXTRA_ARGS` | Extra args appended to every invocation. Whitespace-split. |
177
183
  | `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
178
- | `AGY_MODE` | Override execution mode: `plan` (review-only) or `accept-edits` (default). Wins over the config file. |
184
+ | `AGY_MODE` | Override execution mode: `plan` (provider turns are refused, see Permissions) or `accept-edits` (default). Wins over the config file. |
185
+ | `AGY_READONLY` | `1`/`true`/`on` (or any set value except `0`/`false`/`off`/empty) turns every provider turn and the `AskAntigravity` tool into a visible refusal (fail-closed: the CLI has no review-only enforcement on any path, [#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)). Wins over the config file. |
179
186
  | `AGY_SKIP_PERMISSIONS` | `1`/`true` (default) to pass `--dangerously-skip-permissions` so commands don't hang on an unanswerable prompt in `-p` mode. `0`/`false` to prompt (hangs any `run_command` non-interactively). Wins over the config file. |
180
187
  | `AGY_USAGE_ESTIMATE` | ACP token display: `estimate` (default; live client-side per-delta estimates), `direct` (pass through the server's own per-delta counts when it sends them), `off` (no usage display). Anything else falls back to `estimate`. Wins over the config file. |
181
188
  | `AGY_DEFAULT_MODEL` | Default model alias for the `AskAntigravity` tool (`flash`/`pro`/`gemini`, or a tier/version qualifier). Wins over the config file. |
@@ -219,6 +226,10 @@ pi-antigravity-bridge practical risk is low, near zero. But not zero: the "in co
219
226
 
220
227
  This is engineering analysis, not legal advice. Use against your own Antigravity account at your own risk; I am not responsible for any consequence to your account.
221
228
 
229
+ ## Delegation tool: background is refused
230
+
231
+ The `AskAntigravity` delegation tool rejects `background: true` by design: a background wake would land in the pi session even when the caller is a nested delegation through the bridge catalog, and long delegations already have the bridge's own async flow (early-ack plus `bridge_poll_result`). Call without `background`; for unattended peer runs launch the CLI directly in a terminal.
232
+
222
233
  ## License
223
234
 
224
235
  MIT.
@@ -1053,6 +1053,7 @@ interface AgyCommandCtx {
1053
1053
 
1054
1054
  interface PendingConfig {
1055
1055
  mode?: AgyMode;
1056
+ readOnly?: boolean;
1056
1057
  skipPermissions?: boolean;
1057
1058
  defaultModel?: string;
1058
1059
  defaultThinking?: ThinkingTier;
@@ -1074,6 +1075,10 @@ function settingsRows(ctx: AgyCommandCtx): string[] {
1074
1075
  const row = (label: string, value: string) => ` ${label.padEnd(24)} ${value}`;
1075
1076
  return [
1076
1077
  row("mode:", config.mode),
1078
+ row(
1079
+ "readOnly:",
1080
+ config.readOnly ? "on (provider turns are refused)" : "off",
1081
+ ),
1077
1082
  row("permissions:", perm),
1078
1083
  row("AskAntigravity tool:", config.askTool ? "on" : "off"),
1079
1084
  row("Web tools:", config.webTools ? "on" : "off"),
@@ -1096,7 +1101,7 @@ function statusText(ctx: AgyCommandCtx): string {
1096
1101
  ` sessions: ${ctx.store.size} bound`,
1097
1102
  ` config: ${CONFIG_PATH}`,
1098
1103
  "",
1099
- "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy agent <name|off>, /agy subagents, /agy quota, /agy artifacts [open <n|name>], /agy tasks [tail <id>], /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy timeout <1-1440|off>, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear, /agy doctor",
1104
+ "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy readonly on|off, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy agent <name|off>, /agy subagents, /agy quota, /agy artifacts [open <n|name>], /agy tasks [tail <id>], /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy timeout <1-1440|off>, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear, /agy doctor",
1100
1105
  ].join("\n");
1101
1106
  }
1102
1107
 
@@ -1104,7 +1109,7 @@ function statusText(ctx: AgyCommandCtx): string {
1104
1109
  function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1105
1110
  pi.registerCommand("agy", {
1106
1111
  description:
1107
- "Antigravity provider: doctor (health + settings), settings picker, clear sessions. Usage: /agy [doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|agent <name|off>|subagents|quota|artifacts [open <n|name>]|tasks [tail <id>]|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1112
+ "Antigravity provider: doctor (health + settings), settings picker, clear sessions. Usage: /agy [doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|readonly on|off|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|agent <name|off>|subagents|quota|artifacts [open <n|name>]|tasks [tail <id>]|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1108
1113
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
1109
1114
  const ui = cmdCtx.ui;
1110
1115
  if (ui) activeUi = ui;
@@ -1387,13 +1392,52 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1387
1392
  if (sub === "mode") {
1388
1393
  if (val === "plan" || val === "accept-edits") {
1389
1394
  if (val === "plan" && ctx.engine === "acp") {
1390
- ui?.notify("the ACP engine has no plan mode (RC01). /agy engine stream-json first, or /agy mode accept-edits.", "warning");
1395
+ ui?.notify("plan cannot be enforced on provider turns on either engine: the ACP engine has no plan mode (RC01), and the CLI does not gate writes under plan (google-antigravity/antigravity-cli#1181). /agy mode accept-edits.", "warning");
1391
1396
  return;
1392
1397
  }
1393
1398
  const next = saveConfig({ mode: val as AgyMode });
1394
- ui?.notify(`mode set to ${next.mode}`, "info");
1399
+ // readOnly fail-closes provider turns regardless of this knob;
1400
+ // never let the confirmation imply turns will run. loadConfig, not
1401
+ // next: saveConfig merges the raw file, so an env-only readOnly
1402
+ // would otherwise read as off here.
1403
+ const effectiveReadonly = loadConfig().readOnly;
1404
+ if (effectiveReadonly) {
1405
+ ui?.notify(`mode set to ${next.mode}, but readOnly is on: provider turns are refused. /agy readonly off to lift.`, "info");
1406
+ } else if (val === "plan") {
1407
+ ui?.notify(`mode set to plan: provider turns are REFUSED (the CLI does not gate writes under plan, google-antigravity/antigravity-cli#1181). Only the AskAntigravity tool uses this mode.`, "info");
1408
+ } else {
1409
+ ui?.notify(`mode set to ${next.mode}`, "info");
1410
+ }
1411
+ } else {
1412
+ const cfg = loadConfig();
1413
+ ui?.notify(
1414
+ `current mode: ${cfg.mode}${cfg.readOnly ? " (readOnly on: provider turns are refused)" : ""}\nusage: /agy mode plan|accept-edits`,
1415
+ "info",
1416
+ );
1417
+ }
1418
+ return;
1419
+ }
1420
+ if (sub === "readonly") {
1421
+ if (val === "on" || val === "off") {
1422
+ const next = saveConfig({ readOnly: val === "on" });
1423
+ ui?.notify(
1424
+ next.readOnly
1425
+ ? "readOnly is on: provider turns and AskAntigravity are REFUSED (the agy CLI has no review-only enforcement on any path; probed 2026-10-07). Enforce read-only in the host, then /agy readonly off."
1426
+ : "readOnly off: provider turns follow /agy mode again.",
1427
+ "info",
1428
+ );
1429
+ // Compare effective vs saved: the env can force either direction
1430
+ // (AGY_READONLY=1 keeps refusals on after /agy readonly off;
1431
+ // AGY_READONLY=0 silently keeps readOnly off after /agy readonly on).
1432
+ if (loadConfig().readOnly !== next.readOnly) {
1433
+ ui?.notify(`AGY_READONLY is set in this environment and overrides the config: readOnly stays ${loadConfig().readOnly ? "on (refusing)" : "off"} until you unset it.`, "warning");
1434
+ }
1395
1435
  } else {
1396
- ui?.notify(`current mode: ${loadConfig().mode}\nusage: /agy mode plan|accept-edits`, "info");
1436
+ const cfg = loadConfig();
1437
+ ui?.notify(
1438
+ `readOnly is ${cfg.readOnly ? "on (provider turns and AskAntigravity are refused)" : "off"}\nusage: /agy readonly on|off. Fail-closed: the CLI has no review-only enforcement on any path (probed 2026-10-07), so on = refuse.`,
1439
+ "info",
1440
+ );
1397
1441
  }
1398
1442
  return;
1399
1443
  }
@@ -1755,10 +1799,18 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1755
1799
  id: "mode",
1756
1800
  label: "Execution mode",
1757
1801
  description:
1758
- "accept-edits: agy applies edits directly. plan: review-only, no writes. Takes effect next turn.",
1802
+ "accept-edits: agy applies edits directly. plan: provider turns are REFUSED (the CLI cannot enforce review-only, upstream #1181); AskAntigravity plan runs add a restricted agent as a damper. Takes effect next turn.",
1759
1803
  currentValue: config.mode,
1760
1804
  values: ["accept-edits", "plan"],
1761
1805
  },
1806
+ {
1807
+ id: "readonly",
1808
+ label: "Read-only provider turns",
1809
+ description:
1810
+ "on: provider turns are REFUSED (fail-closed; the CLI has no review-only enforcement on any path). Enforce read-only in the host instead.",
1811
+ currentValue: config.readOnly ? "on" : "off",
1812
+ values: ["off", "on"],
1813
+ },
1762
1814
  {
1763
1815
  id: "permissions",
1764
1816
  label: "Permissions",
@@ -1845,6 +1897,8 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1845
1897
  (id, newValue) => {
1846
1898
  if (id === "mode") {
1847
1899
  pending.mode = newValue as AgyMode;
1900
+ } else if (id === "readonly") {
1901
+ pending.readOnly = newValue === "on";
1848
1902
  } else if (id === "permissions") {
1849
1903
  pending.skipPermissions = newValue === "auto-approved";
1850
1904
  } else if (id === "model") {
@@ -1882,12 +1936,13 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1882
1936
  if (Object.keys(pending).length === 0) return;
1883
1937
 
1884
1938
  // The picker cannot switch engines (command-only: /agy engine), but the
1885
- // mode row can still produce plan while the latched engine is acp. ACP
1886
- // has no review-only mode (RC01); refuse the combination.
1939
+ // mode row can still produce plan while the latched engine is acp. Plan
1940
+ // cannot be enforced on provider turns on either engine (#1181 + RC01);
1941
+ // refuse the combination.
1887
1942
  const nextMode = pending.mode ?? config.mode;
1888
1943
  if (nextMode === "plan" && ctx.engine === "acp") {
1889
1944
  ui.notify(
1890
- "plan + acp is not supported (RC01): the ACP engine has no review-only mode. /agy engine stream-json first, or /agy mode accept-edits.",
1945
+ "plan cannot be enforced on provider turns on either engine (google-antigravity/antigravity-cli#1181; ACP additionally has no plan mode, RC01). /agy mode accept-edits.",
1891
1946
  "warning",
1892
1947
  );
1893
1948
  return;
@@ -1897,6 +1952,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1897
1952
  const next = saveConfig(pending);
1898
1953
  const changed = [
1899
1954
  pending.mode ? `mode=${next.mode}` : null,
1955
+ pending.readOnly !== undefined ? `readOnly=${next.readOnly ? "on" : "off"}` : null,
1900
1956
  pending.skipPermissions !== undefined
1901
1957
  ? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
1902
1958
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -46,7 +46,7 @@
46
46
  ]
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-coding-agent": "*"
49
+ "@earendil-works/pi-coding-agent": "^1.0.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@earendil-works/pi-coding-agent": {
@@ -25,7 +25,7 @@
25
25
  import type {
26
26
  AgentToolResult,
27
27
  AgentToolUpdateCallback,
28
- ExtensionContext,
28
+ ExtensionToolContext,
29
29
  ToolDefinition,
30
30
  } from "@earendil-works/pi-coding-agent";
31
31
 
@@ -151,7 +151,7 @@ export function createShadowTool(base: AnyToolDefinition, policy: GatePolicy, op
151
151
  params: any,
152
152
  signal: AbortSignal | undefined,
153
153
  onUpdate: AgentToolUpdateCallback<any> | undefined,
154
- ctx: ExtensionContext,
154
+ ctx: ExtensionToolContext,
155
155
  ): Promise<AgentToolResult<unknown>> => {
156
156
  const p = (params ?? {}) as Record<string, unknown>;
157
157
  if (p[GATE_MARKER] !== true) {
package/src/ask-tool.ts CHANGED
@@ -56,16 +56,19 @@ const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
56
56
  // Static alias overlay for non-Gemini models agy may or may not surface.
57
57
  // Live catalog entries win on case-insensitive full-string equality; the
58
58
  // overlay resolves the alias when agy doesn't list it. Names are agy's stable
59
- // slugs (the same ids `agy models` prints and `--model` accepts).
59
+ // slugs (the same ids `agy models` prints and `--model` accepts). Claude ships
60
+ // tiered like Gemini now, so the overlay carries the full tier spread.
60
61
  const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
61
- { full: "claude-sonnet-4-6", family: "other", version: null, tier: null },
62
- { full: "claude-opus-4-6-thinking", family: "other", version: null, tier: null },
63
- { full: "gpt-oss-120b-medium", family: "other", version: null, tier: null },
62
+ { full: "claude-sonnet-5-5-low", family: "other", version: null, tier: "low" },
63
+ { full: "claude-sonnet-5-5-medium", family: "other", version: null, tier: "medium" },
64
+ { full: "claude-sonnet-5-5-high", family: "other", version: null, tier: "high" },
65
+ { full: "claude-opus-5-5-low", family: "other", version: null, tier: "low" },
66
+ { full: "claude-opus-5-5-medium", family: "other", version: null, tier: "medium" },
67
+ { full: "claude-opus-5-5-high", family: "other", version: null, tier: "high" },
64
68
  ];
65
69
  const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
66
- ["sonnet", "claude-sonnet-4-6"],
67
- ["opus", "claude-opus-4-6-thinking"],
68
- ["gpt-oss", "gpt-oss-120b-medium"],
70
+ ["sonnet", "claude-sonnet-5-5"],
71
+ ["opus", "claude-opus-5-5"],
69
72
  ]);
70
73
 
71
74
  // agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
@@ -80,8 +83,8 @@ TWO MODES (you choose):
80
83
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
81
84
 
82
85
  EXECUTION MODES (param: mode):
83
- - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs execute in a temporary restricted agent whose toolset has NO file-editing tools (hard block); the reviewer may still execute arbitrary shell commands, kept read-only by the prompt guard alone. Inline the material to review - a plan run cannot fetch it.
84
- - **accept-edits** (default): agy applies edits directly inside the workspace.
86
+ - **plan**: agy proposes a plan for review-shaped tasks. NOT a security boundary: the CLI does not gate writes under plan mode (upstream google-antigravity/antigravity-cli#1181, probed 2026-10-07: the write can execute in the same turn). The run additionally stages a temporary restricted agent whose toolset has no file-editing tools, as a damper, and the skip-permissions flag is never passed. Inline the material to review - a plan run cannot fetch it.
87
+ - **accept-edits**: agy applies edits directly inside the workspace. Default unless the configured mode is plan.
85
88
 
86
89
  COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
87
90
 
@@ -164,19 +167,47 @@ function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): Thinki
164
167
  return sorted[0] ?? preferred;
165
168
  }
166
169
 
167
- /** Build the argv-facing resolution from a picked catalog entry. Gemini bases
168
- * (slugs starting "gemini-") accept a separate --effort, so split the tier
169
- * suffix out of the slug: the base alone (gemini-3.6-flash) is what --model
170
- * wants, and the tier goes to --effort. Fixed-thinking families keep agy's
171
- * exact slug even when it carries a -medium suffix (gpt-oss-120b-medium):
172
- * agy rejects --effort for them, so the suffix stays part of the slug. */
170
+ /** Build the argv-facing resolution from a picked catalog entry. Gemini and
171
+ * Claude bases (slugs starting "gemini-"/"claude-") accept a separate
172
+ * --effort, so split the tier suffix out of the slug: the base alone
173
+ * (claude-sonnet-5-5) is what --model wants, and the tier goes to --effort
174
+ * (agy rejects a bare base: "requires --effort"). Only unverified families
175
+ * keep agy's exact slug whole, so an unknown suffix can never trigger an
176
+ * unsupported --effort. */
173
177
  function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
174
- if (tier && full.toLowerCase().startsWith("gemini-")) {
178
+ if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
175
179
  return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
176
180
  }
177
181
  return { model: full };
178
182
  }
179
183
 
184
+ /** Resolve a tiered base id - a short-alias target ("claude-sonnet-5-5") or a
185
+ * bare base slug the provider itself advertises - to the catalog variant
186
+ * nearest the requested tier. A bare base alone is invalid upstream
187
+ * ("requires --effort"), so the pick always rides the split in toResolved;
188
+ * with no variants in the catalog, the overlay/exact entry or the raw base
189
+ * passes through unchanged. */
190
+ function resolveTieredBase(
191
+ base: string,
192
+ entries: ModelEntry[],
193
+ defaultThinking: ThinkingTier,
194
+ preferredTier: ThinkingTier | undefined,
195
+ ): ResolvedModel {
196
+ const needle = base.toLowerCase();
197
+ const variants = entries.filter((e) => e.full.toLowerCase().startsWith(`${needle}-`));
198
+ if (variants.length > 0) {
199
+ const tiers = variants.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null);
200
+ const preferred =
201
+ preferredTier ??
202
+ (tiers.includes(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER.other);
203
+ const chosen = nearestTier(tiers, preferred);
204
+ const picked = variants.find((e) => e.tier === chosen) ?? variants[0];
205
+ return toResolved(picked.full, picked.tier);
206
+ }
207
+ const fromCatalog = entries.find((e) => e.full.toLowerCase() === needle);
208
+ return toResolved(fromCatalog?.full ?? base, fromCatalog?.tier ?? null);
209
+ }
210
+
180
211
  /** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
181
212
  * Returns null only when the family is unrecognized; the caller then passes
182
213
  * the raw input straight to agy. */
@@ -195,9 +226,12 @@ export function resolveModel(
195
226
  if (exact) return toResolved(exact.full, exact.tier);
196
227
 
197
228
  if (STATIC_SHORT_ALIAS.has(lower)) {
198
- const target = STATIC_SHORT_ALIAS.get(lower) as string;
199
- const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
200
- return toResolved(fromCatalog?.full ?? target, fromCatalog?.tier ?? null);
229
+ return resolveTieredBase(
230
+ STATIC_SHORT_ALIAS.get(lower) as string,
231
+ entries,
232
+ defaultThinking,
233
+ preferredTier,
234
+ );
201
235
  }
202
236
 
203
237
  let family: Family | null = lower.includes("flash")
@@ -213,7 +247,19 @@ export function resolveModel(
213
247
  if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
214
248
  family = "flash";
215
249
  }
216
- if (!family) return null;
250
+ if (!family) {
251
+ // A bare base slug the provider itself advertises ("claude-sonnet-5-5",
252
+ // the id behind the antigravity/ entry in pi's picker) is invalid
253
+ // upstream without an effort: resolve it to the nearest tier variant
254
+ // exactly like the short aliases.
255
+ if (
256
+ entries.some((e) => e.full.toLowerCase().startsWith(`${lower}-`)) ||
257
+ entries.some((e) => e.full.toLowerCase() === lower)
258
+ ) {
259
+ return resolveTieredBase(lower, entries, defaultThinking, preferredTier);
260
+ }
261
+ return null;
262
+ }
217
263
 
218
264
  let candidates = entries.filter((e) => e.family === family);
219
265
  if (candidates.length === 0) return null;
@@ -252,11 +298,18 @@ export function resolveModel(
252
298
  return toResolved(picked.full, picked.tier);
253
299
  }
254
300
 
301
+ /** Families dropped outright even while `agy models` still lists them (same
302
+ * policy as models.ts HIDDEN_FAMILY_RE): never offered, never resolved. */
303
+ const HIDDEN_FAMILY_RE = /^gpt-oss-/;
304
+
255
305
  /** Parse raw `agy models` text into tool-catalog entries (all families, plus
256
- * the static sonnet/opus/gpt-oss overlay). Pure: no spawn. */
306
+ * the static sonnet/opus overlay). Pure: no spawn. */
257
307
  export function toolModelsFromRaw(raw: string): ModelEntry[] {
258
308
  return mergeCatalog(
259
- raw.split("\n").map(parseModelLine).filter((e): e is ModelEntry => e !== null),
309
+ raw
310
+ .split("\n")
311
+ .map(parseModelLine)
312
+ .filter((e): e is ModelEntry => e !== null && !HIDDEN_FAMILY_RE.test(e.full)),
260
313
  );
261
314
  }
262
315
 
@@ -275,15 +328,17 @@ function extraArgs(): string[] {
275
328
  // --- Prompt assembly -------------------------------------------------------
276
329
 
277
330
  /** Guard for plan runs backed by the restricted reviewer agent. Difference
278
- * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
279
- * agent's commandExecutionPolicy auto-approves them headless, probed
280
- * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
281
- * and keeps the answer-from-material discipline. */
331
+ * to PLAN_HEADLESS_GUARD: read commands are the intended analysis path
332
+ * here, so the guard forbids FILE MUTATION - by tool or by shell - and
333
+ * keeps the answer-from-material discipline. The agent is a damper, not
334
+ * enforcement (upstream #1181); a headless command attempt can still be
335
+ * auto-denied, which ends the run, so the guard steers toward view_file
336
+ * and staged material first. */
282
337
  export const AGENT_REVIEW_GUARD = [
283
338
  "",
284
339
  "--- Review constraints ---",
285
340
  "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
286
- "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
341
+ "- Prefer the staged material and view_file for reading. If you run a read-only command (git log, git diff, rg, ls) and it comes back denied, the run ends without an answer: state what you could not check instead of retrying commands.",
287
342
  "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
288
343
  ].join("\n");
289
344
 
@@ -312,11 +367,12 @@ export function buildFinalPrompt(
312
367
  mode: AgyMode,
313
368
  digest: boolean,
314
369
  /** True when the restricted reviewer agent is staged: its empty edit
315
- * toolset is the hard block, so the prompt may ALLOW read-only commands. */
316
- agentEnforced = false,
370
+ * toolset is a damper (the CLI does not enforce review-only, upstream
371
+ * #1181), so the prompt may ALLOW read-only commands. */
372
+ agentDamper = false,
317
373
  ): string {
318
374
  let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
319
- if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
375
+ if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
320
376
  return out;
321
377
  }
322
378
 
@@ -331,12 +387,14 @@ export function askAgentsRoot(): string {
331
387
  const ASK_AGENT_PREFIX = "pi-bridge-ask-";
332
388
  const ASK_AGENT_TOOLS = ["view_file", "run_command"];
333
389
 
334
- /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
335
- * with no file-editing tool present the model cannot emit an edit call at
336
- * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
337
- * edits, with and without the skip flag). commandExecutionPolicy auto is
338
- * what lets read commands run headless with no user allow rules. Never add
339
- * a write-capable tool here. */
390
+ /** agent.md for the plan-mode reviewer. The tools list is a damper, not a
391
+ * guarantee: with no file-editing tool present the model has no edit tool in
392
+ * its menu (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
393
+ * edits; re-verify on 1.3.x), but the CLI itself does not enforce
394
+ * review-only under plan mode (upstream google-antigravity/antigravity-cli
395
+ * #1181, probed 2026-10-07: the write can execute in the same turn).
396
+ * commandExecutionPolicy auto is what lets read commands run headless with
397
+ * no user allow rules. Never add a write-capable tool here. */
340
398
  export function reviewerAgentMd(name: string): string {
341
399
  return [
342
400
  "---",
@@ -431,8 +489,7 @@ export async function registerAskAntigravityTool(
431
489
  mode: Type.Optional(
432
490
  Type.Union([Type.Literal("plan"), Type.Literal("accept-edits")], {
433
491
  description:
434
- "agy execution mode. 'plan' = review-only. 'accept-edits' = agy applies edits (default).",
435
- default: "accept-edits",
492
+ "agy execution mode. 'plan' = plan-shaped run: the CLI does not enforce review-only (see the tool description); the staged reviewer agent's toolset omits file-editing tools as a damper. 'accept-edits' = agy applies edits. Omit to follow the configured default (plan when config mode is plan, else accept-edits).",
436
493
  }),
437
494
  ),
438
495
  digest: Type.Optional(
@@ -456,6 +513,12 @@ export async function registerAskAntigravityTool(
456
513
  "When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
457
514
  }),
458
515
  ),
516
+ background: Type.Optional(
517
+ Type.Boolean({
518
+ description:
519
+ "NOT supported by this tool. The bridge runs agy through its own delegated flow and the answer must return synchronously to the caller. For background runs use the standalone @estebanforge/pi-ask-antigravity extension. Calls with background=true are refused.",
520
+ }),
521
+ ),
459
522
  }),
460
523
  renderCall(args, theme, _context) {
461
524
  // Show RESOLVED model/thinking/mode (config defaults applied) so the
@@ -471,7 +534,10 @@ export async function registerAskAntigravityTool(
471
534
  thinkingArg ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER) : undefined,
472
535
  ) ?? { model: requestedModel };
473
536
  const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
474
- const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
537
+ // Same default rule as execute: plan when the configured mode is
538
+ // plan, so the row always names what will actually run.
539
+ const mode: AgyMode =
540
+ (args.mode as AgyMode | undefined) ?? (cfg.mode === "plan" ? "plan" : "accept-edits");
475
541
  const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
476
542
  const isContinue =
477
543
  typeof args.conversationId === "string" && CONV_ID_RE.test(args.conversationId);
@@ -545,6 +611,37 @@ export async function registerAskAntigravityTool(
545
611
  }
546
612
 
547
613
  const config = loadConfig();
614
+ // readOnly refusal (fail-closed): the kill switch covers this tool too.
615
+ // It must not be bypassable with an explicit mode param: the CLI has no
616
+ // review-only enforcement (upstream google-antigravity/antigravity-cli
617
+ // #1181), so a plan-labeled run here is not read-only.
618
+ if (config.readOnly) {
619
+ return {
620
+ content: [
621
+ {
622
+ type: "text",
623
+ text: 'readOnly is on: AskAntigravity is refused (fail-closed; the agy CLI has no review-only enforcement, google-antigravity/antigravity-cli#1181). Run /agy readonly off to use this tool.',
624
+ },
625
+ ],
626
+ details: emptyDetails(),
627
+ };
628
+ }
629
+ // Background refusal (fail-closed): a wake from this tool would land in
630
+ // the pi session even when the CALLER is agy (nested delegation via the
631
+ // bridge catalog), and the bridge already owns an async delegation flow
632
+ // (early-ack + bridge_poll_result). The standalone pi-ask-antigravity
633
+ // extension is the background-capable tool.
634
+ if (params.background) {
635
+ return {
636
+ content: [
637
+ {
638
+ type: "text",
639
+ text: 'background is not supported by the bridge\'s AskAntigravity: the answer must return synchronously to the caller. Long delegations are already covered by the bridge\'s own async flow (early-ack + bridge_poll_result); for an unattended peer run, launch agy directly in a terminal instead. Call again without background.',
640
+ },
641
+ ],
642
+ details: emptyDetails(),
643
+ };
644
+ }
548
645
  const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
549
646
  if (typeof params.model === "string" && params.model.trim().startsWith("-")) {
550
647
  return {
@@ -605,10 +702,13 @@ export async function registerAskAntigravityTool(
605
702
  typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
606
703
  const snapshot = isContinuation ? null : snapshotConversations();
607
704
 
608
- const mode: AgyMode = (params.mode as AgyMode | undefined) ?? "accept-edits";
705
+ const mode: AgyMode =
706
+ (params.mode as AgyMode | undefined) ?? (config.mode === "plan" ? "plan" : "accept-edits");
609
707
  // Plan runs stage a restricted reviewer agent: its tools list has NO
610
- // file-editing tool, which is the hard edit block (the prompt guard
611
- // alone was observed failing once - a sub-agent still edited files).
708
+ // file-editing tool, a toolset-level damper on edits (the prompt
709
+ // guard alone was observed failing once - a sub-agent still edited
710
+ // files; the CLI itself does not enforce review-only, upstream
711
+ // #1181).
612
712
  // Staging failure degrades to the legacy fallback: no agent, no skip
613
713
  // flag, the stricter command-forbidding guard.
614
714
  let reviewerAgent: { name: string; dir: string } | null = null;
@@ -658,12 +758,14 @@ export async function registerAskAntigravityTool(
658
758
  "info",
659
759
  );
660
760
  const extraRaw = extraArgs();
661
- // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
662
- // mode flags, so an env-injected skip flag would re-arm what the
663
- // plan-mode rule strips. Only the enforced agent makes the flag safe.
761
+ // Fail-closed on every plan run: AGY_EXTRA_ARGS lands before the mode
762
+ // flags, so an env-injected skip flag would re-arm exactly what the
763
+ // never-flag-on-plan rule withholds (upstream #1181: an auto-approved
764
+ // plan run is write-capable). The reviewer agent is a damper, not a
765
+ // license for the flag.
664
766
  const extra =
665
- mode === "plan" && !reviewerAgent
666
- ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
767
+ mode === "plan"
768
+ ? extraRaw.filter((a) => !a.startsWith("--dangerously-skip-permissions"))
667
769
  : extraRaw;
668
770
  if (extra.length) args.push(...extra);
669
771
  if (resolved.model) args.push("--model", resolved.model);
@@ -672,16 +774,16 @@ export async function registerAskAntigravityTool(
672
774
  if (reviewerAgent) args.push("--agent", reviewerAgent.name);
673
775
  // Honor the shared permissions setting (same knob as the provider).
674
776
  // accept-edits: the flag keeps commands from hanging on an
675
- // unanswerable prompt in -p mode. Plan runs WITH the reviewer agent:
676
- // the flag is safe again because the agent's toolset has no
677
- // file-editing tool - the 2026-09-25 write incident rode edit tools
678
- // that existed then; probed 2026-09-28 the restricted toolset reports
679
- // "none" for edits even with the flag, and plan discipline turned a
680
- // create-a-file request into a proposed plan instead of a write. The
681
- // flag is what lets analysis commands (builds, test runners) run for
682
- // users without allow rules. Plan fallback (staging failed): strict
683
- // legacy behavior, no flag - the prompt guard is all that is left.
684
- if (config.skipPermissions !== false && (mode !== "plan" || reviewerAgent !== null)) {
777
+ // unanswerable prompt in -p mode. Plan runs NEVER get the flag: it
778
+ // auto-approves every permission request, and the CLI does not gate
779
+ // writes under plan mode (upstream
780
+ // google-antigravity/antigravity-cli#1181, probed 2026-10-07), so an
781
+ // auto-approved plan run is a write-capable run. The reviewer agent's
782
+ // toolset is a damper, not a guarantee; the 2026-09-28 "safe with the
783
+ // restricted toolset" probe predates the #1181 evidence. Without the
784
+ // flag, command attempts fail visibly (headless auto-deny) instead of
785
+ // running approved.
786
+ if (config.skipPermissions !== false && mode !== "plan") {
685
787
  args.push("--dangerously-skip-permissions");
686
788
  }
687
789
  if (isContinuation) args.push("--conversation", rawConvId as string);
package/src/config.ts CHANGED
@@ -89,11 +89,24 @@ export interface AgyConfig {
89
89
  /** Official-server ACP engine options (used when engine = "acp"). */
90
90
  acp: AcpConfig;
91
91
  mode: AgyMode;
92
+ /** Fail-closed kill switch for provider turns. ON = every provider turn
93
+ * is refused visibly: the agy CLI has no review-only enforcement on any
94
+ * path (probed 2026-10-07, upstream #1181: plan mode stages a plan and
95
+ * executes the write in the same turn on -p and stream-json alike; the
96
+ * ACP engine has no plan mode at all). Built for callers like
97
+ * `pi -p --no-tools` health checks that must never mutate a workspace
98
+ * and prefer a visible refusal over a write-capable turn. Wins over
99
+ * `mode`; the AskAntigravity delegation tool is refused while this is on
100
+ * (the CLI has no review-only enforcement, so nothing here can run
101
+ * read-only). Env AGY_READONLY wins over the file.
102
+ * Default false. */
103
+ readOnly: boolean;
92
104
  /** Auto-approve all agy tool permission requests (--dangerously-skip-permissions).
93
105
  * Required for non-interactive use: without it, any `run_command` triggers an
94
106
  * interactive y/n prompt that hangs forever in `-p` mode. Defaults true.
95
107
  * DANGEROUS: lets agy run arbitrary commands (including destructive ones)
96
- * without review. Turn off only if you also set mode=plan (no execution). */
108
+ * without review. The ask tool withholds the flag on plan runs;
109
+ * accept-edits runs need it to avoid hanging on the unanswerable prompt. */
97
110
  skipPermissions: boolean;
98
111
  /** AskAntigravity tool: default model alias (flash/pro/gemini or exact). */
99
112
  defaultModel: string;
@@ -176,6 +189,7 @@ export interface AgyConfig {
176
189
  const DEFAULTS: AgyConfig = {
177
190
  engine: "stream-json",
178
191
  mode: "accept-edits",
192
+ readOnly: false,
179
193
  skipPermissions: true,
180
194
  defaultModel: "flash",
181
195
  defaultThinking: "medium",
@@ -212,18 +226,24 @@ export function parseCapMinutes(raw: string | number | undefined, fallback: numb
212
226
  return n >= 1 && n <= MAX_TURN_CAP_MIN ? n : fallback;
213
227
  }
214
228
 
215
- /** Load config merged over defaults. Env vars override the file when set. */
216
- export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
217
- let file: Partial<AgyConfig> = {};
229
+ /** Read the raw config file (no defaults, no env). Shared by loadConfig and
230
+ * saveConfig so persisted writes can never bake env overrides into the file. */
231
+ function readConfigFile(configPath: string): Partial<AgyConfig> {
218
232
  try {
219
233
  const raw = fs.readFileSync(configPath, "utf8");
220
234
  const parsed = JSON.parse(raw);
221
235
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
222
- file = parsed as Partial<AgyConfig>;
236
+ return parsed as Partial<AgyConfig>;
223
237
  }
224
238
  } catch {
225
- /* missing or corrupt - fall back to defaults */
239
+ /* missing or corrupt - fall back to empty */
226
240
  }
241
+ return {};
242
+ }
243
+
244
+ /** Load config merged over defaults. Env vars override the file when set. */
245
+ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
246
+ let file: Partial<AgyConfig> = readConfigFile(configPath);
227
247
 
228
248
  // Env overrides file (matches the skipPermissions pattern).
229
249
  // The naive OR `env === "plan" || file.mode === "plan"` would ignore an
@@ -249,6 +269,17 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
249
269
  ? envPerm === "1" || envPerm.toLowerCase() === "true"
250
270
  : file.skipPermissions ?? DEFAULTS.skipPermissions;
251
271
 
272
+ const envReadOnly = process.env.AGY_READONLY;
273
+ // Fail-closed parse for a safety knob: empty string means the user
274
+ // explicitly blanked it (off), but ANY other set value that is not a
275
+ // spelled-out falsity counts as ON. " 1", "yes", typos: all refuse.
276
+ const readOnly =
277
+ envReadOnly !== undefined
278
+ ? envReadOnly === ""
279
+ ? false
280
+ : !["0", "false", "off"].includes(envReadOnly.trim().toLowerCase())
281
+ : file.readOnly ?? DEFAULTS.readOnly;
282
+
252
283
  const defaultModelRaw =
253
284
  process.env.AGY_DEFAULT_MODEL ?? file.defaultModel ?? DEFAULTS.defaultModel;
254
285
  const defaultModel =
@@ -329,6 +360,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
329
360
  engine,
330
361
  acp,
331
362
  mode,
363
+ readOnly,
332
364
  skipPermissions,
333
365
  defaultModel,
334
366
  defaultThinking,
@@ -345,10 +377,30 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
345
377
  };
346
378
  }
347
379
 
348
- /** Atomically persist a config patch (temp + rename). */
380
+ /** Atomically persist a config patch (temp + rename). Merges onto the RAW
381
+ * FILE values, never loadConfig(): env overrides must not be baked into the
382
+ * file (AGY_READONLY=1 in the env would otherwise persist readOnly: true to
383
+ * disk on any unrelated save, outliving the env var). Nested objects are
384
+ * re-normalized to their known keys: a raw passthrough would resurrect
385
+ * legacy keys (acp.permissions) that loadConfig normalization drops. */
349
386
  export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFIG_PATH): AgyConfig {
350
- const current = loadConfig(configPath);
351
- const next: AgyConfig = { ...current, ...patch };
387
+ const file = readConfigFile(configPath);
388
+ // Null-safe on nested objects: a hand-edited or legacy file can carry
389
+ // "acp": null; loadConfig tolerates it, so must saveConfig. Key presence
390
+ // triggers normalization (a null becomes the default shape, never null).
391
+ const fileAcp: Partial<AcpConfig> = file.acp && typeof file.acp === "object" ? file.acp : {};
392
+ const fileApprovals: Partial<GateConfig> = file.approvals && typeof file.approvals === "object" ? file.approvals : {};
393
+ const next: AgyConfig = {
394
+ ...DEFAULTS,
395
+ ...file,
396
+ ...(file.acp !== undefined
397
+ ? { acp: { bin: fileAcp.bin ?? DEFAULTS.acp.bin, usageEstimate: fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate } }
398
+ : {}),
399
+ ...(file.approvals !== undefined
400
+ ? { approvals: { gateMode: fileApprovals.gateMode ?? DEFAULTS.approvals.gateMode, mode: fileApprovals.mode ?? DEFAULTS.approvals.mode } }
401
+ : {}),
402
+ ...patch,
403
+ };
352
404
  const dir = path.dirname(configPath);
353
405
  fs.mkdirSync(dir, { recursive: true });
354
406
  const tmp = `${configPath}.${process.pid}.tmp`;
package/src/driver.ts CHANGED
@@ -80,10 +80,11 @@ interface ActiveTurn {
80
80
  }
81
81
 
82
82
  /** The skip-permissions flag actually passed to agy. Plan mode never
83
- * carries it: the flag auto-approves every permission request including
84
- * plan mode's own approval gate, silently turning review-only into full
85
- * write access. #start (argv + profile) and #recycleCause (comparison)
86
- * must both go through this, or plan turns recycle on every turn. */
83
+ * carries it: the flag auto-approves every permission request, and the CLI
84
+ * does not gate writes under plan mode (upstream #1181), so an
85
+ * auto-approved plan run is a write-capable run. #start (argv + profile)
86
+ * and #recycleCause (comparison) must both go through this, or plan turns
87
+ * recycle on every turn. */
87
88
  function effectiveSkipPermissions(mode: DriverTurnRequest["mode"], skipPermissions: boolean): boolean {
88
89
  return mode !== "plan" && skipPermissions;
89
90
  }
@@ -352,12 +353,11 @@ export class StreamDriver implements TurnDriver {
352
353
  this.#generation += 1;
353
354
  const generation = this.#generation;
354
355
  // Plan mode never carries the skip flag: the flag auto-approves ALL
355
- // permission requests including plan mode's own approval gate, which
356
- // would silently turn "review-only" into full write access (probed
357
- // 2026-09-25: with the flag a plan session wrote files; without it,
358
- // file and command attempts end exit 0 in ~20-30s with a "confirm
359
- // plan" message). The profile stores the EFFECTIVE value so the
360
- // recycle comparison stays consistent with the argv.
356
+ // permission requests, and the CLI does not gate writes under plan
357
+ // mode (upstream google-antigravity/antigravity-cli#1181, probed
358
+ // 2026-10-07: the write executes in the same turn). An auto-approved
359
+ // plan run is a write-capable run. The profile stores the EFFECTIVE
360
+ // value so the recycle comparison stays consistent with the argv.
361
361
  const skipPermissions = effectiveSkipPermissions(request.mode, request.skipPermissions);
362
362
  this.#profile = {
363
363
  cwd: request.cwd,
package/src/models.ts CHANGED
@@ -2,20 +2,21 @@
2
2
  // they appear in the /model picker as antigravity/<slug>.
3
3
  //
4
4
  // agy prints TWO columns per line: "<slug> <display label>". --model takes
5
- // ONLY the slug (col 1); the label is display-only. Verified live, e.g.:
6
- // gemini-3.6-flash-high Gemini 3.6 Flash (High)
7
- // gemini-3.6-flash-medium Gemini 3.6 Flash (Medium) (+ -low)
5
+ // ONLY the slug (col 1); the label is display-only. Verified live 2026-10:
6
+ // gemini-3.8-flash-high Gemini 3.8 Flash (High)
7
+ // gemini-3.8-flash-medium Gemini 3.8 Flash (Medium) (+ -low)
8
8
  // gemini-3.1-pro-high Gemini 3.1 Pro (High) (Pro has NO medium)
9
- // claude-sonnet-4-6 Claude Sonnet 4.6 (Thinking) (fixed, no tiers)
10
- // gpt-oss-120b-medium GPT-OSS 120B (Medium) (fixed, no tiers)
9
+ // claude-sonnet-5-5-high Claude Sonnet 5.5 (High) (+ -medium, -low)
11
10
  //
12
- // Gemini models are collapsed to a BASE slug (gemini-3.6-flash) and exposed
13
- // with a thinking-effort toggle whose levels match exactly the tiers agy
14
- // offers that base (verified: Pro rejects medium). The picked level is sent as
15
- // agy --effort; a base slug is INVALID on its own, so effort is always passed.
16
- // Claude and GPT-OSS keep agy's exact slug with no toggle: their thinking is
17
- // fixed and agy rejects --effort for them. Google's Antigravity subscription
18
- // bills all of these through agy.
11
+ // Gemini AND Claude models are collapsed to a BASE slug (gemini-3.8-flash,
12
+ // claude-sonnet-5-5) and exposed with a thinking-effort toggle whose levels
13
+ // match exactly the tiers agy offers that base (verified: Pro rejects medium;
14
+ // Claude 5.5 accepts low/medium/high). The picked level is sent as agy
15
+ // --effort; a bare base slug is INVALID on its own (agy: "requires --effort"),
16
+ // so effort is always passed. Only unverified families keep agy's exact slug
17
+ // with no toggle, so an unknown future family degrades safely; the retired
18
+ // gpt-oss family is dropped outright (HIDDEN_FAMILY_RE). Google's Antigravity
19
+ // subscription bills all of these through agy.
19
20
 
20
21
  import { spawn } from "node:child_process";
21
22
  import fs from "node:fs";
@@ -81,8 +82,9 @@ export function toAgyEffort(
81
82
  }
82
83
 
83
84
  /** Split an agy slug into (base, tier). tier is null when the slug has no
84
- * -high/-medium/-low suffix (claude-sonnet-4-6, gpt-oss-120b-medium's "medium"
85
- * IS its suffix here, claude-opus-4-6-thinking is not a tier). */
85
+ * -high/-medium/-low suffix - a shape only unverified families should
86
+ * produce now, and those stay whole. Every Claude slug carries a tier
87
+ * suffix and collapses to its base. */
86
88
  const TIER_RE = /^(.+)-(high|medium|low)$/;
87
89
 
88
90
  /** agy emits clean slug ids (gemini-3.6-flash-high). Validate col1 of each
@@ -97,14 +99,21 @@ const MODEL_LINE_RE = /^[A-Za-z0-9][A-Za-z0-9._]*-[A-Za-z0-9._-]*$/;
97
99
  * to a base slug with a thinking toggle. Any other family stays as agy's exact
98
100
  * qualified slug (always valid on its own), so an unknown or fixed-thinking
99
101
  * family degrades safely instead of forcing an unsupported --effort. Add a
100
- * family here only after confirming base+--effort is accepted for it. */
101
- const EFFORT_CAPABLE_FAMILIES: readonly RegExp[] = [/^gemini-/];
102
+ * family here only after confirming base+--effort is accepted for it.
103
+ * Claude 5.5 verified live 2026-10: base+--effort accepted; a bare base is
104
+ * rejected ("requires --effort"). */
105
+ const EFFORT_CAPABLE_FAMILIES: readonly RegExp[] = [/^gemini-/, /^claude-/];
106
+
107
+ /** Families dropped outright, even while `agy models` still lists them: the
108
+ * fixed-thinking single slug loses to the cheaper effort-tiered options and
109
+ * its removal upstream is already announced, so we stop offering it now. */
110
+ const HIDDEN_FAMILY_RE = /^gpt-oss-/;
102
111
 
103
112
  export interface AgyModelEntry {
104
- /** Exact agy --model string to pass: a base slug ("gemini-3.6-flash") when
105
- * effort-driven, else agy's full qualified slug ("claude-opus-4-6-thinking"). */
113
+ /** Exact agy --model string to pass: a base slug ("gemini-3.8-flash") when
114
+ * effort-driven, else agy's full qualified slug (unverified family). */
106
115
  full: string;
107
- /** pi model id, e.g. "gemini-3-6-flash". */
116
+ /** pi model id, e.g. "gemini-3-8-flash". */
108
117
  id: string;
109
118
  /** Present iff this is an effort-driven base slug. Lists the tiers agy
110
119
  * accepts for it; pi's thinking toggle picks among them and we always pass
@@ -284,11 +293,12 @@ export async function loadModelCatalogRaw(
284
293
 
285
294
  /** Parse raw `agy models` text into the provider's model entries.
286
295
  *
287
- * Effort-driven Gemini bases (>= 2 tier variants in the catalog) collapse to
288
- * one BASE-slug entry carrying the tiers they accept. Models with 0 or 1 tier
289
- * variants keep agy's exact qualified slug: a single suffix (gpt-oss-120b-
290
- * medium) or none (claude-sonnet-4-6) means fixed thinking, where --effort is
291
- * unsupported. Insertion order of first-seen bases is preserved. */
296
+ * Effort-driven bases (>= 2 tier variants in the catalog, verified
297
+ * effort-capable family) collapse to one BASE-slug entry carrying the tiers
298
+ * they accept. Models with 0 or 1 tier variants keep agy's exact qualified
299
+ * slug: without a verified effort-capable family match, --effort is never
300
+ * risked. Hidden families are dropped outright. Insertion order of
301
+ * first-seen bases is preserved. */
292
302
  export function entriesFromRaw(raw: string): AgyModelEntry[] {
293
303
  // agy prints TWO columns: "<slug> <display label>". --model takes only the
294
304
  // slug, so split col1 and validate THAT; the label is display-only. A
@@ -298,6 +308,7 @@ export function entriesFromRaw(raw: string): AgyModelEntry[] {
298
308
  for (const line of raw.split("\n")) {
299
309
  const slug = line.trim().split(/\s+/)[0] ?? "";
300
310
  if (!slug || !MODEL_LINE_RE.test(slug)) continue;
311
+ if (HIDDEN_FAMILY_RE.test(slug)) continue;
301
312
  const m = TIER_RE.exec(slug);
302
313
  const base = m ? (m[1] as string) : slug;
303
314
  const tier = m ? (m[2] as AgyEffort) : null;
@@ -365,8 +376,8 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
365
376
  // sentinel that no built-in provider claims, so it can never collide.
366
377
  baseUrl: "agy-bridge://antigravity",
367
378
  // reasoning=true only for effort-driven bases => pi shows the toggle.
368
- // Fixed models (Claude/GPT-OSS) get no toggle: their thinking can't be
369
- // changed and agy rejects --effort for them.
379
+ // Unverified families get no toggle: --effort support is unknown, so
380
+ // none is ever sent.
370
381
  reasoning: effortDriven,
371
382
  ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
372
383
  // Input advertising comes from the caller (engine-dependent): the ACP
@@ -386,9 +397,9 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
386
397
  * can still select a model and get a clear runtime error instead of an empty
387
398
  * list. Update these when agy ships new Gemini versions. */
388
399
  export const FALLBACK_MODELS: AgyModelEntry[] = [
389
- { full: "gemini-3.7-flash", id: "gemini-3-7-flash", efforts: ["low", "medium", "high"] },
400
+ { full: "gemini-3.8-flash", id: "gemini-3-8-flash", efforts: ["low", "medium", "high"] },
390
401
  { full: "gemini-3.1-pro", id: "gemini-3-1-pro", efforts: ["low", "high"] },
391
- { full: "claude-sonnet-4-6", id: "claude-sonnet-4-6" },
402
+ { full: "claude-sonnet-5-5", id: "claude-sonnet-5-5", efforts: ["low", "medium", "high"] },
392
403
  ];
393
404
 
394
405
 
package/src/provider.ts CHANGED
@@ -1435,13 +1435,34 @@ export function createStreamSimple(
1435
1435
  const config = loadConfig();
1436
1436
  const engine = deps.engine ?? config.engine;
1437
1437
  const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
1438
- // RC01: ACP modes are permission modes, there is no review-only plan.
1439
- // A plan turn on ACP would silently run non-plan; fail visibly instead.
1440
- if (selected === deps.acpDriver && config.mode === "plan") {
1438
+ // Plan-shaped provider turns are fail-closed on BOTH engines. ACP has
1439
+ // no plan mode at all (RC01). Stream-json is worse and probed
1440
+ // 2026-10-07: the engine WARNS that --mode plan is a no-op while
1441
+ // --disable-slash-commands is set, executes file writes even with
1442
+ // slash expansion left on (it stages a plan.md and still writes in the
1443
+ // same turn), ignores --agent toolset restrictions, and file writes
1444
+ // bypass the permission system entirely. A plan-labeled turn here is
1445
+ // write-capable, so refuse visibly instead of running false safety.
1446
+ if (config.readOnly || config.mode === "plan") {
1441
1447
  const partial = newAssistant(model);
1442
1448
  const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
1443
- deps.log?.("turn-error", { reason: "acp-plan-refused" }, "warn");
1444
- finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
1449
+ if (config.readOnly) {
1450
+ deps.log?.("turn-error", { reason: "readonly-refused" }, "warn");
1451
+ finalize(
1452
+ stream,
1453
+ blocks,
1454
+ "error",
1455
+ "readOnly cannot be enforced on provider turns: the agy CLI has no review-only enforcement on any path. Plan mode stages a plan and executes the write in the same turn (probed 2026-10-07, google-antigravity/antigravity-cli#1181). Enforce read-only in the host (pi tool restrictions or the approval gate), then /agy readonly off.",
1456
+ );
1457
+ return stream;
1458
+ }
1459
+ deps.log?.("turn-error", { reason: "plan-refused" }, "warn");
1460
+ finalize(
1461
+ stream,
1462
+ blocks,
1463
+ "error",
1464
+ "Plan mode cannot be enforced on provider turns: the agy CLI stages a plan and executes writes in the same turn (probed 2026-10-07, google-antigravity/antigravity-cli#1181), and the ACP engine has no plan mode (RC01). /agy mode accept-edits, and enforce review-only in the host.",
1465
+ );
1445
1466
  return stream;
1446
1467
  }
1447
1468
  if (selected && roundTrips) {