@estebanforge/pi-antigravity-bridge 1.4.8 → 1.4.10
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 +30 -0
- package/README.md +42 -7
- package/docs/ACP-ADOPTION-PLAN.md +83 -18
- package/docs/ACP-PROTOCOL-REFERENCE.md +4 -3
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/DEVELOPMENT.md +16 -0
- package/docs/PI-BRIDGE-GAPS.md +26 -5
- package/docs/TODO.md +21 -0
- package/extensions/index.ts +221 -22
- package/package.json +1 -1
- package/src/acp/connection.ts +12 -0
- package/src/acp/driver.ts +17 -10
- package/src/acp/events.ts +17 -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 +43 -0
- package/src/daily-log.ts +11 -10
- package/src/driver-types.ts +13 -9
- package/src/driver.ts +6 -5
- package/src/mcp-registration.ts +127 -0
- package/src/mcp-server.ts +192 -10
- package/src/models.ts +2 -2
- package/src/provider.ts +216 -31
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,7 +51,15 @@ 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";
|
|
@@ -55,6 +69,11 @@ import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type Bridge
|
|
|
55
69
|
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
56
70
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
57
71
|
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
72
|
+
import {
|
|
73
|
+
registerBridgeServer,
|
|
74
|
+
sweepStaleBridgeServers,
|
|
75
|
+
unregisterBridgeServer,
|
|
76
|
+
} from "../src/mcp-registration.js";
|
|
58
77
|
import {
|
|
59
78
|
ACTIVATE_SKILL_TOOL_NAME,
|
|
60
79
|
activateSkillSchema,
|
|
@@ -107,7 +126,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
107
126
|
const engine: Engine = loadConfig().engine;
|
|
108
127
|
// Engine switching requires a restart, so the catalog-time engine read is
|
|
109
128
|
// authoritative for input advertising: image attach rides only when turns
|
|
110
|
-
// will run on the ACP engine (the
|
|
129
|
+
// will run on the ACP engine (the stream-json CLI prompt is text-only).
|
|
111
130
|
const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
|
|
112
131
|
const models = entries.map((e) => toPiModel(e, modelInput));
|
|
113
132
|
|
|
@@ -116,6 +135,36 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
116
135
|
// src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
|
|
117
136
|
// Support flow: "attach the last days' files from that dir".
|
|
118
137
|
const fileLog = createDailyLogger({ dir: logsDir() });
|
|
138
|
+
// Warn tier = user-facing: the default file log keeps ONLY errors (zero
|
|
139
|
+
// routine disk traffic), so a warn that never reaches the UI is lost.
|
|
140
|
+
// Every warn toasts here instead; AGY_DEBUG=1 restores the full file
|
|
141
|
+
// trail. Silent exceptions: deliberate aborts (pi already shows
|
|
142
|
+
// "Operation aborted"), connection exits (the turn's own error block
|
|
143
|
+
// carries real crashes), and call-tool-fail (same-instant duplicate of
|
|
144
|
+
// round-trip-fail). No UI (headless): warn text falls back to stderr.
|
|
145
|
+
const rawFileLog = fileLog.log.bind(fileLog);
|
|
146
|
+
fileLog.log = (event, data, level) => {
|
|
147
|
+
rawFileLog(event, data, level);
|
|
148
|
+
if (level !== "warn") return;
|
|
149
|
+
if (event.startsWith("abort:") || event === "connection-exited" || event === "call-tool-fail") return;
|
|
150
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
151
|
+
let text: string;
|
|
152
|
+
if (event === "round-trip-fail") {
|
|
153
|
+
text = `Bridge tool call failed: ${String(d.name ?? "tool")} (${String(d.reason ?? "unknown")})`;
|
|
154
|
+
} else if (event.startsWith("stall:")) {
|
|
155
|
+
text = "Antigravity stalled with no output; the turn was stopped";
|
|
156
|
+
} else if (event.startsWith("timeout:")) {
|
|
157
|
+
text = "Antigravity turn timed out";
|
|
158
|
+
} else if (event.startsWith("exit:") && event !== "exit:0") {
|
|
159
|
+
text = "Antigravity process exited unexpectedly";
|
|
160
|
+
} else if (event === "turn-error") {
|
|
161
|
+
text = `Antigravity turn issue: ${String(d.reason ?? "unknown")}`;
|
|
162
|
+
} else {
|
|
163
|
+
text = `Antigravity warning: ${event}`;
|
|
164
|
+
}
|
|
165
|
+
if (activeUi) activeUi.notify(text, "warning");
|
|
166
|
+
else console.error(`[antigravity-bridge] ${text}`);
|
|
167
|
+
};
|
|
119
168
|
fileLog.log(
|
|
120
169
|
"extension-load",
|
|
121
170
|
{ engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool },
|
|
@@ -126,6 +175,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
126
175
|
// MCP bridge handle, declared early: the ACP engine reads the bridge port
|
|
127
176
|
// at session/new / session/load time.
|
|
128
177
|
let mcpHandle: McpServerHandle | null = null;
|
|
178
|
+
// Approval-gate hook script (per-pid, token embedded). Written at session
|
|
179
|
+
// start when the gate is active; removed at session_shutdown.
|
|
180
|
+
let gateScriptPath: string | null = null;
|
|
129
181
|
// ACP self-heal runs once per process (session_start re-fires on /reload;
|
|
130
182
|
// a ready setup is two file stats, so re-running is harmless anyway).
|
|
131
183
|
let acpSelfHealRan = false;
|
|
@@ -154,11 +206,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
154
206
|
"session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
|
|
155
207
|
"unsupported-server-request",
|
|
156
208
|
]);
|
|
157
|
-
const
|
|
158
|
-
// Mirror the
|
|
209
|
+
const streamDriver = new StreamDriver();
|
|
210
|
+
// Mirror the stream driver's lifecycle ring into the daily file log
|
|
159
211
|
// (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
|
|
160
212
|
// through acpLog below.
|
|
161
|
-
|
|
213
|
+
streamDriver.log = (msg, data) => {
|
|
162
214
|
// Level classification mirrors acpLog's failure set: stalls, aborts,
|
|
163
215
|
// timeouts and nonzero exits are the "what broke" greps (warn);
|
|
164
216
|
// turn-start is the per-turn skeleton (info); everything else is
|
|
@@ -183,12 +235,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
183
235
|
: data;
|
|
184
236
|
// Failures warn; turn-start + auth-url are the always-on skeleton;
|
|
185
237
|
// routine per-event lifecycle (spawn, session-load, unparked, ...) is
|
|
186
|
-
// verbose-only.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
238
|
+
// verbose-only. Deliberate teardown exits (Esc abort kill, shutdown,
|
|
239
|
+
// idle recycle) demote to debug: routine, and the warn tier stays the
|
|
240
|
+
// "what broke" grep.
|
|
241
|
+
const expectedExit =
|
|
242
|
+
msg === "connection-exited" &&
|
|
243
|
+
(data as { expected?: boolean } | undefined)?.expected === true;
|
|
244
|
+
const level = expectedExit
|
|
245
|
+
? "debug"
|
|
246
|
+
: acpFailures.has(msg)
|
|
247
|
+
? "warn"
|
|
248
|
+
: msg === "turn-start" || msg === "auth-url"
|
|
249
|
+
? "info"
|
|
250
|
+
: "debug";
|
|
192
251
|
fileLog.log(msg, fileData, level);
|
|
193
252
|
if (msg === "auth-url") {
|
|
194
253
|
const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
|
|
@@ -209,7 +268,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
209
268
|
return;
|
|
210
269
|
}
|
|
211
270
|
if (!acpFailures.has(msg)) return;
|
|
212
|
-
|
|
271
|
+
// Console noise is gone: warns toast through the fileLog wrapper (with
|
|
272
|
+
// a stderr fallback when no UI exists), and raw tails stay in the file
|
|
273
|
+
// log behind AGY_DEBUG.
|
|
213
274
|
};
|
|
214
275
|
const acpDriver = new AcpDriver({
|
|
215
276
|
// Resolved per connection: the setup flow can install the binary and
|
|
@@ -221,7 +282,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
221
282
|
const handle = mcpHandle;
|
|
222
283
|
if (!handle) return [];
|
|
223
284
|
// The bridge 403s any request without the shared-secret header; the
|
|
224
|
-
//
|
|
285
|
+
// stream engine carries it via mcp_config.json, ACP via headers[].
|
|
225
286
|
return [
|
|
226
287
|
{
|
|
227
288
|
name: "pi-bridge",
|
|
@@ -233,15 +294,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
233
294
|
},
|
|
234
295
|
});
|
|
235
296
|
// The active engine is resolved from the latched load-time value.
|
|
236
|
-
const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver :
|
|
237
|
-
// The provider's stream-json slot gets the
|
|
297
|
+
const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
|
|
298
|
+
// The provider's stream-json slot gets the STREAM driver explicitly - never
|
|
238
299
|
// activeDriver(), or a load-time acp engine would make deps.driver and
|
|
239
300
|
// deps.acpDriver the same object and break the engine identity check.
|
|
240
|
-
const driver =
|
|
301
|
+
const driver = streamDriver;
|
|
241
302
|
// The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
|
|
242
303
|
// the provider emits them as real pi toolUse turns and completes them from
|
|
243
304
|
// the next call's toolResult.
|
|
244
|
-
const roundTrips = new ToolRoundTrips(
|
|
305
|
+
const roundTrips = new ToolRoundTrips(
|
|
306
|
+
activeDriver,
|
|
307
|
+
(s, d, level) =>
|
|
308
|
+
fileLog.log(
|
|
309
|
+
s,
|
|
310
|
+
d,
|
|
311
|
+
level ?? (s === "round-trip-fail" ? "warn" : "debug"),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
245
314
|
const replay = new WrapperReplay();
|
|
246
315
|
// Native re-exec only emits for builtins actually active in the session;
|
|
247
316
|
// anything else (or an unknown name) falls back to the wrapper card.
|
|
@@ -267,7 +336,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
267
336
|
);
|
|
268
337
|
roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
|
|
269
338
|
};
|
|
270
|
-
|
|
339
|
+
streamDriver.onTurnEnd = onTurnEnd;
|
|
271
340
|
acpDriver.onTurnEnd = onTurnEnd;
|
|
272
341
|
const streamSimple = createStreamSimple({
|
|
273
342
|
entries,
|
|
@@ -518,9 +587,113 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
518
587
|
isError: !skill,
|
|
519
588
|
});
|
|
520
589
|
};
|
|
521
|
-
const r = await startMcpServer(
|
|
590
|
+
const r = await startMcpServer(
|
|
591
|
+
{
|
|
592
|
+
listTools,
|
|
593
|
+
onToolCall: bridgeOnToolCall,
|
|
594
|
+
onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
|
|
595
|
+
},
|
|
596
|
+
{ log: mcpLog },
|
|
597
|
+
);
|
|
522
598
|
if (r.ok && r.handle) {
|
|
523
599
|
mcpHandle = r.handle;
|
|
600
|
+
// Stream-json engine registration: the agy CLI discovers MCP servers
|
|
601
|
+
// from ~/.gemini/config/mcp_config.json (ACP uses session/new
|
|
602
|
+
// mcpServers instead; verified live 2026-09-07). Per-pid entry,
|
|
603
|
+
// removed at session_shutdown; stale entries swept at start.
|
|
604
|
+
sweepStaleBridgeServers();
|
|
605
|
+
registerBridgeServer({
|
|
606
|
+
pid: process.pid,
|
|
607
|
+
port: r.handle.port,
|
|
608
|
+
token: r.handle.token,
|
|
609
|
+
tokenHeader: TOKEN_HEADER,
|
|
610
|
+
});
|
|
611
|
+
// --- Approval gate (docs/TODO.md 2.5) ------------------------------
|
|
612
|
+
// agy native tool calls pass through a pi-side approval: a PreToolUse
|
|
613
|
+
// hook parks in the bridge, the provider emits a shadow toolUse, and
|
|
614
|
+
// pi's permission extensions gate it like any native call. Off until
|
|
615
|
+
// enabled (auto = on only when a third-party gate extension exists).
|
|
616
|
+
{
|
|
617
|
+
const cfg = loadConfig();
|
|
618
|
+
const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
|
|
619
|
+
if (mode === "dedicated") {
|
|
620
|
+
// The explicit antigravity_approve variant is planned; until it
|
|
621
|
+
// ships, dedicated stages the same shadow tools. Say so, so the
|
|
622
|
+
// config value never lies silently.
|
|
623
|
+
fileLog.log("approval-dedicated-as-shadow", {}, "warn");
|
|
624
|
+
}
|
|
625
|
+
if (mode === "off") {
|
|
626
|
+
// Gate off: remove ONLY this session's group. Other sessions'
|
|
627
|
+
// groups in a shared workspace are never touched - a gate-off
|
|
628
|
+
// session must not strip a gate-on session's matchers.
|
|
629
|
+
const unstaged = removeGateHooks(process.cwd());
|
|
630
|
+
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
631
|
+
} else {
|
|
632
|
+
// Script: per-pid file; 0600 because the bridge token is
|
|
633
|
+
// embedded (peer review 2026-09-07).
|
|
634
|
+
const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
|
|
635
|
+
fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
|
|
636
|
+
fs.writeFileSync(
|
|
637
|
+
scriptPath,
|
|
638
|
+
hookScriptSource({
|
|
639
|
+
port: r.handle.port,
|
|
640
|
+
token: r.handle.token,
|
|
641
|
+
deadlineMs: APPROVAL_PARK_MS,
|
|
642
|
+
}),
|
|
643
|
+
{ mode: 0o600 },
|
|
644
|
+
);
|
|
645
|
+
gateScriptPath = scriptPath;
|
|
646
|
+
const staged = stageGateHooks(process.cwd(), {
|
|
647
|
+
port: r.handle.port,
|
|
648
|
+
token: r.handle.token,
|
|
649
|
+
scriptPath,
|
|
650
|
+
parkBudgetMs: APPROVAL_PARK_MS,
|
|
651
|
+
});
|
|
652
|
+
fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
|
|
653
|
+
|
|
654
|
+
// Shadow bases: factory twins of pi's own builtins (public API).
|
|
655
|
+
// pi.getAllTools() is unusable here: it returns ToolInfo, which
|
|
656
|
+
// strips execute. Marker calls never execute; non-marker calls
|
|
657
|
+
// delegate to the twins, so behavior matches the standard
|
|
658
|
+
// builtins (session-level bash-operations overrides are not
|
|
659
|
+
// inherited; documented in README).
|
|
660
|
+
const gateCwd = process.cwd();
|
|
661
|
+
const bases: Record<string, AnyToolDefinition> = {
|
|
662
|
+
bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
663
|
+
write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
664
|
+
edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
665
|
+
};
|
|
666
|
+
const askMode = cfg.approvals.mode;
|
|
667
|
+
const policy: GatePolicy = async ({ tool, params, ctx }) => {
|
|
668
|
+
if (askMode === "allow") return { allow: true };
|
|
669
|
+
if (askMode === "deny") {
|
|
670
|
+
return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
|
|
671
|
+
}
|
|
672
|
+
const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
|
|
673
|
+
if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
|
|
674
|
+
return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
|
|
675
|
+
}
|
|
676
|
+
const what =
|
|
677
|
+
typeof params.command === "string"
|
|
678
|
+
? params.command
|
|
679
|
+
: typeof params.path === "string"
|
|
680
|
+
? params.path
|
|
681
|
+
: JSON.stringify(stripMarkerFields(params)).slice(0, 200);
|
|
682
|
+
const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
|
|
683
|
+
return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
|
|
684
|
+
};
|
|
685
|
+
const handle = r.handle;
|
|
686
|
+
for (const [name, base] of Object.entries(bases)) {
|
|
687
|
+
pi.registerTool(
|
|
688
|
+
createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
// The park is wired ONLY after the shadows are registered: an
|
|
692
|
+
// approval toolUse must never dispatch to the REAL builtin and
|
|
693
|
+
// execute locally.
|
|
694
|
+
roundTrips.approvalPark = handle.approvals;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
524
697
|
} else {
|
|
525
698
|
console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
|
|
526
699
|
}
|
|
@@ -533,8 +706,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
533
706
|
mcpHandle = null;
|
|
534
707
|
await h?.close();
|
|
535
708
|
roundTrips.failAll("antigravity session shut down");
|
|
536
|
-
|
|
537
|
-
|
|
709
|
+
// "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
|
|
710
|
+
// and /fork (docs/extensions.md session lifecycle), not only on process
|
|
711
|
+
// exit. The drivers are process-lifetime singletons; closing them with
|
|
712
|
+
// "shutdown" latched them permanently and every later turn failed with
|
|
713
|
+
// "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
|
|
714
|
+
// connection now; the next turn respawns it. event.reason is
|
|
715
|
+
// deliberately ignored: recycle is correct even on real process exit
|
|
716
|
+
// ("quit") - the connection kill is identical and nothing runs after.
|
|
717
|
+
await streamDriver.close("recycle", "session shutdown");
|
|
718
|
+
await acpDriver.close("recycle", "session shutdown");
|
|
719
|
+
unregisterBridgeServer(process.pid);
|
|
720
|
+
// Approval gate: unstage hooks and remove the per-pid script. Pending
|
|
721
|
+
// approvals already failed closed via handle close (bridge shutdown deny).
|
|
722
|
+
const unstaged = removeGateHooks(process.cwd());
|
|
723
|
+
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
724
|
+
if (gateScriptPath) {
|
|
725
|
+
try {
|
|
726
|
+
fs.rmSync(gateScriptPath, { force: true });
|
|
727
|
+
} catch {
|
|
728
|
+
/* best effort */
|
|
729
|
+
}
|
|
730
|
+
gateScriptPath = null;
|
|
731
|
+
}
|
|
538
732
|
});
|
|
539
733
|
}
|
|
540
734
|
|
|
@@ -793,7 +987,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
793
987
|
` sessions: ${ctx.store.size} bound`,
|
|
794
988
|
` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
|
|
795
989
|
` config: ${CONFIG_PATH}`,
|
|
796
|
-
` logs: ${logsDir()} (attach recent days' files when reporting issues)`,
|
|
990
|
+
` logs: ${logsDir()} (attach recent days' files when reporting issues; set AGY_DEBUG=1 to capture details)`,
|
|
797
991
|
];
|
|
798
992
|
if (snap.engine === "acp" && snap.acp) {
|
|
799
993
|
lines.push(
|
|
@@ -801,6 +995,11 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
801
995
|
` acp server: ${snap.acp.serverVersion ?? "unknown"}${snap.acp.agentTitle ? ` (${snap.acp.agentTitle})` : ""}`,
|
|
802
996
|
` acp stats: prompts=${snap.acp.prompts} created=${snap.acp.sessionsCreated} loaded=${snap.acp.sessionsLoaded} kills=${snap.acp.kills} reconnects=${snap.acp.reconnects} cancel=${snap.acp.cancelSupported === null ? "unprobed" : snap.acp.cancelSupported ? "supported" : "unsupported (kill+reload)"}`,
|
|
803
997
|
);
|
|
998
|
+
// Gate B watch: silent while the server offers no token counts; one
|
|
999
|
+
// line the day it starts (then real usage mapping is worth wiring).
|
|
1000
|
+
if (snap.acp.usageSeen) {
|
|
1001
|
+
lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
|
|
1002
|
+
}
|
|
804
1003
|
}
|
|
805
1004
|
if (snap.lifecycle.length > 0) {
|
|
806
1005
|
lines.push(" lifecycle (last 5):");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.10",
|
|
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",
|
package/src/acp/connection.ts
CHANGED
|
@@ -24,6 +24,7 @@ import fs from "node:fs";
|
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { parseAuthPort, readLastUrl } from "./browser-capture.js";
|
|
27
|
+
import { frameCarriesUsage } from "./events.js";
|
|
27
28
|
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
28
29
|
|
|
29
30
|
export interface AcpMcpServer {
|
|
@@ -80,6 +81,7 @@ export class AcpConnection {
|
|
|
80
81
|
#child: ChildProcess | undefined;
|
|
81
82
|
#rpc: JsonRpcSession | undefined;
|
|
82
83
|
#stderrTail = "";
|
|
84
|
+
#usageSeen = false;
|
|
83
85
|
#exited = false;
|
|
84
86
|
#killed = false;
|
|
85
87
|
#suppressUpdates = false;
|
|
@@ -106,6 +108,13 @@ export class AcpConnection {
|
|
|
106
108
|
return this.#stderrTail;
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/** Gate B watch: latched true once any session/update frame carried
|
|
112
|
+
* usage/token fields. /agy doctor surfaces it the day upstream starts
|
|
113
|
+
* sending token counts; silent until then. */
|
|
114
|
+
get usageSeen(): boolean {
|
|
115
|
+
return this.#usageSeen;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/** While true, session/update notifications are dropped: they are the
|
|
110
119
|
* full-text history replay that precedes a session/load response, never
|
|
111
120
|
* live generation (run 6). */
|
|
@@ -299,6 +308,9 @@ export class AcpConnection {
|
|
|
299
308
|
|
|
300
309
|
#onNotification(method: string, params: unknown): void {
|
|
301
310
|
if (method === "session/update") {
|
|
311
|
+
// Latched before the suppression gate: history-replay frames are the
|
|
312
|
+
// server's payloads too, and they are the cheapest signal source.
|
|
313
|
+
if (!this.#usageSeen && frameCarriesUsage(params)) this.#usageSeen = true;
|
|
302
314
|
if (this.#suppressUpdates) return; // load replay: history, not live text
|
|
303
315
|
const p = typeof params === "object" && params !== null ? (params as Record<string, unknown>) : {};
|
|
304
316
|
const sessionId = typeof p.sessionId === "string" ? p.sessionId : this.#updateSessionId;
|
package/src/acp/driver.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// AcpDriver: the ACP turn engine. Implements the same TurnDriver surface as
|
|
2
|
-
// the
|
|
2
|
+
// the stream-json driver (see src/driver-types.ts) so provider.ts and
|
|
3
3
|
// the G9 round-trip store work unchanged.
|
|
4
4
|
//
|
|
5
|
-
// Engine differences vs
|
|
5
|
+
// Engine differences vs stream-json, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
|
|
6
6
|
// - no process recycle on profile drift: one server process, sessions
|
|
7
7
|
// selected per turn via session/new / session/load
|
|
8
8
|
// - model/effort via session/set_config_option (configId "model", FULL slug
|
|
@@ -110,7 +110,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
110
110
|
#generation = 0;
|
|
111
111
|
#active: ActiveTurn | undefined;
|
|
112
112
|
#queueTail: Promise<void> = Promise.resolve();
|
|
113
|
-
#shutdown = false;
|
|
114
113
|
#lifecycle: string[] = [];
|
|
115
114
|
#onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
|
|
116
115
|
#stats = {
|
|
@@ -168,7 +167,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
168
167
|
}
|
|
169
168
|
|
|
170
169
|
/** Turns are serialized; a parked turn stays open and the continuation
|
|
171
|
-
* path uses reentry() (same contract as the
|
|
170
|
+
* path uses reentry() (same contract as the stream-json driver). */
|
|
172
171
|
run(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
173
172
|
let release!: () => void;
|
|
174
173
|
const prev = this.#queueTail;
|
|
@@ -190,7 +189,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
190
189
|
}
|
|
191
190
|
|
|
192
191
|
#runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
193
|
-
|
|
192
|
+
// No shutdown latch here: pi fires session_shutdown on /new, /resume and
|
|
193
|
+
// /fork (not only process exit), so a closed driver must respawn on the
|
|
194
|
+
// next turn instead of rejecting forever. Regression 2026-09-07:
|
|
195
|
+
// /compact after a model switch failed with "ACP driver is shut down."
|
|
194
196
|
if (request.signal?.aborted) return Promise.reject(new Error("aborted before start"));
|
|
195
197
|
|
|
196
198
|
const turn = this.#createTurn(request);
|
|
@@ -218,7 +220,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
// Execute asynchronously: the handle returns as soon as the prompt is
|
|
221
|
-
// dispatched, and activities stream through next() (
|
|
223
|
+
// dispatched, and activities stream through next() (stream-json contract).
|
|
222
224
|
void this.#executeTurn(turn).catch((err: unknown) => {
|
|
223
225
|
this.#failTurn(turn, `ACP turn failed: ${describe(err)}`);
|
|
224
226
|
});
|
|
@@ -409,7 +411,12 @@ export class AcpDriver implements TurnDriver {
|
|
|
409
411
|
const turn = this.#active;
|
|
410
412
|
this.#conn = undefined;
|
|
411
413
|
this.#state = "dead";
|
|
412
|
-
|
|
414
|
+
// Teardown exits (abort kill, shutdown, idle recycle) are deliberate:
|
|
415
|
+
// their stderr tail must never reach the UI. Only a live turn dying
|
|
416
|
+
// mid-flight is user-reportable, and it surfaces as a normal turn
|
|
417
|
+
// error, not as raw console noise.
|
|
418
|
+
const turnActive = Boolean(turn && !turn.closed && !turn.aborted && !turn.sawResult);
|
|
419
|
+
this.#log("connection-exited", { expected: !turnActive, tail: info.stderrTail.slice(-200) });
|
|
413
420
|
if (!turn || turn.closed) return;
|
|
414
421
|
if (turn.aborted || turn.sawResult) {
|
|
415
422
|
this.#settle(turn, {
|
|
@@ -421,7 +428,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
421
428
|
});
|
|
422
429
|
return;
|
|
423
430
|
}
|
|
424
|
-
this.#failTurn(turn,
|
|
431
|
+
this.#failTurn(turn, "Antigravity session exited unexpectedly; it restarts on the next turn");
|
|
425
432
|
}
|
|
426
433
|
|
|
427
434
|
#ensureConnection(request: DriverTurnRequest): Promise<AcpConnection> {
|
|
@@ -674,7 +681,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
674
681
|
// --- TurnDriver surface ----------------------------------------------------
|
|
675
682
|
|
|
676
683
|
async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
|
|
677
|
-
if (reason === "shutdown") this.#shutdown = true;
|
|
678
684
|
this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
|
|
679
685
|
const turn = this.#active;
|
|
680
686
|
if (turn && !turn.closed) {
|
|
@@ -682,7 +688,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
682
688
|
conversationId: turn.sessionId,
|
|
683
689
|
status: "ERROR",
|
|
684
690
|
response: turn.response.text,
|
|
685
|
-
error: `ACP driver ${reason}
|
|
691
|
+
error: `ACP driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`,
|
|
686
692
|
finished: true,
|
|
687
693
|
aborted: false,
|
|
688
694
|
});
|
|
@@ -721,6 +727,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
721
727
|
reconnects: Math.max(0, this.#stats.spawns - 1),
|
|
722
728
|
agentName: this.#agentInfo?.name,
|
|
723
729
|
agentTitle: this.#agentInfo?.title,
|
|
730
|
+
usageSeen: this.#conn?.usageSeen ?? false,
|
|
724
731
|
},
|
|
725
732
|
};
|
|
726
733
|
}
|
package/src/acp/events.ts
CHANGED
|
@@ -248,3 +248,20 @@ export class TextAccumulator {
|
|
|
248
248
|
return this.#acc;
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
const USAGE_KEY = /usage|tokens?/i;
|
|
253
|
+
|
|
254
|
+
/** Gate B watch: true when a server payload carries usage/token fields.
|
|
255
|
+
* Key-name based so future shapes (usage blocks, _meta.tokenCount, top-level
|
|
256
|
+
* token totals) all trip it. String values are exempt so auth-style "token"
|
|
257
|
+
* args inside tool-call frames cannot false-positive the latch; depth-capped
|
|
258
|
+
* so the walk stays cheap on every frame until it latches. */
|
|
259
|
+
export function frameCarriesUsage(value: unknown, depth = 0): boolean {
|
|
260
|
+
if (depth > 8 || typeof value !== "object" || value === null) return false;
|
|
261
|
+
if (Array.isArray(value)) return value.some((v) => frameCarriesUsage(v, depth + 1));
|
|
262
|
+
for (const [key, v] of Object.entries(value)) {
|
|
263
|
+
if (USAGE_KEY.test(key) && (typeof v === "number" || typeof v === "object")) return true;
|
|
264
|
+
if (frameCarriesUsage(v, depth + 1)) return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|