@estebanforge/pi-antigravity-bridge 1.7.9 → 1.7.11

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,30 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.11] - 2026-10-10
6
+
7
+ ### Added
8
+
9
+ - AskAntigravity prompts now carry a `[TIME BUDGET]` notice matching the resolved timeout (default 10 minutes), placed before the plan-mode guard banners so the safety constraints stay the last text the model reads. `timeoutMinutes` values that cannot arm a real timer (non-positive, non-finite, above the 35791-minute ceiling) are rejected with a clear error instead of arming an instant-kill watchdog.
10
+
11
+ ### Changed
12
+
13
+ - **AskAntigravity dedup onto `pi-ask-shared` (behavior-preserving).** The tool's copy of the agy model grammar, tiered alias resolution, plan-mode guard prompts, prompt assembly, and argv construction now imports the shared peer adapter (new dependency `@estebanforge/pi-ask-shared`); `src/discovery.ts` is deleted — its conversation-bind technique (snapshot/diff + /proc FD scan, including the `onAmbiguous` hook, now upstream in the shared adapter) was the last consumer. The process block is deliberately kept bridge-local: it settles on close AND exit, leaves stdout/stderr uncapped, and threads the MCP-entry suppression release into cleanup — all three diverge from the shared `runProcess` contract on purpose. Shared `newConversationId` gained the `onAmbiguous` callback and exports `procTreeOpenDbResolver` (both absorbed from discovery.ts; `conversationDbPath` was unused and died with the module).
14
+
15
+ ## [1.7.10] - 2026-10-07
16
+
17
+ ### Added
18
+
19
+ - **`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.
20
+ - **`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.
21
+
22
+ ### Fixed
23
+
24
+ - **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.
25
+ - **`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.
26
+ - **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.
27
+
28
+
5
29
  ## [1.7.9] - 2026-10-06
6
30
 
7
31
  ### Changed
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.9",
3
+ "version": "1.7.11",
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",
@@ -54,6 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
+ "@estebanforge/pi-ask-shared": "^0.1.2",
57
58
  "@modelcontextprotocol/sdk": "^1.29.0"
58
59
  }
59
60
  }
package/src/ask-tool.ts CHANGED
@@ -21,10 +21,21 @@ import { Text } from "@earendil-works/pi-tui";
21
21
  import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
22
22
  import { Type } from "typebox";
23
23
  import {
24
+ buildAgyArgs,
25
+ buildFinalPrompt,
26
+ buildTimeBudgetNotice,
27
+ MAX_TIMEOUT_MINUTES,
24
28
  CONVERSATIONS_DIR,
29
+ CONV_ID_RE,
30
+ filterHiddenModels,
31
+ mergeCatalog,
25
32
  newConversationId,
33
+ parseModelLine,
34
+ resolveAgyModel,
26
35
  snapshotConversations,
27
- } from "./discovery.js";
36
+ type ModelEntry,
37
+ type Mode,
38
+ } from "@estebanforge/pi-ask-shared";
28
39
  import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
29
40
  import { redactText } from "./redact.js";
30
41
  import { acquireBridgeSuppression } from "./mcp-registration.js";
@@ -33,49 +44,6 @@ import { sweepStaleWebAgents, webAgentsRoot } from "./web-tools.js";
33
44
 
34
45
  // --- Constants -------------------------------------------------------------
35
46
 
36
- const DEFAULT_TIMEOUT_MIN = 10;
37
- const GRACE_AFTER_TIMEOUT_MS = 5000;
38
- const STATUS_INTERVAL_MS = 1000;
39
- const STATUS_TAIL_CHARS = 160;
40
-
41
- // renderCall / renderResult preview limits (match pi-claude-bridge).
42
- const PREVIEW_MAX_CHARS = 1000;
43
- const PREVIEW_MAX_LINES = 6;
44
- const DISCOVERY_POLL_ATTEMPTS = 5;
45
- const DISCOVERY_POLL_MS = 100;
46
-
47
- // Per-family fallback tier when none is specified and no config default.
48
- const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
49
- flash: "medium",
50
- pro: "high",
51
- other: "medium",
52
- };
53
-
54
- const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
55
-
56
- // Static alias overlay for non-Gemini models agy may or may not surface.
57
- // Live catalog entries win on case-insensitive full-string equality; the
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). Claude ships
60
- // tiered like Gemini now, so the overlay carries the full tier spread.
61
- const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
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" },
68
- ];
69
- const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
70
- ["sonnet", "claude-sonnet-5-5"],
71
- ["opus", "claude-opus-5-5"],
72
- ]);
73
-
74
- // agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
75
- // leading-dash value can't misbind on agy's arg parser; hyphens allowed in the
76
- // body (real UUIDs contain them).
77
- const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
78
-
79
47
  const AGY_DESCRIPTION = `Delegate a self-contained sub-task to Google Antigravity. agy is the CLI for Gemini, so this tool is reached under three equivalent names the user may use interchangeably: **gemini**, **antigravity**, and **agy**. When the user says "ask gemini", "ask antigravity", "ask agy", or otherwise refers to any of these, call THIS tool. agy runs its OWN tool loop: it can read, write, edit, and execute inside the workspace, then returns its final answer. Use for a second opinion from a different model family, Gemini-specific reasoning, or isolated sub-tasks you do not need to drive step-by-step. Provide a complete, self-contained task description; agy will not see this conversation.
