mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* Revert "Remove the unreleased Antigravity provider plugin (#5781)"
This reverts commit 9d9d9af993.
* fix(providers): commit plugin notices emitted during session opening
* feat(providers): restore Antigravity with full access and session warnings
* fix(providers): warn once when opening Antigravity sessions
Driver respawn retains the live transcript and unchanged permission posture. Emit the warning only when opening a session, including resume after a daemon restart.
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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<number, string>;
|
|
usageSteps: Set<number>;
|
|
tools: Map<string, Extract<ProviderTimelineItem, { type: "tool_call" }>>;
|
|
usage: Required<Pick<ProviderUsage, "inputTokens" | "outputTokens" | "cachedInputTokens">>;
|
|
}
|
|
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<string, number>();
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Frame, { event: "result" }>["result"]["denied_actions"],
|
|
reported: Map<string, number>,
|
|
) {
|
|
const counts = new Map<string, number>();
|
|
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<Frame, { event: "result" }>["result"]["denied_actions"],
|
|
): string {
|
|
if (actions.length > 1) return `${actions.length} actions denied`;
|
|
return actions[0].action === "command" ? "Shell command denied" : "Action denied";
|
|
}
|