@firespoon/pi-antigravity-bridge 0.0.0-stage → 1.10.7

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +235 -3
  4. package/extensions/index.ts +1993 -0
  5. package/package.json +71 -6
  6. package/src/acp/connection.ts +623 -0
  7. package/src/acp/driver.ts +953 -0
  8. package/src/acp/events.ts +331 -0
  9. package/src/acp/home.ts +238 -0
  10. package/src/acp/jsonrpc.ts +232 -0
  11. package/src/acp/setup.ts +107 -0
  12. package/src/acp/usage-estimate.ts +61 -0
  13. package/src/agents.ts +64 -0
  14. package/src/agy-install.ts +85 -0
  15. package/src/agy-paths.ts +30 -0
  16. package/src/agy-setup.ts +72 -0
  17. package/src/agy-version.ts +283 -0
  18. package/src/approval-detect.ts +146 -0
  19. package/src/approval-gate.ts +208 -0
  20. package/src/approval-hook.ts +270 -0
  21. package/src/artifacts-ui.ts +88 -0
  22. package/src/artifacts.ts +144 -0
  23. package/src/ask-tool.ts +1222 -0
  24. package/src/background-watch.ts +225 -0
  25. package/src/bridge-catalog.ts +27 -0
  26. package/src/config.ts +485 -0
  27. package/src/daily-log.ts +196 -0
  28. package/src/dialog-lock.ts +48 -0
  29. package/src/diff-render.ts +205 -0
  30. package/src/driver-types.ts +172 -0
  31. package/src/driver.ts +907 -0
  32. package/src/engine-picker.ts +160 -0
  33. package/src/frame-guard.ts +38 -0
  34. package/src/mcp-registration.ts +382 -0
  35. package/src/mcp-server.ts +620 -0
  36. package/src/models.ts +407 -0
  37. package/src/native-tools.ts +104 -0
  38. package/src/parked-turn.ts +125 -0
  39. package/src/patch-cleanup.ts +519 -0
  40. package/src/poll-fold.ts +55 -0
  41. package/src/prompt-images.ts +92 -0
  42. package/src/provider.ts +2066 -0
  43. package/src/queued-messages.ts +152 -0
  44. package/src/redact.ts +27 -0
  45. package/src/sessions.ts +185 -0
  46. package/src/skills.ts +192 -0
  47. package/src/stream-events.ts +122 -0
  48. package/src/stream-sanitizer.ts +180 -0
  49. package/src/subagent-roster.ts +245 -0
  50. package/src/sysprompt-agent.ts +163 -0
  51. package/src/tasks-ui.ts +90 -0
  52. package/src/tasks.ts +174 -0
  53. package/src/usage.ts +174 -0
  54. package/src/web-tools.ts +320 -0
