@estebanforge/pi-antigravity-bridge 1.4.9 → 1.5.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 +31 -0
- package/README.md +16 -29
- package/docs/ACP-ADOPTION-PLAN.md +83 -18
- package/docs/ACP-PROTOCOL-REFERENCE.md +4 -3
- package/docs/APPROVAL-GATE.md +33 -0
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/DEVELOPMENT.md +17 -0
- package/docs/ENGINES.md +46 -0
- package/docs/PI-BRIDGE-GAPS.md +26 -5
- package/docs/TODO.md +21 -0
- package/extensions/index.ts +314 -19
- package/package.json +1 -1
- package/src/acp/driver.ts +67 -9
- package/src/acp/usage-estimate.ts +59 -0
- package/src/approval-detect.ts +146 -0
- package/src/approval-gate.ts +208 -0
- package/src/approval-hook.ts +252 -0
- package/src/config.ts +62 -1
- package/src/driver-types.ts +9 -9
- package/src/driver.ts +6 -5
- package/src/engine-picker.ts +155 -0
- package/src/mcp-registration.ts +127 -0
- package/src/mcp-server.ts +192 -10
- package/src/models.ts +2 -2
- package/src/provider.ts +209 -26
package/extensions/index.ts
CHANGED
|
@@ -17,10 +17,15 @@
|
|
|
17
17
|
// ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
|
|
18
18
|
|
|
19
19
|
import os from "node:os";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
20
22
|
import {
|
|
21
23
|
type ExtensionAPI,
|
|
22
24
|
type ExtensionCommandContext,
|
|
23
25
|
type ExtensionUIContext,
|
|
26
|
+
createBashToolDefinition,
|
|
27
|
+
createEditToolDefinition,
|
|
28
|
+
createWriteToolDefinition,
|
|
24
29
|
getSettingsListTheme,
|
|
25
30
|
} from "@earendil-works/pi-coding-agent";
|
|
26
31
|
import {
|
|
@@ -38,6 +43,7 @@ import {
|
|
|
38
43
|
} from "../src/models.js";
|
|
39
44
|
import { SessionStore } from "../src/sessions.js";
|
|
40
45
|
import {
|
|
46
|
+
APPROVAL_PARK_MS,
|
|
41
47
|
POLL_TOOL_NAME,
|
|
42
48
|
ToolRoundTrips,
|
|
43
49
|
WrapperReplay,
|
|
@@ -45,16 +51,30 @@ import {
|
|
|
45
51
|
formatEscalatedAck,
|
|
46
52
|
formatPollAnswer,
|
|
47
53
|
} from "../src/provider.js";
|
|
48
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
createShadowTool,
|
|
56
|
+
stripMarkerFields,
|
|
57
|
+
type AnyToolDefinition,
|
|
58
|
+
type GatePolicy,
|
|
59
|
+
} from "../src/approval-gate.js";
|
|
60
|
+
import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
|
|
61
|
+
import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
|
|
62
|
+
import { StreamDriver } from "../src/driver.js";
|
|
49
63
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
50
64
|
import { runAcpAuth } from "../src/acp/auth.js";
|
|
51
65
|
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
52
66
|
import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
53
67
|
import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
|
|
54
68
|
import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
69
|
+
import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
|
|
55
70
|
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
56
71
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
57
72
|
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
73
|
+
import {
|
|
74
|
+
registerBridgeServer,
|
|
75
|
+
sweepStaleBridgeServers,
|
|
76
|
+
unregisterBridgeServer,
|
|
77
|
+
} from "../src/mcp-registration.js";
|
|
58
78
|
import {
|
|
59
79
|
ACTIVATE_SKILL_TOOL_NAME,
|
|
60
80
|
activateSkillSchema,
|
|
@@ -107,7 +127,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
107
127
|
const engine: Engine = loadConfig().engine;
|
|
108
128
|
// Engine switching requires a restart, so the catalog-time engine read is
|
|
109
129
|
// authoritative for input advertising: image attach rides only when turns
|
|
110
|
-
// will run on the ACP engine (the
|
|
130
|
+
// will run on the ACP engine (the stream-json CLI prompt is text-only).
|
|
111
131
|
const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
|
|
112
132
|
const models = entries.map((e) => toPiModel(e, modelInput));
|
|
113
133
|
|
|
@@ -156,9 +176,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
156
176
|
// MCP bridge handle, declared early: the ACP engine reads the bridge port
|
|
157
177
|
// at session/new / session/load time.
|
|
158
178
|
let mcpHandle: McpServerHandle | null = null;
|
|
179
|
+
// Approval-gate hook script (per-pid, token embedded). Written at session
|
|
180
|
+
// start when the gate is active; removed at session_shutdown.
|
|
181
|
+
let gateScriptPath: string | null = null;
|
|
159
182
|
// ACP self-heal runs once per process (session_start re-fires on /reload;
|
|
160
183
|
// a ready setup is two file stats, so re-running is harmless anyway).
|
|
161
184
|
let acpSelfHealRan = false;
|
|
185
|
+
// Warn-once-per-process flag for the missing-agy-CLI toast (stream-json).
|
|
186
|
+
let agyMissingWarned = false;
|
|
162
187
|
// OAuth URL capture: the server hands the login URL only to the
|
|
163
188
|
// browser-open call (nothing on stdio), so a BROWSER wrapper records it
|
|
164
189
|
// and the driver logs it as "auth-url". Local users keep the automatic
|
|
@@ -184,11 +209,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
184
209
|
"session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
|
|
185
210
|
"unsupported-server-request",
|
|
186
211
|
]);
|
|
187
|
-
const
|
|
188
|
-
// Mirror the
|
|
212
|
+
const streamDriver = new StreamDriver();
|
|
213
|
+
// Mirror the stream driver's lifecycle ring into the daily file log
|
|
189
214
|
// (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
|
|
190
215
|
// through acpLog below.
|
|
191
|
-
|
|
216
|
+
streamDriver.log = (msg, data) => {
|
|
192
217
|
// Level classification mirrors acpLog's failure set: stalls, aborts,
|
|
193
218
|
// timeouts and nonzero exits are the "what broke" greps (warn);
|
|
194
219
|
// turn-start is the per-turn skeleton (info); everything else is
|
|
@@ -254,13 +279,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
254
279
|
// Resolved per connection: the setup flow can install the binary and
|
|
255
280
|
// update acp.bin mid-session; the next turn picks it up (no restart).
|
|
256
281
|
bin: () => loadConfig().acp.bin,
|
|
282
|
+
// Resolved per turn: /agy and AGY_USAGE_ESTIMATE changes apply without
|
|
283
|
+
// a restart. Without this the driver defaults to "estimate" and the
|
|
284
|
+
// config knob (incl. "off") is dead.
|
|
285
|
+
usageEstimate: () => loadConfig().acp.usageEstimate,
|
|
257
286
|
...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
|
|
258
287
|
log: acpLog,
|
|
259
288
|
mcpServers: () => {
|
|
260
289
|
const handle = mcpHandle;
|
|
261
290
|
if (!handle) return [];
|
|
262
291
|
// The bridge 403s any request without the shared-secret header; the
|
|
263
|
-
//
|
|
292
|
+
// stream engine carries it via mcp_config.json, ACP via headers[].
|
|
264
293
|
return [
|
|
265
294
|
{
|
|
266
295
|
name: "pi-bridge",
|
|
@@ -272,15 +301,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
272
301
|
},
|
|
273
302
|
});
|
|
274
303
|
// The active engine is resolved from the latched load-time value.
|
|
275
|
-
const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver :
|
|
276
|
-
// The provider's stream-json slot gets the
|
|
304
|
+
const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
|
|
305
|
+
// The provider's stream-json slot gets the STREAM driver explicitly - never
|
|
277
306
|
// activeDriver(), or a load-time acp engine would make deps.driver and
|
|
278
307
|
// deps.acpDriver the same object and break the engine identity check.
|
|
279
|
-
const driver =
|
|
308
|
+
const driver = streamDriver;
|
|
280
309
|
// The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
|
|
281
310
|
// the provider emits them as real pi toolUse turns and completes them from
|
|
282
311
|
// the next call's toolResult.
|
|
283
|
-
const roundTrips = new ToolRoundTrips(
|
|
312
|
+
const roundTrips = new ToolRoundTrips(
|
|
313
|
+
activeDriver,
|
|
314
|
+
(s, d, level) =>
|
|
315
|
+
fileLog.log(
|
|
316
|
+
s,
|
|
317
|
+
d,
|
|
318
|
+
level ?? (s === "round-trip-fail" ? "warn" : "debug"),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
284
321
|
const replay = new WrapperReplay();
|
|
285
322
|
// Native re-exec only emits for builtins actually active in the session;
|
|
286
323
|
// anything else (or an unknown name) falls back to the wrapper card.
|
|
@@ -306,7 +343,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
306
343
|
);
|
|
307
344
|
roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
|
|
308
345
|
};
|
|
309
|
-
|
|
346
|
+
streamDriver.onTurnEnd = onTurnEnd;
|
|
310
347
|
acpDriver.onTurnEnd = onTurnEnd;
|
|
311
348
|
const streamSimple = createStreamSimple({
|
|
312
349
|
entries,
|
|
@@ -350,6 +387,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
350
387
|
acpLog,
|
|
351
388
|
fileLog,
|
|
352
389
|
authCapture: authCapture ?? null,
|
|
390
|
+
runAcpPickSetup,
|
|
353
391
|
});
|
|
354
392
|
|
|
355
393
|
// AskAntigravity tool: one-shot delegation to agy (ported from
|
|
@@ -382,12 +420,115 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
382
420
|
},
|
|
383
421
|
});
|
|
384
422
|
|
|
423
|
+
/** ACP pick follow-through (first-run wizard): the same self-service
|
|
424
|
+
* setup the /agy engine acp command runs, but immediately - the 1.5 GB
|
|
425
|
+
* download starts while the toast is still on screen, progress rides the
|
|
426
|
+
* footer status, and the Google sign-in opens when the install lands.
|
|
427
|
+
* Restart still applies the engine (drivers wire at load); this only
|
|
428
|
+
* removes the wait. Fire-and-forget: the caller already toasted the
|
|
429
|
+
* promise, failures land in the daily log + a warning toast. */
|
|
430
|
+
// eslint-disable-next-line @typescript-eslint/no-inner-declarations -- hoisted: registerAgyCommand below injects it
|
|
431
|
+
async function runAcpPickSetup(ctx: { ui: ExtensionUIContext }): Promise<void> {
|
|
432
|
+
acpSelfHealRan = true;
|
|
433
|
+
let lastPhase = "";
|
|
434
|
+
ctx.ui.setStatus("agy-acp", "downloading ACP server…");
|
|
435
|
+
try {
|
|
436
|
+
const status = await ensureAcpReady({
|
|
437
|
+
configBin: loadConfig().acp.bin,
|
|
438
|
+
onProgress: (m) => {
|
|
439
|
+
// Dual surface: the status bar carries the live percent (cleared
|
|
440
|
+
// on completion, zero footprint); the chat window gets phase
|
|
441
|
+
// milestones only (download start, unpacking, installed) - same
|
|
442
|
+
// line-in-chat feel as other extensions' notify() notices. The
|
|
443
|
+
// percent variant updates every chunk and would spam the chat.
|
|
444
|
+
ctx.ui.setStatus("agy-acp", m);
|
|
445
|
+
if (m !== lastPhase && !/\d+%/.test(m)) {
|
|
446
|
+
ctx.ui.notify(m, "info");
|
|
447
|
+
lastPhase = m;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
ctx.ui.setStatus("agy-acp", undefined);
|
|
452
|
+
fileLog.log(
|
|
453
|
+
"acp-setup",
|
|
454
|
+
status.ok
|
|
455
|
+
? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
|
|
456
|
+
: { ok: false, error: status.error },
|
|
457
|
+
status.ok ? "info" : "warn",
|
|
458
|
+
);
|
|
459
|
+
if (!status.ok) {
|
|
460
|
+
ctx.ui.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Spread, not a bare acp patch: a bare {bin} patch would drop
|
|
464
|
+
// sibling keys (usageEstimate) from the file.
|
|
465
|
+
saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
|
|
466
|
+
if (!status.needsLogin) {
|
|
467
|
+
ctx.ui.notify(`ACP server ready (auth: ${status.auth}). Restart applies the engine.`, "info");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
ctx.ui.notify(
|
|
471
|
+
"ACP server ready. Signing in: the Google sign-in opens in your browser and completes when you finish it.",
|
|
472
|
+
"info",
|
|
473
|
+
);
|
|
474
|
+
const r = await runAcpAuth({
|
|
475
|
+
bin: status.bin,
|
|
476
|
+
...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
|
|
477
|
+
log: acpLog,
|
|
478
|
+
});
|
|
479
|
+
fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
|
|
480
|
+
if (r.ok) ctx.ui.notify("Signed in. The ACP engine is ready; restart applies it.", "info");
|
|
481
|
+
else ctx.ui.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
|
|
482
|
+
} catch (err) {
|
|
483
|
+
ctx.ui.setStatus("agy-acp", undefined);
|
|
484
|
+
fileLog.log("acp-setup", { error: String(err) }, "warn");
|
|
485
|
+
ctx.ui.notify(`ACP setup failed (${String(err)}). /agy auth retries; /agy doctor inspects.`, "warning");
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
385
489
|
// MCP tool bridge: expose pi's tools to agy over localhost Streamable HTTP.
|
|
386
490
|
// Calls park in the provider's round-trip store and complete through pi's
|
|
387
491
|
// normal toolUse loop (native cards, permissions, hooks) - no patch, no
|
|
388
492
|
// privileged API. Started on session_start, torn down on session_shutdown.
|
|
389
|
-
pi.on("session_start", async (
|
|
493
|
+
pi.on("session_start", async (event, ctx) => {
|
|
390
494
|
if (ctx.hasUI) activeUi = ctx.ui;
|
|
495
|
+
// First-run engine picker: ask once, on the first interactive start,
|
|
496
|
+
// which turn engine to use. Skipped headless (ctx.mode !== "tui"),
|
|
497
|
+
// when AGY_ENGINE is set, or once any config file exists (any save -
|
|
498
|
+
// even of an unrelated knob - means the user has been here before).
|
|
499
|
+
// esc = decide later: nothing is written, the picker reappears next
|
|
500
|
+
// start. Like /agy engine, the choice applies on the next start
|
|
501
|
+
// (drivers wire at load). The await intentionally runs before the
|
|
502
|
+
// bridge startup below: on a genuine first run the modal blocks input
|
|
503
|
+
// anyway, so the delay is invisible.
|
|
504
|
+
if (event.reason === "startup" && ctx.mode === "tui" && shouldOfferEnginePicker(CONFIG_PATH)) {
|
|
505
|
+
// Best-effort, like the legacy-patch notice below: a picker failure
|
|
506
|
+
// (mid-prompt TUI teardown, resize races) must never take down the
|
|
507
|
+
// rest of session_start - the MCP bridge startup included. The
|
|
508
|
+
// default engine keeps working untouched.
|
|
509
|
+
try {
|
|
510
|
+
const picked = await showEnginePicker(ctx.ui);
|
|
511
|
+
if (picked) {
|
|
512
|
+
saveConfig({ engine: picked });
|
|
513
|
+
ctx.ui.notify(savedEngineMessage(picked), "info");
|
|
514
|
+
if (picked === "acp") void runAcpPickSetup(ctx);
|
|
515
|
+
}
|
|
516
|
+
} catch (err) {
|
|
517
|
+
fileLog.log("engine-picker", { error: String(err) }, "warn");
|
|
518
|
+
console.error(`[antigravity-bridge] engine picker failed: ${String(err)}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// agy presence check (stream-json engine): the CLI is the whole engine,
|
|
522
|
+
// so a missing binary means every Antigravity turn would fail. Warn on
|
|
523
|
+
// every process start until it is installed (per-process flag so /new,
|
|
524
|
+
// /resume and /reload re-fires do not nag mid-session). Runs after the
|
|
525
|
+
// picker above, so a first-run stream-json pick warns immediately.
|
|
526
|
+
if (engine === "stream-json" && !agyMissingWarned && !isAgyInstalled(binary)) {
|
|
527
|
+
agyMissingWarned = true;
|
|
528
|
+
const msg = agyMissingMessage();
|
|
529
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
530
|
+
else console.error(`[antigravity-bridge] ${msg}`);
|
|
531
|
+
}
|
|
391
532
|
// Legacy cleanup: users who ran the old consent-gated patcher still
|
|
392
533
|
// carry pi.invokeTool in their installed pi. Inert, but tell them once
|
|
393
534
|
// and offer /agy patch-cleanup. Never auto-edits the install.
|
|
@@ -421,7 +562,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
421
562
|
);
|
|
422
563
|
if (status.ok) {
|
|
423
564
|
if (status.binarySource === "installed" || status.binarySource === "existing") {
|
|
424
|
-
saveConfig({ acp: { bin: status.bin
|
|
565
|
+
saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
|
|
425
566
|
}
|
|
426
567
|
if (status.needsLogin) {
|
|
427
568
|
const msg = acpLoginPending();
|
|
@@ -557,9 +698,113 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
557
698
|
isError: !skill,
|
|
558
699
|
});
|
|
559
700
|
};
|
|
560
|
-
const r = await startMcpServer(
|
|
701
|
+
const r = await startMcpServer(
|
|
702
|
+
{
|
|
703
|
+
listTools,
|
|
704
|
+
onToolCall: bridgeOnToolCall,
|
|
705
|
+
onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
|
|
706
|
+
},
|
|
707
|
+
{ log: mcpLog },
|
|
708
|
+
);
|
|
561
709
|
if (r.ok && r.handle) {
|
|
562
710
|
mcpHandle = r.handle;
|
|
711
|
+
// Stream-json engine registration: the agy CLI discovers MCP servers
|
|
712
|
+
// from ~/.gemini/config/mcp_config.json (ACP uses session/new
|
|
713
|
+
// mcpServers instead; verified live 2026-09-07). Per-pid entry,
|
|
714
|
+
// removed at session_shutdown; stale entries swept at start.
|
|
715
|
+
sweepStaleBridgeServers();
|
|
716
|
+
registerBridgeServer({
|
|
717
|
+
pid: process.pid,
|
|
718
|
+
port: r.handle.port,
|
|
719
|
+
token: r.handle.token,
|
|
720
|
+
tokenHeader: TOKEN_HEADER,
|
|
721
|
+
});
|
|
722
|
+
// --- Approval gate (docs/TODO.md 2.5) ------------------------------
|
|
723
|
+
// agy native tool calls pass through a pi-side approval: a PreToolUse
|
|
724
|
+
// hook parks in the bridge, the provider emits a shadow toolUse, and
|
|
725
|
+
// pi's permission extensions gate it like any native call. Off until
|
|
726
|
+
// enabled (auto = on only when a third-party gate extension exists).
|
|
727
|
+
{
|
|
728
|
+
const cfg = loadConfig();
|
|
729
|
+
const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
|
|
730
|
+
if (mode === "dedicated") {
|
|
731
|
+
// The explicit antigravity_approve variant is planned; until it
|
|
732
|
+
// ships, dedicated stages the same shadow tools. Say so, so the
|
|
733
|
+
// config value never lies silently.
|
|
734
|
+
fileLog.log("approval-dedicated-as-shadow", {}, "warn");
|
|
735
|
+
}
|
|
736
|
+
if (mode === "off") {
|
|
737
|
+
// Gate off: remove ONLY this session's group. Other sessions'
|
|
738
|
+
// groups in a shared workspace are never touched - a gate-off
|
|
739
|
+
// session must not strip a gate-on session's matchers.
|
|
740
|
+
const unstaged = removeGateHooks(process.cwd());
|
|
741
|
+
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
742
|
+
} else {
|
|
743
|
+
// Script: per-pid file; 0600 because the bridge token is
|
|
744
|
+
// embedded (peer review 2026-09-07).
|
|
745
|
+
const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
|
|
746
|
+
fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
|
|
747
|
+
fs.writeFileSync(
|
|
748
|
+
scriptPath,
|
|
749
|
+
hookScriptSource({
|
|
750
|
+
port: r.handle.port,
|
|
751
|
+
token: r.handle.token,
|
|
752
|
+
deadlineMs: APPROVAL_PARK_MS,
|
|
753
|
+
}),
|
|
754
|
+
{ mode: 0o600 },
|
|
755
|
+
);
|
|
756
|
+
gateScriptPath = scriptPath;
|
|
757
|
+
const staged = stageGateHooks(process.cwd(), {
|
|
758
|
+
port: r.handle.port,
|
|
759
|
+
token: r.handle.token,
|
|
760
|
+
scriptPath,
|
|
761
|
+
parkBudgetMs: APPROVAL_PARK_MS,
|
|
762
|
+
});
|
|
763
|
+
fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
|
|
764
|
+
|
|
765
|
+
// Shadow bases: factory twins of pi's own builtins (public API).
|
|
766
|
+
// pi.getAllTools() is unusable here: it returns ToolInfo, which
|
|
767
|
+
// strips execute. Marker calls never execute; non-marker calls
|
|
768
|
+
// delegate to the twins, so behavior matches the standard
|
|
769
|
+
// builtins (session-level bash-operations overrides are not
|
|
770
|
+
// inherited; documented in README).
|
|
771
|
+
const gateCwd = process.cwd();
|
|
772
|
+
const bases: Record<string, AnyToolDefinition> = {
|
|
773
|
+
bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
774
|
+
write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
775
|
+
edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
776
|
+
};
|
|
777
|
+
const askMode = cfg.approvals.mode;
|
|
778
|
+
const policy: GatePolicy = async ({ tool, params, ctx }) => {
|
|
779
|
+
if (askMode === "allow") return { allow: true };
|
|
780
|
+
if (askMode === "deny") {
|
|
781
|
+
return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
|
|
782
|
+
}
|
|
783
|
+
const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
|
|
784
|
+
if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
|
|
785
|
+
return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
|
|
786
|
+
}
|
|
787
|
+
const what =
|
|
788
|
+
typeof params.command === "string"
|
|
789
|
+
? params.command
|
|
790
|
+
: typeof params.path === "string"
|
|
791
|
+
? params.path
|
|
792
|
+
: JSON.stringify(stripMarkerFields(params)).slice(0, 200);
|
|
793
|
+
const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
|
|
794
|
+
return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
|
|
795
|
+
};
|
|
796
|
+
const handle = r.handle;
|
|
797
|
+
for (const [name, base] of Object.entries(bases)) {
|
|
798
|
+
pi.registerTool(
|
|
799
|
+
createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
// The park is wired ONLY after the shadows are registered: an
|
|
803
|
+
// approval toolUse must never dispatch to the REAL builtin and
|
|
804
|
+
// execute locally.
|
|
805
|
+
roundTrips.approvalPark = handle.approvals;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
563
808
|
} else {
|
|
564
809
|
console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
|
|
565
810
|
}
|
|
@@ -572,8 +817,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
572
817
|
mcpHandle = null;
|
|
573
818
|
await h?.close();
|
|
574
819
|
roundTrips.failAll("antigravity session shut down");
|
|
575
|
-
|
|
576
|
-
|
|
820
|
+
// "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
|
|
821
|
+
// and /fork (docs/extensions.md session lifecycle), not only on process
|
|
822
|
+
// exit. The drivers are process-lifetime singletons; closing them with
|
|
823
|
+
// "shutdown" latched them permanently and every later turn failed with
|
|
824
|
+
// "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
|
|
825
|
+
// connection now; the next turn respawns it. event.reason is
|
|
826
|
+
// deliberately ignored: recycle is correct even on real process exit
|
|
827
|
+
// ("quit") - the connection kill is identical and nothing runs after.
|
|
828
|
+
await streamDriver.close("recycle", "session shutdown");
|
|
829
|
+
await acpDriver.close("recycle", "session shutdown");
|
|
830
|
+
unregisterBridgeServer(process.pid);
|
|
831
|
+
// Approval gate: unstage hooks and remove the per-pid script. Pending
|
|
832
|
+
// approvals already failed closed via handle close (bridge shutdown deny).
|
|
833
|
+
const unstaged = removeGateHooks(process.cwd());
|
|
834
|
+
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
835
|
+
if (gateScriptPath) {
|
|
836
|
+
try {
|
|
837
|
+
fs.rmSync(gateScriptPath, { force: true });
|
|
838
|
+
} catch {
|
|
839
|
+
/* best effort */
|
|
840
|
+
}
|
|
841
|
+
gateScriptPath = null;
|
|
842
|
+
}
|
|
577
843
|
});
|
|
578
844
|
}
|
|
579
845
|
|
|
@@ -600,6 +866,9 @@ interface AgyCommandCtx {
|
|
|
600
866
|
acpLog: (msg: string, data?: unknown) => void;
|
|
601
867
|
/** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
|
|
602
868
|
fileLog: DailyLogger;
|
|
869
|
+
/** Wizard-pick follow-through (download now + chained sign-in); reused
|
|
870
|
+
* by /agy engine's no-args modal so both entry points behave alike. */
|
|
871
|
+
runAcpPickSetup: (cmdCtx: { ui: ExtensionUIContext }) => Promise<void>;
|
|
603
872
|
/** BROWSER-capture handles; null when unavailable (Windows, unwritable
|
|
604
873
|
* data dir). /agy auth passes them to the sign-in process. */
|
|
605
874
|
authCapture: { browserEnv: Record<string, string>; file: string } | null;
|
|
@@ -711,7 +980,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
711
980
|
ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
712
981
|
return;
|
|
713
982
|
}
|
|
714
|
-
saveConfig({ acp: { bin: status.bin
|
|
983
|
+
saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
|
|
715
984
|
if (status.needsLogin) {
|
|
716
985
|
ui?.notify(
|
|
717
986
|
`ACP engine set. ${acpLoginPending()}`,
|
|
@@ -720,6 +989,28 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
720
989
|
} else {
|
|
721
990
|
ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
|
|
722
991
|
}
|
|
992
|
+
} else if (!val && mode === "tui" && ui) {
|
|
993
|
+
// Same modal as the first-run wizard: switching engines deserves
|
|
994
|
+
// the explanations, not a bare usage line. Semantics match the
|
|
995
|
+
// direct path above: plan blocks acp, an acp pick chains setup
|
|
996
|
+
// + sign-in immediately, restart applies the switch.
|
|
997
|
+
const current = loadConfig().engine;
|
|
998
|
+
const picked = await showEnginePicker(ui);
|
|
999
|
+
if (picked === null) {
|
|
1000
|
+
ui.notify(`engine unchanged: ${current}.`, "info");
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (picked === current) {
|
|
1004
|
+
ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (picked === "acp" && loadConfig().mode === "plan") {
|
|
1008
|
+
ui.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
saveConfig({ engine: picked });
|
|
1012
|
+
ui.notify(savedEngineMessage(picked), "info");
|
|
1013
|
+
if (picked === "acp") void ctx.runAcpPickSetup({ ui });
|
|
723
1014
|
} else {
|
|
724
1015
|
ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
|
|
725
1016
|
}
|
|
@@ -746,7 +1037,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
746
1037
|
ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
747
1038
|
return;
|
|
748
1039
|
}
|
|
749
|
-
saveConfig({ acp: { bin: status.bin
|
|
1040
|
+
saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
|
|
750
1041
|
if (!status.needsLogin) {
|
|
751
1042
|
ui?.notify(`Already signed in (auth: ${status.auth}). Nothing to do.`, "info");
|
|
752
1043
|
return;
|
|
@@ -774,7 +1065,9 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
774
1065
|
if (rest.length > 0) {
|
|
775
1066
|
// Only the keyword compares case-insensitively; the path keeps its case.
|
|
776
1067
|
const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
|
|
777
|
-
|
|
1068
|
+
// Spread, not a bare acp patch: a bare {bin, permissions} object
|
|
1069
|
+
// would drop sibling keys (usageEstimate) from the file.
|
|
1070
|
+
saveConfig({ acp: { ...loadConfig().acp, bin } });
|
|
778
1071
|
ui?.notify(
|
|
779
1072
|
bin
|
|
780
1073
|
? `acp.bin set to ${bin}. The next ACP turn (re)connects with it.`
|
|
@@ -844,6 +1137,8 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
844
1137
|
// line the day it starts (then real usage mapping is worth wiring).
|
|
845
1138
|
if (snap.acp.usageSeen) {
|
|
846
1139
|
lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
|
|
1140
|
+
} else if (config.acp.usageEstimate !== "off") {
|
|
1141
|
+
lines.push(` acp tokens: ESTIMATED client-side (mode: ${config.acp.usageEstimate}; auto-off once the server sends real usage)`);
|
|
847
1142
|
}
|
|
848
1143
|
}
|
|
849
1144
|
if (snap.lifecycle.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|