import { randomUUID } from "node:crypto"; import { z } from "zod"; import type { ProviderCatalog, ProviderConfigChanges, ProviderEvent, ProviderPersistence, ProviderPrompt, ProviderSessionConfig, ProviderLaunch, ProviderTimelineItem, ProviderUsage, } from "@getpaseo/plugin/server/provider"; import { startDriver, selection, type Driver } from "./process.js"; import { validateSelection } from "./catalog.js"; import { PromptFiles } from "./prompt.js"; import { toolItem } from "./timeline.js"; import { AntigravityError, diagnostic, type Frame, type Step } from "./wire.js"; const persistenceSchema = z .object({ version: z.literal(1), data: z.object({ conversationId: z.string().min(1) }).strict() }) .strict(); interface Turn { id: string; text: Map; usageSteps: Set; tools: Map>; usage: Required>; } type State = | { type: "dormant" } | { type: "starting"; driver: Driver } | { type: "idle"; driver: Driver } | { type: "running"; driver: Driver; turn: Turn } | { type: "stopping"; driver: Driver } | { type: "closed" }; interface SessionOptions { id: string; config: ProviderSessionConfig; launch: ProviderLaunch; catalog(): ProviderCatalog; persistence?: ProviderPersistence; emit(event: ProviderEvent): void; } export class Session { private state: State = { type: "dormant" }; private config: ProviderSessionConfig; private conversationId: string | null; private firstMessage: boolean; private readonly promptFiles = new PromptFiles(); private readonly reportedDenials = new Map(); constructor(private readonly options: SessionOptions) { this.config = { ...options.config, mode: options.config.mode ?? "full-access" }; this.conversationId = options.persistence ? persistenceSchema.parse(options.persistence).data.conversationId : null; this.firstMessage = this.conversationId === null; validateSelection(this.config, options.catalog()); } async open(requestId: string, capabilities: readonly string[]): Promise { await this.ensureDriver(); this.emit({ type: "session.opened", requestId, sessionId: this.options.id, capabilities, restoration: "core", persistence: this.config.persist ? this.persistence() : undefined, cwd: this.config.cwd, title: this.config.title, }); this.publishFullAccessNotice(); this.publishConfig(); this.emit({ type: "session.commands", sessionId: this.options.id, commands: [] }); this.emit({ type: "session.ready", requestId, sessionId: this.options.id }); } async prompt(prompt: ProviderPrompt): Promise { if (this.state.type === "running") throw new AntigravityError( "Antigravity already has an active turn; wait for it to finish", "TURN_ACTIVE", ); const { text, nativeText } = await this.promptFiles.encode(prompt); await this.ensureDriver(); if (this.state.type !== "idle") throw new AntigravityError("Antigravity session is not ready"); const driver = this.state.driver; const turn: Turn = { id: randomUUID(), text: new Map(), usageSteps: new Set(), tools: new Map(), usage: { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }, }; this.state = { type: "running", driver, turn }; this.emit({ type: "session.prompt_result", sessionId: this.options.id, clientMessageId: prompt.clientMessageId, result: { type: "turn", turnId: turn.id }, }); this.emit({ type: "session.turn", sessionId: this.options.id, turnId: turn.id, state: "started", }); this.item({ type: "user_message", id: prompt.clientMessageId, clientMessageId: prompt.clientMessageId, text, }); const driverText = this.firstMessage && this.config.systemPrompt ? `${this.config.systemPrompt}\n\n${nativeText}` : nativeText; try { await driver.prompt(driverText); this.firstMessage = false; } catch (error) { if (!(error instanceof Error)) throw error; this.failTurn(new AntigravityError(error.message)); await driver.stop("interrupt"); } } configure(changes: ProviderConfigChanges): void { const config = { ...this.config, model: changes.model === null ? undefined : (changes.model ?? this.config.model), mode: changes.mode === null ? "full-access" : (changes.mode ?? this.config.mode), thinkingOption: changes.thinkingOption === null ? undefined : (changes.thinkingOption ?? this.config.thinkingOption), }; if (changes.settings && Object.keys(changes.settings).length > 0) throw new AntigravityError("Antigravity has no configurable settings"); validateSelection(config, this.options.catalog()); this.config = config; this.publishConfig(); } async interrupt(): Promise { if (this.state.type !== "running") return; const { driver, turn } = this.state; this.state = { type: "stopping", driver }; await driver.stop("interrupt"); this.finishTools(turn, "canceled"); this.emit({ type: "session.turn", sessionId: this.options.id, turnId: turn.id, state: "canceled", }); this.state = { type: "dormant" }; } async close(): Promise { const state = this.state; this.state = { type: "closed" }; try { if (state.type !== "dormant" && state.type !== "closed") await state.driver.stop("close"); } finally { await this.promptFiles.close(); } if (state.type === "running") { this.finishTools(state.turn, "canceled"); this.emit({ type: "session.turn", sessionId: this.options.id, turnId: state.turn.id, state: "canceled", }); } } private publishFullAccessNotice(): void { this.emit({ type: "session.notice", sessionId: this.options.id, notice: { id: `${this.options.id}:full-access`, severity: "warning", title: "Antigravity is running with full access", description: "Antigravity's CLI cannot ask for permission when another app drives it, so Paseo starts it with --dangerously-skip-permissions. Every tool call, including shell commands, runs without asking.", }, }); } private async ensureDriver(): Promise { if (this.state.type === "closed") throw new AntigravityError("Antigravity session is closed"); if (this.state.type === "stopping") { await this.state.driver.stop("interrupt"); this.state = { type: "dormant" }; } if (this.state.type === "idle" && this.state.driver.selection !== selection(this.config)) { const driver = this.state.driver; this.state = { type: "stopping", driver }; await driver.stop("close"); this.state = { type: "dormant" }; } if (this.state.type !== "dormant") return; this.reportedDenials.clear(); const driver = startDriver({ launch: this.options.launch, config: this.config, conversationId: this.conversationId, onFrame: (frame) => this.accept(frame), onExit: (error) => this.failTurn(error), }); this.state = { type: "starting", driver }; try { const init = await driver.ready; if (this.conversationId !== null && init.conversation_id !== this.conversationId) throw new AntigravityError( "Antigravity restored a different conversation", "INVALID_RESUME", ); this.conversationId = init.conversation_id; this.state = { type: "idle", driver }; if (this.config.persist) this.emit({ type: "session.persistence", sessionId: this.options.id, persistence: this.persistence(), }); } catch (error) { this.state = { type: "dormant" }; await driver.stop("interrupt"); throw error; } } private accept(frame: Frame): void { if (this.state.type !== "running") return; const { turn, driver } = this.state; if (frame.event === "step_update") { this.acceptStep(frame.step_update, turn); return; } if (frame.event !== "result") return; const result = frame.result; const denied = takeNewDenials(result.denied_actions, this.reportedDenials); if (denied.length > 0) { this.emit({ type: "session.notice", sessionId: this.options.id, notice: { id: `${turn.id}:denied`, severity: "warning", title: denialNoticeTitle(denied), description: "Antigravity's own policy denied it.", }, }); } if (result.status === "ERROR") { const canceled = result.error === "interrupted"; this.finishTools(turn, canceled ? "canceled" : "failed"); this.retire(driver); this.emit({ type: "session.turn", sessionId: this.options.id, turnId: turn.id, state: canceled ? "canceled" : "failed", error: { message: diagnostic(result.error) }, }); } else { this.finishTools(turn, "completed"); this.state = { type: "idle", driver }; this.emit({ type: "session.turn", sessionId: this.options.id, turnId: turn.id, state: "completed", }); } } private acceptStep(step: Step, turn: Turn): void { const id = `${turn.id}:${step.step_index}`; if (step.step_type === "agent_response") { const previous = turn.text.get(step.step_index) || ""; const text = previous + step.text_delta; turn.text.set(step.step_index, text); if (step.text_delta) this.item({ type: "assistant_message", id, messageId: id, text }); if (step.state === "DONE" && step.usage && !turn.usageSteps.has(step.step_index)) { turn.usageSteps.add(step.step_index); turn.usage.inputTokens += step.usage.input_tokens; turn.usage.outputTokens += step.usage.output_tokens; turn.usage.cachedInputTokens += step.usage.cache_read_tokens; this.emit({ type: "session.usage", sessionId: this.options.id, turnId: turn.id, usage: { ...turn.usage }, }); } } else if (step.step_type === "tool" || step.step_type === "subagent") { const item = toolItem(step, id); turn.tools.set(id, item); this.item(item); } } private failTurn(error: AntigravityError): void { const state = this.state; if (state.type !== "running" && state.type !== "idle") return; this.retire(state.driver); if (state.type === "running") { this.finishTools(state.turn, "failed"); this.emit({ type: "session.turn", sessionId: this.options.id, turnId: state.turn.id, state: "failed", error: { message: error.message, code: error.code }, }); } else this.emit({ type: "session.runtime_failed", sessionId: this.options.id, error: { message: error.message, code: error.code }, }); } private retire(driver: Driver): void { this.state = { type: "stopping", driver }; void driver.stop("interrupt").then(() => { if (this.state.type === "stopping" && this.state.driver === driver) this.state = { type: "dormant" }; return undefined; }); } private finishTools(turn: Turn, status: "completed" | "failed" | "canceled"): void { for (const item of turn.tools.values()) { if (item.status !== "running") continue; if (status === "failed") this.item({ ...item, status, error: "Antigravity turn failed" }); else this.item({ ...item, status, error: null }); } } private persistence(): ProviderPersistence { if (this.conversationId === null) throw new AntigravityError("Antigravity has no conversation"); return { version: 1, data: { conversationId: this.conversationId } }; } private publishConfig(): void { this.emit({ type: "session.config", sessionId: this.options.id, config: { model: this.config.model, mode: this.config.mode || "full-access", models: this.options.catalog().models, modes: this.options.catalog().modes, thinkingOptions: [], settings: [], }, }); } private item(item: ProviderTimelineItem): void { this.emit({ type: "timeline.item", sessionId: this.options.id, item }); } private emit(event: ProviderEvent): void { this.options.emit(event); } } // Native results contain every denial since this driver started, including repeated actions. function takeNewDenials( actions: Extract["result"]["denied_actions"], reported: Map, ) { const counts = new Map(); const fresh = actions.filter((action) => { const key = JSON.stringify([action.action, action.display_name]); const count = (counts.get(key) ?? 0) + 1; counts.set(key, count); return count > (reported.get(key) ?? 0); }); for (const [key, count] of counts) reported.set(key, Math.max(count, reported.get(key) ?? 0)); return fresh; } function denialNoticeTitle( actions: Extract["result"]["denied_actions"], ): string { if (actions.length > 1) return `${actions.length} actions denied`; return actions[0].action === "command" ? "Shell command denied" : "Action denied"; }