@estebanforge/pi-antigravity-bridge 1.6.4 → 1.7.0
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 +20 -1
- package/README.md +18 -16
- package/extensions/index.ts +163 -30
- package/package.json +1 -1
- package/src/acp/connection.ts +42 -2
- package/src/acp/driver.ts +42 -19
- package/src/acp/events.ts +8 -4
- package/src/acp/usage-estimate.ts +3 -1
- package/src/bridge-catalog.ts +29 -0
- package/src/config.ts +11 -0
- package/src/driver-types.ts +3 -2
- package/src/provider.ts +50 -15
- package/src/web-tools.ts +311 -0
package/src/web-tools.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Web tools for Pi: agy_web_search + agy_read_url, backed by one-shot
|
|
2
|
+
// search-only `agy` runs. Off by default (config.webTools); their audience is
|
|
3
|
+
// sessions driven by NON-Antigravity providers - inside agy sessions the model
|
|
4
|
+
// already has search_web/read_url_content natively (live-probed 2026-09-23 on
|
|
5
|
+
// both engines).
|
|
6
|
+
//
|
|
7
|
+
// Each call spawns `agy --agent <temporary> --print <query> --mode plan
|
|
8
|
+
// --output-format stream-json` with a temporary agent restricted to
|
|
9
|
+
// search_web + read_url_content (bridge MCP inheritance off), then gates the
|
|
10
|
+
// answer on an observed allowed-tool step. Gate accepts both status spellings
|
|
11
|
+
// the CLI has shipped (driver.ts: "SUCCESS" live, "OK" older builds).
|
|
12
|
+
//
|
|
13
|
+
// Temp agent dirs live in the user-global ~/.gemini/config/agents (the only
|
|
14
|
+
// location agy 1.2.9 discovers; no --agent-dir flag exists). Normal cleanup is
|
|
15
|
+
// the `finally`; SIGKILL can orphan, so sweepStaleWebAgents() removes leftovers
|
|
16
|
+
// at registration - same hygiene doctrine as mcp-registration's pid sweep.
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
|
|
26
|
+
export const WEB_AGENT_PREFIX = "pi-bridge-web-";
|
|
27
|
+
|
|
28
|
+
const DEADLINE_MS = 120_000;
|
|
29
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
30
|
+
const MAX_QUERY_CHARS = 2000;
|
|
31
|
+
const ALLOWED_TOOLS = new Set(["search_web", "read_url_content"]);
|
|
32
|
+
const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/** ~/.gemini/config/agents - the directory agy scans for named agents. */
|
|
35
|
+
export function webAgentsRoot(): string {
|
|
36
|
+
return path.join(os.homedir(), ".gemini", "config", "agents");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Remove leftover pi-bridge-web-* agent dirs: dead-pid markers always, and
|
|
40
|
+
* marker-less dirs only after a grace period (a live sibling process may be
|
|
41
|
+
* between mkdir and its pid write). Never touches foreign agent dirs. */
|
|
42
|
+
export function sweepStaleWebAgents(root: string = webAgentsRoot(), now = Date.now()): void {
|
|
43
|
+
let entries: string[];
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(root);
|
|
46
|
+
} catch {
|
|
47
|
+
return; // no agents dir yet: nothing to sweep
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (!entry.startsWith(WEB_AGENT_PREFIX)) continue;
|
|
51
|
+
try {
|
|
52
|
+
if (isStaleWebAgentDir(path.join(root, entry), now)) {
|
|
53
|
+
rmSync(path.join(root, entry), { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// vanished mid-sweep: nothing to remove
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stale = a dead-pid marker, or no marker at all and older than the grace
|
|
62
|
+
* period (a live sibling may sit between mkdir and its pid write). */
|
|
63
|
+
function isStaleWebAgentDir(dir: string, now: number): boolean {
|
|
64
|
+
let raw = "";
|
|
65
|
+
try {
|
|
66
|
+
raw = readFileSync(path.join(dir, ".pid"), "utf8").trim();
|
|
67
|
+
} catch {
|
|
68
|
+
raw = ""; // no marker yet: decide by age
|
|
69
|
+
}
|
|
70
|
+
const pid = Number.parseInt(raw, 10);
|
|
71
|
+
if (Number.isInteger(pid) && pid > 0) return !pidAlive(pid);
|
|
72
|
+
try {
|
|
73
|
+
return now - statSync(dir).mtimeMs > ORPHAN_GRACE_MS;
|
|
74
|
+
} catch {
|
|
75
|
+
return true; // dir vanished mid-sweep
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Best effort: a signal-able pid proves *a* live process, not the original
|
|
80
|
+
* owner; pid reuse can mask an orphan until the 24h age bound. Same residual
|
|
81
|
+
* as mcp-registration's sweep. */
|
|
82
|
+
function pidAlive(pid: number): boolean {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, 0);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type WebRunResult =
|
|
92
|
+
| { ok: true; response: string }
|
|
93
|
+
| { ok: false; error: string };
|
|
94
|
+
|
|
95
|
+
export interface WebRunOptions {
|
|
96
|
+
/** Query (agy_web_search) or URL-reading instruction (agy_read_url). */
|
|
97
|
+
prompt: string;
|
|
98
|
+
/** The single allowed tool whose DONE step the answer is gated on. */
|
|
99
|
+
gatedTool: "search_web" | "read_url_content";
|
|
100
|
+
/** Server binary; defaults to AGY_BIN or PATH, matching ask-tool. */
|
|
101
|
+
bin?: string;
|
|
102
|
+
/** Agents root override (tests); defaults to the user-global agy dir. */
|
|
103
|
+
agentsRoot?: string;
|
|
104
|
+
/** Working directory handed to agy. */
|
|
105
|
+
cwd?: string;
|
|
106
|
+
timeoutMs?: number;
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
log?: (event: string, data?: unknown, level?: "info" | "warn" | "error") => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One gated one-shot web run. Creates a unique restricted agent, streams the
|
|
112
|
+
* NDJSON frames, refuses answers with no observed allowed-tool step, and
|
|
113
|
+
* always removes the agent dir. */
|
|
114
|
+
export async function runWebAgent(opts: WebRunOptions): Promise<WebRunResult> {
|
|
115
|
+
const root = opts.agentsRoot ?? webAgentsRoot();
|
|
116
|
+
mkdirSync(root, { recursive: true });
|
|
117
|
+
// Nonce: two parallel tool calls in one process can share a millisecond;
|
|
118
|
+
// a shared dir would let one call's finally delete the other's agent.
|
|
119
|
+
const agentName = `${WEB_AGENT_PREFIX}${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
120
|
+
const agentDir = path.join(root, agentName);
|
|
121
|
+
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
122
|
+
// commandExecutionPolicy stays at the fork-validated "auto": the template's
|
|
123
|
+
// tools list has no command tool and plan mode blocks edits; an unvalidated
|
|
124
|
+
// stricter enum risks agent discovery failing outright.
|
|
125
|
+
writeFileSync(
|
|
126
|
+
path.join(agentDir, "agent.md"),
|
|
127
|
+
`---\nname: ${agentName}\ndescription: Temporary Pi web delegate\nmainAgent: true\nsubagent: false\nmodel: inherit\nexcludeDefaultComponents: true\ninheritCustomizations: false\ninheritMcp: false\ncommandExecutionPolicy: auto\ntools:\n - search_web\n - read_url_content\nskills: []\nrules: []\nagents: []\nmcpServers: []\n---\n\nAnswer the user's query using search_web (and read_url_content when a page must be opened). Cite sources. Use no other tools.\n`,
|
|
128
|
+
{ mode: 0o600 },
|
|
129
|
+
);
|
|
130
|
+
writeFileSync(path.join(agentDir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
|
|
131
|
+
|
|
132
|
+
const bin = opts.bin ?? process.env.AGY_BIN ?? "agy";
|
|
133
|
+
const args = [
|
|
134
|
+
"--agent", agentName,
|
|
135
|
+
"--print", opts.prompt,
|
|
136
|
+
"--mode", "plan",
|
|
137
|
+
"--output-format", "stream-json",
|
|
138
|
+
"--disable-slash-commands",
|
|
139
|
+
];
|
|
140
|
+
opts.log?.("web-run-start", { agent: agentName, gatedTool: opts.gatedTool });
|
|
141
|
+
|
|
142
|
+
const child = spawn(bin, args, { cwd: opts.cwd ?? process.cwd(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
143
|
+
const stop = () => {
|
|
144
|
+
if (!child.killed) child.kill();
|
|
145
|
+
};
|
|
146
|
+
const onAbort = () => stop();
|
|
147
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
const timer = setTimeout(stop, opts.timeoutMs ?? DEADLINE_MS);
|
|
149
|
+
|
|
150
|
+
let buffer = "";
|
|
151
|
+
let bytes = 0;
|
|
152
|
+
let stderrTail = "";
|
|
153
|
+
let failed = "";
|
|
154
|
+
let allowedToolUsed = false;
|
|
155
|
+
let response = "";
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
159
|
+
bytes += chunk.length;
|
|
160
|
+
if (bytes > MAX_OUTPUT_BYTES && !failed) {
|
|
161
|
+
failed = "Antigravity web run output exceeded the 2 MiB limit";
|
|
162
|
+
stop();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
buffer += chunk.toString("utf8");
|
|
166
|
+
let newline = buffer.indexOf("\n");
|
|
167
|
+
while (newline !== -1 && !failed) {
|
|
168
|
+
handleFrame(buffer.slice(0, newline));
|
|
169
|
+
buffer = buffer.slice(newline + 1);
|
|
170
|
+
newline = buffer.indexOf("\n");
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
174
|
+
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-1000);
|
|
175
|
+
});
|
|
176
|
+
const code = await new Promise<number | null>((resolve, reject) => {
|
|
177
|
+
child.once("error", reject);
|
|
178
|
+
child.once("close", resolve);
|
|
179
|
+
});
|
|
180
|
+
if (!failed && opts.signal?.aborted) failed = "web run aborted";
|
|
181
|
+
if (!failed && code !== 0) failed = `agy exited with code ${code}${stderrTail ? `: ${stderrTail.trim().slice(-300)}` : ""}`;
|
|
182
|
+
if (!failed && !allowedToolUsed) failed = `Antigravity returned an answer without an observed ${opts.gatedTool} step; refusing it as unverified`;
|
|
183
|
+
if (!failed && response === "") failed = "Antigravity web run produced no answer";
|
|
184
|
+
if (failed) {
|
|
185
|
+
opts.log?.("web-run-failed", { agent: agentName, error: failed }, "warn");
|
|
186
|
+
return { ok: false, error: failed };
|
|
187
|
+
}
|
|
188
|
+
opts.log?.("web-run-ok", { agent: agentName });
|
|
189
|
+
return { ok: true, response };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
192
|
+
opts.log?.("web-run-failed", { agent: agentName, error: message }, "warn");
|
|
193
|
+
return { ok: false, error: message };
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
197
|
+
stop();
|
|
198
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handleFrame(line: string): void {
|
|
202
|
+
if (failed || !line.startsWith("{")) return;
|
|
203
|
+
let frame: unknown;
|
|
204
|
+
try {
|
|
205
|
+
frame = JSON.parse(line);
|
|
206
|
+
} catch {
|
|
207
|
+
return; // non-JSON chatter: ignore, matching the driver's line loop
|
|
208
|
+
}
|
|
209
|
+
if (typeof frame !== "object" || frame === null) return;
|
|
210
|
+
const record = frame as Record<string, unknown>;
|
|
211
|
+
if (record.event === "step_update" && typeof record.step_update === "object" && record.step_update !== null) {
|
|
212
|
+
const step = record.step_update as Record<string, unknown>;
|
|
213
|
+
if (step.step_type !== "tool" || typeof step.tool_name !== "string") return;
|
|
214
|
+
if (ALLOWED_TOOLS.has(step.tool_name)) {
|
|
215
|
+
// Drift tolerance: DONE today, OK on older builds (driver.ts:507).
|
|
216
|
+
if (step.state === "DONE" || step.state === "OK") allowedToolUsed = true;
|
|
217
|
+
} else if (step.state === "ACTIVE" || step.state === "DONE") {
|
|
218
|
+
failed = `Antigravity web agent used a disallowed tool: ${step.tool_name}`;
|
|
219
|
+
stop();
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (record.event === "result" && typeof record.result === "object" && record.result !== null) {
|
|
224
|
+
const result = record.result as Record<string, unknown>;
|
|
225
|
+
if (result.status !== "SUCCESS" && result.status !== "OK") {
|
|
226
|
+
failed = `Antigravity web run reported status ${String(result.status)}`;
|
|
227
|
+
stop();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (typeof result.response === "string") response = result.response;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const QUOTA_NOTE =
|
|
236
|
+
"Each call spawns a one-shot Antigravity CLI process and consumes your Antigravity quota.";
|
|
237
|
+
|
|
238
|
+
const toolText = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
|
239
|
+
const toolError = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true as const, details: {} });
|
|
240
|
+
|
|
241
|
+
/** Register agy_web_search + agy_read_url. Call only when config.webTools is
|
|
242
|
+
* on - registration is the opt-in. */
|
|
243
|
+
export function registerWebTools(
|
|
244
|
+
pi: ExtensionAPI,
|
|
245
|
+
opts: { bin?: string; cwd?: string; timeoutMs?: number; log?: WebRunOptions["log"] } = {},
|
|
246
|
+
): void {
|
|
247
|
+
sweepStaleWebAgents();
|
|
248
|
+
|
|
249
|
+
pi.registerTool({
|
|
250
|
+
name: "agy_web_search",
|
|
251
|
+
label: "Antigravity Web Search",
|
|
252
|
+
description: `Search the live web via Google Antigravity. Returns a short cited answer, not raw results. Use when the question needs current information. ${QUOTA_NOTE}`,
|
|
253
|
+
parameters: Type.Object({
|
|
254
|
+
query: Type.String({
|
|
255
|
+
description: `What to search for. Max ${MAX_QUERY_CHARS} characters.`,
|
|
256
|
+
}),
|
|
257
|
+
}),
|
|
258
|
+
async execute(_toolCallId, params, signal) {
|
|
259
|
+
const query = typeof params.query === "string" ? params.query.trim() : "";
|
|
260
|
+
if (query === "") return toolError("query must be a non-empty string");
|
|
261
|
+
if (query.length > MAX_QUERY_CHARS) return toolError(`query exceeds ${MAX_QUERY_CHARS} characters`);
|
|
262
|
+
const run = await runWebAgent({
|
|
263
|
+
prompt: query,
|
|
264
|
+
gatedTool: "search_web",
|
|
265
|
+
bin: opts.bin,
|
|
266
|
+
cwd: opts.cwd,
|
|
267
|
+
timeoutMs: opts.timeoutMs,
|
|
268
|
+
signal,
|
|
269
|
+
log: opts.log,
|
|
270
|
+
});
|
|
271
|
+
if (!run.ok) return toolError(`Web search failed: ${run.error}`);
|
|
272
|
+
return toolText(run.response);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
pi.registerTool({
|
|
277
|
+
name: "agy_read_url",
|
|
278
|
+
label: "Antigravity Read URL",
|
|
279
|
+
description: `Fetch the content of a public http(s) URL via Google Antigravity and get its main content back as text. Use for pages you need to read. ${QUOTA_NOTE}`,
|
|
280
|
+
parameters: Type.Object({
|
|
281
|
+
url: Type.String({
|
|
282
|
+
description: "Absolute http(s) URL to read.",
|
|
283
|
+
}),
|
|
284
|
+
}),
|
|
285
|
+
async execute(_toolCallId, params, signal) {
|
|
286
|
+
const raw = typeof params.url === "string" ? params.url.trim() : "";
|
|
287
|
+
let url: URL;
|
|
288
|
+
try {
|
|
289
|
+
url = new URL(raw);
|
|
290
|
+
} catch {
|
|
291
|
+
return toolError("url must be an absolute http(s) URL");
|
|
292
|
+
}
|
|
293
|
+
// Allowlist: a URL parameter is untrusted input, and schemes like
|
|
294
|
+
// file: or javascript: must never reach the delegate.
|
|
295
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
296
|
+
return toolError("only http and https URLs are supported");
|
|
297
|
+
}
|
|
298
|
+
const run = await runWebAgent({
|
|
299
|
+
prompt: `Read the content of ${url.toString()} with read_url_content and return its main content as plain text. Keep the structure (headings, lists) but drop navigation and ads.`,
|
|
300
|
+
gatedTool: "read_url_content",
|
|
301
|
+
bin: opts.bin,
|
|
302
|
+
cwd: opts.cwd,
|
|
303
|
+
timeoutMs: opts.timeoutMs,
|
|
304
|
+
signal,
|
|
305
|
+
log: opts.log,
|
|
306
|
+
});
|
|
307
|
+
if (!run.ok) return toolError(`URL read failed: ${run.error}`);
|
|
308
|
+
return toolText(run.response);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|