80
48
 
81
49
  TWO MODES (you choose):
@@ -83,8 +51,8 @@ TWO MODES (you choose):
83
51
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
84
52
 
85
53
  EXECUTION MODES (param: mode):
86
- - **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.
87
- - **accept-edits** (default): agy applies edits directly inside the workspace.
54
+ - **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.
55
+ - **accept-edits**: agy applies edits directly inside the workspace. Default unless the configured mode is plan.
88
56
 
89
57
  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.
90
58
 
@@ -93,223 +61,28 @@ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
93
61
  - Values (pi vocabulary): minimal|low|medium|high|xhigh|max. Clamped to agy's low|medium|high; unknown values fall back to low. "peer review on high thinking" -> thinking: "high".
94
62
  - An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
95
63
 
96
- // --- Types -----------------------------------------------------------------
97
-
98
- type Family = "flash" | "pro" | "other";
99
-
100
- interface ModelEntry {
101
- full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
102
- family: Family;
103
- version: string | null; // "3.6"
104
- tier: ThinkingTier | null;
105
- }
106
-
107
- /** Argv-facing model resolution: the exact --model slug plus an optional
108
- * --effort tier. Gemini bases split the tier out (the base slug alone is
109
- * invalid without --effort); fixed-thinking families keep agy's exact slug
110
- * and carry no effort. */
111
- interface ResolvedModel {
112
- model: string;
113
- effort?: ThinkingTier;
114
- }
115
-
116
- // --- Version helpers -------------------------------------------------------
117
-
118
- /** Descending numeric version compare (3.10 > 3.9, not lexical). */
119
- function compareVersionsDesc(a: string, b: string): number {
120
- const pa = a.split(".").map(Number);
121
- const pb = b.split(".").map(Number);
122
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
123
- const da = pa[i] ?? 0;
124
- const db = pb[i] ?? 0;
125
- if (da !== db) return db - da;
126
- }
127
- return 0;
128
- }
129
-
130
- // --- Model catalog ---------------------------------------------------------
131
-
132
- function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
133
- const seen = new Set(live.map((e) => e.full.toLowerCase()));
134
- const merged = [...live];
135
- for (const entry of STATIC_ALIAS_OVERLAY) {
136
- if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
137
- }
138
- return merged;
139
- }
140
-
141
- function parseModelLine(line: string): ModelEntry | null {
142
- // agy prints TWO columns: "<slug> <display label>". --model takes only the
143
- // slug (col 1), so split it off; the label is display-only and must never
144
- // reach --model. A bare-slug line (no whitespace) splits to itself.
145
- const full = line.trim().split(/\s+/)[0] ?? "";
146
- if (!full) return null;
147
- const lower = full.toLowerCase();
148
- const family: Family = lower.includes("flash")
149
- ? "flash"
150
- : lower.includes("pro")
151
- ? "pro"
152
- : "other";
153
- const versionMatch = lower.match(/(\d+\.\d+)/);
154
- const version = versionMatch ? versionMatch[1] : null;
155
- const tierMatch = lower.match(/-(low|medium|high)$/);
156
- const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
157
- return { full, family, version, tier };
158
- }
159
-
160
- function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
161
- if (available.includes(preferred)) return preferred;
162
- const sorted = [...available].sort((a, b) => {
163
- const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
164
- const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
165
- return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
166
- });
167
- return sorted[0] ?? preferred;
168
- }
169
-
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. */
177
- function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
178
- if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
179
- return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
180
- }
181
- return { model: full };
182
- }
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
-
211
- /** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
212
- * Returns null only when the family is unrecognized; the caller then passes
213
- * the raw input straight to agy. */
214
- export function resolveModel(
215
- input: string,
216
- entries: ModelEntry[],
217
- defaultThinking: ThinkingTier,
218
- /** Explicit thinking param (thinking/effort). Beats a tier embedded in
219
- * the alias ("flash high") and the configured default; clamped to the
220
- * family's real tiers, ignored for fixed-thinking families. */
221
- preferredTier?: ThinkingTier,
222
- ): ResolvedModel | null {
223
- const lower = input.toLowerCase().trim();
224
-
225
- const exact = entries.find((e) => e.full.toLowerCase() === lower);
226
- if (exact) return toResolved(exact.full, exact.tier);
227
-
228
- if (STATIC_SHORT_ALIAS.has(lower)) {
229
- return resolveTieredBase(
230
- STATIC_SHORT_ALIAS.get(lower) as string,
231
- entries,
232
- defaultThinking,
233
- preferredTier,
234
- );
235
- }
236
-
237
- let family: Family | null = lower.includes("flash")
238
- ? "flash"
239
- : lower.includes("pro")
240
- ? "pro"
241
- : null;
242
- const versionMatch = lower.match(/(\d+\.\d+)/);
243
- const version = versionMatch ? versionMatch[1] : null;
244
- const tierMatch = lower.match(/\b(low|medium|high)\b/);
245
- const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
246
-
247
- if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
248
- family = "flash";
249
- }
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
- }
263
-
264
- let candidates = entries.filter((e) => e.family === family);
265
- if (candidates.length === 0) return null;
266
-
267
- if (version) {
268
- const versioned = candidates.filter((e) => e.version === version);
269
- if (versioned.length > 0) candidates = versioned;
270
- } else {
271
- const aliases = candidates.filter((e) => e.version === null);
272
- if (aliases.length > 0) {
273
- candidates = aliases;
274
- } else {
275
- const versions = candidates
276
- .map((e) => e.version)
277
- .filter((v): v is string => v !== null)
278
- .sort(compareVersionsDesc);
279
- if (versions.length > 0) {
280
- const top = versions[0];
281
- const latest = candidates.filter((e) => e.version === top);
282
- if (latest.length > 0) candidates = latest;
283
- }
284
- }
285
- }
286
-
287
- const familyTiers = new Set(
288
- candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
289
- );
290
- if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
291
-
292
- const preferred =
293
- preferredTier ??
294
- tier ??
295
- (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
296
- const chosenTier = nearestTier([...familyTiers], preferred);
297
- const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
298
- return toResolved(picked.full, picked.tier);
299
- }
64
+ const DEFAULT_TIMEOUT_MIN = 10;
65
+ const GRACE_AFTER_TIMEOUT_MS = 5000;
66
+ const STATUS_INTERVAL_MS = 1000;
67
+ const STATUS_TAIL_CHARS = 160;
300
68
 
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-/;
69
+ // renderCall / renderResult preview limits (match pi-claude-bridge).
70
+ const PREVIEW_MAX_CHARS = 1000;
71
+ const PREVIEW_MAX_LINES = 6;
72
+ const DISCOVERY_POLL_ATTEMPTS = 5;
73
+ const DISCOVERY_POLL_MS = 100;
304
74
 