@@ -0,0 +1,1993 @@
1
+ // pi-antigravity-bridge - extension entry point.
2
+ //
3
+ // Registers Gemini (via the agy CLI) as a pi model provider so it shows up in
4
+ // the /model picker as antigravity/gemini-*. When selected, pi routes each turn
5
+ // through streamSimple, which feeds the persistent stream-json driver process
6
+ // and streams the agent text back into pi's TUI.
7
+ //
8
+ // Architectural wall (cannot be worked around - see PLAN.md):
9
+ // agy runs its OWN closed tool loop against --add-dir. pi's read/write/edit/
10
+ // bash tools never fire. Tool activity is surfaced as thinking events
11
+ // ("[agy tool: editing foo.ts]") for visibility, but the edits already landed
12
+ // on disk and pi's inline diff review does not engage.
13
+ //
14
+ // /agy command: full runtime config surface (engine, mode, permissions,
15
+ // bridge tools, model, thinking, digest, system prompt) plus doctor,
16
+ // authentication instructions, patch-cleanup, and session clear. Config persists to
17
+ // ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
18
+
19
+ import os from "node:os";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import {
23
+ type ExtensionAPI,
24
+ type ExtensionCommandContext,
25
+ type ExtensionUIContext,
26
+ createBashToolDefinition,
27
+ createEditToolDefinition,
28
+ createWriteToolDefinition,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import { Text } from "@earendil-works/pi-tui";
31
+ import { DIALOG_OPTIONS, DialogMenu, dialogBlocked, type DialogRow } from "@estebanforge/pi-tui-dialogs";
32
+ import {
33
+ entriesFromRaw,
34
+ FALLBACK_MODELS,
35
+ loadModelCatalogRaw,
36
+ toPiModel,
37
+ type AgyModelEntry,
38
+ } from "../src/models.js";
39
+ import { SessionStore } from "../src/sessions.js";
40
+ import {
41
+ APPROVAL_PARK_MS,
42
+ POLL_TOOL_NAME,
43
+ ToolRoundTrips,
44
+ WrapperReplay,
45
+ createStreamSimple,
46
+ defaultEscalateAfterMs,
47
+ formatEscalatedAck,
48
+ formatPollAnswer,
49
+ type NativeDisplayEvent,
50
+ } from "../src/provider.js";
51
+ import { PollRunFolder, type PollRunData } from "../src/poll-fold.js";
52
+ import {
53
+ createShadowTool,
54
+ stripMarkerFields,
55
+ type AnyToolDefinition,
56
+ type GatePolicy,
57
+ } from "../src/approval-gate.js";
58
+ import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
59
+ import { hookScriptSource, removeGateHooks, stageGateHooks, sweepWorkspaceGateGroups } from "../src/approval-hook.js";
60
+ import { StreamDriver } from "../src/driver.js";
61
+ import { AcpDriver } from "../src/acp/driver.js";
62
+ import { removeStagedAgyHome, stageAgyHome, sweepStagedAgyHomes, writeOwnBridgeEntry } from "../src/acp/home.js";
63
+ import { cleanupManagedAcpInstall } from "../src/acp/setup.js";
64
+ import { agyAuthenticationMessage } from "../src/agy-install.js";
65
+ import { createAgySetupOffer } from "../src/agy-setup.js";
66
+ import { ensureQueuedMessagesDefaults } from "../src/queued-messages.js";
67
+ import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
68
+ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, removeLegacyAcpBin, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
69
+ import { agyMissingMessage, ENGINE_SETTINGS_ROW_ID, engineSettingsRow, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
70
+ import { agyVersionVerdict, checkAgyCliVersion, describeAgyVersionCheck, MIN_AGY_ACP_VERSION, MIN_AGY_VERSION, MIN_GEMINI_DIR_VERSION, probeGeminiDirFlag, resolveStreamHomeMode } from "../src/agy-version.js";
71
+ import { isValidAgyAgentName, listAgyAgents } from "../src/agents.js";
72
+ import { formatSubagentRoster, SubagentRoster } from "../src/subagent-roster.js";
73
+ import { spawn } from "node:child_process";
74
+ import { fetchAgyQuota, formatAgyQuotaReport } from "../src/usage.js";
75
+ import { artifactOpenCommand, listAgyArtifacts } from "../src/artifacts.js";
76
+ import { showArtifactsBrowser } from "../src/artifacts-ui.js";
77
+ import { listAgyTasks, tailAgyTaskLog } from "../src/tasks.js";
78
+ import { showTasksBrowser } from "../src/tasks-ui.js";
79
+ import { agyConversationDir } from "../src/agy-paths.js";
80
+ import { armBackgroundWatch, rearmPlansFromEntries, type BackgroundWatchEntry, type BackgroundWatchHandle } from "../src/background-watch.js";
81
+ import { redactText } from "../src/redact.js";
82
+ import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
83
+ import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
84
+ import { registerWebTools } from "../src/web-tools.js";
85
+ import { bridgeMcpConfigDir, startMcpServer, sweepStaleBridgeDirs, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
86
+ import {
87
+ registerBridgeServer,
88
+ healBridgeSuppression,
89
+ sweepStaleBridgeServers,
90
+ unregisterBridgeServer,
91
+ } from "../src/mcp-registration.js";
92
+ import {
93
+ ACTIVATE_SKILL_TOOL_NAME,
94
+ activateSkillSchema,
95
+ catalogSummary,
96
+ findSkillByName,
97
+ readSkillBody,
98
+ scanSkills,
99
+ type SkillLite,
100
+ } from "../src/skills.js";
101
+ import { mapAgyToolToNative } from "../src/native-tools.js";
102
+ import { bridgedPiTools } from "../src/bridge-catalog.js";
103
+ import { Type } from "typebox";
104
+ import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
105
+ import { withDialogLock } from "../src/dialog-lock.js";
106
+
107
+ // Last UI seen (session_start / /agy commands). Module scope: both the
108
+ // default export and registerAgyCommand assign it.
109
+ let activeUi: ExtensionUIContext | null = null;
110
+
111
+ function resolveAgyBinary(): string {
112
+ return process.env.AGY_BIN || "agy";
113
+ }
114
+
115
+ export default async function (pi: ExtensionAPI): Promise<void> {
116
+ ensureQueuedMessagesDefaults({ onlyIfDirExists: true });
117
+
118
+ // Claim the AskAntigravity tool for this process. pi-ask-antigravity (if also
119
+ // installed) checks this in-process flag OR the bridge's package.json on disk
120
+ // and defers. See that extension's isBridgeInstalled().
121
+ (globalThis as Record<symbol, unknown>)[Symbol.for("pi-antigravity-bridge:active")] = true;
122
+
123
+ const binary = resolveAgyBinary();
124
+
125
+ // Discover once at load. Failure is non-fatal: FALLBACK_MODELS keeps the
126
+ // picker populated so the user gets a clear runtime error from agy rather
127
+ // than an empty model list. /reload re-runs this and refreshes after an
128
+ // `agy update`.
129
+ // loadModelCatalogRaw serves a short-TTL cache (~/.pi/agent/antigravity-bridge/
130
+ // models-cache.json) so reloads are instant and only re-spawn in the
131
+ // background when stale. Derive both catalogs from the same raw text
132
+ // (provider's slugified Gemini entries + the tool's family/version/tier
133
+ // entries).
134
+ const raw = await loadModelCatalogRaw(binary);
135
+ const discovered = entriesFromRaw(raw);
136
+ const toolModels = toolModelsFromRaw(raw);
137
+ const usingFallback = discovered.length === 0;
138
+ const entries: AgyModelEntry[] = usingFallback ? FALLBACK_MODELS : discovered;
139
+ // Engine latched at load: /agy engine takes effect on the next pi start
140
+ // (documented). Everything below resolves from THIS value - per-call
141
+ // config reads would let a mid-session flip leave ToolRoundTrips,
142
+ // kickIdle, and reentry pointing at the other engine (round-7 finding).
143
+ const engine: Engine = loadConfig().engine;
144
+ // Engine switching requires a restart, so the catalog-time engine read is
145
+ // authoritative for input advertising. Both engines accept images: ACP
146
+ // rides native content blocks; stream-json stages user attachments to
147
+ // temp files and the prompt points the model at them (prompt-images.ts).
148
+ const modelInput: Array<"text" | "image"> = ["text", "image"];
149
+ const models = entries.map((e) => toPiModel(e, modelInput));
150
+
151
+ // failAll reasons the bridge treats as routine (turn end / shutdown
152
+ // sweep), not faults. Shared by the emitters below and the mcpLog
153
+ // classifier so the substring match cannot drift from the text.
154
+ const FAIL_REASON_TURN_END = "antigravity turn ended with an unresolved pi tool call";
155
+ const FAIL_REASON_SHUTDOWN = "antigravity session shut down";
156
+
157
+ // Daily file log: every sink below feeds ~/.pi/extensions-data/
158
+ // estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson (see
159
+ // src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
160
+ // Support flow: "attach the last days' files from that dir".
161
+ const fileLog = createDailyLogger({ dir: logsDir() });
162
+ // Warn tier = user-facing: the default file log keeps ONLY errors (zero
163
+ // routine disk traffic), so a warn that never reaches the UI is lost.
164
+ // Every warn toasts here instead; AGY_DEBUG=1 restores the full file
165
+ // trail. Silent exceptions: deliberate aborts (pi already shows
166
+ // "Operation aborted") and connection exits (the turn's own error block
167
+ // carries real crashes). call-tool-fail needs no exclusion here: its tier
168
+ // is debug or error, never warn. No UI (headless): warn text falls back
169
+ // to stderr.
170
+ const rawFileLog = fileLog.log.bind(fileLog);
171
+ fileLog.log = (event, data, level) => {
172
+ rawFileLog(event, data, level);
173
+ if (level !== "warn") return;
174
+ if (event.startsWith("abort:") || event === "connection-exited") return;
175
+ const d = (data ?? {}) as Record<string, unknown>;
176
+ let text: string;
177
+ if (event === "round-trip-fail") {
178
+ text = `Bridge tool call failed: ${String(d.name ?? "tool")} (${String(d.reason ?? "unknown")})`;
179
+ } else if (event.startsWith("stall:")) {
180
+ text = "Antigravity stalled with no output; the turn was stopped";
181
+ } else if (event.startsWith("timeout:")) {
182
+ text = "Antigravity turn timed out";
183
+ } else if (event.startsWith("exit:") && event !== "exit:0") {
184
+ text = "Antigravity process exited unexpectedly";
185
+ } else if (event === "turn-error") {
186
+ text = `Antigravity turn issue: ${String(d.reason ?? "unknown")}`;
187
+ } else {
188
+ text = `Antigravity warning: ${event}`;
189
+ }
190
+ if (activeUi) activeUi.notify(text, "warning");
191
+ else console.error(`[antigravity-bridge] ${text}`);
192
+ };
193
+ fileLog.log(
194
+ "extension-load",
195
+ { engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool, webTools: loadConfig().webTools },
196
+ "info",
197
+ );
198
+
199
+ const store = new SessionStore();
200
+ // MCP bridge handle, declared early: the ACP engine reads the bridge port
201
+ // at session/new / session/load time.
202
+ let mcpHandle: McpServerHandle | null = null;
203
+ // Session-only visibility overrides; never change either application's MCP config.
204
+ const hiddenBridgeTools = new Set<string>();
205
+ const getBridgeTools = () => bridgedPiTools(pi.getAllTools(), pi.getActiveTools(), loadConfig().bridgeTools, hiddenBridgeTools);
206
+ // Populated on session_start once ctx.isProjectTrusted() is known; empty before start.
207
+ let bridgeSkills: SkillLite[] = [];
208
+ const listTools = () => {
209
+ // /mcp and pi.setActiveTools() update the same live catalog. ACP gets
210
+ // this endpoint through the private carrier agent + session/new or session/load;
211
+ // no global config write.
212
+ const tools = getBridgeTools().map((t) => {
213
+ let inputSchema: object = { type: "object", properties: {}, additionalProperties: true };
214
+ try {
215
+ if (t.parameters) inputSchema = JSON.parse(JSON.stringify(t.parameters)) as object;
216
+ } catch {
217
+ /* keep default schema */
218
+ }
219
+ return { name: t.name, description: t.description ?? t.name, inputSchema };
220
+ });
221
+ if (bridgeSkills.length > 0 && loadConfig().bridgeTools !== "none") {
222
+ tools.push({
223
+ name: ACTIVATE_SKILL_TOOL_NAME,
224
+ description: `Activate a pi Agent Skill by name. Catalog:\n${catalogSummary(bridgeSkills)}`,
225
+ inputSchema: activateSkillSchema(bridgeSkills) as object,
226
+ });
227
+ }
228
+ // Bridge-local, like activate_skill: answered from the escalation
229
+ // registry without a pi round-trip. Pairs with the STILL RUNNING
230
+ // early-ack that keeps slow calls under agy's ~180s request deadline.
231
+ tools.push({
232
+ name: POLL_TOOL_NAME,
233
+ description:
234
+ "Fetch the result of a long-running bridge tool call that answered STILL RUNNING with a callId. Poll again if it still reports running; the result or an error arrives here.",
235
+ inputSchema: {
236
+ type: "object",
237
+ properties: { callId: { type: "string", description: "callId from the STILL RUNNING answer" } },
238
+ required: ["callId"],
239
+ },
240
+ });
241
+ return tools;
242
+ };
243
+ // Approval-gate hook script (per-pid, token embedded). Written at session
244
+ // start when the gate is active; removed at session_shutdown.
245
+ let gateScriptPath: string | null = null;
246
+ let managedAcpCleanupRan = false;
247
+ let legacyConfigCleanupRan = false;
248
+ let legacyConfigCleanupFailed = false;
249
+ let legacyEnvAcpWarningShown = false;
250
+ let legacyFileAcpWarningShown = false;
251
+ let agySetupOffer: (() => Promise<void>) | null = null;
252
+ let agyMissingWarned = false;
253
+ let agyVersionWarned = false;
254
+ // Two turn engines behind one contract: `agy acp` (default for new
255
+ // installs) and the stream-json `agy` CLI
256
+ // (fallback). Neither spawns anything until its first turn.
257
+ // ACP log routing: only genuine failures reach stderr. Routine lifecycle
258
+ // (driver-created, spawn, session-new, ...) stays in the driver's
259
+ // #lifecycle ring buffer, visible via /agy doctor. An unfiltered sink fired
260
+ // console.error at extension load ("driver-created"), before any UI exists,
261
+ // and leaked raw driver lines into the terminal on every startup.
262
+ const acpFailures = new Set([
263
+ "start-failed", "spawn-error", "parse-error", "write-failed",
264
+ "mode-apply-failed", "timeout", "stall", "auth-required",
265
+ "session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
266
+ "unsupported-server-request",
267
+ ]);
268
+ const streamDriver = new StreamDriver();
269
+ // Mirror the stream driver's lifecycle ring into the daily file log
270
+ // (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
271
+ // through acpLog below.
272
+ streamDriver.log = (msg, data) => {
273
+ // Level classification mirrors acpLog's failure set: stalls, aborts,
274
+ // timeouts and nonzero exits are the "what broke" greps (warn);
275
+ // turn-start is the per-turn skeleton (info); everything else is
276
+ // verbose-only (debug, needs AGY_DEBUG).
277
+ const failed =
278
+ msg.startsWith("timeout:") ||
279
+ msg.startsWith("stall:") ||
280
+ msg.startsWith("abort:") ||
281
+ (msg.startsWith("exit:") && msg !== "exit:0");
282
+ const level = failed ? "warn" : msg === "turn-start" ? "info" : "debug";
283
+ fileLog.log(msg, data, level);
284
+ };
285
+ // --- Isolated stream-json MCP home (issue #14) ----------------------------
286
+ // ACP has isolated its MCP view since the private-home work. stream-json
287
+ // spawned against the GLOBAL config, where every session's pi-bridge-<pid>
288
+ // entry is visible to every agy on the machine - and agy hot-reloads the
289
+ // watched config, so the ask-tool suppression dances cannot protect
290
+ // long-lived provider sessions. When the CLI parses --gemini_dir, each
291
+ // stream-json child gets a staged home instead: the global servers minus
292
+ // every foreign bridge, plus this session's own entry (enabled - the
293
+ // suppression marker never applies to our own home, a private-home agy
294
+ // does not read the global file at all).
295
+ const agentsRootOverride = Boolean(process.env.AGY_AGENTS_ROOT);
296
+ // The override must gate the PROBE too, not just staging: carriers stage
297
+ // under AGY_AGENTS_ROOT, outside the home's live agents view.
298
+ const streamHomeProbe = engine === "stream-json" && !agentsRootOverride ? await probeGeminiDirFlag(binary) : undefined;
299
+ const homeMode = resolveStreamHomeMode({ engine, agentsRootOverride, probe: streamHomeProbe });
300
+ const streamHome: { isolated: boolean; path: string | undefined; fallbackReason?: string } = {
301
+ isolated: homeMode === "isolated",
302
+ path: undefined,
303
+ };
304
+ if (homeMode === "isolated") {
305
+ if (streamHomeProbe?.unverifiedVersion) {
306
+ fileLog.log("gemini-dir-unverified", { version: streamHomeProbe.version, verifiedFloor: MIN_GEMINI_DIR_VERSION }, "info");
307
+ }
308
+ streamDriver.stagedHome = () => {
309
+ if (streamHome.path) return streamHome.path;
310
+ try {
311
+ streamHome.path = stageAgyHome({
312
+ prefix: "agy-home",
313
+ ownBridge: mcpHandle
314
+ ? { pid: process.pid, port: mcpHandle.port, token: mcpHandle.token, tokenHeader: TOKEN_HEADER }
315
+ : undefined,
316
+ });
317
+ } catch (err) {
318
+ fileLog.log("stream-home-stage-failed", { error: String(err) }, "warn");
319
+ // The per-pid --add-dir config is not the stream-json delivery
320
+ // path; restore the global registration so the bridge stays
321
+ // reachable when the staged home is unusable.
322
+ if (mcpHandle) {
323
+ registerBridgeServer({ pid: process.pid, port: mcpHandle.port, token: mcpHandle.token, tokenHeader: TOKEN_HEADER });
324
+ }
325
+ return undefined;
326
+ }
327
+ return streamHome.path;
328
+ };
329
+ streamDriver.releaseStagedHome = (home) => {
330
+ if (streamHome.path === home) streamHome.path = undefined;
331
+ removeStagedAgyHome(home);
332
+ };
333
+ } else if (homeMode === "override-skip") {
334
+ fileLog.log("stream-home-skipped", { reason: "AGY_AGENTS_ROOT override" }, "info");
335
+ } else if (engine === "stream-json") {
336
+ const version = streamHomeProbe?.version ?? "unknown";
337
+ const why =
338
+ streamHomeProbe?.detail === "timeout"
339
+ ? `the agy --gemini_dir probe timed out (agy ${version})`
340
+ : streamHomeProbe?.detail === "spawn-failure"
341
+ ? `the agy --gemini_dir probe failed to run (agy ${version})`
342
+ : `agy ${version} refused --gemini_dir`;
343
+ streamHome.fallbackReason = why;
344
+ fileLog.log("gemini-dir-refused", { version, detail: streamHomeProbe?.detail ?? "refused" }, "warn");
345
+ }
346
+ const acpLog = (msg: string, data?: unknown): void => {
347
+ const expectedExit =
348
+ msg === "connection-exited" &&
349
+ (data as { expected?: boolean } | undefined)?.expected === true;
350
+ const level = expectedExit ? "debug" : acpFailures.has(msg) ? "warn" : msg === "turn-start" ? "info" : "debug";
351
+ fileLog.log(msg, data, level);
352
+ };
353
+ const acpDriver = new AcpDriver({
354
+ bin: binary,
355
+ binArgs: ["acp"],
356
+ isolateHome: true,
357
+ // Resolved per turn: /agy and AGY_USAGE_ESTIMATE changes apply without
358
+ // a restart. Without this the driver defaults to "estimate" and the
359
+ // config knob (incl. "off") is dead.
360
+ usageEstimate: () => loadConfig().acp.usageEstimate,
361
+ // ACP request_permission parity with the approval gate: skipPermissions
362
+ // turns auto-allow in the driver; approvals.mode allow short-circuits
363
+ // to the same synchronous allow; everything else opens a dialog (esc
364
+ // and headless deny fail-closed) under the shared dialog lock.
365
+ permissionPolicy: () => (loadConfig().approvals.mode === "allow" ? "auto" : "deny"),
366
+ onPermissionRequest: async (req) => {
367
+ const ui = activeUi;
368
+ if (loadConfig().approvals.mode === "deny" || !ui) return undefined;
369
+ return await withDialogLock(async () => {
370
+ const labels = req.options.map((o) => o.name ?? o.kind ?? o.optionId ?? "?");
371
+ const title = req.toolCall?.title ? `agy requests: ${req.toolCall.title}` : "agy requests permission";
372
+ const picked = await ui.select(title, labels);
373
+ if (picked === undefined) return undefined;
374
+ const idx = labels.indexOf(picked);
375
+ return idx >= 0 ? (req.options[idx]?.optionId ?? undefined) : undefined;
376
+ });
377
+ },
378
+ log: acpLog,
379
+ mcpServers: () => {
380
+ const handle = mcpHandle;
381
+ if (!handle || loadConfig().bridgeTools === "none") return [];
382
+ // The bridge 403s any request without the shared-secret header; the
383
+ // stream engine carries it via mcp_config.json, ACP via headers[].
384
+ return [
385
+ {
386
+ name: "pi-bridge",
387
+ type: "http",
388
+ url: `http://127.0.0.1:${handle.port}/mcp`,
389
+ headers: [{ name: TOKEN_HEADER, value: handle.token }],
390
+ },
391
+ ];
392
+ },
393
+ });
394
+ // The active engine is resolved from the latched load-time value.
395
+ const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
396
+ // The provider's stream-json slot gets the STREAM driver explicitly - never
397
+ // activeDriver(), or a load-time acp engine would make deps.driver and
398
+ // deps.acpDriver the same object and break the engine identity check.
399
+ const driver = streamDriver;
400
+ // The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
401
+ // the provider emits them as real pi toolUse turns and completes them from
402
+ // the next call's toolResult.
403
+ const roundTrips = new ToolRoundTrips(
404
+ activeDriver,
405
+ (s, d, level) =>
406
+ fileLog.log(
407
+ s,
408
+ d,
409
+ level ?? (s === "round-trip-fail" ? "warn" : "debug"),
410
+ ),
411
+ // Delegation tools hold the sync window 120s (issue #6); config adds
412
+ // extra long-running names, re-read per call so edits apply live.
413
+ { escalateAfterMs: (name) => defaultEscalateAfterMs(name, loadConfig().escalateLongTools) },
414
+ );
415
+ const replay = new WrapperReplay();
416
+ // Subagent roster: folded from every driver activity (both engines) via
417
+ // the provider; /agy subagents renders it. In-memory, process-lifetime.
418
+ const roster = new SubagentRoster();
419
+ // ACP tool activity is display-only: durable Pi entries with native-looking
420
+ // titles and optional diffs. Unlike a synthetic Pi toolCall this does not
421
+ // re-execute edits, park the turn, or add tool results to model context.
422
+ if (engine === "acp") {
423
+ pi.registerEntryRenderer<NativeDisplayEvent>("agy-native-event", (entry, { expanded }, theme) => {
424
+ const event = entry.data;
425
+ // Collapsed detail: most-specific signal wins (path, command, agy's own
426
+ // display text). The name is NOT a fallback here - it rendered "ls ls"
427
+ // duplication whenever the event carried neither path nor command.
428
+ const firstLine = (value: string | undefined): string | undefined => value?.split(/\r?\n/, 1)[0].trim() || undefined;
429
+ const detail = event?.path ? path.basename(event.path) : firstLine(event?.command) ?? firstLine(event?.output);
430
+ const status = event?.status === "failed" ? theme.fg("error", "✗") : theme.fg("success", "✓");
431
+ const lines = [`${status} ${theme.fg("toolTitle", event?.name ?? "Antigravity")}${detail ? ` ${theme.fg("muted", detail.slice(0, 160))}` : ""}`];
432
+ if (expanded) {
433
+ if (event?.path) lines.push(theme.fg("dim", event.path));
434
+ if (event?.diff) {
435
+ for (const line of event.diff.split("\n")) {
436
+ const color = line.startsWith("+") ? "toolDiffAdded" : line.startsWith("-") ? "toolDiffRemoved" : "toolDiffContext";
437
+ lines.push(theme.fg(color, line));
438
+ }
439
+ }
440
+ if (event?.output && (!event.diff || event.status === "failed")) lines.push(theme.fg("toolOutput", event.output));
441
+ }
442
+ return new Text(lines.join("\n"), 0, 0);
443
+ });
444
+ // Collapsed poll runs: one ♊︎-marked counted line per consecutive poll
445
+ // burst instead of one entry per poll.
446
+ pi.registerEntryRenderer<PollRunData>("agy-poll-run", (entry, { expanded }, theme) => {
447
+ const run = entry.data;
448
+ const count = run?.count ?? 0;
449
+ const label = `Poll ${run?.target ?? "bridge call"} (x${count}${run?.state === "failed" ? ", failed" : ""})`;
450
+ const lines = [`♊︎ ${theme.fg("toolTitle", POLL_TOOL_NAME)} ${theme.fg("muted", label)}`];
451
+ if (expanded && run) {
452
+ const time = (iso: string): string => new Date(iso).toLocaleTimeString();
453
+ lines.push(theme.fg("dim", `${count} ${count === 1 ? "poll" : "polls"}, ${time(run.firstAt)} → ${time(run.lastAt)}`));
454
+ }
455
+ return new Text(lines.join("\n"), 0, 0);
456
+ });
457
+ }
458
+ let pendingNativeTools = 0;
459
+ const pollRuns = new PollRunFolder();
460
+ const closePollRun = (): void => {
461
+ const settled = pollRuns.flush();
462
+ if (settled) pi.appendEntry<PollRunData>("agy-poll-run", settled);
463
+ setDimStatus("agy-poll", undefined);
464
+ };
465
+ // Footer statuses render muted, matching pi-hostname's dim treatment.
466
+ const setDimStatus = (key: string, text: string | undefined): void => {
467
+ const ui = activeUi;
468
+ if (!ui) return;
469
+ ui.setStatus(key, text === undefined ? undefined : ui.theme.fg("dim", text));
470
+ };
471
+ const onNativeEvent = (event: NativeDisplayEvent): void => {
472
+ if (engine !== "acp") return;
473
+ // Polls are bridge-local (never get a genuine Pi card); the mcpServer
474
+ // tag is unreliable on these frames, so the tool name discriminates.
475
+ if (event.name === POLL_TOOL_NAME) {
476
+ const view = event.callId ? roundTrips.poll(event.callId) : undefined;
477
+ const switched = pollRuns.add(view?.name ?? "unknown call", view?.state ?? "running", new Date());
478
+ if (switched) pi.appendEntry<PollRunData>("agy-poll-run", switched);
479
+ const open = pollRuns.open;
480
+ setDimStatus("agy-poll", open ? `♊︎ bridge_poll_result → ${open.target} (x${open.count})` : undefined);
481
+ return;
482
+ }
483
+ closePollRun();
484
+ if (/^pi-bridge(?:-\d+)?$/.test(event.mcpServer ?? "")) return;
485
+ // MCP calls through our own pi-bridge already have genuine Pi tool cards;
486
+ // only native Antigravity / other MCP events need display-only entries.
487
+ if (event.status === "started") {
488
+ pendingNativeTools++;
489
+ setDimStatus("agy-native", `agy ${event.name}… (${pendingNativeTools})`);
490
+ return;
491
+ }
492
+ pendingNativeTools = Math.max(0, pendingNativeTools - 1);
493
+ if (pendingNativeTools === 0) activeUi?.setStatus("agy-native", undefined);
494
+ // Bound persisted session size; the ACP result and diff may be megabytes.
495
+ pi.appendEntry<NativeDisplayEvent>("agy-native-event", {
496
+ ...event,
497
+ output: event.output?.slice(0, 4000),
498
+ diff: event.diff?.slice(0, 12000),
499
+ command: event.command?.slice(0, 500),
500
+ });
501
+ };
502
+ // Native re-exec only emits for builtins actually active in the session;
503
+ // anything else (or an unknown name) falls back to the wrapper card.
504
+ const nativeActive = (name: string): boolean => {
505
+ try {
506
+ return pi.getActiveTools().includes(name);
507
+ } catch {
508
+ return false;
509
+ }
510
+ };
511
+ // A settled turn cannot answer its parked calls; the driver never sees
512
+ // ToolRoundTrips, so the provider bridges the two here (both engines).
513
+ // The file log records the outcome first: ERROR/aborted turns are the
514
+ // single most useful support signal.
515
+ const onTurnEnd = (outcome: TurnOutcome) => {
516
+ fileLog.log(
517
+ "turn-end",
518
+ // Error text can embed the child's stderr tail; cap it in line with
519
+ // the ACP driver's 200-char stderr slices.
520
+ { status: outcome.status, error: outcome.error?.slice(0, 500), aborted: outcome.aborted },
521
+ outcome.status === "OK" ? "info" : "warn",
522
+ );
523
+ roundTrips.failAll(FAIL_REASON_TURN_END);
524
+ // Closes a poll run that no display event flushed.
525
+ closePollRun();
526
+ // Stamp live escalations with the ended turn's conversation: the park
527
+ // pass returns before the provider's store.set, so a first-turn park
528
+ // has no other record of where its result must be re-delivered.
529
+ if (outcome.conversationId) roundTrips.stampConversation(outcome.conversationId);
530
+ };
531
+ streamDriver.onTurnEnd = onTurnEnd;
532
+ acpDriver.onTurnEnd = onTurnEnd;
533
+ // Pi's normal footer receives the incremental usage above. Mark the
534
+ // numbers as estimates in the status area too; ACP currently drops the
535
+ // SDK's exact usage metadata before it reaches the client.
536
+ if (engine === "acp") {
537
+ let lastUsageStatus = "";
538
+ pi.on("message_update", (event, ctx) => {
539
+ if (!ctx.hasUI || event.message.role !== "assistant" || event.message.provider !== "antigravity" || loadConfig().acp.usageEstimate === "off") return;
540
+ const { input, output } = event.message.usage;
541
+ const status = ctx.ui.theme.fg("dim", `ACP ≈ ${input} in / ${output} out`);
542
+ if (status !== lastUsageStatus) {
543
+ lastUsageStatus = status;
544
+ ctx.ui.setStatus("agy-usage", status);
545
+ }
546
+ });
547
+ const clearUsageStatus = (_event: unknown, ctx: { hasUI: boolean; ui: ExtensionUIContext }) => {
548
+ lastUsageStatus = "";
549
+ if (ctx.hasUI) ctx.ui.setStatus("agy-usage", undefined);
550
+ };
551
+ pi.on("agent_end", (event, ctx) => {
552
+ clearUsageStatus(event, ctx);
553
+ pendingNativeTools = 0;
554
+ closePollRun();
555
+ if (ctx.hasUI) ctx.ui.setStatus("agy-native", undefined);
556
+ });
557
+ pi.on("session_shutdown", (event, ctx) => {
558
+ clearUsageStatus(event, ctx);
559
+ pendingNativeTools = 0;
560
+ if (ctx.hasUI) ctx.ui.setStatus("agy-native", undefined);
561
+ });
562
+ }
563
+ // agy-native background-task watcher (src/background-watch.ts). The
564
+ // provider flags turns that used the WaitMsBeforeAsync shape; at the
565
+ // settled turn end this arms a filesystem watcher over the conversation's
566
+ // task logs and wakes the model when they go quiet. One watcher per
567
+ // conversation; armed/settled pi entries survive /resume so a reload
568
+ // mid-watch re-arms instead of losing the completion notice.
569
+ const backgroundWatchers = new Map<string, BackgroundWatchHandle>();
570
+ let backgroundWatchDisposed = false;
571
+ const sendTaskWake = (content: string): void => {
572
+ if (backgroundWatchDisposed) return;
573
+ try {
574
+ // Custom message at user-role authority: redact secrets, banner and
575
+ // liveness-only wording come from buildTaskWakeContent.
576
+ pi.sendMessage({ customType: "agy-background-task", content: redactText(content), display: true }, { triggerTurn: true, deliverAs: "followUp" });
577
+ } catch {
578
+ // Stale runtime after shutdown won the race; the notice is lost, stay quiet.
579
+ }
580
+ };
581
+ const armConversationWatcher = (conversationId: string, turnStartMs: number, opts?: { armedAt?: number; recordArmed?: boolean }): void => {
582
+ const conversationDir = agyConversationDir(engine, conversationId);
583
+ backgroundWatchers.get(conversationId)?.stop();
584
+ const armedAt = opts?.armedAt ?? Date.now();
585
+ if (opts?.recordArmed !== false) {
586
+ try {
587
+ pi.appendEntry<BackgroundWatchEntry>("agy-background-watch", { kind: "armed", conversationId, turnStartMs, armedAt });
588
+ } catch { /* persistence is best-effort; the in-memory watch still works */ }
589
+ }
590
+ const handle = armBackgroundWatch({
591
+ conversationId,
592
+ conversationDir,
593
+ turnStartMs,
594
+ listTasks: listAgyTasks,
595
+ tail: tailAgyTaskLog,
596
+ onSettle: (result) => {
597
+ try {
598
+ pi.appendEntry<BackgroundWatchEntry>("agy-background-watch", { kind: "settled", conversationId, armedAt, settledAt: Date.now(), taskIds: result.taskIds });
599
+ } catch { /* best-effort */ }
600
+ },
601
+ sendWake: sendTaskWake,
602
+ log: fileLog.log.bind(fileLog),
603
+ });
604
+ backgroundWatchers.set(conversationId, handle);
605
+ };
606
+ // Reload resume: pi wipes extension module state on /new, /resume, /fork
607
+ // and /reload while the agy-side task keeps running. Armed records without
608
+ // a settled twin re-arm; the settled record prevents any double wake.
609
+ pi.on("session_start", (event, ctx) => {
610
+ try {
611
+ const records: Array<{ customType: string; data: unknown }> = [];
612
+ for (const e of ctx.sessionManager.getEntries()) {
613
+ if ("customType" in e && e.customType === "agy-background-watch") {
614
+ records.push({ customType: e.customType, data: (e as { data?: unknown }).data });
615
+ }
616
+ }
617
+ for (const plan of rearmPlansFromEntries(records)) {
618
+ if (!backgroundWatchers.has(plan.conversationId)) {
619
+ armConversationWatcher(plan.conversationId, plan.turnStartMs, { armedAt: plan.armedAt, recordArmed: false });
620
+ fileLog.log("background-watch-rearm", { conversation: plan.conversationId, armedAt: plan.armedAt }, "info");
621
+ }
622
+ }
623
+ } catch (err) {
624
+ fileLog.log("background-watch-sweep-error", { error: err instanceof Error ? err.message : String(err) }, "warn");
625
+ }
626
+ });
627
+ pi.on("session_shutdown", () => {
628
+ backgroundWatchDisposed = true;
629
+ for (const handle of backgroundWatchers.values()) handle.stop();
630
+ backgroundWatchers.clear();
631
+ });
632
+ const streamSimple = createStreamSimple({
633
+ entries,
634
+ store,
635
+ driver,
636
+ acpDriver,
637
+ roundTrips,
638
+ replay,
639
+ nativeActive,
640
+ onNativeEvent,
641
+ onNativeBackground: (info) => armConversationWatcher(info.conversationId, info.turnStartMs),
642
+ roster,
643
+ bridgeCatalog: () => (mcpHandle && loadConfig().bridgeTools !== "none" ? listTools() : []),
644
+ engine,
645
+ log: fileLog.log.bind(fileLog),
646
+ });
647
+
648
+ pi.registerProvider("antigravity", {
649
+ name: "Antigravity (agy)",
650
+ baseUrl: "agy-bridge://antigravity",
651
+ apiKey: "not-used",
652
+ api: "agy-bridge",
653
+ models: models.map((m) => ({
654
+ id: m.id,
655
+ name: m.name,
656
+ api: m.api,
657
+ reasoning: m.reasoning,
658
+ thinkingLevelMap: m.thinkingLevelMap,
659
+ input: m.input,
660
+ cost: m.cost,
661
+ contextWindow: m.contextWindow,
662
+ maxTokens: m.maxTokens,
663
+ })),
664
+ streamSimple,
665
+ });
666
+
667
+ registerAgyCommand(pi, {
668
+ entries,
669
+ store,
670
+ usingFallback,
671
+ driver,
672
+ acpDriver,
673
+ engine,
674
+ getMcpPort: () => mcpHandle?.port ?? null,
675
+ streamIsolated: streamHome.isolated,
676
+ bridgeTools: getBridgeTools,
677
+ hiddenBridgeTools,
678
+ fileLog,
679
+ roster,
680
+ checkAgySetup,
681
+ });
682
+
683
+ // AskAntigravity tool: one-shot delegation to agy (ported from
684
+ // pi-ask-antigravity). When both extensions are installed, the bridge wins
685
+ // and pi-ask-antigravity registers nothing (its load-time defer guard
686
+ // detects this package via import.meta.resolve). Opt-out: askTool=false
687
+ // (config file, AGY_ASK_TOOL, /agy ask, or the picker) skips registration
688
+ // entirely - users who want only the provider keep a clean tool list.
689
+ // Note: the active flag below is set regardless of askTool, so
690
+ // pi-ask-antigravity keeps deferring even then: off means NO delegation
691
+ // tool from either package, not a fallback to pi-ask-antigravity.
692
+ if (loadConfig().askTool) await registerAskAntigravityTool(pi, toolModels, fileLog.log.bind(fileLog));
693
+ // Web tools are opt-in (config.webTools, default off): Antigravity sessions
694
+ // already have native web tools; these serve NON-Antigravity providers.
695
+ if (loadConfig().webTools) registerWebTools(pi, { log: fileLog.log.bind(fileLog) });
696
+
697
+ // Display-only wrapper tool: the provider emits mutating agy steps as
698
+ // toolCalls against it (never re-executed - execute() replays the output
699
+ // agy already recorded). Empty description on purpose: no model should
700
+ // call it, it exists so pi renders proper toolCall/toolResult cards.
701
+ pi.registerTool({
702
+ name: "antigravity",
703
+ label: "Antigravity",
704
+ description: "",
705
+ parameters: Type.Object({
706
+ tool: Type.String({ description: "agy tool name that produced this step." }),
707
+ key: Type.String({ description: "Internal replay key. Do not fabricate." }),
708
+ }),
709
+ execute: async (_toolCallId, params) => {
710
+ const key = (params as { key?: string }).key ?? "";
711
+ const output = replay.take(key) ?? `(no recorded output for ${key})`;
712
+ return { content: [{ type: "text", text: output }], details: { replay: true } };
713
+ },
714
+ });
715
+
716
+ function offerSetup(ui: ExtensionUIContext, explicit = false): void {
717
+ if (agySetupOffer && explicit) {
718
+ ui.notify(`CLI setup was already offered in this session. ${agyMissingMessage()}`, "info");
719
+ }
720
+ agySetupOffer ??= createAgySetupOffer({
721
+ ui,
722
+ binaryOverride: process.env.AGY_BIN || undefined,
723
+ missingMessage: agyMissingMessage,
724
+ log: (event, data, level) => fileLog.log(event, data, level),
725
+ });
726
+ void agySetupOffer();
727
+ }
728
+
729
+ // Availability checks never block bridge startup. The setup offer owns
730
+ // prompting, deduplication, and failure containment.
731
+ async function checkAgySetup(ctx: { ui: ExtensionUIContext; mode: string }, selectedEngine: Engine, explicit = false): Promise<void> {
732
+ try {
733
+ if (!isAgyInstalled(binary)) {
734
+ if (ctx.mode === "tui") {
735
+ offerSetup(ctx.ui, explicit);
736
+ } else if (!agyMissingWarned) {
737
+ agyMissingWarned = true;
738
+ console.error(`[antigravity-bridge] ${agyMissingMessage()}`);
739
+ }
740
+ return;
741
+ }
742
+ if (agyVersionWarned) {
743
+ if (explicit && ctx.mode === "tui") offerSetup(ctx.ui, true);
744
+ return;
745
+ }
746
+ const rawCheck = await checkAgyCliVersion(binary);
747
+ const floor = selectedEngine === "acp" ? MIN_AGY_ACP_VERSION : MIN_AGY_VERSION;
748
+ const check = agyVersionVerdict(rawCheck.raw, floor);
749
+ if (check.status !== "unsupported") return;
750
+ agyVersionWarned = true;
751
+ const msg = `agy ${check.version} is too old for ${selectedEngine}; install ${floor} or newer.`;
752
+ if (ctx.mode === "tui") {
753
+ ctx.ui.notify(msg, "warning");
754
+ offerSetup(ctx.ui, explicit);
755
+ } else console.error(`[antigravity-bridge] ${msg}`);
756
+ } catch (err) {
757
+ fileLog.log("agy-version-check", { error: String(err) }, "warn");
758
+ }
759
+ }
760
+
761
+ // MCP tool bridge: expose pi's tools to agy over localhost Streamable HTTP.
762
+ // Calls park in the provider's round-trip store and complete through pi's
763
+ // normal toolUse loop (native cards, permissions, hooks) - no patch, no
764
+ // privileged API. Started on session_start, torn down on session_shutdown.
765
+ pi.on("session_start", async (event, ctx) => {
766
+ if (ctx.hasUI) activeUi = ctx.ui;
767
+ // First-run engine picker: ask once, on the first interactive start,
768
+ // which turn engine to use. Skipped headless (ctx.mode !== "tui"),
769
+ // when AGY_ENGINE is set, or once any config file exists (any save -
770
+ // even of an unrelated knob - means the user has been here before).
771
+ // esc = decide later: nothing is written, the picker reappears next
772
+ // start. Like /agy engine, the choice applies on the next start
773
+ // (drivers wire at load). The await intentionally runs before the
774
+ // bridge startup below: on a genuine first run the modal blocks input
775
+ // anyway, so the delay is invisible.
776
+ let setupEngine = engine;
777
+ if (event.reason === "startup" && ctx.mode === "tui" && shouldOfferEnginePicker(CONFIG_PATH)) {
778
+ // Best-effort, like the legacy-patch notice below: a picker failure
779
+ // (mid-prompt TUI teardown, resize races) must never take down the
780
+ // rest of session_start - the MCP bridge startup included. The
781
+ // default engine keeps working untouched.
782
+ try {
783
+ const picked = await showEnginePicker(ctx.ui);
784
+ if (picked) {
785
+ setupEngine = picked;
786
+ saveConfig({ engine: picked });
787
+ ctx.ui.notify(savedEngineMessage(picked), "info");
788
+ }
789
+ } catch (err) {
790
+ fileLog.log("engine-picker", { error: String(err) }, "warn");
791
+ console.error(`[antigravity-bridge] engine picker failed: ${String(err)}`);
792
+ }
793
+ }
794
+ // The retired acp.bin key heals itself in the config file; the env var
795
+ // lives outside our reach, so it is the only case that still warns.
796
+ // Independent once-flags: a failed cleanup must never suppress the
797
+ // env-var warning (or vice versa) for the rest of the process.
798
+ if (!legacyConfigCleanupRan) {
799
+ legacyConfigCleanupRan = true;
800
+ try {
801
+ removeLegacyAcpBin();
802
+ } catch (err) {
803
+ legacyConfigCleanupFailed = true;
804
+ fileLog.log("legacy-acp-config-cleanup", { error: String(err) }, "error");
805
+ }
806
+ }
807
+ if (!legacyEnvAcpWarningShown && process.env.AGY_ACP_BIN?.trim()) {
808
+ legacyEnvAcpWarningShown = true;
809
+ const msg = "AGY_ACP_BIN is set but no longer used; turns run \"agy acp\" from the CLI. Remove it from your shell config, then start a new terminal.";
810
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
811
+ else console.error(`[antigravity-bridge] ${msg}`);
812
+ }
813
+ if (!legacyFileAcpWarningShown && legacyConfigCleanupFailed) {
814
+ legacyFileAcpWarningShown = true;
815
+ const msg = `Couldn't remove the old "bin" setting from ${CONFIG_PATH} (details in the bridge log). Delete the "bin" line under "acp" by hand; turns run "agy acp" from the CLI.`;
816
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
817
+ else console.error(`[antigravity-bridge] ${msg}`);
818
+ }
819
+ if (!managedAcpCleanupRan) {
820
+ managedAcpCleanupRan = true;
821
+ try {
822
+ const cleanup = cleanupManagedAcpInstall();
823
+ if (cleanup.removedBuilds.length > 0) fileLog.log("legacy-acp-cleanup", cleanup, "info");
824
+ } catch (err) {
825
+ fileLog.log("legacy-acp-cleanup", { error: String(err) }, "warn");
826
+ }
827
+ }
828
+ void checkAgySetup(ctx, setupEngine);
829
+ // Legacy cleanup: users who ran the old consent-gated patcher still
830
+ // carry pi.invokeTool in their installed pi. Inert, but tell them once
831
+ // and offer /agy patch-cleanup. Never auto-edits the install.
832
+ try {
833
+ if (!loadConfig().patchCleanupNotified && patchStatus().present) {
834
+ // Flag after surfacing, not before: headless sessions log to
835
+ // stderr (ctx.ui.notify is a no-op without a UI), so the notice
836
+ // is never silently dropped.
837
+ const msg =
838
+ "Your pi install still carries the old pi.invokeTool patch. It is unused and harmless; a pi update also removes it. To restore the original files from the backup now: /agy patch-cleanup";
839
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
840
+ else console.error(`[antigravity-bridge] ${msg}`);
841
+ saveConfig({ patchCleanupNotified: true });
842
+ }
843
+ } catch {
844
+ /* detection is best-effort */
845
+ }
846
+ // Bridge failure logger. Routine lifecycle (listening,
847
+ // bridge-config-written/removed, closed) is normal startup/teardown
848
+ // traffic: toasting it every session, or pinning it via stderr in
849
+ // headless mode, was noise. Only genuine failures surface - as a
850
+ // warning toast (ctx.ui.notify, ephemeral) or stderr when headless.
851
+ // Per-turn success events (list-tools / call-tool) stay silent.
852
+ const mcpLog = (s: string, d?: unknown) => {
853
+ const failures = new Set([
854
+ "http-error", "bridge-config-write-failed", "call-tool-fail",
855
+ "transport-error", "handleRequest-error", "request-error",
856
+ "request-handler-error", "unauthorized",
857
+ ]);
858
+ // Daily file log gets every bridge event (call-tool/list-tools
859
+ // traffic included - it is how a parked round-trip is traced); the
860
+ // filters below only decide what reaches the user. Bridge calls
861
+ // start/end at info (one record per tool call, the fragile-path
862
+ // skeleton); list-tools and startup chatter stay verbose.
863
+ // Routine abort traffic (failAll on turn end / session shutdown
864
+ // answers every parked call with an error) is not a fault: debug
865
+ // only. Any other call-tool-fail is a real rejection (e.g. "no
866
+ // active antigravity turn" from a client that should not see the
867
+ // bridge) and lands at error tier - default mode records errors
868
+ // only, so this is the sole durable trace of the incident.
869
+ const detail = (d as { msg?: string } | undefined)?.msg ?? "";
870
+ const routineAbort =
871
+ s === "call-tool-fail" &&
872
+ (detail.includes(FAIL_REASON_TURN_END) || detail.includes(FAIL_REASON_SHUTDOWN));
873
+ const level = !failures.has(s)
874
+ ? s === "call-tool" || s === "call-tool-ok"
875
+ ? "info"
876
+ : "debug"
877
+ : routineAbort
878
+ ? "debug"
879
+ : s === "call-tool-fail"
880
+ ? "error"
881
+ : "warn";
882
+ fileLog.log(s, d, level);
883
+ if (routineAbort) return;
884
+ if (!failures.has(s)) return;
885
+ const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
886
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
887
+ else console.error(msg);
888
+ };
889
+ // Sweep crashed sessions even when the bridge is disabled.
890
+ sweepStaleBridgeServers();
891
+ sweepStaleBridgeDirs();
892
+ sweepStagedAgyHomes();
893
+ // Start the bridge unless the user turned it off. No patch gate, no
894
+ // consent flow: calls route through pi's normal toolUse loop.
895
+ const bridgeMode: BridgeTools = loadConfig().bridgeTools;
896
+ if (bridgeMode === "none") return; // user opted out
897
+ if (mcpHandle) return; // already running (reload re-fires session_start)
898
+ // pi loads project skill locations only after the project is trusted;
899
+ // mirror that gate. Global skill dirs are always scanned.
900
+ bridgeSkills = scanSkills(ctx.isProjectTrusted() ? process.cwd() : undefined);
901
+ // activate_skill never round-trips through pi: the bridge answers it
902
+ // directly by reading the SKILL.md (pi has no skill tool to execute).
903
+ const bridgeOnToolCall = (
904
+ callId: string,
905
+ name: string,
906
+ args: Record<string, unknown>,
907
+ signal: AbortSignal,
908
+ ) => {
909
+ // Bridge-local, like activate_skill: answered from the escalation
910
+ // registry, never parked into pi.
911
+ if (name === POLL_TOOL_NAME) {
912
+ const wanted = typeof args.callId === "string" ? args.callId : "";
913
+ mcpLog("poll-tool", { callId: wanted });
914
+ const view = roundTrips.poll(wanted);
915
+ // A terminal answer means the model RECEIVED the result; mark it
916
+ // so the redelivery path never double-delivers. poll() itself
917
+ // stays pure (internal peeks must not mark). Accepted gap: the
918
+ // mark fires when the poll ANSWERS, not when the HTTP response
919
+ // lands; a drop inside that one-write window would mark a result
920
+ // the model never saw.
921
+ if (view && view.state !== "running") roundTrips.markDelivered(wanted);
922
+ return Promise.resolve(formatPollAnswer(wanted, view));
923
+ }
924
+ if (name !== ACTIVATE_SKILL_TOOL_NAME) {
925
+ // A cached MCP catalog is not authorization. An inactive/hidden tool
926
+ // must not get parked into Pi, even if agy retained its old schema.
927
+ if (!getBridgeTools().some((tool) => tool.name === name)) {
928
+ return Promise.resolve({ content: [{ type: "text", text: `Pi tool ${name} is not active or not exposed to Antigravity` }], isError: true });
929
+ }
930
+ return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
931
+ // Early-ack: answer the HTTP request before agy's ~180s client
932
+ // deadline with a poll handle; pi keeps executing meanwhile.
933
+ if (!("escalated" in r)) return r;
934
+ mcpLog("call-tool-escalated", { name, callId: r.callId });
935
+ return formatEscalatedAck(r);
936
+ });
937
+ }
938
+ const wanted = typeof args.name === "string" ? args.name : "";
939
+ const skill = loadConfig().bridgeTools !== "none" ? findSkillByName(bridgeSkills, wanted) : undefined;
940
+ const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
941
+ return Promise.resolve({
942
+ content: [
943
+ {
944
+ type: "text",
945
+ text: skill ? `${body}\n\n[skill resources dir: ${skill.dir}]` : `Error: ${body}`,
946
+ },
947
+ ],
948
+ isError: !skill,
949
+ });
950
+ };
951
+ const r = await startMcpServer(
952
+ {
953
+ listTools,
954
+ onToolCall: bridgeOnToolCall,
955
+ onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
956
+ },
957
+ { log: mcpLog },
958
+ );
959
+ if (r.ok && r.handle) {
960
+ mcpHandle = r.handle;
961
+ // Entries a crashed delegation left suppressed are healed here,
962
+ // but only when no live delegation is in
963
+ // flight anywhere (marker-aware heal). A blind re-enable used to
964
+ // un-hide the bridge during another session's active delegation.
965
+ // Global-config hygiene runs on BOTH engines: a crashed stream-json
966
+ // session must not leave dead-pid entries or stuck suppression flags
967
+ // for the next one.
968
+ healBridgeSuppression();
969
+ // ACP supplies the bridge per-session (session/new | session/load).
970
+ // stream-json skips the global file entirely when the isolated home
971
+ // is active (issue #14): this session's bridge rides the staged
972
+ // config instead, so no other agy on the machine can see or call it.
973
+ if (engine === "stream-json" && !streamHome.isolated) {
974
+ registerBridgeServer({
975
+ pid: process.pid,
976
+ port: r.handle.port,
977
+ token: r.handle.token,
978
+ tokenHeader: TOKEN_HEADER,
979
+ });
980
+ } else if (streamHome.path) {
981
+ // Bridge enabled after the home was staged without one: refresh
982
+ // the staged copy directly. agy reads the home once at spawn;
983
+ // the running child sees it on its next start.
984
+ try {
985
+ writeOwnBridgeEntry(streamHome.path, { pid: process.pid, port: r.handle.port, token: r.handle.token, tokenHeader: TOKEN_HEADER });
986
+ } catch (err) {
987
+ fileLog.log("stream-home-entry-failed", { error: String(err) }, "warn");
988
+ }
989
+ }
990
+ if (streamHome.fallbackReason) {
991
+ // Isolation degraded to the legacy global path: say so once.
992
+ const msg = `[antigravity-bridge] ${streamHome.fallbackReason}; using the global MCP registration (issue #14 isolation unavailable).`;
993
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
994
+ else console.error(msg);
995
+ }
996
+ // --- Approval gate (docs/TODO.md 2.5) ------------------------------
997
+ // agy native tool calls pass through a pi-side approval: a PreToolUse
998
+ // hook parks in the bridge, the provider emits a shadow toolUse, and
999
+ // pi's permission extensions gate it like any native call. Off until
1000
+ // enabled (auto = on only when a third-party gate extension exists).
1001
+ {
1002
+ const cfg = loadConfig();
1003
+ const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
1004
+ if (mode === "dedicated") {
1005
+ // The explicit antigravity_approve variant is planned; until it
1006
+ // ships, dedicated stages the same shadow tools. Say so, so the
1007
+ // config value never lies silently.
1008
+ fileLog.log("approval-dedicated-as-shadow", {}, "warn");
1009
+ }
1010
+ // Legacy cleanup (issue #5): 1.6.x staged the gate into the shared
1011
+ // workspace hooks.json, where standalone IDE/CLI sessions load it.
1012
+ // Staging now lives in the session-private add-dir; sweep groups left
1013
+ // behind by dead sessions from the old scheme. Live sessions' and
1014
+ // foreign groups are never touched.
1015
+ const sweptLegacy = sweepWorkspaceGateGroups(process.cwd());
1016
+ if (sweptLegacy > 0) fileLog.log("approval-workspace-swept", { swept: sweptLegacy }, "info");
1017
+ if (mode === "off") {
1018
+ // Gate off: drop this session's private hooks file. Other sessions
1019
+ // own their own per-pid files; nothing shared to preserve.
1020
+ const unstaged = removeGateHooks();
1021
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
1022
+ } else {
1023
+ // Script: per-pid file; 0600 because the bridge token is
1024
+ // embedded (peer review 2026-09-07).
1025
+ const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
1026
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
1027
+ fs.writeFileSync(
1028
+ scriptPath,
1029
+ hookScriptSource({
1030
+ port: r.handle.port,
1031
+ token: r.handle.token,
1032
+ deadlineMs: APPROVAL_PARK_MS,
1033
+ }),
1034
+ { mode: 0o600 },
1035
+ );
1036
+ gateScriptPath = scriptPath;
1037
+ // Private per-pid staging (issue #5 isolation): the gate group goes
1038
+ // into the bridge add-dir that ONLY this session's agy receives.
1039
+ // Standalone sessions in the workspace never load it.
1040
+ const staged = stageGateHooks(bridgeMcpConfigDir(), {
1041
+ port: r.handle.port,
1042
+ token: r.handle.token,
1043
+ scriptPath,
1044
+ parkBudgetMs: APPROVAL_PARK_MS,
1045
+ });
1046
+ fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
1047
+
1048
+ // Shadow bases: factory twins of pi's own builtins (public API).
1049
+ // pi.getAllTools() is unusable here: it returns ToolInfo, which
1050
+ // strips execute. Marker calls never execute; non-marker calls
1051
+ // delegate to the twins, so behavior matches the standard
1052
+ // builtins (session-level bash-operations overrides are not
1053
+ // inherited; documented in README).
1054
+ const gateCwd = process.cwd();
1055
+ const bases: Record<string, AnyToolDefinition> = {
1056
+ bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
1057
+ write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
1058
+ edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
1059
+ };
1060
+ const askMode = cfg.approvals.mode;
1061
+ const policy: GatePolicy = async ({ tool, params, ctx }) => {
1062
+ if (askMode === "allow") return { allow: true };
1063
+ if (askMode === "deny") {
1064
+ return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
1065
+ }
1066
+ const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
1067
+ if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
1068
+ return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
1069
+ }
1070
+ const what =
1071
+ typeof params.command === "string"
1072
+ ? params.command
1073
+ : typeof params.path === "string"
1074
+ ? params.path
1075
+ : JSON.stringify(stripMarkerFields(params)).slice(0, 200);
1076
+ // Dialog lock: parallel agy tool approvals queue up instead of
1077
+ // clobbering the live dialog (which silently loses the approval).
1078
+ // Capture the narrowed method: TS drops the guard's narrowing
1079
+ // inside the deferred closure.
1080
+ const uiConfirm = extCtx.ui.confirm.bind(extCtx.ui);
1081
+ const ok = await withDialogLock(() =>
1082
+ uiConfirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS }),
1083
+ );
1084
+ return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
1085
+ };
1086
+ const handle = r.handle;
1087
+ for (const [name, base] of Object.entries(bases)) {
1088
+ pi.registerTool(
1089
+ createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
1090
+ );
1091
+ }
1092
+ // The park is wired ONLY after the shadows are registered: an
1093
+ // approval toolUse must never dispatch to the REAL builtin and
1094
+ // execute locally.
1095
+ roundTrips.approvalPark = handle.approvals;
1096
+ }
1097
+ }
1098
+ } else {
1099
+ console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
1100
+ }
1101
+ });
1102
+ pi.on("session_shutdown", async () => {
1103
+ // The UI is going away; a later auth-url must fall back to stderr
1104
+ // instead of toasting into a dead UI (where it would be lost).
1105
+ activeUi = null;
1106
+ const h = mcpHandle;
1107
+ mcpHandle = null;
1108
+ // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
1109
+ // and /fork (docs/extensions.md session lifecycle), not only on process
1110
+ // exit. The drivers are process-lifetime singletons; closing them with
1111
+ // "shutdown" latched them permanently and every later turn failed with
1112
+ // "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
1113
+ // connection now; the next turn respawns it. event.reason is
1114
+ // deliberately ignored: recycle is correct even on real process exit
1115
+ // ("quit") - the connection kill is identical and nothing runs after.
1116
+ await streamDriver.close("recycle", "session shutdown");
1117
+ await acpDriver.close("recycle", "session shutdown");
1118
+ roundTrips.failAll(FAIL_REASON_SHUTDOWN);
1119
+ await h?.close();
1120
+ if (engine === "stream-json") unregisterBridgeServer(process.pid);
1121
+ hiddenBridgeTools.clear();
1122
+ // Approval gate: unstage hooks and remove the per-pid script. Pending
1123
+ // approvals already failed closed via handle close (bridge shutdown deny).
1124
+ const unstaged = removeGateHooks();
1125
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
1126
+ if (gateScriptPath) {
1127
+ try {
1128
+ fs.rmSync(gateScriptPath, { force: true });
1129
+ } catch {
1130
+ /* best effort */
1131
+ }
1132
+ gateScriptPath = null;
1133
+ }
1134
+ });
1135
+ }
1136
+
1137
+ // --- /agy command -----------------------------------------------------------
1138
+
1139
+ interface AgyCommandCtx {
1140
+ entries: AgyModelEntry[];
1141
+ store: SessionStore;
1142
+ usingFallback: boolean;
1143
+ driver: TurnDriver;
1144
+ acpDriver: AcpDriver;
1145
+ /** Engine latched at extension load (see the provider wiring note). */
1146
+ engine: Engine;
1147
+ getMcpPort: () => number | null;
1148
+ /** stream-json MCP-home isolation state (issue #14): false when the CLI
1149
+ * refused --gemini_dir or a sandbox override kept the legacy path. */
1150
+ streamIsolated: boolean;
1151
+ bridgeTools: () => Array<{ name: string }>;
1152
+ hiddenBridgeTools: Set<string>;
1153
+ /** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
1154
+ fileLog: DailyLogger;
1155
+ /** Live subagent roster (folded in the provider); /agy subagents. */
1156
+ roster: SubagentRoster;
1157
+ checkAgySetup: (ctx: { ui: ExtensionUIContext; mode: string }, engine: Engine, explicit?: boolean) => Promise<void>;
1158
+ }
1159
+
1160
+ interface PendingConfig {
1161
+ mode?: AgyMode;
1162
+ readOnly?: boolean;
1163
+ skipPermissions?: boolean;
1164
+ defaultModel?: string;
1165
+ defaultThinking?: ThinkingTier;
1166
+ turnTimeoutMin?: number;
1167
+ askTool?: boolean;
1168
+ webTools?: boolean;
1169
+ bridgeTools?: BridgeTools;
1170
+ digest?: boolean;
1171
+ systemPrompt?: boolean;
1172
+ }
1173
+
1174
+ function settingsRows(ctx: AgyCommandCtx): string[] {
1175
+ const config = loadConfig();
1176
+ const perm = config.skipPermissions ? "auto-approved (DANGEROUS)" : "prompt (hangs in -p)";
1177
+ // padEnd keyed to the longest label ("AskAntigravity thinking:") so the
1178
+ // value column stays aligned as labels grow. Doctor-only state (engine,
1179
+ // models, sessions, config path) lives in /agy doctor, not here: one
1180
+ // surface per concern — this is the config surface, doctor is health.
1181
+ const row = (label: string, value: string) => ` ${label.padEnd(24)} ${value}`;
1182
+ return [
1183
+ row("mode:", config.mode),
1184
+ row(
1185
+ "readOnly:",
1186
+ config.readOnly ? "on (provider turns are refused)" : "off",
1187
+ ),
1188
+ row("permissions:", perm),
1189
+ row("AskAntigravity tool:", config.askTool ? "on" : "off"),
1190
+ row("Web tools:", config.webTools ? "on" : "off"),
1191
+ row("AskAntigravity model:", config.defaultModel),
1192
+ row("AskAntigravity thinking:", config.defaultThinking),
1193
+ row("bridge tools:", config.bridgeTools),
1194
+ row("digest:", config.digest ? "on" : "off"),
1195
+ row("system prompt:", config.systemPrompt ? "on" : "off"),
1196
+ ];
1197
+ }
1198
+
1199
+ function statusText(ctx: AgyCommandCtx): string {
1200
+ const config = loadConfig();
1201
+ const source = ctx.usingFallback ? "fallback (agy models failed)" : "discovered";
1202
+ return [
1203
+ "Antigravity bridge",
1204
+ ` engine: ${config.engine}${config.engine === "stream-json" ? " (agy CLI fallback)" : " (agy acp)"}`,
1205
+ ` models: ${ctx.entries.length} ${source}`,
1206
+ ...settingsRows(ctx),
1207
+ ` sessions: ${ctx.store.size} bound`,
1208
+ ` config: ${CONFIG_PATH}`,
1209
+ "",
1210
+ "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 patch-cleanup, /agy clear, /agy doctor",
1211
+ ].join("\n");
1212
+ }
1213
+
1214
+
1215
+ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1216
+ pi.registerCommand("agy", {
1217
+ description:
1218
+ "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>|patch-cleanup|clear]",
1219
+ handler: async (args, cmdCtx: ExtensionCommandContext) => {
1220
+ const ui = cmdCtx.ui;
1221
+ if (ui) activeUi = ui;
1222
+ const mode = cmdCtx.mode;
1223
+ const sub = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase();
1224
+ const val = (args ?? "").trim().split(/\s+/)[1]?.toLowerCase();
1225
+ ctx.fileLog.log("agy-command", { args }, "info");
1226
+
1227
+ // Direct subcommands work everywhere (headless + TUI).
1228
+ if (sub === "clear") {
1229
+ ctx.store.clear();
1230
+ ui?.notify("Cleared all antigravity session bindings.", "info");
1231
+ return;
1232
+ }
1233
+ if (sub === "patch-cleanup") {
1234
+ const st = patchStatus();
1235
+ if (!st.present) {
1236
+ ui?.notify(
1237
+ st.root
1238
+ ? `No invokeTool patch detected on pi ${st.version}. Nothing to clean.`
1239
+ : "Could not locate the installed pi package. Nothing cleaned.",
1240
+ "info",
1241
+ );
1242
+ return;
1243
+ }
1244
+ const r = restorePatch();
1245
+ ui?.notify(
1246
+ r.ok
1247
+ ? `Restored ${r.restoredFiles.length} file(s) from ${r.backupDir}. The running session is unaffected; the files on disk are clean again.`
1248
+ : `patch-cleanup failed: ${r.reason}`,
1249
+ r.ok ? "info" : "error",
1250
+ );
1251
+ return;
1252
+ }
1253
+ if (sub === "timeout") {
1254
+ // Free-type entry point for the turn cap (the TUI picker only offers
1255
+ // presets). Same sanity rules as loadConfig: 1..MAX valid, off/0
1256
+ // disables, anything else rejected with the valid range.
1257
+ const live = loadConfig();
1258
+ const cur = live.turnTimeoutMin;
1259
+ if (!val) {
1260
+ ui?.notify(
1261
+ `Turn time cap: ${cur === 0 ? "off (no cap)" : `${cur}m`}. Usage: /agy timeout <1-${MAX_TURN_CAP_MIN}|off>. Default: off on an interactive pi, 20m headless.`,
1262
+ "info",
1263
+ );
1264
+ return;
1265
+ }
1266
+ const parsed = val === "off" ? 0 : parseCapMinutes(val, Number.NaN);
1267
+ if (Number.isNaN(parsed)) {
1268
+ ui?.notify(`Invalid turn cap "${val}". Use 1-${MAX_TURN_CAP_MIN} minutes, or off/0 to disable.`, "error");
1269
+ return;
1270
+ }
1271
+ saveConfig({ turnTimeoutMin: parsed });
1272
+ ui?.notify(
1273
+ parsed === 0
1274
+ ? `Turn time cap disabled. The inactivity stall guard (${live.inactivityTimeoutMin}m silence) still applies.`
1275
+ : `Turn time cap set to ${parsed}m. Takes effect next turn.`,
1276
+ "info",
1277
+ );
1278
+ return;
1279
+ }
1280
+ if (sub === "engine") {
1281
+ if (val === "acp" || val === "stream-json") {
1282
+ if (val === "acp" && loadConfig().mode === "plan") {
1283
+ ui?.notify("mode is plan; provider plan turns are disabled on ACP. /agy mode accept-edits first.", "warning");
1284
+ return;
1285
+ }
1286
+ const next = saveConfig({ engine: val });
1287
+ if (next.engine !== "acp") {
1288
+ ui?.notify("engine set to stream-json. Takes effect on the next pi start (or /reload).", "info");
1289
+ return;
1290
+ }
1291
+ ui?.notify("engine set to acp. Turns will use `agy acp` and the CLI's existing authentication. Takes effect on the next pi start (or /reload).", "info");
1292
+ void ctx.checkAgySetup(cmdCtx, "acp", true);
1293
+ } else if (!val && mode === "tui" && ui) {
1294
+ // Same modal as the first-run wizard: switching engines deserves
1295
+ // the explanations, not a bare usage line. Semantics match the
1296
+ // direct path above: plan blocks acp and restart applies the switch.
1297
+ await runEngineModal(ui, ctx);
1298
+ } else {
1299
+ ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
1300
+ }
1301
+ return;
1302
+ }
1303
+ if (sub === "auth") {
1304
+ ui?.notify(agyAuthenticationMessage(), "info");
1305
+ return;
1306
+ }
1307
+ if (sub === "auth-manual") {
1308
+ ui?.notify(
1309
+ [
1310
+ "Both engines use the agy CLI and its cached Google credentials.",
1311
+ "",
1312
+ "Install on Linux or macOS:",
1313
+ " curl -fsSL https://antigravity.google/cli/install.sh | bash",
1314
+ "Install on Windows PowerShell:",
1315
+ " irm https://antigravity.google/cli/install.ps1 | iex",
1316
+ "",
1317
+ agyAuthenticationMessage(),
1318
+ ].join("\n"),
1319
+ "info",
1320
+ );
1321
+ return;
1322
+ }
1323
+ if (sub === "doctor") {
1324
+ const config = loadConfig();
1325
+ const engine = ctx.engine;
1326
+ const snap = (engine === "acp" ? ctx.acpDriver : ctx.driver).snapshot();
1327
+ const port = ctx.getMcpPort();
1328
+ const rawCliCheck = await checkAgyCliVersion(resolveAgyBinary());
1329
+ const cliFloor = engine === "acp" ? MIN_AGY_ACP_VERSION : MIN_AGY_VERSION;
1330
+ const cliCheck = rawCliCheck.status === "ok" || rawCliCheck.status === "unsupported"
1331
+ ? agyVersionVerdict(rawCliCheck.raw, cliFloor)
1332
+ : rawCliCheck;
1333
+ const lines = [
1334
+ "Antigravity doctor (no tokens spent)",
1335
+ ` engine: ${engine}`,
1336
+ ` bridge: ${config.bridgeTools}${port ? ` (port ${port})` : " (not running)"}`,
1337
+ ` driver: ${snap.state}${snap.pid ? ` pid=${snap.pid}` : ""}${snap.conversationId ? ` session=${snap.conversationId.slice(0, 8)}` : ""}`,
1338
+ ` driver stats: spawns=${snap.stats.spawns} turns=${snap.stats.turns} reused=${snap.stats.reused} recycles=${snap.stats.recycles}${snap.stats.lastRecycleReason ? ` (last: ${snap.stats.lastRecycleReason})` : ""}`,
1339
+ ` sessions: ${ctx.store.size} bound`,
1340
+ ` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
1341
+ ` agy cli: ${describeAgyVersionCheck(cliCheck)} (min ${cliFloor})`,
1342
+ ` config: ${CONFIG_PATH}`,
1343
+ ` logs: ${logsDir()} (attach recent days' files when reporting issues; set AGY_DEBUG=1 to capture details)`,
1344
+ ];
1345
+ if (snap.engine === "acp" && snap.acp) {
1346
+ lines.push(
1347
+ ` acp session: ${snap.acp.sessionId ?? "(none)"}`,
1348
+ ` acp server: ${snap.acp.serverVersion ?? "unknown"}${snap.acp.agentTitle ? ` (${snap.acp.agentTitle})` : ""}`,
1349
+ ` acp stats: prompts=${snap.acp.prompts} created=${snap.acp.sessionsCreated} loaded=${snap.acp.sessionsLoaded} kills=${snap.acp.kills} reconnects=${snap.acp.reconnects}`,
1350
+ );
1351
+ // Gate B watch: silent while the server offers no token counts; one
1352
+ // line the day it starts (then real usage mapping is worth wiring).
1353
+ if (snap.acp.usageSeen) {
1354
+ lines.push(" acp tokens: NATIVE exact usage in PromptResponse");
1355
+ } else if (config.acp.usageEstimate !== "off") {
1356
+ lines.push(` acp tokens: ESTIMATED client-side (mode: ${config.acp.usageEstimate}; auto-off once the server sends real usage)`);
1357
+ }
1358
+ }
1359
+ if (snap.lifecycle.length > 0) {
1360
+ lines.push(" lifecycle (last 5):");
1361
+ for (const entry of snap.lifecycle.slice(-5)) lines.push(` ${entry}`);
1362
+ }
1363
+ if (engine === "acp") lines.push(" acp transport: agy acp (shared CLI authentication, isolated MCP home)");
1364
+ if (engine === "stream-json" && ctx.streamIsolated) lines.push(" stream transport: agy stream-json (shared CLI authentication, isolated MCP home)");
1365
+ if (engine === "stream-json" && !ctx.streamIsolated) lines.push(" stream transport: agy stream-json (shared CLI authentication, GLOBAL MCP registration: isolation unavailable)");
1366
+ const retiredAuth = path.join(os.homedir(), ".gemini", "antigravity-acp");
1367
+ if (fs.existsSync(retiredAuth)) lines.push(` retired auth: ${retiredAuth} is unused; remove it manually when no longer needed`);
1368
+ // The config surface lives here too, so doctor is the ONE status
1369
+ // surface (former /agy status is gone).
1370
+ lines.push("", "settings:", ...settingsRows(ctx));
1371
+ ui?.notify(lines.join("\n"), "info");
1372
+ return;
1373
+ }
1374
+ if (sub === "mode") {
1375
+ if (val === "plan" || val === "accept-edits") {
1376
+ if (val === "plan" && ctx.engine === "acp") {
1377
+ ui?.notify("plan cannot be enforced on provider turns: the CLI does not gate writes under plan (google-antigravity/antigravity-cli#1181), and provider plan turns are disabled on ACP. /agy mode accept-edits.", "warning");
1378
+ return;
1379
+ }
1380
+ const next = saveConfig({ mode: val as AgyMode });
1381
+ // readOnly fail-closes provider turns regardless of this knob;
1382
+ // never let the confirmation imply turns will run. loadConfig, not
1383
+ // next: saveConfig merges the raw file, so an env-only readOnly
1384
+ // would otherwise read as off here.
1385
+ const effectiveReadonly = loadConfig().readOnly;
1386
+ if (effectiveReadonly) {
1387
+ ui?.notify(`mode set to ${next.mode}, but readOnly is on: provider turns are refused. /agy readonly off to lift.`, "info");
1388
+ } else if (val === "plan") {
1389
+ 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");
1390
+ } else {
1391
+ ui?.notify(`mode set to ${next.mode}`, "info");
1392
+ }
1393
+ } else {
1394
+ const cfg = loadConfig();
1395
+ ui?.notify(
1396
+ `current mode: ${cfg.mode}${cfg.readOnly ? " (readOnly on: provider turns are refused)" : ""}\nusage: /agy mode plan|accept-edits`,
1397
+ "info",
1398
+ );
1399
+ }
1400
+ return;
1401
+ }
1402
+ if (sub === "readonly") {
1403
+ if (val === "on" || val === "off") {
1404
+ const next = saveConfig({ readOnly: val === "on" });
1405
+ ui?.notify(
1406
+ next.readOnly
1407
+ ? "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."
1408
+ : "readOnly off: provider turns follow /agy mode again.",
1409
+ "info",
1410
+ );
1411
+ // Compare effective vs saved: the env can force either direction
1412
+ // (AGY_READONLY=1 keeps refusals on after /agy readonly off;
1413
+ // AGY_READONLY=0 silently keeps readOnly off after /agy readonly on).
1414
+ if (loadConfig().readOnly !== next.readOnly) {
1415
+ 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");
1416
+ }
1417
+ } else {
1418
+ const cfg = loadConfig();
1419
+ ui?.notify(
1420
+ `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.`,
1421
+ "info",
1422
+ );
1423
+ }
1424
+ return;
1425
+ }
1426
+ if (sub === "tasks") {
1427
+ const engine = ctx.engine;
1428
+ const snap = (engine === "acp" ? ctx.acpDriver : ctx.driver).snapshot();
1429
+ const conversationId = snap.conversationId ?? undefined;
1430
+ if (!conversationId) {
1431
+ ui?.notify("no Antigravity conversation bound yet; run a turn first.", "warning");
1432
+ return;
1433
+ }
1434
+ const dir = agyConversationDir(engine, conversationId);
1435
+ const rest = (args ?? "").trim().split(/\s+/);
1436
+
1437
+ // /agy tasks tail <id>: last bytes of one task log into chat.
1438
+ // Resolved against the scanned list, not a rebuilt filename:
1439
+ // the id->filename convention must not live in two places.
1440
+ if (rest[0]?.toLowerCase() === "tail") {
1441
+ const id = Number(rest[1]);
1442
+ if (!Number.isInteger(id) || id < 0) {
1443
+ ui?.notify("usage: /agy tasks tail <id>. /agy tasks lists them.", "warning");
1444
+ return;
1445
+ }
1446
+ const tasks = await listAgyTasks(dir);
1447
+ const task = tasks.find((t) => t.id === id);
1448
+ if (!task) {
1449
+ ui?.notify(`no task #${id} in this conversation. /agy tasks lists them.`, "warning");
1450
+ return;
1451
+ }
1452
+ const tail = await tailAgyTaskLog(task.logPath);
1453
+ if (tail === "") {
1454
+ ui?.notify(`task #${id} has no readable log.`, "warning");
1455
+ return;
1456
+ }
1457
+ const lines = tail.split("\n");
1458
+ const body = lines.length > 40 ? `…\n${lines.slice(-40).join("\n")}` : tail;
1459
+ ui?.notify(`task #${id} tail:\n${body}`, "info");
1460
+ return;
1461
+ }
1462
+
1463
+ // Dashboard form; r rescans by re-listing fresh.
1464
+ let tasks = await listAgyTasks(dir);
1465
+ if (tasks.length === 0) {
1466
+ ui?.notify("no background tasks recorded for this conversation.", "info");
1467
+ return;
1468
+ }
1469
+ if (!ui) {
1470
+ const lines = tasks.map((t) => {
1471
+ const state = t.livenessKnown ? (t.active ? "ACTIVE" : "idle") : "liveness?";
1472
+ return `#${t.id} ${state} ${t.bytes}B`;
1473
+ });
1474
+ console.log([`antigravity tasks (${tasks.length}):`, ...lines].join("\n"));
1475
+ return;
1476
+ }
1477
+ for (;;) {
1478
+ const action = await showTasksBrowser(ui, tasks, tasks[0]?.livenessKnown ?? false);
1479
+ if (action.type === "rescan") {
1480
+ tasks = await listAgyTasks(dir);
1481
+ if (tasks.length === 0) {
1482
+ ui?.notify("no background tasks recorded for this conversation.", "info");
1483
+ }
1484
+ continue;
1485
+ }
1486
+ break;
1487
+ }
1488
+ return;
1489
+ }
1490
+ if (sub === "artifacts") {
1491
+ const engine = ctx.engine;
1492
+ const snap = (engine === "acp" ? ctx.acpDriver : ctx.driver).snapshot();
1493
+ const conversationId = snap.conversationId ?? undefined;
1494
+ if (!conversationId) {
1495
+ ui?.notify("no Antigravity conversation bound yet; run a turn first.", "warning");
1496
+ return;
1497
+ }
1498
+ const dir = agyConversationDir(engine, conversationId);
1499
+ const rest = (args ?? "").trim().split(/\s+/).slice(1);
1500
+ const openWord = rest[0]?.toLowerCase();
1501
+
1502
+ // /agy artifacts open <name|index>: direct open, no overlay.
1503
+ if (openWord === "open") {
1504
+ const artifacts = await listAgyArtifacts(dir);
1505
+ if (artifacts.length === 0) {
1506
+ ui?.notify("no artifacts in this conversation.", "info");
1507
+ return;
1508
+ }
1509
+ const target = rest.slice(1).join(" ").trim();
1510
+ if (target === "") {
1511
+ ui?.notify("usage: /agy artifacts open <name|index>. /agy artifacts lists them.", "warning");
1512
+ return;
1513
+ }
1514
+ const byIndex = /^\d+$/.test(target) ? Number(target) : undefined;
1515
+ const artifact =
1516
+ byIndex !== undefined
1517
+ ? artifacts[byIndex]
1518
+ : artifacts.find((a) => a.name.toLowerCase() === target.toLowerCase()) ??
1519
+ (artifacts.filter((a) => a.name.toLowerCase().includes(target.toLowerCase())).length === 1
1520
+ ? artifacts.find((a) => a.name.toLowerCase().includes(target.toLowerCase()))
1521
+ : undefined); // ambiguous or no match
1522
+ if (!artifact) {
1523
+ ui?.notify(`no unique artifact matches ${JSON.stringify(target)}. /agy artifacts lists them.`, "warning");
1524
+ return;
1525
+ }
1526
+ const openCmd = artifactOpenCommand();
1527
+ if (!openCmd) {
1528
+ ui?.notify(`no file-open handler for ${process.platform}; the file is at ${artifact.absolutePath}`, "warning");
1529
+ return;
1530
+ }
1531
+ spawn(openCmd.cmd, [artifact.absolutePath], { detached: true, stdio: "ignore", shell: false }).unref();
1532
+ ui?.notify(`opened ${artifact.name}`, "info");
1533
+ return;
1534
+ }
1535
+
1536
+ // Browser loop; r rescans by re-listing fresh. The first scan is
1537
+ // reused so the emptiness gate does not cost a second readdir pass.
1538
+ let artifacts = await listAgyArtifacts(dir);
1539
+ if (artifacts.length === 0) {
1540
+ ui?.notify("no artifacts in this conversation (media dirs appear once agy generates or receives files).", "info");
1541
+ return;
1542
+ }
1543
+ if (!ui) {
1544
+ // Headless: no overlay is possible; print the indexed list.
1545
+ const lines = artifacts.map((a, i) => `${i} ${a.name} (${a.kind}, ${a.bytes}B)`);
1546
+ console.log([`antigravity artifacts (${artifacts.length}):`, ...lines].join("\n"));
1547
+ return;
1548
+ }
1549
+ for (;;) {
1550
+ const action = await showArtifactsBrowser(ui, artifacts);
1551
+ if (action.type === "rescan") {
1552
+ artifacts = await listAgyArtifacts(dir);
1553
+ continue;
1554
+ }
1555
+ if (action.type === "open") {
1556
+ const openCmd = artifactOpenCommand();
1557
+ if (!openCmd) {
1558
+ ui.notify(`no file-open handler for ${process.platform}.`, "warning");
1559
+ continue;
1560
+ }
1561
+ spawn(openCmd.cmd, [action.artifact.absolutePath], { detached: true, stdio: "ignore", shell: false }).unref();
1562
+ }
1563
+ break;
1564
+ }
1565
+ return;
1566
+ }
1567
+ if (sub === "quota") {
1568
+ // Quota lives in the CLI's Google identity. A pure-ACP install
1569
+ // without the CLI is a supported shape; say what is missing instead
1570
+ // of rendering a fetch failure.
1571
+ const bin = resolveAgyBinary();
1572
+ if (!isAgyInstalled(bin)) {
1573
+ ui?.notify(
1574
+ "quota comes from the agy CLI, which is not installed. ACP-only setups can install the CLI separately (same Google account) to see subscription quota (/agy doctor shows what IS available).",
1575
+ "warning",
1576
+ );
1577
+ return;
1578
+ }
1579
+ // /usage answers in ~10s live; surface the wait instead of a dead
1580
+ // command line.
1581
+ ui?.setStatus("agy-quota", "checking agy quota…");
1582
+ try {
1583
+ const report = await fetchAgyQuota(bin);
1584
+ if (report === undefined) {
1585
+ ui?.notify("quota unavailable: agy did not return a /usage payload (see /agy doctor for binary health).", "warning");
1586
+ return;
1587
+ }
1588
+ ui?.notify(formatAgyQuotaReport(report), "info");
1589
+ } finally {
1590
+ ui?.setStatus("agy-quota", undefined);
1591
+ }
1592
+ return;
1593
+ }
1594
+ if (sub === "subagents") {
1595
+ const entries = ctx.roster.snapshot();
1596
+ if (entries.length === 0) {
1597
+ ui?.notify("antigravity subagents: none tracked this session\n\nagy spawns subagents as ordinary tool steps; they appear here once a turn uses them.", "info");
1598
+ return;
1599
+ }
1600
+ ui?.notify(formatSubagentRoster(entries), "info");
1601
+ return;
1602
+ }
1603
+ if (sub === "agent") {
1604
+ // ACP has no per-turn agent slot in the protocol: refuse instead of
1605
+ // silently ignoring a configured agent.
1606
+ if (ctx.engine === "acp") {
1607
+ ui?.notify("the ACP engine has no per-turn agent selection. /agy engine stream-json first.", "warning");
1608
+ return;
1609
+ }
1610
+ const name = (args ?? "").trim().split(/\s+/).slice(1).join(" ").trim();
1611
+ if (!name) {
1612
+ const agents = await listAgyAgents(resolveAgyBinary());
1613
+ const current = loadConfig().agent;
1614
+ const agentList =
1615
+ agents.length > 0
1616
+ ? agents.map((a) => `${a === current ? ">" : " "} ${a}`).join("\n")
1617
+ : "(no custom agents defined)";
1618
+ ui?.notify(`current agent: ${current ?? "(agy default)"}\n\n${agentList}\n\nusage: /agy agent <name|off>`, "info");
1619
+ return;
1620
+ }
1621
+ if (name === "off" || name === "none" || name === "default") {
1622
+ saveConfig({ agent: undefined });
1623
+ ui?.notify("agent cleared: agy default", "info");
1624
+ return;
1625
+ }
1626
+ if (!isValidAgyAgentName(name)) {
1627
+ ui?.notify(`invalid agent name: ${name} (use letters, digits, dot, dash, underscore)`, "warning");
1628
+ return;
1629
+ }
1630
+ saveConfig({ agent: name });
1631
+ ui?.notify(`agent set: ${name} (takes effect on the next stream-json turn)`, "info");
1632
+ return;
1633
+ }
1634
+ if (sub === "permissions") {
1635
+ if (val === "on" || val === "off") {
1636
+ const next = saveConfig({ skipPermissions: val === "on" });
1637
+ const warn = next.skipPermissions ? "\nWARNING: agy can now run arbitrary commands without review." : "";
1638
+ ui?.notify(`permissions: ${next.skipPermissions ? "auto-approved (DANGEROUS)" : "prompt"}${warn}`, next.skipPermissions ? "warning" : "info");
1639
+ } else {
1640
+ ui?.notify(`permissions: ${loadConfig().skipPermissions ? "auto-approved (DANGEROUS)" : "prompt"}\nusage: /agy permissions on|off\n(off hangs any run_command in non-interactive mode)`, "info");
1641
+ }
1642
+ return;
1643
+ }
1644
+ if (sub === "bridge") {
1645
+ if (val === "all" || val === "mcp" || val === "none") {
1646
+ const next = saveConfig({ bridgeTools: val });
1647
+ ui?.notify(
1648
+ ctx.getMcpPort() === null
1649
+ ? `bridge ${next.bridgeTools}. The bridge server is not running; /reload to apply.`
1650
+ : `bridge ${next.bridgeTools}. The active catalog and call guard update now; ACP may retain cached schemas until the next session/load.`,
1651
+ "info",
1652
+ );
1653
+ } else {
1654
+ ui?.notify(`bridge: ${loadConfig().bridgeTools}\nusage: /agy bridge all|mcp|none\n all: active pi tools (incl. builtins, extensions, mcp). mcp: active pi-mcp-adapter tools + skills. none: bridge off.`, "info");
1655
+ }
1656
+ return;
1657
+ }
1658
+ if (sub === "tools") {
1659
+ const name = (args ?? "").trim().split(/\s+/).slice(2).join(" ");
1660
+ if (val === "reset") {
1661
+ ctx.hiddenBridgeTools.clear();
1662
+ ui?.notify("Antigravity tool visibility reset for this Pi session. Pi's active tools are unchanged.", "info");
1663
+ } else if ((val === "hide" || val === "show") && name) {
1664
+ if (!pi.getAllTools().some((tool) => tool.name === name)) {
1665
+ ui?.notify(`Unknown Pi tool: ${name}`, "error");
1666
+ return;
1667
+ }
1668
+ if (val === "hide") ctx.hiddenBridgeTools.add(name);
1669
+ else ctx.hiddenBridgeTools.delete(name);
1670
+ ui?.notify(`${name}: ${val === "hide" ? "hidden from Antigravity" : "visible if active in Pi"}. No MCP config changed.`, "info");
1671
+ } else {
1672
+ ui?.notify(`Exposed active Pi tools: ${ctx.bridgeTools().map((tool) => tool.name).join(", ") || "(none)"}\nHidden in this session: ${[...ctx.hiddenBridgeTools].join(", ") || "(none)"}\n/agy tools hide|show <exact Pi tool name> | reset\nUse Pi's /mcp or pi.setActiveTools() to change the underlying active tools.`, "info");
1673
+ }
1674
+ return;
1675
+ }
1676
+ if (sub === "model") {
1677
+ if (val && val.length > 0) {
1678
+ const next = saveConfig({ defaultModel: val });
1679
+ ui?.notify(`AskAntigravity default model set to ${next.defaultModel}`, "info");
1680
+ } else {
1681
+ ui?.notify(`AskAntigravity model: ${loadConfig().defaultModel} (fallback; callers may override per call)\nusage: /agy model flash|pro|gemini|<exact>`, "info");
1682
+ }
1683
+ return;
1684
+ }
1685
+ if (sub === "ask") {
1686
+ if (val === "on" || val === "off") {
1687
+ const next = saveConfig({ askTool: val === "on" });
1688
+ ui?.notify(
1689
+ next.askTool
1690
+ ? "AskAntigravity tool on. Registered on the next pi start (or /reload)."
1691
+ : "AskAntigravity tool off. It is removed from the model's tool list on the next pi start (or /reload). Provider and models stay.",
1692
+ "info",
1693
+ );
1694
+ } else {
1695
+ ui?.notify(`AskAntigravity tool: ${loadConfig().askTool ? "on" : "off"}\nusage: /agy ask on|off`, "info");
1696
+ }
1697
+ return;
1698
+ }
1699
+ if (sub === "web") {
1700
+ if (val === "on" || val === "off") {
1701
+ const next = saveConfig({ webTools: val === "on" });
1702
+ ui?.notify(
1703
+ next.webTools
1704
+ ? "Web tools on: agy_web_search and agy_read_url register on the next pi start (or /reload). Each call spends Antigravity quota."
1705
+ : "Web tools off: agy_web_search and agy_read_url will not register on the next pi start (or /reload).",
1706
+ "info",
1707
+ );
1708
+ } else {
1709
+ ui?.notify(`Web tools: ${loadConfig().webTools ? "on" : "off"}\nusage: /agy web on|off\nExposes agy_web_search + agy_read_url as Pi tools for ANY provider (Antigravity sessions already have native web tools). Default off; each call spends Antigravity quota.`, "info");
1710
+ }
1711
+ return;
1712
+ }
1713
+ if (sub === "digest") {
1714
+ if (val === "on" || val === "off") {
1715
+ const next = saveConfig({ digest: val === "on" });
1716
+ ui?.notify(
1717
+ next.digest
1718
+ ? "digest on. pi-side context (compaction summaries, other-provider turns) is injected into each agy prompt. Note: this defeats agy's prompt cache (~25-30k tokens re-billed per turn)."
1719
+ : "digest off. agy prompts contain only your message; agy's prompt cache stays stable. Enable when mixing providers in one session and agy must see pi-side context.",
1720
+ "info",
1721
+ );
1722
+ } else {
1723
+ ui?.notify(`digest: ${loadConfig().digest ? "on" : "off"}\nusage: /agy digest on|off`, "info");
1724
+ }
1725
+ return;
1726
+ }
1727
+ if (sub === "system-prompt") {
1728
+ if (val === "on" || val === "off") {
1729
+ const next = saveConfig({ systemPrompt: val === "on" });
1730
+ ui?.notify(
1731
+ next.systemPrompt
1732
+ ? "system-prompt on. pi's system prompt (incl. global and project AGENTS.md) is prepended to the first prompt of each new agy conversation. Existing conversations keep the version they started with."
1733
+ : "system-prompt off. agy runs on its own system prompt; pi instructions and AGENTS.md files are not sent.",
1734
+ "info",
1735
+ );
1736
+ } else {
1737
+ ui?.notify(`system-prompt: ${loadConfig().systemPrompt ? "on" : "off"}\nusage: /agy system-prompt on|off`, "info");
1738
+ }
1739
+ return;
1740
+ }
1741
+
1742
+ if (sub === "thinking") {
1743
+ if (val === "low" || val === "medium" || val === "high") {
1744
+ const next = saveConfig({ defaultThinking: val as ThinkingTier });
1745
+ ui?.notify(`AskAntigravity default thinking set to ${next.defaultThinking}`, "info");
1746
+ } else {
1747
+ ui?.notify(`AskAntigravity thinking: ${loadConfig().defaultThinking} (fallback; callers may override per call)\nusage: /agy thinking low|medium|high`, "info");
1748
+ }
1749
+ return;
1750
+ }
1751
+
1752
+
1753
+ // No subcommand: print status, or open the picker in TUI. The old
1754
+ // "status" alias is retired; /agy doctor is the one status surface.
1755
+ if (sub) {
1756
+ const pointer = sub === "status" ? "/agy status was removed; use /agy doctor.\n" : "";
1757
+ ui?.notify(`${pointer}unknown subcommand: ${sub}\n${statusText(ctx)}`, "warning");
1758
+ return;
1759
+ }
1760
+
1761
+ if (mode !== "tui" || !ui) {
1762
+ ui?.notify(statusText(ctx), "info");
1763
+ return;
1764
+ }
1765
+
1766
+ await openAgyPicker(ui, ctx);
1767
+ },
1768
+ });
1769
+ }
1770
+
1771
+ /** Interactive settings picker (TUI only). Rows: the runtime config surface
1772
+ * (mode, permissions, model, thinking, bridge, digest, system prompt) plus
1773
+ * the engine row, which resolves the menu on Enter and opens the same
1774
+ * picker modal as /agy engine (self-service binary + auth setup for acp). */
1775
+ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
1776
+ const config = loadConfig();
1777
+ const pending: PendingConfig = {};
1778
+
1779
+ const rows: DialogRow[] = [
1780
+ // Value-less action row: DialogMenu resolves the menu with this id on
1781
+ // Enter (see runEngineModal below); every other row cycles in place.
1782
+ engineSettingsRow(config.engine),
1783
+ {
1784
+ id: "mode",
1785
+ label: "Execution mode",
1786
+ description:
1787
+ "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.",
1788
+ currentValue: config.mode,
1789
+ values: ["accept-edits", "plan"],
1790
+ },
1791
+ {
1792
+ id: "readonly",
1793
+ label: "Read-only provider turns",
1794
+ description:
1795
+ "on: provider turns are REFUSED (fail-closed; the CLI has no review-only enforcement on any path). Enforce read-only in the host instead.",
1796
+ currentValue: config.readOnly ? "on" : "off",
1797
+ values: ["off", "on"],
1798
+ },
1799
+ {
1800
+ id: "permissions",
1801
+ label: "Permissions",
1802
+ description:
1803
+ "auto-approved: --dangerously-skip-permissions (required so commands don't hang in -p mode). prompt: agy asks y/n (hangs non-interactively).",
1804
+ currentValue: config.skipPermissions ? "auto-approved" : "prompt",
1805
+ values: ["auto-approved", "prompt"],
1806
+ },
1807
+ {
1808
+ id: "ask",
1809
+ label: "AskAntigravity tool",
1810
+ description:
1811
+ "Register the AskAntigravity one-shot delegation tool. off removes it from the model's tool list (provider and models stay, even with the separate pi-ask-antigravity package installed). Takes effect on the next pi start (or /reload).",
1812
+ currentValue: config.askTool ? "on" : "off",
1813
+ values: ["on", "off"],
1814
+ },
1815
+ {
1816
+ id: "web",
1817
+ label: "Web tools",
1818
+ description:
1819
+ "Register agy_web_search + agy_read_url as Pi tools for ANY provider. Default off: Antigravity sessions already have native web tools, and each call spends Antigravity quota. Takes effect on the next pi start (or /reload).",
1820
+ currentValue: config.webTools ? "on" : "off",
1821
+ values: ["on", "off"],
1822
+ },
1823
+ {
1824
+ id: "model",
1825
+ label: "AskAntigravity model",
1826
+ description:
1827
+ "AskAntigravity one-shot delegation tool: model used when its caller omits the model param. Callers may override per call; this is only the fallback. flash/pro/gemini, or an exact id. Does not affect the provider model you pick in /model.",
1828
+ currentValue: config.defaultModel,
1829
+ values: ["flash", "pro", "gemini", "gemini-4-hydrogen"],
1830
+ },
1831
+ {
1832
+ id: "thinking",
1833
+ label: "AskAntigravity thinking",
1834
+ description:
1835
+ "AskAntigravity one-shot delegation tool: thinking tier used when the call names none. Callers may override per call; this is only the fallback. Pro has no Medium; it falls back to nearest.",
1836
+ currentValue: config.defaultThinking,
1837
+ values: ["low", "medium", "high"],
1838
+ },
1839
+ {
1840
+ id: "bridge",
1841
+ label: "Bridge tools",
1842
+ description:
1843
+ "Which pi tools the MCP bridge exposes to agy. all: every active pi tool (default). mcp: pi-mcp-adapter tools + skills. none: bridge off.",
1844
+ currentValue: config.bridgeTools,
1845
+ values: ["all", "mcp", "none"],
1846
+ },
1847
+ {
1848
+ id: "turn-cap",
1849
+ label: "Turn time cap",
1850
+ description:
1851
+ "Max minutes for ONE agy turn before the bridge kills it. Default: no cap on an interactive pi (abort with Esc), 20 headless. 0 disables; 1-1440 enables the gate. Free type via /agy timeout <minutes|off> or config.json. Takes effect next turn.",
1852
+ currentValue: String(config.turnTimeoutMin),
1853
+ values: ["0", "1", "5", "10", "15", "30", "60", "120", "360", "720", "1440"],
1854
+ },
1855
+ {
1856
+ id: "digest",
1857
+ label: "Context digest",
1858
+ description:
1859
+ "Inject a delta of pi-side context into each agy prompt. Defeats agy's prompt cache (~25-30k tokens re-billed per turn).",
1860
+ currentValue: config.digest ? "on" : "off",
1861
+ values: ["on", "off"],
1862
+ },
1863
+ {
1864
+ id: "system-prompt",
1865
+ label: "System prompt",
1866
+ description:
1867
+ "Prepend pi's system prompt (incl. AGENTS.md files) to the first prompt of each new agy conversation.",
1868
+ currentValue: config.systemPrompt ? "on" : "off",
1869
+ values: ["on", "off"],
1870
+ },
1871
+ ];
1872
+
1873
+ const resolved = await ui.custom<string | undefined>(
1874
+ (host, theme, _kb, done) => {
1875
+ const menu = new DialogMenu(
1876
+ host,
1877
+ theme,
1878
+ {
1879
+ title: "Antigravity provider",
1880
+ subtitle: `Config file: ${CONFIG_PATH} — changes save when the dialog closes`,
1881
+ rows,
1882
+ footer: "↑↓ navigate · Enter/Space cycle · Type to search · Esc close",
1883
+ onChange: (id, newValue) => {
1884
+ if (id === "mode") {
1885
+ pending.mode = newValue as AgyMode;
1886
+ } else if (id === "readonly") {
1887
+ pending.readOnly = newValue === "on";
1888
+ } else if (id === "permissions") {
1889
+ pending.skipPermissions = newValue === "auto-approved";
1890
+ } else if (id === "model") {
1891
+ pending.defaultModel = newValue;
1892
+ } else if (id === "thinking") {
1893
+ pending.defaultThinking = newValue as ThinkingTier;
1894
+ } else if (id === "ask") {
1895
+ pending.askTool = newValue === "on";
1896
+ } else if (id === "web") {
1897
+ pending.webTools = newValue === "on";
1898
+ } else if (id === "bridge") {
1899
+ pending.bridgeTools = newValue as BridgeTools;
1900
+ } else if (id === "turn-cap") {
1901
+ pending.turnTimeoutMin = Number(newValue);
1902
+ } else if (id === "digest") {
1903
+ pending.digest = newValue === "on";
1904
+ } else if (id === "system-prompt") {
1905
+ pending.systemPrompt = newValue === "on";
1906
+ }
1907
+ },
1908
+ },
1909
+ (rowId) => done(rowId),
1910
+ );
1911
+ return {
1912
+ render: (w: number) => menu.render(w),
1913
+ invalidate: () => menu.invalidate(),
1914
+ handleInput: (data: string) => {
1915
+ menu.handleInput(data);
1916
+ host.requestRender();
1917
+ },
1918
+ };
1919
+ },
1920
+ { ...DIALOG_OPTIONS, blocked: dialogBlocked("Antigravity provider") },
1921
+ );
1922
+
1923
+ if (Object.keys(pending).length === 0) {
1924
+ if (resolved === ENGINE_SETTINGS_ROW_ID) await runEngineModal(ui, ctx);
1925
+ return;
1926
+ }
1927
+
1928
+ // The picker cannot switch engines (command-only: /agy engine), but the
1929
+ // mode row can still produce plan while the engine is acp - either the
1930
+ // session-latched engine or the on-disk one, which the Switch engine row
1931
+ // can change mid-session without a restart. Plan cannot be enforced on
1932
+ // provider turns on either engine; refuse the combination.
1933
+ const nextMode = pending.mode ?? config.mode;
1934
+ if (nextMode === "plan" && (ctx.engine === "acp" || config.engine === "acp")) {
1935
+ ui.notify(
1936
+ "plan cannot be enforced on provider turns (google-antigravity/antigravity-cli#1181); provider plan turns are disabled on ACP too. /agy mode accept-edits.",
1937
+ "warning",
1938
+ );
1939
+ return;
1940
+ }
1941
+
1942
+ try {
1943
+ const next = saveConfig(pending);
1944
+ const changed = [
1945
+ pending.mode ? `mode=${next.mode}` : null,
1946
+ pending.readOnly !== undefined ? `readOnly=${next.readOnly ? "on" : "off"}` : null,
1947
+ pending.skipPermissions !== undefined
1948
+ ? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
1949
+ : null,
1950
+ pending.askTool !== undefined ? `AskAntigravity tool=${next.askTool ? "on" : "off"}` : null,
1951
+ pending.webTools !== undefined ? `webTools=${next.webTools ? "on" : "off"}` : null,
1952
+ pending.defaultModel !== undefined ? `AskAntigravity model=${next.defaultModel}` : null,
1953
+ pending.defaultThinking !== undefined ? `AskAntigravity thinking=${next.defaultThinking}` : null,
1954
+ pending.bridgeTools !== undefined ? `bridge=${next.bridgeTools}` : null,
1955
+ pending.digest !== undefined ? `digest=${next.digest ? "on" : "off"}` : null,
1956
+ pending.systemPrompt !== undefined ? `system-prompt=${next.systemPrompt ? "on" : "off"}` : null,
1957
+ pending.turnTimeoutMin !== undefined ? `turn cap=${next.turnTimeoutMin === 0 ? "off" : `${next.turnTimeoutMin}m`}` : null,
1958
+ ]
1959
+ .filter(Boolean)
1960
+ .join(", ");
1961
+ ui.notify(`Saved: ${changed}`, "info");
1962
+ } catch (err) {
1963
+ ui.notify(
1964
+ `Failed to save config: ${err instanceof Error ? err.message : String(err)}`,
1965
+ "error",
1966
+ );
1967
+ }
1968
+
1969
+ if (resolved === ENGINE_SETTINGS_ROW_ID) await runEngineModal(ui, ctx);
1970
+ }
1971
+
1972
+ /** The engine-pick modal shared by /agy engine (no args) and the settings
1973
+ * picker's engine row: the same explanations as the first-run wizard and the
1974
+ * same semantics: plan blocks acp and restart applies the switch. */
1975
+ async function runEngineModal(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
1976
+ const current = loadConfig().engine;
1977
+ const picked = await showEnginePicker(ui);
1978
+ if (picked === undefined) {
1979
+ ui.notify(`engine unchanged: ${current}.`, "info");
1980
+ return;
1981
+ }
1982
+ if (picked === current) {
1983
+ ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
1984
+ return;
1985
+ }
1986
+ if (picked === "acp" && loadConfig().mode === "plan") {
1987
+ ui.notify("mode is plan; provider plan turns are disabled on ACP. /agy mode accept-edits first.", "warning");
1988
+ return;
1989
+ }
1990
+ saveConfig({ engine: picked });
1991
+ ui.notify(savedEngineMessage(picked), "info");
1992
+ if (picked === "acp") void ctx.checkAgySetup({ ui, mode: "tui" }, "acp", true);
1993
+ }