@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 +24 -0
- package/README.md +14 -3
- package/extensions/index.ts +65 -9
- package/package.json +2 -1
- package/src/ask-tool.ts +127 -355
- package/src/config.ts +61 -9
- package/src/driver.ts +10 -10
- package/src/provider.ts +26 -5
- package/src/discovery.ts +0 -199
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
|
|
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,
|
|
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` (
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
1886
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
87
|
-
- **accept-edits
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
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
|
|
306
|
-
*
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
388
|
-
* with no file-editing tool present the model
|
|
389
|
-
*
|
|
390
|
-
* edits
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
664
|
-
// alone was observed failing once - a sub-agent still edited
|
|
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
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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.
|
|
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
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
236
|
+
return parsed as Partial<AgyConfig>;
|
|
223
237
|
}
|
|
224
238
|
} catch {
|
|
225
|
-
/* missing or corrupt - fall back to
|
|
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
|
|
351
|
-
|
|
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
|
|
84
|
-
*
|
|
85
|
-
* write
|
|
86
|
-
* must both go through this, or plan turns
|
|
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
|
|
356
|
-
//
|
|
357
|
-
// 2026-
|
|
358
|
-
//
|
|
359
|
-
//
|
|
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
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
|
|
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
|
-
|
|
1444
|
-
|
|
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
|
-
}
|