305
- /** Parse raw `agy models` text into tool-catalog entries (all families, plus
306
- * the static sonnet/opus overlay). Pure: no spawn. */
75
+ /** Parse raw `agy models` text into tool-catalog entries: the shared line
76
+ * grammar + hidden-family filter + static sonnet/opus overlay. Pure: no
77
+ * spawn. */
307
78
  export function toolModelsFromRaw(raw: string): ModelEntry[] {
308
79
  return mergeCatalog(
309
- raw
310
- .split("\n")
311
- .map(parseModelLine)
312
- .filter((e): e is ModelEntry => e !== null && !HIDDEN_FAMILY_RE.test(e.full)),
80
+ filterHiddenModels(
81
+ raw
82
+ .split("\n")
83
+ .map(parseModelLine)
84
+ .filter((e): e is ModelEntry => e !== null),
85
+ ),
313
86
  );
314
87
  }
315
88
 
@@ -325,53 +98,11 @@ function extraArgs(): string[] {
325
98
  return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
326
99
  }
327
100
 
328
- // --- Prompt assembly -------------------------------------------------------
329
-
330
- /** Guard for plan runs backed by the restricted reviewer agent. Difference
331
- * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
332
- * agent's commandExecutionPolicy auto-approves them headless, probed
333
- * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
334
- * and keeps the answer-from-material discipline. */
335
- export const AGENT_REVIEW_GUARD = [
336
- "",
337
- "--- Review constraints ---",
338
- "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
339
- "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
340
- "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
341
- ].join("\n");
342
-
343
- /** Appended to every headless plan-mode prompt. `agy -p` cannot answer
344
- * permission prompts: in plan mode a run_command attempt is soft-denied and
345
- * the turn ends AT the denial (exit 0, empty stdout, notice only on stderr -
346
- * the empty-output branch in execute). Probed 2026-09-28 on agy 1.2.12:
347
- * allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
348
- * --sandbox does NOT relax the command gate, so the only input every user
349
- * is guaranteed is the prompt itself. The guard steers the model to answer
350
- * from that material. accept-edits runs keep their tools and never get it. */
351
- export const PLAN_HEADLESS_GUARD = [
352
- "",
353
- "--- Headless session constraints ---",
354
- "- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
355
- "- Work only from the material provided in this prompt.",
356
- "- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
357
- ].join("\n");
358
-
359
- /** Assemble the prompt sent to agy: digest marker first (existing behavior),
360
- * then the caller's prompt, then the plan-mode guard last - the position
361
- * the model reads with the most recency. No user-config mutation: this is
362
- * the only plan-mode lever the tool itself owns. */
363
- export function buildFinalPrompt(
364
- prompt: string,
365
- mode: AgyMode,
366
- digest: boolean,
367
- /** True when the restricted reviewer agent is staged: its empty edit
368
- * toolset is the hard block, so the prompt may ALLOW read-only commands. */
369
- agentEnforced = false,
370
- ): string {
371
- let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
372
- if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
373
- return out;
374
- }
101
+ // --- Prompt assembly: shared peer adapter ----------------------------------
102
+ // PLAN_HEADLESS_GUARD / AGENT_REVIEW_GUARD / buildFinalPrompt live in
103
+ // pi-ask-shared (peers/antigravity.ts); the bridge inherits the identical
104
+ // guard discipline. Re-exported for the bridge's own tests.
105
+ export { AGENT_REVIEW_GUARD, buildFinalPrompt, PLAN_HEADLESS_GUARD } from "@estebanforge/pi-ask-shared";
375
106
 
