@estebanforge/pi-antigravity-bridge 1.6.3 → 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.
@@ -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
+ }