diff --git a/ANTIGRAVITY-INTEGRATION-REPORT.md b/ANTIGRAVITY-INTEGRATION-REPORT.md new file mode 100644 index 0000000..d5fa617 --- /dev/null +++ b/ANTIGRAVITY-INTEGRATION-REPORT.md @@ -0,0 +1,19 @@ +# Google Antigravity ACP integration + +Updated 24 September 2026. After testing an installed-CLI bridge, the user chose Google's official ACP server. The CLI's headless mode soft-denied tool actions that needed approval. The official server speaks ACP directly, allowing Pipper to display and answer permission requests. [Google's Zed setup](https://antigravity.google/docs/ide/extensions/zed/), [ACP registry entry](https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json), [CLI headless permissions](https://antigravity.google/docs/cli/headless/). + +## Implementation + +- Pipper fetches the pinned Google ACP server version 1.2.1 on first use into a versioned user cache. The application bundle does not contain the Google server. macOS ARM64 and Windows x64 correspond to Pipper's current packaged targets; each archive has a release-pinned SHA-256 and exact expected file list. Installation uses a temporary directory, checksum verification, and atomic promotion. Concurrent callers share the install, with a cross-process lock. The macOS archive is 107 MiB compressed and includes the server and its `localharness_external` sibling. +- The existing ACP connection lifecycle, session routing, transcript rendering, MCP attachment, and client tool handlers now talk directly to Google's server. The first-party CLI bridge and npm adapter launch are removed. Permission requests enter Pipper's existing approval UI. An unanswered approval now cancels after two minutes instead of auto-allowing. +- Pipper offers the server's advertised Google OAuth, enterprise OAuth, Gemini API-key, and agent-platform authentication methods in setup. The CLI's cached credentials did not authenticate the ACP server in the local handshake, so users must complete ACP sign-in. The agent decides how each method obtains credentials; Pipper does not read credential files. +- Antigravity restoration errors preserve the thread's existing Pipper snapshot and session ID rather than silently starting a replacement. Prior CLI-bridge session IDs are explicitly identified as incompatible with the official server. A new Antigravity thread is required to continue those conversations. +- The app and public agent setup copy now describe the official server rather than the installed CLI. + +## Verification and limits + +The real macOS ARM64 server archive was downloaded and its SHA-256 recorded. A local install exercised checksum verification, extraction of both required files, atomic cache promotion, and a repeated cache hit. The verified files were moved to this machine's Pipper cache. The official executable negotiated ACP protocol version 1, advertised load/resume and four auth methods, and returned `auth_required` for `session/new` before ACP sign-in. Pipper's actual handshake probe classified this as `needs-auth` and returned those methods. + +The earlier authenticated text-prompt test applied to the CLI bridge and does not prove an authenticated official-server prompt. A user must complete ACP sign-in to validate live prompts and interactive tool approvals. Windows installation and permission behavior require a Windows runtime check. Existing npm-adapter and CLI-bridge sessions remain displayable in Pipper but are not imported into Google's server. + +Repository checks after the switch: app build, Electron TypeScript check, lint, React Doctor (100/100), and the full 443-test suite passed. The marketing site build was unavailable in this worktree because its separate `astro` dependency is not installed. diff --git a/electron/agent-connection-manager.ts b/electron/agent-connection-manager.ts index fe2ffa6..b9b6edf 100644 --- a/electron/agent-connection-manager.ts +++ b/electron/agent-connection-manager.ts @@ -1005,6 +1005,56 @@ export class AgentConnectionManager { return this.lifecycle.active?.authRequiredMessage ?? null; } + /** + * Record a genuine "not signed in" signal. Only an ACP `auth_required` + * rejection proves it — advertising `authMethods` at `initialize` does not — + * so every session phase records it the same way for `authMessage()` and the + * renderer's persistent auth banner. + */ + private recordAuthRequired(live: LiveConnection, error: unknown): void { + if (!isAuthRequiredError(error)) return; + const descriptor = getAgentDescriptor(live.agentId); + live.authRequiredMessage = + descriptor?.authHint ?? + `${descriptor?.displayName ?? live.agentId} requires authentication. Please sign in from your terminal first.`; + } + + /** + * A snapshot-restored Antigravity thread stays displayable when its agent + * session cannot be established — a legacy `pipper-agy-` id the official + * server cannot resume, a missing install, a sign-in failure. Removing the + * runtime would blank the transcript the user opened the project for, so + * keep it with `agentReady: false` (prompts reject via `waitForThreadReady`) + * and drop any partial replay so a later retry does not append onto it. + * Other agents keep the existing eviction: their fallback chain already + * tried load → resume → new, so a failure there is a dead placeholder. + */ + private preserveSnapshotRuntimeAfterFailure( + threadId: string, + runtime: ThreadSessionRuntime | undefined, + ): boolean { + if (!runtime || runtime.agentReady !== false || !runtime.snapshotRestored) return false; + if (runtime.agentId !== "antigravity-acp") return false; + // A prompt queued onto the in-flight load was appended optimistically; the + // failed load never delivered it, so it must not be published as history. + const pending = runtime.pendingLocalEntries ?? []; + if (pending.length > 0) { + const pendingIds = new Set(pending.map((entry) => entry.id)); + runtime.slice = { + ...runtime.slice, + entries: runtime.slice.entries.filter((entry) => !pendingIds.has(entry.id)), + isStreaming: false, + }; + } + runtime.pendingLocalEntries = []; + runtime.replaySlice = undefined; + runtime.replayToolPayloads = undefined; + this.threadActivationGenerations.delete(threadId); + this.endThreadLoad(threadId); + this.pushState(threadId); + return true; + } + private buildSessionState(threadId: string): AcpSessionState { const runtime = this.sessions.get(threadId); const thread = getThread(threadId); @@ -1165,6 +1215,35 @@ export class AgentConnectionManager { return this.lifecycle.acquire(descriptor); } + async authenticateAgent(agentId: string, methodId: string): Promise { + const live = await this.acquireConnection(agentId); + const method = live.authMethods.find((candidate) => candidate.id === methodId); + if (!method || ("type" in method && method.type === "terminal")) + throw new Error("This authentication method cannot be started from Pipper."); + await requestWithTimeout( + live.agent.request(acp.methods.agent.authenticate, { methodId }), + 5 * 60_000, + "agent/authenticate", + ); + live.authRequiredMessage = null; + // A thread whose restore failed with `auth_required` is still snapshot-only. + // Now that credentials exist, re-run its activation in the background so + // signing in unblocks the thread it was requested from. Not awaited: the + // caller's button state must not hang on a slow re-activation. + const threadId = this.activeThreadId; + const runtime = threadId ? this.sessions.get(threadId) : undefined; + if ( + threadId && + runtime?.agentReady === false && + runtime.agentId === agentId && + !this.loadingSessionThreads.has(threadId) + ) { + void this.switchThread(threadId).catch((error) => { + console.warn(`[agent-auth] retry activation for ${threadId} failed:`, error); + }); + } + } + async switchAgent(agentId: string): Promise { this.emit({ type: "session-state", @@ -1290,6 +1369,18 @@ export class AgentConnectionManager { return; } + // A preserved snapshot runtime has no activation owning its session: a + // failed or superseded session/load can keep streaming, and those updates + // must not be written into the restored transcript as live content. Only a + // new activation (which marks the thread loading) may receive them again. + if ( + runtime.agentReady === false && + runtime.snapshotRestored && + !this.loadingSessionThreads.has(runtime.threadId) + ) { + return; + } + this.trackToolCallTiming(sessionId, runtime, update); // Running-threads emission depends solely on per-runtime isStreaming @@ -1444,9 +1535,10 @@ export class AgentConnectionManager { this.emit({ type: "thread-tool-calls", threadId: runtime.threadId, - toolCalls: changedToolCall - ? { [updateToolCallId]: changedToolCall } - : runtime.slice.toolCalls, + toolCalls: + changedToolCall && updateToolCallId + ? { [updateToolCallId]: changedToolCall } + : runtime.slice.toolCalls, replace: !changedToolCall, }); } else { @@ -1519,6 +1611,7 @@ export class AgentConnectionManager { void this.requestPrompt(live, runtime, prompt.blocks, prompt.streamingBehavior) .then((result) => { + live.authRequiredMessage = null; this.settleRuntime(runtime); this.captureAnalytics?.("turn_completed", { ...agentProps, @@ -1549,6 +1642,11 @@ export class AgentConnectionManager { prompt.resolve(result); }) .catch((error) => { + if (isAuthRequiredError(error)) { + live.authRequiredMessage = + getAgentDescriptor(live.agentId)?.authHint ?? "Sign in to your agent CLI, then retry."; + this.prompts.rejectQueued(runtime.threadId, live.authRequiredMessage); + } this.settleRuntime(runtime); this.captureAnalytics?.("turn_failed", { ...agentProps, @@ -1647,7 +1745,7 @@ export class AgentConnectionManager { /** * Normalize and sanitize session config options. Some third-party ACP adapters - * (e.g. antigravity-acp) leak raw tab-separated output from CLI discovery (`id\tDisplay Name`). + * Some legacy adapters leak raw tab-separated output from CLI discovery (`id\tDisplay Name`). * Clean them so modelId matches what the CLI expects and UI renders clean names. */ private sanitizeConfigOptions( @@ -1703,7 +1801,8 @@ export class AgentConnectionManager { live: LiveConnection, options: SessionConfigOption[], ): SessionConfigOption[] { - const sanitized = this.sanitizeConfigOptions(options); + const sanitized = + live.agentId === "antigravity-acp" ? options : this.sanitizeConfigOptions(options); const ms = live.modelState; const models = ms?.availableModels ?? []; if (models.length === 0) return sanitized; @@ -1757,12 +1856,7 @@ export class AgentConnectionManager { } catch (err) { // A genuine "not signed in" surfaces here as an ACP `auth_required` // error — the only reliable signal — so record it for `authMessage()`. - if (isAuthRequiredError(err)) { - const descriptor = getAgentDescriptor(live.agentId); - live.authRequiredMessage = - descriptor?.authHint ?? - `${descriptor?.displayName ?? live.agentId} requires authentication. Please sign in from your terminal first.`; - } + this.recordAuthRequired(live, err); attached.release(); throw err; } @@ -1774,50 +1868,32 @@ export class AgentConnectionManager { (result.configOptions as SessionConfigOption[] | null | undefined) ?? [], ); - // If an agent (e.g. antigravity-acp) provided a raw/tab-separated default model, - // explicitly sync the clean model back to the adapter session so it doesn't - // pass the raw tab-separated string to its CLI subprocess. - const modelOpt = configOptions.find((o) => o.category === "model" || o.id === "model"); - if (modelOpt && typeof modelOpt.currentValue === "string") { - const rawOpt = ( - (result.configOptions as SessionConfigOption[] | null | undefined) ?? [] - ).find((o) => o.category === "model" || o.id === "model"); - if ( - live.agentId.includes("antigravity") || - (typeof rawOpt?.currentValue === "string" && rawOpt.currentValue.includes("\t")) - ) { - try { - await requestWithTimeout( - live.agent.request(acp.methods.agent.session.setConfigOption, { - sessionId: result.sessionId, - configId: modelOpt.id, - value: modelOpt.currentValue as never, - }), - ACP_SWITCH_PHASE_TIMEOUT_MS, - "session/set_config_option", - ); - } catch { - // best-effort sync - } - } - } - - // For antigravity-acp, also ensure mode is initialized to bypassPermissions - // so tool executions never deadlock waiting on headless stdin. - const modeOpt = configOptions.find((o) => o.category === "mode" || o.id === "mode"); - if (modeOpt && live.agentId.includes("antigravity")) { + // Preserve the compatibility sync for other adapters that leak tab-separated + // defaults. Official Antigravity ACP values remain opaque and are never rewritten. + const rawModel = result.configOptions?.find( + (option) => option.category === "model" || option.id === "model", + ); + const cleanModel = configOptions.find( + (option) => option.category === "model" || option.id === "model", + ); + if ( + live.agentId !== "antigravity-acp" && + typeof rawModel?.currentValue === "string" && + rawModel.currentValue.includes("\t") && + cleanModel + ) { try { await requestWithTimeout( live.agent.request(acp.methods.agent.session.setConfigOption, { sessionId: result.sessionId, - configId: modeOpt.id, - value: "bypassPermissions" as never, + configId: cleanModel.id, + value: cleanModel.currentValue, }), ACP_SWITCH_PHASE_TIMEOUT_MS, "session/set_config_option", ); } catch { - // best-effort sync + /* compatibility sync is best effort */ } } @@ -1845,9 +1921,14 @@ export class AgentConnectionManager { "session/load", ); } catch (err) { + // A restore that needs sign-in is the same persistent signal as a new + // session: without this the renderer hides the transient switch error + // and shows nothing lasting to act on. + this.recordAuthRequired(live, err); attached.release(); throw err; } + live.authRequiredMessage = null; attached.bind(result?.sessionId ?? sessionId); return { sessionId: result?.sessionId ?? sessionId, @@ -1865,7 +1946,7 @@ export class AgentConnectionManager { try { result = await requestWithTimeout( live.agent.request(acp.methods.agent.session.resume, { - prevSessionId, + sessionId: prevSessionId, cwd, mcpServers: attached.servers as never, } as never), @@ -1873,9 +1954,11 @@ export class AgentConnectionManager { "session/resume", ); } catch (err) { + this.recordAuthRequired(live, err); attached.release(); throw err; } + live.authRequiredMessage = null; attached.bind(result?.sessionId ?? prevSessionId); return { sessionId: result?.sessionId ?? prevSessionId, @@ -1962,7 +2045,29 @@ export class AgentConnectionManager { // switchThread reconciles the persisted workspace to the activated // thread's cwd, so header/tabs/terminals agree after restart and after // project switches. - await this.switchThreadInternal(thread.id, "restore", signal); + try { + await this.switchThreadInternal(thread.id, "restore", signal); + } catch (error) { + // A newer activation superseded this one: the caller must not treat the + // abandoned switch as a completed launch. + if (isActivationSuperseded(error)) throw error; + // A snapshot-restored thread must not block project launch: the user + // needs the workspace open to read its saved history and start a + // replacement thread (e.g. a legacy `pipper-agy-` id the official + // Antigravity server cannot resume). The failure is still recorded on + // the switch monitor and the transcript stays displayable. + if (!this.sessions.get(thread.id)?.snapshotRestored) throw error; + console.warn(`[thread-restore] opening ${thread.id} snapshot-only:`, error); + // switchThreadCore's reconciliation never ran, so a snapshot-only launch + // must still select the thread's workspace and give it an open tab. + await Promise.all([ + updateWorkspaceSelection( + project.id, + this.resolveThreadCwd(thread.worktree_path, project.path), + ), + recordThreadSwitch(thread.id), + ]); + } await updateLaunchSelection({ projectId, threadId: thread.id }); } @@ -2134,7 +2239,7 @@ export class AgentConnectionManager { try { await raceActivation(this.switchAgent(thread.agent_id), signal); } catch (err) { - if (isActivationSuperseded(err)) throw err; + if (isActivationSuperseded(err) || thread.agent_id === "antigravity-acp") throw err; await raceActivation(this.ensureConnection(this.preferredAgentId), signal); } } else { @@ -2144,6 +2249,7 @@ export class AgentConnectionManager { ); } } catch (error) { + if (this.preserveSnapshotRuntimeAfterFailure(threadId, runtime)) throw error; if (runtime?.agentReady === false) { this.sessions.remove(threadId); this.endThreadLoad(threadId); @@ -2206,6 +2312,10 @@ export class AgentConnectionManager { let sessionId = thread.agent_session_id; let configOptions: SessionConfigOption[] = []; try { + if (live.agentId === "antigravity-acp" && sessionId?.startsWith("pipper-agy-")) + throw new Error( + "This Antigravity thread used Pipper's earlier CLI bridge. Its saved history is preserved, but the official ACP server cannot resume that CLI session. Start a new Antigravity thread to continue.", + ); const loaded = await raceActivation(this.sessionLoad(live, cwd, sessionId), signal); if (this.threadActivationGenerations.get(threadId) !== generation) { throw new Error(`Stale activation for thread ${threadId}`); @@ -2220,6 +2330,9 @@ export class AgentConnectionManager { // Same rule when superseded: nobody is waiting on this thread, so // abandon instead of establishing sessions behind the newer request. if (isActivationSuperseded(err)) throw err; + // Antigravity session IDs belong to a specific implementation. A failed + // restore must leave its snapshot and identity intact. + if (live.agentId === "antigravity-acp") throw err; // Agent restarted — try resume. A failed load may have streamed a // partial replay before erroring; drop it so the fallback path // doesn't append onto half a timeline. @@ -2242,7 +2355,11 @@ export class AgentConnectionManager { configOptions = resumed.configOptions; updateThreadAgentSessionId(threadId, sessionId); } catch (err) { - if (isActivationSuperseded(err)) throw err; + if ( + isActivationSuperseded(err) || + (err instanceof Error && /timed out/i.test(err.message)) + ) + throw err; const created = await raceActivation(this.sessionNew(live, cwd), signal); onPhase("session_new"); sessionId = created.sessionId; @@ -2283,22 +2400,28 @@ export class AgentConnectionManager { runtime.agentReady = true; if (!runtime.slice.isStreaming) this.scheduleSnapshot(runtime); } catch (err) { - // No session could be established — remove the placeholder so a - // retry doesn't silently reuse a dead runtime. - this.sessions.remove(threadId); - this.monitorObserver?.onSessionCacheEvent?.({ - timestamp: Date.now(), - action: "evict", - threadId, - agentSessionId: runtime.agentSessionId, - agentId: runtime.agentId, - trigger: "switch_load", - cachedSessionCount: this.sessions.size, - openTabCount: 0, - cachedThreadIds: [...this.sessions.keys()], - reason: "Session establishment failed", - }); - this.threadActivationGenerations.delete(threadId); + // A snapshot-restored Antigravity thread keeps its runtime: the + // restored transcript is the user's only access to that history, and + // evicting it here is what blanked the view. Everything else is a + // placeholder with nothing to lose. + if (!this.preserveSnapshotRuntimeAfterFailure(threadId, runtime)) { + // No session could be established — remove the placeholder so a + // retry doesn't silently reuse a dead runtime. + this.sessions.remove(threadId); + this.monitorObserver?.onSessionCacheEvent?.({ + timestamp: Date.now(), + action: "evict", + threadId, + agentSessionId: runtime.agentSessionId, + agentId: runtime.agentId, + trigger: "switch_load", + cachedSessionCount: this.sessions.size, + openTabCount: 0, + cachedThreadIds: [...this.sessions.keys()], + reason: "Session establishment failed", + }); + this.threadActivationGenerations.delete(threadId); + } throw err; } finally { this.endThreadLoad(threadId); @@ -2671,7 +2794,16 @@ export class AgentConnectionManager { if (!runtime) throw new Error("No session for thread"); let appendedWhileLoading = false; if (runtime.agentReady === false || this.loadingSessionThreads.has(threadId)) { - if (appendUserMessage && (input.message || input.images?.length)) { + // Only append optimistically while a load is actually in progress. An + // unready runtime with no load is a failed restore kept for its snapshot + // (see preserveSnapshotRuntimeAfterFailure); it can never accept the + // prompt, so reject before writing a phantom user turn into the restored + // history or leaving isStreaming set. + if ( + this.loadingSessionThreads.has(threadId) && + appendUserMessage && + (input.message || input.images?.length) + ) { const nextSlice = appendLocalUserMessage( runtime.slice, input.message ?? "", @@ -2874,7 +3006,10 @@ export class AgentConnectionManager { const rawResultOptions = (result.configOptions as SessionConfigOption[] | null | undefined) ?? runtime.slice.configOptions; - const options = this.sanitizeConfigOptions(rawResultOptions); + const options = + owner.agentId === "antigravity-acp" + ? rawResultOptions + : this.sanitizeConfigOptions(rawResultOptions); runtime.slice = { ...runtime.slice, configOptions: options }; this.pushState(threadId); return options; diff --git a/electron/agents/antigravity-official.test.ts b/electron/agents/antigravity-official.test.ts new file mode 100644 index 0000000..8c0f8af --- /dev/null +++ b/electron/agents/antigravity-official.test.ts @@ -0,0 +1,177 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { mkdtemp, mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; +import { + antigravityCacheRoot, + antigravityRelease, + ensureAntigravityInstalled, + type AntigravityRelease, +} from "./antigravity-official.ts"; +import { getAgentDescriptor, resolveAgentSpawn } from "./registry.ts"; + +const originalPlatform = process.platform; +let temporary: string | null = null; +afterEach(async () => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + Object.defineProperty(process, "platform", { value: originalPlatform }); + if (temporary) await rm(temporary, { recursive: true, force: true }); + temporary = null; +}); + +function zipAvailable(): boolean { + try { + execFileSync("zip", ["-v"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** Build a real zip fixture with the given entries, then return its bytes. */ +function makeFixtureZip(directory: string, files: Record): Buffer { + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(directory, name), content); + } + execFileSync("zip", ["-q", "fixture.zip", ...Object.keys(files)], { cwd: directory }); + return readFileSync(join(directory, "fixture.zip")); +} + +function fixtureRelease(archive: Buffer): AntigravityRelease { + return { + archive: "https://dl.google.com/agy-extensions/releases/fixture.zip", + sha256: createHash("sha256").update(archive).digest("hex"), + executable: "agy_acp_server.exe", + files: ["agy_acp_server.exe", "localharness_external.exe"], + args: [], + }; +} + +function stubArchiveFetch(archive: Buffer) { + const fetch = vi.fn(async () => { + const response = new Response(new Uint8Array(archive)); + Object.defineProperty(response, "url", { + value: "https://dl.google.com/agy-extensions/releases/fixture.zip", + }); + return response; + }); + vi.stubGlobal("fetch", fetch); + return fetch; +} + +test.skipIf(!antigravityRelease())( + "reuses a verified cached server without a download", + async () => { + const release = antigravityRelease()!; + temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-")); + vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary); + const root = antigravityCacheRoot(); + await mkdir(root, { recursive: true }); + const sizes: Record = {}; + for (const file of release.files) { + await writeFile(join(root, file), "fixture"); + sizes[file] = (await stat(join(root, file))).size; + } + await writeFile( + join(root, "install.json"), + JSON.stringify({ version: "1.2.1", sha256: release.sha256, files: sizes }), + ); + const fetch = vi.fn(() => Promise.reject(new Error("Unexpected download"))); + vi.stubGlobal("fetch", fetch); + const executable = await ensureAntigravityInstalled(); + expect(executable).toBe(join(root, release.executable)); + expect(resolveAgentSpawn(getAgentDescriptor("antigravity-acp")!).command).toBe(executable); + expect(fetch).not.toHaveBeenCalled(); + }, +); + +test.skipIf(!antigravityRelease())("repairs a cache whose executable was truncated", async () => { + const release = antigravityRelease()!; + temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-")); + vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary); + const root = antigravityCacheRoot(); + await mkdir(root, { recursive: true }); + const sizes: Record = {}; + for (const file of release.files) { + await writeFile(join(root, file), "fixture"); + sizes[file] = (await stat(join(root, file))).size; + } + await writeFile( + join(root, "install.json"), + JSON.stringify({ version: "1.2.1", sha256: release.sha256, files: sizes }), + ); + // A present-but-damaged executable must not pass the cache check: the + // truncated file fails the recorded size and triggers a reinstall. + await writeFile(join(root, release.executable), "trunc"); + const fetch = vi.fn(() => Promise.reject(new Error("Unexpected download"))); + vi.stubGlobal("fetch", fetch); + await expect(ensureAntigravityInstalled()).rejects.toThrow("Unexpected download"); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +test.skipIf(!antigravityRelease())( + "reclaims a stale installer lock instead of waiting it out", + async () => { + temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-")); + vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary); + const root = antigravityCacheRoot(); + await mkdir(join(root, ".."), { recursive: true }); + const lockPath = `${root}.lock`; + await writeFile(lockPath, "dead-installer-nonce"); + const stale = new Date(Date.now() - 10 * 60_000); + await utimes(lockPath, stale, stale); + const fetch = vi.fn(() => Promise.reject(new Error("Unexpected download"))); + vi.stubGlobal("fetch", fetch); + // Reaching the download proves the stale lock was reclaimed; otherwise the + // installer would sit on its 6-minute wait. + await expect(ensureAntigravityInstalled()).rejects.toThrow("Unexpected download"); + expect(fetch).toHaveBeenCalledTimes(1); + }, +); + +test.skipIf(process.platform !== "darwin" || !zipAvailable())( + "installs through the Windows tar layout", + async () => { + // Exercise the win32 branch (tar -tf / tar -xOf, no chmod) with macOS + // bsdtar, which reads the same zip archive the Windows release ships. + Object.defineProperty(process, "platform", { value: "win32" }); + temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-")); + vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary); + const fixtureDir = join(temporary, "fixture"); + await mkdir(fixtureDir, { recursive: true }); + const archive = makeFixtureZip(fixtureDir, { + "agy_acp_server.exe": "server-binary", + "localharness_external.exe": "harness-binary", + }); + const release = fixtureRelease(archive); + const fetch = stubArchiveFetch(archive); + + const executable = await ensureAntigravityInstalled({ release }); + const root = antigravityCacheRoot(); + expect(executable).toBe(join(root, "agy_acp_server.exe")); + expect(await readFile(executable, "utf8")).toBe("server-binary"); + expect(await readFile(join(root, "localharness_external.exe"), "utf8")).toBe("harness-binary"); + // The promoted cache carries per-file sizes and is reused without a fetch. + const marker = JSON.parse(await readFile(join(root, "install.json"), "utf8")); + expect(marker.files["agy_acp_server.exe"]).toBe("server-binary".length); + await expect(ensureAntigravityInstalled({ release })).resolves.toBe(executable); + expect(fetch).toHaveBeenCalledTimes(1); + }, +); + +test.skipIf(!antigravityRelease())("rejects an archive with the wrong checksum", async () => { + temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-")); + vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary); + const fetch = vi.fn(async () => { + const response = new Response("not the pinned archive"); + Object.defineProperty(response, "url", { value: antigravityRelease()!.archive }); + return response; + }); + vi.stubGlobal("fetch", fetch); + await expect(ensureAntigravityInstalled()).rejects.toThrow("checksum"); + expect(fetch).toHaveBeenCalledTimes(1); +}); diff --git a/electron/agents/antigravity-official.ts b/electron/agents/antigravity-official.ts new file mode 100644 index 0000000..e35bbf9 --- /dev/null +++ b/electron/agents/antigravity-official.ts @@ -0,0 +1,309 @@ +import { createHash, randomUUID } from "node:crypto"; +import { spawn, execFile } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir, platform, arch } from "node:os"; +import { join } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const VERSION = "1.2.1"; +const MAX_ARCHIVE_BYTES = 300 * 1024 * 1024; +/** + * How long a caller with a bounded UI phase budget (a thread switch) waits for + * a first-use install before reporting progress. Matches the download's own + * abort timeout. The install is shared and keeps running, so a later call + * returns as soon as it completes. + */ +export const ANTIGRAVITY_INSTALL_WAIT_MS = 180_000; +/** A live installer refreshes its lock mtime on this cadence. */ +const LOCK_HEARTBEAT_MS = 30_000; +/** Silence longer than this means the lock holder died without cleaning up. */ +const LOCK_STALE_MS = 5 * 60_000; +const LOCK_WAIT_ATTEMPTS = 1200; +const LOCK_WAIT_INTERVAL_MS = 300; + +export interface AntigravityRelease { + archive: string; + sha256: string; + executable: string; + files: readonly string[]; + args: readonly string[]; +} + +const RELEASES: Record = { + "darwin-arm64": { + archive: + "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.2.1-darwin-arm64.zip", + sha256: "0fab9938812e6b32b3b543e65e4f3a0025ceef755413db13542d9a9b81ea803c", + executable: "agy_acp_server.par", + files: ["agy_acp_server.par", "localharness_external"], + args: [], + }, + "win32-x64": { + archive: + "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.2.1-windows-x86_64.zip", + sha256: "9b82493819bc14613baa76264d55ad307ddd8ab4a8d6e110edb32da35498c07b", + executable: "agy_acp_server.exe", + files: ["agy_acp_server.exe", "localharness_external.exe"], + args: [], + }, +}; + +export function antigravityRelease(): AntigravityRelease | null { + return RELEASES[`${platform()}-${arch()}`] ?? null; +} + +export function antigravityCacheRoot(): string { + const base = + process.env.PIPPER_ACP_AGENT_CACHE ?? + (platform() === "darwin" + ? join(homedir(), "Library", "Caches", "Pipper Code") + : platform() === "win32" + ? join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "Pipper Code") + : join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "pipper-code")); + return join(base, "agents", "antigravity-acp", VERSION); +} + +export function installedAntigravityPath(): string | null { + const release = antigravityRelease(); + if (!release) return null; + return join(antigravityCacheRoot(), release.executable); +} + +interface InstallMarker { + version: string; + sha256: string; + /** Extracted file name → byte size, so a damaged cache is repaired. */ + files: Record; +} + +async function isInstalled(release: AntigravityRelease): Promise { + try { + const root = antigravityCacheRoot(); + const marker = JSON.parse(await readFile(join(root, "install.json"), "utf8")) as InstallMarker; + if (marker.version !== VERSION || marker.sha256 !== release.sha256) return false; + // Presence alone is not proof the cache is intact: a truncated or replaced + // executable would be spawned on every use instead of being repaired. The + // marker records each extracted file's size, so a damaged cache fails this + // check and reinstalls. A marker without sizes (pre-integrity cache) also + // fails, which reinstalls once. + if (!marker.files) return false; + for (const file of release.files) { + if ((await stat(join(root, file))).size !== marker.files[file]) return false; + } + return true; + } catch { + return false; + } +} + +function command(command: string, args: string[], timeout = 30_000): Promise { + return execFileAsync(command, args, { timeout, maxBuffer: 1024 * 1024 }).then( + ({ stdout }) => stdout, + ); +} + +async function extractOne(archive: string, file: string, destination: string): Promise { + const args = platform() === "win32" ? ["-xOf", archive, file] : ["-p", archive, file]; + const child = spawn(platform() === "win32" ? "tar" : "unzip", args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr = (stderr + chunk.toString()).slice(-1000); + }); + const closed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + try { + await pipeline(child.stdout, createWriteStream(destination, { mode: 0o700 })); + if ((await closed) !== 0) throw new Error(`Could not extract ${file}: ${stderr}`); + } catch (error) { + child.kill("SIGKILL"); + throw error; + } +} + +async function download(release: AntigravityRelease, archive: string): Promise { + const response = await fetch(release.archive, { signal: AbortSignal.timeout(180_000) }); + if (!response.ok || !response.body || new URL(response.url).hostname !== "dl.google.com") + throw new Error(`Antigravity download failed (HTTP ${response.status}).`); + const hash = createHash("sha256"); + let size = 0; + await pipeline( + Readable.fromWeb(response.body as never), + new Transform({ + transform(chunk: Buffer, _encoding, callback) { + size += chunk.length; + if (size > MAX_ARCHIVE_BYTES) callback(new Error("Antigravity archive is too large.")); + else { + hash.update(chunk); + callback(null, chunk); + } + }, + }), + createWriteStream(archive, { mode: 0o600 }), + ); + if (hash.digest("hex") !== release.sha256) + throw new Error("Antigravity download checksum did not match the pinned release."); +} + +/** Read the cross-process lock's nonce; null when no lock file exists. */ +async function readLockNonce(lockPath: string): Promise { + try { + return (await readFile(lockPath, "utf8")).trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +/** + * Remove a lock whose holder died. The nonce is re-read immediately before + * unlinking: a concurrent installer may already have reclaimed the stale lock + * and created its own, and deleting that fresh lock would let two installers + * promote the same cache directory. + */ +async function reclaimStaleLock(lockPath: string): Promise { + const observed = await readLockNonce(lockPath); + if (observed === null) return; + let stale: boolean; + try { + stale = (await stat(lockPath)).mtimeMs < Date.now() - LOCK_STALE_MS; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return; + } + if (!stale) return; + if ((await readLockNonce(lockPath)) !== observed) return; + await rm(lockPath, { force: true }); +} + +let installing: Promise | null = null; +/** Download Google's pinned ACP server into an app cache, never into the app bundle. */ +export async function ensureAntigravityInstalled( + options: { release?: AntigravityRelease } = {}, +): Promise { + const release = options.release ?? antigravityRelease(); + if (!release) + throw new Error(`Official Antigravity ACP is not supported on ${platform()} ${arch()}.`); + if (await isInstalled(release)) return join(antigravityCacheRoot(), release.executable); + if (installing) return installing; + installing = (async () => { + const root = antigravityCacheRoot(); + const parent = join(root, ".."); + await mkdir(parent, { recursive: true }); + const lockPath = `${root}.lock`; + const lockNonce = randomUUID(); + let lock: Awaited> | null = null; + for (let attempt = 0; attempt < LOCK_WAIT_ATTEMPTS && !lock; attempt++) { + let created: Awaited> | null = null; + try { + created = await open(lockPath, "wx", 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (await isInstalled(release)) return join(root, release.executable); + await reclaimStaleLock(lockPath); + await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_INTERVAL_MS)); + continue; + } + try { + await created.writeFile(lockNonce); + lock = created; + } catch (error) { + // We own a brand-new lock but cannot stamp it. Close and remove it so a + // retry is not stranded behind a fresh lock until it goes stale. + await created.close().catch(() => {}); + await rm(lockPath, { force: true }); + throw error; + } + } + if (!lock) throw new Error("Timed out waiting for Antigravity installation."); + const heldLock = lock; + // Keep the lock mtime fresh for the whole install. Without a heartbeat a + // long download or extraction looks abandoned and a second installer + // reclaims the lock, letting both promote the same cache directory. + const heartbeat = setInterval(() => { + const now = new Date(); + void heldLock.utimes(now, now).catch(() => { + // Losing the heartbeat means the lock was reclaimed; promotion below + // re-verifies ownership before touching the cache. + }); + }, LOCK_HEARTBEAT_MS); + heartbeat.unref?.(); + const stage = `${root}.${randomUUID()}.tmp`; + try { + if (await isInstalled(release)) return join(root, release.executable); + await mkdir(stage, { recursive: true, mode: 0o700 }); + const archive = join(stage, "download.zip"); + await download(release, archive); + const names = ( + await command( + platform() === "win32" ? "tar" : "unzip", + platform() === "win32" ? ["-tf", archive] : ["-Z", "-1", archive], + ) + ) + .trim() + .split(/\r?\n/); + if ( + names.length !== release.files.length || + names.some((name) => !release.files.includes(name)) + ) + throw new Error("Antigravity archive contained unexpected files."); + const files: Record = {}; + for (const file of release.files) { + await extractOne(archive, file, join(stage, file)); + if (platform() !== "win32") await chmod(join(stage, file), 0o700); + files[file] = (await stat(join(stage, file))).size; + } + await rm(archive); + await writeFile( + join(stage, "install.json"), + JSON.stringify({ version: VERSION, sha256: release.sha256, files }), + { mode: 0o600 }, + ); + // Promotion is destructive (remove + rename): only the lock owner may + // do it, or two installers can interleave cache generations. + if ((await readLockNonce(lockPath)) !== lockNonce) + throw new Error("Antigravity installation lock was reclaimed; retry the install."); + await rm(root, { recursive: true, force: true }); + await rename(stage, root); + return join(root, release.executable); + } finally { + await rm(stage, { recursive: true, force: true }); + clearInterval(heartbeat); + // Only release the lock if it is still ours: a concurrent installer that + // judged this lock stale and replaced it owns the path now. + if ((await readLockNonce(lockPath)) === lockNonce) await rm(lockPath, { force: true }); + await heldLock.close(); + } + })().finally(() => { + installing = null; + }); + return installing; +} + +/** + * Wait for a first-use install without outlasting the caller's UI budget. + * Resolves false when the install is still running at the deadline; the + * install itself is shared and keeps going, so a later call returns as soon as + * it finishes. Genuine install failures still reject. + */ +export async function waitForAntigravityInstall(timeoutMs: number): Promise { + const install = ensureAntigravityInstalled(); + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + timer.unref?.(); + }); + try { + return await Promise.race([install.then(() => true), deadline]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/electron/agents/config.json b/electron/agents/config.json index c381a0c..c297752 100644 --- a/electron/agents/config.json +++ b/electron/agents/config.json @@ -108,19 +108,15 @@ "id": "antigravity-acp", "name": "antigravity", "displayName": "Antigravity", - "description": "Google Antigravity CLI via ACP adapter (stdio JSON-RPC).", - "command": "npx", + "description": "Google's official ACP agent, installed into Pipper's cache on first use.", + "command": "agy_acp_server.par", "args": [], "icon": "antigravity", - "docsUrl": "https://antigravity.google/docs", - "authHint": "Run `agy` in your terminal to sign in (or set GEMINI_API_KEY) before connecting.", - "installHint": "npm install -g antigravity-acp (or use npx on first launch)", - "installKind": "npx", - "npmPackage": "antigravity-acp", - "detectCommands": ["antigravity-acp", "agy-acp"], - "env": { - "AGY_EXTRA_ARGS": "--dangerously-skip-permissions" - } + "docsUrl": "https://antigravity.google/docs/ide/extensions/zed/", + "authHint": "Sign in to Google Antigravity to continue.", + "installHint": "Pipper downloads Google's official ACP server on first use.", + "installKind": "binary", + "detectCommands": ["agy_acp_server.par", "agy_acp_server.exe"] }, { "id": "devin-acp", @@ -147,7 +143,7 @@ "installKind": "mock", "docsUrl": "", "authHint": "", - "installHint": "Bundled with Pipper — always available." + "installHint": "Bundled with Pipper \u2014 always available." } ], "defaultAgentId": "codex-acp" diff --git a/electron/agents/handshake-probe.ts b/electron/agents/handshake-probe.ts index 9658fed..564ab9c 100644 --- a/electron/agents/handshake-probe.ts +++ b/electron/agents/handshake-probe.ts @@ -19,6 +19,7 @@ import { Readable, Writable } from "node:stream"; import * as acp from "@agentclientprotocol/sdk"; import type { AcpAgentDescriptor, AgentProbeResult } from "../../contracts/acp.ts"; import { getAgentDescriptor, resolveAgentSpawn } from "./registry.ts"; +import { ensureAntigravityInstalled } from "./antigravity-official.ts"; /** Covers initialize + throwaway session/new (Codex init alone can take a few seconds). */ const DEFAULT_PROBE_TIMEOUT_MS = 20_000; @@ -67,6 +68,7 @@ export async function probeAgentHandshake( let child: ChildProcessWithoutNullStreams; let spawnCommand = ""; try { + if (descriptor.id === "antigravity-acp") await ensureAntigravityInstalled(); const { command, args, env } = resolveAgentSpawn(descriptor); spawnCommand = command; const useShell = process.platform === "win32" && /\.cmd$/i.test(command); @@ -91,6 +93,8 @@ export async function probeAgentHandshake( let timeoutHandle: ReturnType | undefined; let probeCwd: string | null = null; + let authMethods: acp.AuthMethod[] = []; + let canCloseSession = false; /** Once true, exit/error listeners must not reject — teardown kill is expected. */ let settled = false; @@ -130,7 +134,7 @@ export async function probeAgentHandshake( }); const run = async () => { - await connection.agent.request(acp.methods.agent.initialize, { + const initialized = await connection.agent.request(acp.methods.agent.initialize, { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, @@ -142,6 +146,8 @@ export async function probeAgentHandshake( version: options.clientVersion ?? "0.0.0", }, }); + authMethods = initialized.authMethods ?? []; + canCloseSession = Boolean(initialized.agentCapabilities?.sessionCapabilities?.close); // Throwaway session proves the agent will accept work in Pipper — including // that the user is authenticated when the agent requires it. No prompt is @@ -153,7 +159,7 @@ export async function probeAgentHandshake( })) as { sessionId?: string }; const sessionId = created?.sessionId; - if (sessionId) { + if (sessionId && canCloseSession) { try { await connection.agent.request(acp.methods.agent.session.close, { sessionId }); } catch { @@ -174,6 +180,7 @@ export async function probeAgentHandshake( message: descriptor.authHint ?? `${descriptor.displayName} requires authentication. Sign in from your terminal first.`, + authMethods, }; } const message = err instanceof Error ? err.message : String(err); diff --git a/electron/agents/registry.test.ts b/electron/agents/registry.test.ts index 897a1cb..ebdfad7 100644 --- a/electron/agents/registry.test.ts +++ b/electron/agents/registry.test.ts @@ -48,8 +48,11 @@ describe("ACP agent registry", () => { expect(copilot.docsUrl).toContain("copilot-cli-reference/acp-server"); const antigravity = BUILTIN_ACP_AGENTS.find((a) => a.id === "antigravity-acp")!; - expect(antigravity.npmPackage).toBe("antigravity-acp"); - expect(antigravity.detectCommands).toEqual(["antigravity-acp", "agy-acp"]); + expect(antigravity.npmPackage).toBeUndefined(); + expect(antigravity.command).toBe("agy_acp_server.par"); + expect(antigravity.installKind).toBe("binary"); + expect(antigravity.env).toBeUndefined(); + expect(antigravity.detectCommands).toEqual(["agy_acp_server.par", "agy_acp_server.exe"]); expect(antigravity.docsUrl).toContain("antigravity.google"); const devin = BUILTIN_ACP_AGENTS.find((a) => a.id === "devin-acp")!; @@ -104,7 +107,7 @@ describe("ACP agent registry", () => { const codex = listRegisteredAgents().find((a) => a.id === "codex-acp"); if (!codex?.available) return; // skip if neither codex-acp nor npx is on PATH const spawn = resolveAgentSpawn(codex); - if (/npx/.test(spawn.command)) { + if (/(?:^|[/\\])npx(?:\.cmd)?$/i.test(spawn.command)) { // npx fallback path: args must contain -y and the package name exactly once expect(spawn.args).toContain("-y"); expect(spawn.args).toContain("@agentclientprotocol/codex-acp"); diff --git a/electron/agents/registry.ts b/electron/agents/registry.ts index 84e7767..3866e4e 100644 --- a/electron/agents/registry.ts +++ b/electron/agents/registry.ts @@ -3,6 +3,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { homedir } from "node:os"; +import { antigravityRelease, installedAntigravityPath } from "./antigravity-official.ts"; import type { AcpAgentDescriptor } from "../../contracts/acp.ts"; interface RegistryFile { @@ -146,19 +147,15 @@ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ id: "antigravity-acp", name: "antigravity", displayName: "Antigravity", - description: "Google Antigravity CLI via ACP adapter (stdio JSON-RPC).", - command: "npx", + description: "Google's official ACP agent, installed into Pipper's cache on first use.", + command: "agy_acp_server.par", args: [], icon: "antigravity", - docsUrl: "https://antigravity.google/docs", - authHint: "Run `agy` in your terminal to sign in (or set GEMINI_API_KEY) before connecting.", - installHint: "npm install -g antigravity-acp (or use npx on first launch)", - installKind: "npx", - npmPackage: "antigravity-acp", - detectCommands: ["antigravity-acp", "agy-acp"], - env: { - AGY_EXTRA_ARGS: "--dangerously-skip-permissions", - }, + docsUrl: "https://antigravity.google/docs/ide/extensions/zed/", + authHint: "Sign in to Google Antigravity to continue.", + installHint: "Pipper downloads Google's official ACP server on first use.", + installKind: "binary", + detectCommands: ["agy_acp_server.par", "agy_acp_server.exe"], }, { id: "devin-acp", @@ -337,6 +334,18 @@ export function probeAgentAvailability(agent: AcpAgentDescriptor): AcpAgentDescr }; } + if (base.id === "antigravity-acp") { + const release = antigravityRelease(); + return { + ...base, + available: Boolean(release), + resolvedCommand: installedAntigravityPath(), + statusMessage: release + ? "Google's official ACP server downloads to Pipper's cache on first use." + : `Official Antigravity ACP is not supported on ${process.platform} ${process.arch}.`, + }; + } + // Cursor's CLI binary is literally named `agent`, a name other unrelated CLIs also // install (e.g. Grok). A generic PATH lookup can silently pick one of those instead, // which then hangs forever because it doesn't speak ACP — so resolve it separately @@ -487,6 +496,18 @@ export function resolveAgentSpawn(agent: AcpAgentDescriptor): { }; } + if (descriptorDriverId(agent) === "antigravity-acp") { + const binary = installedAntigravityPath(); + if (!binary || !existsSync(binary)) + throw new Error("Google Antigravity ACP is not installed in Pipper's cache yet."); + delete env.AGY_EXTRA_ARGS; + return { + command: binary, + args: [...(antigravityRelease()?.args ?? [])], + env, + }; + } + // Re-probe so spawn uses latest PATH resolution. Probe the *driver* // descriptor (id = driverId) rather than the instance id, so driver-specific // binary resolution (e.g. Cursor's `agent` disambiguation) still applies; diff --git a/electron/antigravity-snapshot-restore.test.ts b/electron/antigravity-snapshot-restore.test.ts new file mode 100644 index 0000000..cf3e991 --- /dev/null +++ b/electron/antigravity-snapshot-restore.test.ts @@ -0,0 +1,259 @@ +import { afterAll, describe, expect, test, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("electron", () => ({ + app: { + getPath: () => process.env.PIPPER_LIBRARY_PATH ?? process.env.TMPDIR ?? "/tmp", + connect: vi.fn(), + }, +})); + +import { AgentConnectionManager } from "./agent-connection-manager.ts"; +import { createProject } from "./projects.ts"; +import { createThread } from "./threads.ts"; +import { readOpenTabsState } from "./open-tabs.ts"; +import { ActivationSupersededError } from "./activation.ts"; +import { appendLocalUserMessage, createEmptySessionSlice } from "../src/lib/acp-session-reducer.ts"; +import { SessionRetentionTracker } from "../src/lib/session-retention.ts"; +import type { ThreadSessionRuntime } from "./thread-session-registry.ts"; +import type { LiveConnection } from "./connection-lifecycle.ts"; +import type { AcpBridgeEvent } from "../contracts/acp.ts"; +import type { Thread } from "../contracts/threads.ts"; +import type { SessionUpdate } from "@agentclientprotocol/sdk"; + +// One library root for the file: db.ts caches its handle on first use, so the +// sqlite file must outlive every test here. +const root = mkdtempSync(join(tmpdir(), "pipper-agy-restore-")); +process.env.PIPPER_LIBRARY_PATH = root; + +afterAll(async () => { + const { closeDb } = await import("./db.ts"); + closeDb(); + delete process.env.PIPPER_LIBRARY_PATH; + rmSync(root, { recursive: true, force: true }); +}); + +function makeManager() { + const events: AcpBridgeEvent[] = []; + const manager = new AgentConnectionManager({ + sendToRenderer: (event: AcpBridgeEvent) => events.push(event), + setWindowTitle: () => {}, + }); + return { manager, events }; +} + +function seedSnapshotRuntime( + manager: AgentConnectionManager, + thread: Thread, + projectId: string, +): ThreadSessionRuntime { + const runtime: ThreadSessionRuntime = { + threadId: thread.id, + agentSessionId: thread.agent_session_id, + agentId: thread.agent_id, + projectId, + cwd: root, + // A restored snapshot is settled history, not an in-flight turn. + slice: { + ...appendLocalUserMessage(createEmptySessionSlice(), "saved history", "saved"), + isStreaming: false, + }, + editorText: "", + promptInFlight: false, + activeTurnId: null, + monitorUpdateCount: 0, + toolPayloads: new Map(), + retention: new SessionRetentionTracker(), + emittedToolCalls: null, + agentReady: false, + snapshotRestored: true, + replaySlice: createEmptySessionSlice(), + replayToolPayloads: new Map(), + pendingLocalEntries: [], + payloadsReady: true, + snapshotDirty: false, + payloadRevision: 0, + }; + ( + manager as unknown as { sessions: { register: (runtime: ThreadSessionRuntime) => void } } + ).sessions.register(runtime); + return runtime; +} + +function sessionsOf(manager: AgentConnectionManager) { + return ( + manager as unknown as { + sessions: { get: (threadId: string) => ThreadSessionRuntime | undefined }; + } + ).sessions; +} + +/** Make `switchAgent` succeed with a live connection the manager considers active. */ +function stubAgentSwitch(manager: AgentConnectionManager, agentId: string): LiveConnection { + const live = { agentId } as LiveConnection; + vi.spyOn(manager, "switchAgent").mockImplementation(async () => { + ( + manager as unknown as { lifecycle: { setActive: (live: LiveConnection) => void } } + ).lifecycle.setActive(live); + return live; + }); + return live; +} + +describe("Antigravity snapshot-restored threads", () => { + test("a legacy CLI-bridge thread keeps its restored transcript when the session cannot be resumed", async () => { + const project = createProject({ name: "legacy", path: join(root, "legacy"), icon: "folder" }); + const thread = createThread(project.id, "Legacy", "antigravity-acp", "pipper-agy-old"); + const { manager, events } = makeManager(); + const runtime = seedSnapshotRuntime(manager, thread, project.id); + stubAgentSwitch(manager, "antigravity-acp"); + + await expect(manager.switchThread(thread.id)).rejects.toThrow("earlier CLI bridge"); + + // The runtime survives with its snapshot; the partial replay buffer is + // dropped so a retry cannot append onto half a timeline. + expect(sessionsOf(manager).get(thread.id)).toBe(runtime); + expect(runtime.agentReady).toBe(false); + expect(runtime.replaySlice).toBeUndefined(); + const state = manager.getState(); + expect(state.threadId).toBe(thread.id); + expect(state.entries).toHaveLength(1); + expect(state.entries[0]).toMatchObject({ type: "user_text", text: "saved history" }); + const published = events.filter((event) => event.type === "session-state").at(-1); + expect( + published?.type === "session-state" ? published.state.entries[0] : undefined, + ).toMatchObject({ text: "saved history" }); + }); + + test("project launch opens a legacy Antigravity thread snapshot-only instead of failing", async () => { + const project = createProject({ + name: "legacy-launch", + path: join(root, "legacy-launch"), + icon: "folder", + }); + const thread = createThread(project.id, "Legacy", "antigravity-acp", "pipper-agy-older"); + const { manager } = makeManager(); + seedSnapshotRuntime(manager, thread, project.id); + stubAgentSwitch(manager, "antigravity-acp"); + + // launch:complete awaits activateProject; a snapshot-only thread must not + // reject it or the main window never gets created. + await expect(manager.activateProject(project.id, thread.id)).resolves.toBeUndefined(); + expect(manager.getState().entries[0]).toMatchObject({ + type: "user_text", + text: "saved history", + }); + // The fallback still reconciles the open tab that switchThreadCore would + // have recorded, so a project with no prior tab shows this thread's tab. + expect((await readOpenTabsState()).openThreadIds).toContain(thread.id); + }); + + test("late replay updates do not alter the preserved transcript", async () => { + const project = createProject({ + name: "late-replay", + path: join(root, "late-replay"), + icon: "folder", + }); + const thread = createThread(project.id, "Late", "antigravity-acp", "pipper-agy-late"); + const { manager } = makeManager(); + const runtime = seedSnapshotRuntime(manager, thread, project.id); + stubAgentSwitch(manager, "antigravity-acp"); + await expect(manager.switchThread(thread.id)).rejects.toThrow("earlier CLI bridge"); + + // The abandoned session/load can keep streaming under the same session id; + // those updates must not be written into the restored saved history. + await ( + manager as unknown as { + handleSessionUpdate: (sessionId: string, update: SessionUpdate) => Promise; + } + ).handleSessionUpdate(thread.agent_session_id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "late replay" }, + } as SessionUpdate); + + expect(runtime.slice.entries).toHaveLength(1); + expect(runtime.slice.entries[0]).toMatchObject({ text: "saved history" }); + }); + + test("a prompt on a snapshot-only thread is rejected without a phantom message", async () => { + const project = createProject({ name: "prompt", path: join(root, "prompt"), icon: "folder" }); + const thread = createThread(project.id, "Prompt", "antigravity-acp", "pipper-agy-prompt"); + const { manager } = makeManager(); + const runtime = seedSnapshotRuntime(manager, thread, project.id); + stubAgentSwitch(manager, "antigravity-acp"); + await expect(manager.switchThread(thread.id)).rejects.toThrow("earlier CLI bridge"); + + await expect(manager.sendPrompt({ threadId: thread.id, message: "hello" })).rejects.toThrow( + "not ready", + ); + expect(runtime.slice.entries).toHaveLength(1); + expect(runtime.slice.entries[0]).toMatchObject({ text: "saved history" }); + expect(runtime.slice.isStreaming).toBe(false); + }); + + test("a superseded project activation is not reported as a snapshot-only launch", async () => { + const project = createProject({ + name: "superseded", + path: join(root, "superseded"), + icon: "folder", + }); + const thread = createThread(project.id, "Superseded", "antigravity-acp", "pipper-agy-super"); + const { manager } = makeManager(); + seedSnapshotRuntime(manager, thread, project.id); + vi.spyOn( + manager as unknown as { switchThreadInternal: (...args: unknown[]) => Promise }, + "switchThreadInternal", + ).mockRejectedValue(new ActivationSupersededError()); + + await expect(manager.activateProject(project.id, thread.id)).rejects.toBeInstanceOf( + ActivationSupersededError, + ); + }); + + test("pending local entries are dropped when an in-progress load fails", () => { + const project = createProject({ + name: "pending", + path: join(root, "pending"), + icon: "folder", + }); + const thread = createThread(project.id, "Pending", "antigravity-acp", "pipper-agy-pending"); + const { manager } = makeManager(); + const runtime = seedSnapshotRuntime(manager, thread, project.id); + const optimistic = appendLocalUserMessage(runtime.slice, "queued while loading", "pending"); + runtime.slice = optimistic; + runtime.pendingLocalEntries = [optimistic.entries.at(-1)!]; + const internal = manager as unknown as { + loadingSessionThreads: Set; + preserveSnapshotRuntimeAfterFailure: ( + threadId: string, + runtime: ThreadSessionRuntime, + ) => boolean; + }; + internal.loadingSessionThreads.add(thread.id); + + expect(internal.preserveSnapshotRuntimeAfterFailure(thread.id, runtime)).toBe(true); + expect(runtime.slice.entries).toHaveLength(1); + expect(runtime.slice.entries[0]).toMatchObject({ text: "saved history" }); + expect(runtime.slice.isStreaming).toBe(false); + expect(runtime.pendingLocalEntries).toEqual([]); + }); + + test("a runtime without a restored snapshot is still evicted when its agent cannot start", async () => { + const project = createProject({ name: "dead", path: join(root, "dead"), icon: "folder" }); + const thread = createThread(project.id, "Dead", "codex-acp", "codex-session"); + const { manager } = makeManager(); + const runtime = seedSnapshotRuntime(manager, thread, project.id); + runtime.agentId = "codex-acp"; + runtime.snapshotRestored = false; + vi.spyOn(manager, "switchAgent").mockRejectedValue(new Error("agent unavailable")); + vi.spyOn( + manager as unknown as { ensureConnection: (agentId: string) => Promise }, + "ensureConnection", + ).mockRejectedValue(new Error("no fallback")); + + await expect(manager.switchThread(thread.id)).rejects.toThrow(); + expect(sessionsOf(manager).get(thread.id)).toBeUndefined(); + }); +}); diff --git a/electron/connection-lifecycle.ts b/electron/connection-lifecycle.ts index 257bf82..3f12e1b 100644 --- a/electron/connection-lifecycle.ts +++ b/electron/connection-lifecycle.ts @@ -11,6 +11,10 @@ import type { } from "../contracts/monitor.ts"; import type { AcpAgentDescriptor } from "../contracts/acp.ts"; import { resolveAgentSpawn } from "./agents/registry.ts"; +import { + ANTIGRAVITY_INSTALL_WAIT_MS, + waitForAntigravityInstall, +} from "./agents/antigravity-official.ts"; import type { TerminalManager } from "./terminal-manager.ts"; const configuredSwitchTimeout = Number(process.env.PIPPER_ACP_SWITCH_TIMEOUT_MS); @@ -298,6 +302,25 @@ export class ConnectionLifecycle { } private async spawnAndInitialize(descriptor: AcpAgentDescriptor): Promise { + if (descriptor.id === "antigravity-acp") { + // First use downloads Google's server (~107 MiB). Bound the wait to the + // download's own budget so a slow install fails with a retryable message + // instead of letting the renderer's thread-switch timeout fire first and + // then receive the thread after it already reported failure. + const installed = await waitForAntigravityInstall(ANTIGRAVITY_INSTALL_WAIT_MS).catch( + (error) => { + throw new Error( + `Antigravity could not be installed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }, + ); + if (!installed) + throw new Error( + "Antigravity is still downloading for first use. Try again once the download finishes.", + ); + } const { command, args, env } = resolveAgentSpawn(descriptor); const useShell = process.platform === "win32" && /\.cmd$/i.test(command); const child = spawn(command, args, { diff --git a/electron/main.ts b/electron/main.ts index 058d969..588ca5f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2285,6 +2285,9 @@ function registerIpc(): void { } return result; }); + ipcMain.handle("agent:authenticate", (_event, agentId: string, methodId: string) => + requireAgentManager().authenticateAgent(agentId, methodId), + ); ipcMain.handle("agent:switchAgent", (_event, agentId: string) => requireAgentManager().switchAgent(agentId), ); diff --git a/electron/permission-coordinator.test.ts b/electron/permission-coordinator.test.ts index 582ac6c..50bdbcc 100644 --- a/electron/permission-coordinator.test.ts +++ b/electron/permission-coordinator.test.ts @@ -5,7 +5,7 @@ import { PermissionCoordinator } from "./permission-coordinator.ts"; /** * Pending-permission lifecycle: requests surface as bridge events, settle via - * user response, timeout to allow_once, displace duplicates, and cancel when + * user response, timeout cancellation, displace duplicates, and cancel when * their session goes away. */ @@ -79,14 +79,12 @@ describe("PermissionCoordinator", () => { }); }); - test("times out to allow_once so an agent never blocks forever", async () => { + test("cancels an unanswered request instead of granting permission", async () => { const { coordinator, events } = makeCoordinator(); const promise = coordinator.handle(requestParams(), "r1"); await vi.advanceTimersByTimeAsync(121_000); - await expect(promise).resolves.toEqual({ - outcome: { outcome: "selected", optionId: "allow" }, - }); + await expect(promise).resolves.toEqual({ outcome: { outcome: "cancelled" } }); expect(events.at(-1)?.type).toBe("permission-resolved"); }); diff --git a/electron/permission-coordinator.ts b/electron/permission-coordinator.ts index 9f3f3ed..ad16950 100644 --- a/electron/permission-coordinator.ts +++ b/electron/permission-coordinator.ts @@ -2,7 +2,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import type { AcpBridgeEvent, AcpPermissionRequest } from "../contracts/acp.ts"; import type { AgentOsNotification } from "./os-notifications.ts"; -/** Default allow_once after this long if the UI never responds. */ +/** Cancel unanswered permissions after this long; never grant unattended access. */ const PERMISSION_TIMEOUT_MS = 120_000; interface PendingPermission { @@ -66,15 +66,8 @@ export class PermissionCoordinator { const timer = setTimeout(() => { const pending = this.pending.get(key); if (!pending) return; - const allow = request.options.find((o) => o.kind === "allow_once") ?? request.options[0]; this.pending.delete(key); - if (allow) { - resolve({ - outcome: { outcome: "selected", optionId: allow.optionId }, - }); - } else { - resolve({ outcome: { outcome: "cancelled" } }); - } + resolve({ outcome: { outcome: "cancelled" } }); this.deps.emit({ type: "permission-resolved", sessionId, requestId: stableRequestId }); }, PERMISSION_TIMEOUT_MS); const displaced = this.pending.get(key); diff --git a/electron/preload.ts b/electron/preload.ts index 5ecff7d..836d83f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -398,6 +398,8 @@ const api = { > => ipcRenderer.invoke("agent:getModelCatalogs"), probeAgent: (agentId: string): Promise => ipcRenderer.invoke("agent:probeAgent", agentId), + authenticate: (agentId: string, methodId: string): Promise => + ipcRenderer.invoke("agent:authenticate", agentId, methodId), switchAgent: (agentId: string): Promise => ipcRenderer.invoke("agent:switchAgent", agentId), getPreferredAgentId: (): Promise => ipcRenderer.invoke("agent:getPreferredAgentId"), diff --git a/electron/push-state-snapshot.test.ts b/electron/push-state-snapshot.test.ts index 8453d07..a794426 100644 --- a/electron/push-state-snapshot.test.ts +++ b/electron/push-state-snapshot.test.ts @@ -95,7 +95,9 @@ describe("activation snapshot", () => { const streamed = events.find((event) => event.type === "session-update"); expect( - streamed?.type === "session-update" ? streamed.update.rawOutput : undefined, + streamed?.type === "session-update" && "rawOutput" in streamed.update + ? streamed.update.rawOutput + : undefined, ).toBeUndefined(); const leanMap = events.find((event) => event.type === "thread-tool-calls"); expect( diff --git a/marketing/src/pages/docs/agents.astro b/marketing/src/pages/docs/agents.astro index 2a849a0..09366e3 100644 --- a/marketing/src/pages/docs/agents.astro +++ b/marketing/src/pages/docs/agents.astro @@ -16,7 +16,7 @@ const agents: AgentDoc[] = [ { id: "cursor", handle: "@cursor", installCmd: "curl https://cursor.com/install -fsS | bash", signinCmd: "agent login" }, { id: "opencode", handle: "@opencode", installCmd: "curl -fsSL https://opencode.ai/install | bash", signinCmd: "opencode auth login" }, { id: "gemini", handle: "@Gemini", installCmd: "npm install -g @google/gemini-cli", signinCmd: "gemini" }, - { id: "antigravity", handle: "@antigravity", installCmd: "curl -fsSL https://antigravity.google/cli/install.sh | bash", signinCmd: "agy" }, + { id: "antigravity", handle: "@antigravity" }, { id: "devin", handle: "@devin", installCmd: "curl -fsSL https://cli.devin.ai/install.sh | bash", signinCmd: "devin" }, ]; --- @@ -48,7 +48,13 @@ const agents: AgentDoc[] = [ Download {agents.map((a, i) => (
-
    + {a.id === "antigravity" ? ( +
      +
    1. 1. Select Antigravity in Pipper.
    2. +
    3. 2. Pipper downloads Google’s official ACP server into its app cache.
    4. +
    5. 3. Choose a Google sign-in method in the setup card. Pipper handles the server’s ACP tool approval requests.
    6. +
    + ) :
    1. Open the Terminal @@ -71,7 +77,7 @@ const agents: AgentDoc[] = [ reopen pipper and retry
    2. -
    +
}
))} diff --git a/src/components/agent-auth-actions.tsx b/src/components/agent-auth-actions.tsx new file mode 100644 index 0000000..9a81ef3 --- /dev/null +++ b/src/components/agent-auth-actions.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { WarningIcon } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import type { AuthMethod } from "../../contracts/acp.ts"; + +/** + * Auth methods Pipper can complete through the ACP `authenticate` request. + * + * - `terminal` methods are run by the client as an interactive process; the + * spec forbids passing them to `authenticate`, so a button for one would + * always fail. + * - `gemini-api-key` needs an API key that `authenticate` has no field for, so + * the agent reads it from the environment at spawn instead. + */ +export function signInMethods(methods: AuthMethod[] | null | undefined): AuthMethod[] { + return (methods ?? []).filter( + (method) => !("type" in method && method.type === "terminal") && method.id !== "gemini-api-key", + ); +} + +/** + * Sign-in buttons for the methods an agent actually advertises, shared by the + * onboarding setup card and the workspace's persistent auth banner. The agent + * runs the flow; Pipper never reads credential files. + */ +export function AgentAuthActions({ + agentId, + methods, + onAuthenticated, +}: { + agentId: string; + methods: AuthMethod[] | null | undefined; + onAuthenticated?: () => void | Promise; +}) { + const [authenticating, setAuthenticating] = useState(false); + const [authError, setAuthError] = useState(null); + const supported = signInMethods(methods); + if (supported.length === 0) return null; + + const authenticate = async (methodId: string) => { + setAuthenticating(true); + setAuthError(null); + try { + await window.omni.agent.authenticate(agentId, methodId); + await onAuthenticated?.(); + } catch (error) { + setAuthError(error instanceof Error ? error.message : "Sign-in failed."); + } finally { + setAuthenticating(false); + } + }; + + return ( +
+ {supported.map((method) => ( + + ))} + {authError &&

{authError}

} +
+ ); +} + +/** + * Persistent sign-in notice for the workspace. A failed session restore that + * needs authentication keeps this visible (unlike a dismissible switch error), + * with the agent's advertised sign-in methods inline. + */ +export function AgentAuthBanner({ + message, + agentId, + methods, + onAuthenticated, +}: { + message: string; + agentId: string | null; + methods: AuthMethod[] | null | undefined; + onAuthenticated?: () => void | Promise; +}) { + return ( +
+ +
+ {message} + {agentId && ( + + )} +
+
+ ); +} diff --git a/src/components/agent-panel.tsx b/src/components/agent-panel.tsx index 784ab95..239292c 100644 --- a/src/components/agent-panel.tsx +++ b/src/components/agent-panel.tsx @@ -14,6 +14,7 @@ import { ChatMessage } from "@/components/ui/chat-message"; import { ThreadComposer, initialDraftContent } from "@/components/thread-composer"; import { ConversationTurnIdentity } from "@/components/conversation-turn-identity"; import { AgentRuntimeControls } from "@/components/agent-runtime-controls"; +import { AgentAuthBanner } from "@/components/agent-auth-actions"; import type { MentionProvider } from "@/components/mention-popover"; import { useIcon } from "@/lib/icon-context"; import { Elevated } from "@/lib/elevated"; @@ -599,6 +600,33 @@ function cleanRuntimeStatusText(text: string | null | undefined): string | null return cleaned ? cleaned : null; } +function isAntigravityAuthFailure(message: string): boolean { + return /auth(?:entication)?[\s_-]*(?:required|failed)|not authenticated|sign in to .*antigravity/i.test( + message, + ); +} + +function sendFailureToast( + error: unknown, + agentId: string | null | undefined, + fallbackTitle: string, +) { + if ( + agentId === "antigravity-acp" && + error instanceof Error && + isAntigravityAuthFailure(error.message) + ) { + return { + title: "Antigravity not authenticated", + description: "Sign in to Antigravity, then try again.", + }; + } + return { + title: fallbackTitle, + description: error instanceof Error ? error.message : "The agent did not accept the message.", + }; +} + export function getRuntimeStatusItems(snapshot: AgentPanelSnapshot | null): string[] { if (!snapshot) return []; @@ -642,6 +670,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { snapshot, error: agentError, isConnecting, + authMethods, uiRequest, uiRequestQueue, subagentRuns, @@ -1653,18 +1682,18 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { message: check.text, images: newImages.length ? newImages : undefined, }).catch((err) => { + const failure = sendFailureToast(err, check.agentId, "Send failed"); toast({ icon: , - title: "Send failed", - description: err instanceof Error ? err.message : "The agent did not accept the message.", + ...failure, }); }); setAttachedFiles([]); } catch (err) { + const failure = sendFailureToast(err, check.agentId, "Create thread failed"); toast({ icon: , - title: "Create thread failed", - description: err instanceof Error ? err.message : "The thread was not created.", + ...failure, }); } finally { setIsSubmitting(false); @@ -1769,10 +1798,14 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { streamingBehavior: isStreaming ? streamingBehavior : undefined, }); sendOp.catch((err) => { + const failure = sendFailureToast( + err, + snapshot?.agentId, + editState ? "Edit failed" : "Send failed", + ); toast({ icon: , - title: editState ? "Edit failed" : "Send failed", - description: err instanceof Error ? err.message : "The agent did not accept the message.", + ...failure, }); }); if (useAgentStore.getState().state?.threadId === operationThreadId) { @@ -1788,10 +1821,14 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { } setStreamingBehavior("followUp"); } catch (err) { + const failure = sendFailureToast( + err, + snapshot?.agentId, + editState ? "Edit failed" : "Send failed", + ); toast({ icon: , - title: editState ? "Edit failed" : "Send failed", - description: err instanceof Error ? err.message : "The agent did not accept the message.", + ...failure, }); } finally { setIsSubmitting(false); @@ -2035,7 +2072,17 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { } }; - const visibleAgentError = agentError && agentError !== dismissedAgentError ? agentError : null; + // Antigravity sign-in failures are rendered as the persistent auth banner + // above (with in-place sign-in buttons), not as a dismissible switch error. + // Suppress the raw error only when that banner actually renders; a text + // match without an auth_required message must stay visible. + const authBannerVisible = Boolean(snapshot?.authRequiredMessage); + const visibleAgentError = + agentError && + agentError !== dismissedAgentError && + !(snapshot?.agentId === "antigravity-acp" && authBannerVisible) + ? agentError + : null; const runtimeControlsDisabled = isRuntimeActionPending || isSwitchingThread || isConnecting || !snapshot; const composerDisabled = isDraftMode @@ -2494,6 +2541,14 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { )} >
+ {snapshot?.authRequiredMessage && ( + refresh()} + /> + )} {visibleAgentError && (
diff --git a/src/components/agent-selector.tsx b/src/components/agent-selector.tsx index 12b9896..3a8637e 100644 --- a/src/components/agent-selector.tsx +++ b/src/components/agent-selector.tsx @@ -15,6 +15,7 @@ import { CardTitle, } from "@/components/ui/card"; import { createProviderLogoIcon } from "@/components/provider-logos"; +import { AgentAuthActions } from "@/components/agent-auth-actions"; import { cn } from "@/lib/utils"; import { isDefaultInstance, isInstanceSelected } from "@/lib/agent-selection"; import type { AcpAgentDescriptor, AgentProbeResult } from "../../contracts/acp.ts"; @@ -359,6 +360,19 @@ function AgentSetupCard({ {descriptor.displayName} + {descriptor.id === "antigravity-acp" && ( +

+ Google’s official ACP server is downloaded to Pipper’s cache on first use. Sign in here + to use its interactive tool approvals. +

+ )} + {result.status === "needs-auth" && descriptor.id === "antigravity-acp" && ( + + )}
{status === "probing" ? ( diff --git a/src/electron.d.ts b/src/electron.d.ts index 8652c41..1190609 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -277,6 +277,7 @@ declare global { Record> >; probeAgent: (agentId: string) => Promise; + authenticate: (agentId: string, methodId: string) => Promise; switchAgent: (agentId: string) => Promise; getPreferredAgentId: () => Promise; setPreferredAgentId: (agentId: string) => Promise; diff --git a/src/store/agent-store.ts b/src/store/agent-store.ts index 8d5f51e..40b79a8 100644 --- a/src/store/agent-store.ts +++ b/src/store/agent-store.ts @@ -9,6 +9,7 @@ import type { AcpSessionState, AcpToolCallState, AgentCapabilities, + AuthMethod, AvailableCommand, SessionConfigOption, SubagentRunSnapshot, @@ -101,7 +102,8 @@ interface AgentState { /** Incremental slice for the active session (mirrors main; applied optimistically). */ slice: AcpSessionSlice; agentCapabilities: AgentCapabilities | null; - authMethods: Array<{ id: string; name?: string | null }>; + /** Sign-in methods the active connection advertises (ACP `authMethods`). */ + authMethods: AuthMethod[]; /** Thread IDs whose agent is currently streaming, across all open threads. */ runningThreadIds: string[]; /** Latest tool-call map for every known thread, including background threads. */ @@ -172,10 +174,12 @@ let threadSwitchQueue: Promise = Promise.resolve(); let pendingThreadTarget: string | null = null; let latestRefreshId = 0; // Main activation can spend up to 10s on initialize plus three 10s session -// phases (load, resume, new). Keep the renderer pending until that budget has -// elapsed so a late session-state cannot surprise the user after a false error. +// phases (load, resume, new), and Antigravity's first-use install alone waits +// up to 180s for its download. Keep the renderer pending until that whole +// budget has elapsed so a late session-state cannot surprise the user after a +// false error. // Read lazily: Node tests import this module without a preload `window.omni`. -const DEFAULT_THREAD_SWITCH_TIMEOUT_MS = 60_000; +const DEFAULT_THREAD_SWITCH_TIMEOUT_MS = 240_000; function threadSwitchTimeoutMs(): number { const benchmark = typeof window === "undefined" ? undefined : window.omni?.benchmark;