376
107
  // --- Plan-mode reviewer agent ----------------------------------------------
377
108
 
@@ -384,12 +115,14 @@ export function askAgentsRoot(): string {
384
115
  const ASK_AGENT_PREFIX = "pi-bridge-ask-";
385
116
  const ASK_AGENT_TOOLS = ["view_file", "run_command"];
386
117
 
387
- /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
388
- * with no file-editing tool present the model cannot emit an edit call at
389
- * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
390
- * edits, with and without the skip flag). commandExecutionPolicy auto is
391
- * what lets read commands run headless with no user allow rules. Never add
392
- * a write-capable tool here. */
118
+ /** agent.md for the plan-mode reviewer. The tools list is a damper, not a
119
+ * guarantee: with no file-editing tool present the model has no edit tool in
120
+ * its menu (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
121
+ * edits; re-verify on 1.3.x), but the CLI itself does not enforce
122
+ * review-only under plan mode (upstream google-antigravity/antigravity-cli
123
+ * #1181, probed 2026-10-07: the write can execute in the same turn).
124
+ * commandExecutionPolicy auto is what lets read commands run headless with
125
+ * no user allow rules. Never add a write-capable tool here. */
393
126
  export function reviewerAgentMd(name: string): string {
394
127
  return [
395
128
  "---",
@@ -484,8 +217,7 @@ export async function registerAskAntigravityTool(
484
217
  mode: Type.Optional(
485
218
  Type.Union([Type.Literal("plan"), Type.Literal("accept-edits")], {
486
219
  description:
487
- "agy execution mode. 'plan' = review-only. 'accept-edits' = agy applies edits (default).",
488
- default: "accept-edits",
220
+ "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).",
489
221
  }),
490
222
  ),
491
223
  digest: Type.Optional(
@@ -501,7 +233,7 @@ export async function registerAskAntigravityTool(
501
233
  }),
502
234
  ),
503
235
  timeoutMinutes: Type.Optional(
504
- Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.` }),
236
+ Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}. Fractional minutes allowed; must be above 0 and at most ${MAX_TIMEOUT_MINUTES}. The prompt carries a matching [TIME BUDGET] notice.` }),
505
237
  ),
506
238
  includeContext: Type.Optional(
507
239
  Type.Boolean({
@@ -509,6 +241,12 @@ export async function registerAskAntigravityTool(
509
241
  "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.",
510
242
  }),
511
243
  ),
244
+ background: Type.Optional(
245
+ Type.Boolean({
246
+ description:
247
+ "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.",
248
+ }),
249
+ ),
512
250
  }),
513
251
  renderCall(args, theme, _context) {
514
252
  // Show RESOLVED model/thinking/mode (config defaults applied) so the
@@ -517,14 +255,17 @@ export async function registerAskAntigravityTool(
517
255
  const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
518
256
  const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
519
257
  const resolved =
520
- resolveModel(
258
+ resolveAgyModel(
521
259
  requestedModel,
522
260
  entries,
523
261
  cfg.defaultThinking,
524
262
  thinkingArg ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER) : undefined,
525
263
  ) ?? { model: requestedModel };
526
264
  const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
527
- const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
265
+ // Same default rule as execute: plan when the configured mode is
266
+ // plan, so the row always names what will actually run.
267
+ const mode: AgyMode =
268
+ (args.mode as AgyMode | undefined) ?? (cfg.mode === "plan" ? "plan" : "accept-edits");
528
269
  const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
529
270
  const isContinue =
530
271
  typeof args.conversationId === "string" && CONV_ID_RE.test(args.conversationId);
@@ -598,6 +339,37 @@ export async function registerAskAntigravityTool(
598
339
  }
599
340
 
600
341
  const config = loadConfig();
342
+ // readOnly refusal (fail-closed): the kill switch covers this tool too.
343
+ // It must not be bypassable with an explicit mode param: the CLI has no
344
+ // review-only enforcement (upstream google-antigravity/antigravity-cli
345
+ // #1181), so a plan-labeled run here is not read-only.
346
+ if (config.readOnly) {
347
+ return {
348
+ content: [
349
+ {
350
+ type: "text",
351
+ 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.',
352
+ },
353
+ ],
354
+ details: emptyDetails(),
355
+ };
356
+ }
357
+ // Background refusal (fail-closed): a wake from this tool would land in
358
+ // the pi session even when the CALLER is agy (nested delegation via the
359
+ // bridge catalog), and the bridge already owns an async delegation flow
360
+ // (early-ack + bridge_poll_result). The standalone pi-ask-antigravity
361
+ // extension is the background-capable tool.
362
+ if (params.background) {
363
+ return {
364
+ content: [
365
+ {
366
+ type: "text",
367
+ 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.',
368
+ },
369
+ ],
370
+ details: emptyDetails(),
371
+ };
372
+ }
601
373
  const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
602
374
  if (typeof params.model === "string" && params.model.trim().startsWith("-")) {
603
375
  return {
@@ -630,7 +402,7 @@ export async function registerAskAntigravityTool(
630
402
  ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER)
631
403
  : undefined;
632
404
  const resolved =
633
- resolveModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
405
+ resolveAgyModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
634
406
  model: requestedModel,
635
407
  };
636
408
 
@@ -652,16 +424,27 @@ export async function registerAskAntigravityTool(
652
424
  }
653
425
 
654
426
  const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
427
+ if (!(timeoutMin > 0 && Number.isFinite(timeoutMin) && timeoutMin <= MAX_TIMEOUT_MINUTES)) {
428
+ // timeoutMinutes: 0 would arm runProcess's watchdog with a 0ms
429
+ // timeout (Node clamps to 1ms) and kill the peer instantly.
430
+ return {
431
+ content: [{ type: "text", text: `timeoutMinutes must be a positive number of minutes up to ${MAX_TIMEOUT_MINUTES} (for example 0.5 for 30 seconds).` }],
432
+ details: emptyDetails(requestedModel, resolved.model),
433
+ };
434
+ }
655
435
 
656
436
  const rawConvId = params.conversationId;
657
437
  const isContinuation =
658
438
  typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
659
- const snapshot = isContinuation ? null : snapshotConversations();
439
+ const snapshot = isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR);
660
440
 
661
- const mode: AgyMode = (params.mode as AgyMode | undefined) ?? "accept-edits";
441
+ const mode: AgyMode =
442
+ (params.mode as AgyMode | undefined) ?? (config.mode === "plan" ? "plan" : "accept-edits");
662
443
  // Plan runs stage a restricted reviewer agent: its tools list has NO
663
- // file-editing tool, which is the hard edit block (the prompt guard
664
- // alone was observed failing once - a sub-agent still edited files).
444
+ // file-editing tool, a toolset-level damper on edits (the prompt
445
+ // guard alone was observed failing once - a sub-agent still edited
446
+ // files; the CLI itself does not enforce review-only, upstream
447
+ // #1181).
665
448
  // Staging failure degrades to the legacy fallback: no agent, no skip
666
449
  // flag, the stricter command-forbidding guard.
667
450
  let reviewerAgent: { name: string; dir: string } | null = null;
@@ -674,8 +457,13 @@ export async function registerAskAntigravityTool(
674
457
  }
675
458
  const useDigest: boolean =
676
459
  typeof params.digest === "boolean" ? params.digest : mode === "plan";
460
+ // Budget notice into the prompt BEFORE buildFinalPrompt: the plan-mode
461
+ // guard banners must stay the final text (last-read recency), so the
462
+ // notice can't go after them. The builder itself stays notice-free so
463
+ // callers can't double-wrap.
464
+ const budget = buildTimeBudgetNotice(timeoutMin * 60_000);
677
465
  const finalPrompt: string = buildFinalPrompt(
678
- params.prompt,
466
+ budget ? `${params.prompt}\n\n${budget}` : params.prompt,
679
467
  mode,
680
468
  useDigest,
681
469
  reviewerAgent !== null,
@@ -704,43 +492,27 @@ export async function registerAskAntigravityTool(
704
492
  ? `The full pi conversation context (as markdown) is at: ${contextFile}\nRead that file first for context, then do the task below.\n\n---\n\n${finalPrompt}`
705
493
  : finalPrompt;
706
494
 
707
- const args: string[] = ["--add-dir", cwd];
708
495
  log?.(
709
496
  "ask-start",
710
497
  { model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
711
498
  "info",
712
499
  );
713
- const extraRaw = extraArgs();
714
- // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
715
- // mode flags, so an env-injected skip flag would re-arm what the
716
- // plan-mode rule strips. Only the enforced agent makes the flag safe.
717
- const extra =
718
- mode === "plan" && !reviewerAgent
719
- ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
720
- : extraRaw;
721
- if (extra.length) args.push(...extra);
722
- if (resolved.model) args.push("--model", resolved.model);
723
- if (resolved.effort) args.push("--effort", resolved.effort);
724
- args.push("--mode", mode);
725
- if (reviewerAgent) args.push("--agent", reviewerAgent.name);
726
- // Honor the shared permissions setting (same knob as the provider).
727
- // accept-edits: the flag keeps commands from hanging on an
728
- // unanswerable prompt in -p mode. Plan runs WITH the reviewer agent:
729
- // the flag is safe again because the agent's toolset has no
730
- // file-editing tool - the 2026-09-25 write incident rode edit tools
731
- // that existed then; probed 2026-09-28 the restricted toolset reports
732
- // "none" for edits even with the flag, and plan discipline turned a
733
- // create-a-file request into a proposed plan instead of a write. The
734
- // flag is what lets analysis commands (builds, test runners) run for
735
- // users without allow rules. Plan fallback (staging failed): strict
736
- // legacy behavior, no flag - the prompt guard is all that is left.
737
- if (config.skipPermissions !== false && (mode !== "plan" || reviewerAgent !== null)) {
738
- args.push("--dangerously-skip-permissions");
739
- }
740
- if (isContinuation) args.push("--conversation", rawConvId as string);
741
- args.push("--print-timeout", `${timeoutMin}m`);
742
- if (contextFile) args.push("--add-dir", askContextDir());
743
- args.push("-p", effectivePrompt);
500
+ // Build argv via the shared peer adapter (see buildAgyArgs there for
501
+ // the flag-order contract and the fail-closed plan-run rules). The
502
+ // bridge's permissions gate is truthy (`!== false`), so an unset
503
+ // config counts as enabled - same knob as the provider.
504
+ const args = buildAgyArgs({
505
+ cwd,
506
+ resolved,
507
+ mode,
508
+ reviewerAgentName: reviewerAgent?.name ?? null,
509
+ skipPermissions: config.skipPermissions !== false,
510
+ conversationId: isContinuation ? (rawConvId as string) : null,
511
+ timeoutMinutes: timeoutMin,
512
+ addDirs: contextFile ? [askContextDir()] : [],
513
+ extraArgs: extraArgs(),
514
+ prompt: effectivePrompt,
515
+ });
744
516
 
745
517
  const details: AgyDetails = {
746
518
  model: requestedModel,
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/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) {
package/src/discovery.ts DELETED
@@ -1,199 +0,0 @@
1
- // Discover the agy conversation id that `agy -p` creates but never prints.
2
- //
3
- // agy's print mode writes its steps to a fresh SQLite DB at
4
- // ~/.gemini/antigravity-cli/conversations/<uuid>.db. It does NOT echo the id.
5
- // The only reliable bind is to snapshot the *.db stems in that dir before
6
- // spawn, then diff after: exactly one new file = ours. If several appear we
7
- // refuse to bind (can't tell which is ours) - same approach as antigravity-acp
8
- // (scan.ts), agy-acp (scan.ts), and pi-ask-antigravity.
9
- //
10
- // CONCURRENCY: when another agy (or subagent) starts in parallel, more than
11
- // one new .db can land in the dir during our turn. mtime filtering does NOT
12
- // disambiguate two *active* concurrent runs (both DBs are >= turn-start), and
13
- // prompt-content matching is fragile (the user-message step_type 98 payload
14
- // is undocumented and deeply nested; a field-number shift would silently
15
- // misbind). The authoritative signal is which candidate .db OUR spawned agy
16
- // process tree holds open right now - resolved via /proc/<pid>/fd on Linux.
17
- // When that signal is unavailable (no pid, non-Linux, process already exited,
18
- // or the scan itself is ambiguous) we fail safe to null, preserving the
19
- // original "refuse to bind" behavior rather than guessing.
20
-
21
- import fs from "node:fs";
22
- import os from "node:os";
23
- import path from "node:path";
24
-
25
- /** Default conversations dir. Override with AGY_CONVERSATIONS_DIR. */
26
- export const CONVERSATIONS_DIR =
27
- process.env.AGY_CONVERSATIONS_DIR ||
28
- path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
29
-
30
- /** Snapshot the set of conversation ids (*.db stems) currently on disk.
31
- * Empty set (not throw) when the dir is missing - agy will create it. */
32
- export function snapshotConversations(dir: string = CONVERSATIONS_DIR): Set<string> {
33
- const out = new Set<string>();
34
- let entries: string[];
35
- try {
36
- entries = fs.readdirSync(dir);
37
- } catch {
38
- return out;
39
- }
40
- for (const f of entries) {
41
- if (f.endsWith(".db")) out.add(f.slice(0, -3));
42
- }
43
- return out;
44
- }
45
-
46
- /** Resolve which of the `candidates` DB ids is held open by the process tree
47
- * rooted at `rootPid`. Used to disambiguate concurrent agy runs.
48
- *
49
- * Returns the single matching id, or null when none / several are open, when
50
- * /proc is unavailable, or when reading fails. Injectable so tests can stub
51
- * it without touching the filesystem. */
52
- export type OpenDbResolver = (
53
- rootPid: number,
54
- dir: string,
55
- candidates: Set<string>,
56
- ) => string | null;
57
-
58
- /** Read pid + ppid from /proc/<pid>/stat. Returns null when the entry is gone
59
- * (process exited) or unparseable. comm may contain spaces and parens, so we
60
- * split on the LAST ')' rather than naively tokenizing. */
61
- function readProcStat(pid: number): { pid: number; ppid: number } | null {
62
- let raw: string;
63
- try {
64
- raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
65
- } catch {
66
- return null;
67
- }
68
- const closeParen = raw.lastIndexOf(")");
69
- if (closeParen < 0) return null;
70
- // After "pid (comm)" come: state ppid pgrp session ...
71
- const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
72
- const ppid = Number(fields[1]);
73
- if (!Number.isFinite(ppid)) return null;
74
- return { pid, ppid };
75
- }
76
-
77
- /** Collect rootPid and every descendant pid by walking /proc/<pid>/stat
78
- * parent links. Single pass over /proc building a pid->ppid map, then
79
- * iterated to closure. */
80
- function collectDescendants(rootPid: number): Set<number> {
81
- const out = new Set<number>([rootPid]);
82
- let entries: string[];
83
- try {
84
- entries = fs.readdirSync("/proc");
85
- } catch {
86
- return out;
87
- }
88
- const ppidOf = new Map<number, number>();
89
- for (const e of entries) {
90
- if (!/^\d+$/.test(e)) continue;
91
- const s = readProcStat(Number(e));
92
- if (s) ppidOf.set(s.pid, s.ppid);
93
- }
94
- let changed = true;
95
- while (changed) {
96
- changed = false;
97
- for (const [pid, ppid] of ppidOf) {
98
- if (out.has(pid)) continue;
99
- if (out.has(ppid)) {
100
- out.add(pid);
101
- changed = true;
102
- }
103
- }
104
- }
105
- return out;
106
- }
107
-
108
- /** Linux /proc implementation of OpenDbResolver. Scans every FD symlink in
109
- * the root process's tree and returns the one candidate .db that is open.
110
- * Non-authoritative when zero or >1 candidates are open - caller fails safe. */
111
- export const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
112
- if (candidates.size <= 1) return null; // nothing to disambiguate
113
- if (process.platform !== "linux") return null; // /proc/<pid>/fd is Linux-only
114
- const dirResolved = safeRealpath(dir);
115
- const tree = collectDescendants(rootPid);
116
- const found = new Set<string>();
117
- for (const pid of tree) {
118
- let fds: string[];
119
- try {
120
- fds = fs.readdirSync(`/proc/${pid}/fd`);
121
- } catch {
122
- continue; // process gone or fd dir unreadable
123
- }
124
- for (const fd of fds) {
125
- let target: string;
126
- try {
127
- target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
128
- } catch {
129
- continue;
130
- }
131
- // agy also holds .db-wal / .db-shm open; only the base .db matters.
132
- const base = path.basename(target);
133
- if (!base.endsWith(".db")) continue;
134
- if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
135
- const id = base.slice(0, -3);
136
- if (candidates.has(id)) found.add(id);
137
- }
138
- }
139
- if (found.size === 1) return [...found][0] ?? null;
140
- return null;
141
- };
142
-
143
- /** fs.realpathSync that swallows ENOENT (dir may not be canonicalized yet). */
144
- function safeRealpath(p: string): string | null {
145
- try {
146
- return fs.realpathSync(p);
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- /** Options for {@link newConversationId}. */
153
- export interface BindOptions {
154
- /** Pid of the spawned agy (the process we want to bind to). When set and
155
- * multiple candidate DBs exist, the process-tree FD resolver tries to
156
- * pick ours. Omit to keep the legacy fail-safe behavior. */
157
- pid?: number;
158
- /** Override resolver (tests). Defaults to the Linux /proc scanner. */
159
- resolveOpenDb?: OpenDbResolver;
160
- /** Invoked when the snapshot is ambiguous (more than one new DB since
161
- * `before`) and the resolver could not authoritatively pick ours. Lets a
162
- * caller bound its retry budget to the genuinely-ambiguous case only, so
163
- * the ordinary "agy hasn't created its DB yet" wait is not counted
164
- * against it. Not invoked when there is nothing new yet, or when exactly
165
- * one new DB binds, or when the resolver succeeds. */
166
- onAmbiguous?: () => void;
167
- }
168
-
169
- /** Find the conversation id created since `before`. Returns null when none
170
- * appeared, or when several appeared and we cannot authoritatively tie one
171
- * to our process. Pass `opts.pid` to enable concurrent-run disambiguation. */
172
- export function newConversationId(
173
- dir: string,
174
- before: Set<string>,
175
- opts: BindOptions = {},
176
- ): string | null {
177
- const after = snapshotConversations(dir);
178
- const created = [...after].filter((id) => !before.has(id));
179
- if (created.length === 0) return null;
180
- if (created.length === 1) return created[0] ?? null;
181
- // Ambiguous: more than one new DB since the snapshot. Try to authoritatively
182
- // identify ours via the spawned process's open files. Fail safe to null
183
- // (refuse to bind) when the resolver can't pick exactly one.
184
- if (opts.pid !== undefined) {
185
- const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
186
- const hit = resolve(opts.pid, dir, new Set(created));
187
- if (hit && created.includes(hit)) return hit;
188
- }
189
- opts.onAmbiguous?.();
190
- return null;
191
- }
192
-
193
- /** Path to the DB file for a given conversation id. */
194
- export function conversationDbPath(
195
- id: string,
196
- dir: string = CONVERSATIONS_DIR,
197
- ): string {
198
- return path.join(dir, `${id}.db`);
199
- }