@estebanforge/pi-antigravity-bridge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/docs/ARCHITECTURE.md +48 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/docs/PI-BRIDGE-GAPS.md +186 -0
- package/docs/PI-INVOKETOOL-PATCH.md +227 -0
- package/extensions/index.ts +474 -0
- package/package.json +69 -0
- package/src/ask-tool.ts +579 -0
- package/src/config.ts +119 -0
- package/src/diff-render.ts +190 -0
- package/src/discovery.ts +199 -0
- package/src/mcp-server.ts +443 -0
- package/src/models.ts +261 -0
- package/src/patcher.ts +571 -0
- package/src/poller.ts +202 -0
- package/src/protobuf.ts +184 -0
- package/src/provider.ts +502 -0
- package/src/runner.ts +386 -0
- package/src/sessions.ts +159 -0
package/src/ask-tool.ts
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
// The AskAntigravity tool: delegate a self-contained sub-task to Google
|
|
2
|
+
// Antigravity's `agy` CLI. Ported from pi-ask-antigravity v1.1.0 so this
|
|
3
|
+
// extension (pi-antigravity-bridge) provides BOTH the streaming provider AND
|
|
4
|
+
// the one-shot delegation tool - the same shape as pi-claude-bridge.
|
|
5
|
+
//
|
|
6
|
+
// One self-contained tool. Spawns `agy -p`, streams its stdout as partial
|
|
7
|
+
// output, returns the final response. agy runs its OWN tool loop (read,
|
|
8
|
+
// write, edit, exec) inside the workspace.
|
|
9
|
+
//
|
|
10
|
+
// When both pi-antigravity-bridge and pi-ask-antigravity are installed, the
|
|
11
|
+
// bridge wins: pi-ask-antigravity detects the bridge package and registers
|
|
12
|
+
// nothing (see its defer guard). This module is the single source of truth.
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
import {
|
|
20
|
+
CONVERSATIONS_DIR,
|
|
21
|
+
newConversationId,
|
|
22
|
+
snapshotConversations,
|
|
23
|
+
} from "./discovery.js";
|
|
24
|
+
import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
|
|
25
|
+
import { spawnAgyModelsRaw } from "./models.js";
|
|
26
|
+
|
|
27
|
+
// --- Constants -------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TIMEOUT_MIN = 10;
|
|
30
|
+
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
31
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
32
|
+
const STATUS_TAIL_CHARS = 160;
|
|
33
|
+
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
34
|
+
const DISCOVERY_POLL_MS = 100;
|
|
35
|
+
|
|
36
|
+
// Per-family fallback tier when none is specified and no config default.
|
|
37
|
+
const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
|
|
38
|
+
flash: "medium",
|
|
39
|
+
pro: "high",
|
|
40
|
+
other: "medium",
|
|
41
|
+
};
|
|
42
|
+
const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
|
|
43
|
+
|
|
44
|
+
// Static alias overlay for non-Gemini models agy may or may not surface.
|
|
45
|
+
// Live catalog entries win on case-insensitive full-string equality; the
|
|
46
|
+
// overlay resolves the alias when agy doesn't list it.
|
|
47
|
+
const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
|
|
48
|
+
{ full: "Claude Sonnet 4.6 (Thinking)", family: "other", version: null, tier: null },
|
|
49
|
+
{ full: "Claude Opus 4.6 (Thinking)", family: "other", version: null, tier: null },
|
|
50
|
+
{ full: "GPT-OSS 120B (Medium)", family: "other", version: null, tier: null },
|
|
51
|
+
];
|
|
52
|
+
const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
|
|
53
|
+
["sonnet", "Claude Sonnet 4.6 (Thinking)"],
|
|
54
|
+
["opus", "Claude Opus 4.6 (Thinking)"],
|
|
55
|
+
["gpt-oss", "GPT-OSS 120B (Medium)"],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
|
|
59
|
+
// leading-dash value can't misbind on agy's arg parser; hyphens allowed in the
|
|
60
|
+
// body (real UUIDs contain them).
|
|
61
|
+
const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
|
|
62
|
+
|
|
63
|
+
const AGY_DESCRIPTION = `Delegate a self-contained sub-task to Google Antigravity. agy is the CLI for Gemini, so this tool is reached under three equivalent names the user may use interchangeably: **gemini**, **antigravity**, and **agy**. When the user says "ask gemini", "ask antigravity", "ask agy", or otherwise refers to any of these, call THIS tool. agy runs its OWN tool loop: it can read, write, edit, and execute inside the workspace, then returns its final answer. Use for a second opinion from a different model family, Gemini-specific reasoning, or isolated sub-tasks you do not need to drive step-by-step. Provide a complete, self-contained task description; agy will not see this conversation.
|
|
64
|
+
|
|
65
|
+
TWO MODES (you choose):
|
|
66
|
+
- **One-shot (isolated)**: omit conversationId. agy starts fresh with no memory of prior calls. Use for independent questions.
|
|
67
|
+
- **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
|
|
68
|
+
|
|
69
|
+
EXECUTION MODES (param: mode):
|
|
70
|
+
- **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks.
|
|
71
|
+
- **accept-edits** (default): agy applies edits directly inside the workspace.
|
|
72
|
+
|
|
73
|
+
COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.`;
|
|
74
|
+
|
|
75
|
+
// --- Types -----------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
type Family = "flash" | "pro" | "other";
|
|
78
|
+
|
|
79
|
+
interface ModelEntry {
|
|
80
|
+
full: string; // exact agy string, e.g. "Gemini 3.6 Flash (Medium)"
|
|
81
|
+
family: Family;
|
|
82
|
+
version: string | null; // "3.6"
|
|
83
|
+
tier: ThinkingTier | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Version helpers -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Descending numeric version compare (3.10 > 3.9, not lexical). */
|
|
89
|
+
function compareVersionsDesc(a: string, b: string): number {
|
|
90
|
+
const pa = a.split(".").map(Number);
|
|
91
|
+
const pb = b.split(".").map(Number);
|
|
92
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
93
|
+
const da = pa[i] ?? 0;
|
|
94
|
+
const db = pb[i] ?? 0;
|
|
95
|
+
if (da !== db) return db - da;
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Model catalog ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
|
|
103
|
+
const seen = new Set(live.map((e) => e.full.toLowerCase()));
|
|
104
|
+
const merged = [...live];
|
|
105
|
+
for (const entry of STATIC_ALIAS_OVERLAY) {
|
|
106
|
+
if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
|
|
107
|
+
}
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseModelLine(line: string): ModelEntry | null {
|
|
112
|
+
const full = line.trim();
|
|
113
|
+
if (!full) return null;
|
|
114
|
+
const lower = full.toLowerCase();
|
|
115
|
+
const family: Family = lower.includes("flash")
|
|
116
|
+
? "flash"
|
|
117
|
+
: lower.includes("pro")
|
|
118
|
+
? "pro"
|
|
119
|
+
: "other";
|
|
120
|
+
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
121
|
+
const version = versionMatch ? versionMatch[1] : null;
|
|
122
|
+
const tierMatch = lower.match(/\((low|medium|high)\)/);
|
|
123
|
+
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
124
|
+
return { full, family, version, tier };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
|
|
128
|
+
if (available.includes(preferred)) return preferred;
|
|
129
|
+
const sorted = [...available].sort((a, b) => {
|
|
130
|
+
const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
|
|
131
|
+
const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
|
|
132
|
+
return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
|
|
133
|
+
});
|
|
134
|
+
return sorted[0] ?? preferred;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Resolve a friendly alias / partial name to an exact agy model string. */
|
|
138
|
+
export function resolveModel(
|
|
139
|
+
input: string,
|
|
140
|
+
entries: ModelEntry[],
|
|
141
|
+
defaultThinking: ThinkingTier,
|
|
142
|
+
): string | null {
|
|
143
|
+
const lower = input.toLowerCase().trim();
|
|
144
|
+
|
|
145
|
+
const exact = entries.find((e) => e.full.toLowerCase() === lower);
|
|
146
|
+
if (exact) return exact.full;
|
|
147
|
+
|
|
148
|
+
if (STATIC_SHORT_ALIAS.has(lower)) {
|
|
149
|
+
const target = STATIC_SHORT_ALIAS.get(lower) as string;
|
|
150
|
+
const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
|
|
151
|
+
return fromCatalog ? fromCatalog.full : target;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let family: Family | null = lower.includes("flash")
|
|
155
|
+
? "flash"
|
|
156
|
+
: lower.includes("pro")
|
|
157
|
+
? "pro"
|
|
158
|
+
: null;
|
|
159
|
+
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
160
|
+
const version = versionMatch ? versionMatch[1] : null;
|
|
161
|
+
const tierMatch = lower.match(/\b(low|medium|high)\b/);
|
|
162
|
+
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
163
|
+
|
|
164
|
+
if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
|
|
165
|
+
family = "flash";
|
|
166
|
+
}
|
|
167
|
+
if (!family) return null;
|
|
168
|
+
|
|
169
|
+
let candidates = entries.filter((e) => e.family === family);
|
|
170
|
+
if (candidates.length === 0) return null;
|
|
171
|
+
|
|
172
|
+
if (version) {
|
|
173
|
+
const versioned = candidates.filter((e) => e.version === version);
|
|
174
|
+
if (versioned.length > 0) candidates = versioned;
|
|
175
|
+
} else {
|
|
176
|
+
const aliases = candidates.filter((e) => e.version === null);
|
|
177
|
+
if (aliases.length > 0) {
|
|
178
|
+
candidates = aliases;
|
|
179
|
+
} else {
|
|
180
|
+
const versions = candidates
|
|
181
|
+
.map((e) => e.version)
|
|
182
|
+
.filter((v): v is string => v !== null)
|
|
183
|
+
.sort(compareVersionsDesc);
|
|
184
|
+
if (versions.length > 0) {
|
|
185
|
+
const top = versions[0];
|
|
186
|
+
const latest = candidates.filter((e) => e.version === top);
|
|
187
|
+
if (latest.length > 0) candidates = latest;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const familyTiers = new Set(
|
|
193
|
+
candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
|
|
194
|
+
);
|
|
195
|
+
if (familyTiers.size === 0) return candidates[0].full;
|
|
196
|
+
|
|
197
|
+
const preferred =
|
|
198
|
+
tier ??
|
|
199
|
+
(familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
|
|
200
|
+
const chosenTier = nearestTier([...familyTiers], preferred);
|
|
201
|
+
return (candidates.find((e) => e.tier === chosenTier) ?? candidates[0]).full;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Parse raw `agy models` text into tool-catalog entries (all families, plus
|
|
205
|
+
* the static sonnet/opus/gpt-oss overlay). Pure: no spawn. */
|
|
206
|
+
export function toolModelsFromRaw(raw: string): ModelEntry[] {
|
|
207
|
+
return mergeCatalog(
|
|
208
|
+
raw.split("\n").map(parseModelLine).filter((e): e is ModelEntry => e !== null),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Query `agy models` and return tool-catalog entries. Returns [] on failure.
|
|
213
|
+
* Kept for standalone use; the extension entry spawns once and parses via
|
|
214
|
+
* toolModelsFromRaw to avoid a second `agy models` invocation. */
|
|
215
|
+
export async function discoverToolModels(binary: string): Promise<ModelEntry[]> {
|
|
216
|
+
return toolModelsFromRaw(await spawnAgyModelsRaw(binary));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extraArgs(): string[] {
|
|
220
|
+
const raw = process.env.AGY_EXTRA_ARGS;
|
|
221
|
+
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
225
|
+
|
|
226
|
+
// --- Registration ----------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/** Register the AskAntigravity tool. Call once from the extension entry.
|
|
229
|
+
* `entries` is the merged live+overlay catalog discovered at load. */
|
|
230
|
+
export async function registerAskAntigravityTool(
|
|
231
|
+
pi: ExtensionAPI,
|
|
232
|
+
entries: ModelEntry[],
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: "AskAntigravity",
|
|
236
|
+
label: "Ask Antigravity",
|
|
237
|
+
description: AGY_DESCRIPTION,
|
|
238
|
+
parameters: Type.Object({
|
|
239
|
+
prompt: Type.String({
|
|
240
|
+
description:
|
|
241
|
+
"Self-contained task for agy. Include all context agy needs; it cannot see this conversation.",
|
|
242
|
+
}),
|
|
243
|
+
cwd: Type.Optional(
|
|
244
|
+
Type.String({
|
|
245
|
+
description: "Absolute workspace path agy runs in. Defaults to the current project root.",
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
model: Type.Optional(
|
|
249
|
+
Type.String({
|
|
250
|
+
description:
|
|
251
|
+
"Model alias or exact id. Friendly: 'flash', 'pro', 'gemini'. Add a tier: 'flash high'. Pin a version: '3.5 flash'. Exact: 'Gemini 3.6 Flash (Medium)'. Omit for the configured default.",
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
mode: Type.Optional(
|
|
255
|
+
Type.Union([Type.Literal("plan"), Type.Literal("accept-edits")], {
|
|
256
|
+
description:
|
|
257
|
+
"agy execution mode. 'plan' = review-only. 'accept-edits' = agy applies edits (default).",
|
|
258
|
+
default: "accept-edits",
|
|
259
|
+
}),
|
|
260
|
+
),
|
|
261
|
+
digest: Type.Optional(
|
|
262
|
+
Type.Boolean({
|
|
263
|
+
description:
|
|
264
|
+
"Request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.",
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
conversationId: Type.Optional(
|
|
268
|
+
Type.String({
|
|
269
|
+
description:
|
|
270
|
+
"Omit for a one-shot. To CONTINUE a previous agy conversation, pass the conversationId returned in that call's details.",
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
timeoutMinutes: Type.Optional(
|
|
274
|
+
Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.` }),
|
|
275
|
+
),
|
|
276
|
+
}),
|
|
277
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
278
|
+
// Circular-delegation guard: refuse if already running through the
|
|
279
|
+
// antigravity provider.
|
|
280
|
+
if (ctx.model?.provider === "antigravity" || ctx.model?.provider === "agy") {
|
|
281
|
+
return {
|
|
282
|
+
content: [
|
|
283
|
+
{
|
|
284
|
+
type: "text",
|
|
285
|
+
text: "Error: AskAntigravity cannot be used when the active provider is already antigravity - you're already running through it.",
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
details: emptyDetails(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const config = loadConfig();
|
|
293
|
+
const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
|
|
294
|
+
if (typeof params.model === "string" && params.model.trim().startsWith("-")) {
|
|
295
|
+
return {
|
|
296
|
+
content: [
|
|
297
|
+
{
|
|
298
|
+
type: "text",
|
|
299
|
+
text: `model value "${params.model}" starts with "-" - not a valid model id.`,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
details: emptyDetails(requestedModel),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const resolved =
|
|
306
|
+
resolveModel(requestedModel, entries, config.defaultThinking) ?? requestedModel;
|
|
307
|
+
|
|
308
|
+
const start = Date.now();
|
|
309
|
+
const cwd = params.cwd || ctx.cwd || process.cwd();
|
|
310
|
+
try {
|
|
311
|
+
const stat = fs.statSync(cwd);
|
|
312
|
+
if (!stat.isDirectory()) {
|
|
313
|
+
return {
|
|
314
|
+
content: [{ type: "text", text: `cwd is not a directory: ${cwd}` }],
|
|
315
|
+
details: emptyDetails(requestedModel, resolved),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
return {
|
|
320
|
+
content: [{ type: "text", text: `cwd does not exist: ${cwd}` }],
|
|
321
|
+
details: emptyDetails(requestedModel, resolved),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
|
|
326
|
+
|
|
327
|
+
const rawConvId = params.conversationId;
|
|
328
|
+
const isContinuation =
|
|
329
|
+
typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
|
|
330
|
+
const snapshot = isContinuation ? null : snapshotConversations();
|
|
331
|
+
|
|
332
|
+
const mode: AgyMode = (params.mode as AgyMode | undefined) ?? "accept-edits";
|
|
333
|
+
const useDigest: boolean =
|
|
334
|
+
typeof params.digest === "boolean" ? params.digest : mode === "plan";
|
|
335
|
+
const finalPrompt: string = useDigest
|
|
336
|
+
? `(Use compact digests, not full file contents.)\n${params.prompt}`
|
|
337
|
+
: params.prompt;
|
|
338
|
+
|
|
339
|
+
const args: string[] = ["--add-dir", cwd];
|
|
340
|
+
const extra = extraArgs();
|
|
341
|
+
if (extra.length) args.push(...extra);
|
|
342
|
+
if (resolved) args.push("--model", resolved);
|
|
343
|
+
args.push("--mode", mode);
|
|
344
|
+
// Honor the shared permissions setting (same knob as the provider). Non-
|
|
345
|
+
// interactive -p can't answer a permission prompt, so when this is off
|
|
346
|
+
// any run_command will hang - but the setting must mean what it says.
|
|
347
|
+
if (config.skipPermissions !== false) args.push("--dangerously-skip-permissions");
|
|
348
|
+
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
349
|
+
args.push("--print-timeout", `${timeoutMin}m`);
|
|
350
|
+
args.push("-p", finalPrompt);
|
|
351
|
+
|
|
352
|
+
const details: AgyDetails = {
|
|
353
|
+
model: requestedModel,
|
|
354
|
+
resolvedModel: resolved,
|
|
355
|
+
mode,
|
|
356
|
+
digest: useDigest,
|
|
357
|
+
conversationId: isContinuation ? (rawConvId as string) : null,
|
|
358
|
+
exitCode: 0,
|
|
359
|
+
aborted: false,
|
|
360
|
+
timedOut: false,
|
|
361
|
+
durationMs: 0,
|
|
362
|
+
stderr: "",
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const binary = process.env.AGY_BIN || "agy";
|
|
366
|
+
let out = "";
|
|
367
|
+
|
|
368
|
+
const statusInterval = onUpdate
|
|
369
|
+
? setInterval(() => {
|
|
370
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
371
|
+
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
372
|
+
const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
|
|
373
|
+
onUpdate({
|
|
374
|
+
content: [{ type: "text", text }],
|
|
375
|
+
details: { ...details, durationMs: Date.now() - start },
|
|
376
|
+
});
|
|
377
|
+
}, STATUS_INTERVAL_MS)
|
|
378
|
+
: null;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
// Bind the conversation id DURING the run (agy is alive then) so the
|
|
382
|
+
// pid-based /proc FD resolver can disambiguate when a concurrent agy
|
|
383
|
+
// also drops a new .db. Awaited after the run; the post-exit loop
|
|
384
|
+
// below is the fallback for runs that exit before the poll binds.
|
|
385
|
+
let bindDuringRun: Promise<void> = Promise.resolve();
|
|
386
|
+
const outcome = await new Promise<{
|
|
387
|
+
exitCode: number;
|
|
388
|
+
aborted: boolean;
|
|
389
|
+
timedOut: boolean;
|
|
390
|
+
}>((resolveP, rejectP) => {
|
|
391
|
+
const proc = spawn(binary, args, {
|
|
392
|
+
cwd,
|
|
393
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
394
|
+
shell: false,
|
|
395
|
+
detached: true,
|
|
396
|
+
});
|
|
397
|
+
proc.stdout?.setEncoding("utf8");
|
|
398
|
+
proc.stderr?.setEncoding("utf8");
|
|
399
|
+
proc.stdout?.on("data", (d: string) => (out += d));
|
|
400
|
+
proc.stderr?.on("data", (d: string) => (details.stderr += d));
|
|
401
|
+
|
|
402
|
+
// Concurrent bind: poll for the new id while agy is alive. The FD
|
|
403
|
+
// resolver needs a live process tree, so this stops (and the post-
|
|
404
|
+
// exit fallback below takes over) once agy has exited.
|
|
405
|
+
if (!isContinuation && snapshot && proc.pid) {
|
|
406
|
+
bindDuringRun = (async () => {
|
|
407
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
408
|
+
if (details.conversationId) return;
|
|
409
|
+
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
410
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
411
|
+
pid: proc.pid,
|
|
412
|
+
});
|
|
413
|
+
if (found) {
|
|
414
|
+
details.conversationId = found;
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
418
|
+
}
|
|
419
|
+
})().catch(() => {
|
|
420
|
+
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
425
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
426
|
+
let settled = false;
|
|
427
|
+
let timedOut = false;
|
|
428
|
+
|
|
429
|
+
const killTree = () => {
|
|
430
|
+
try {
|
|
431
|
+
if (proc.pid) process.kill(-proc.pid, "SIGTERM");
|
|
432
|
+
} catch {
|
|
433
|
+
/* process group already gone */
|
|
434
|
+
}
|
|
435
|
+
if (!sigkillTimer) {
|
|
436
|
+
sigkillTimer = setTimeout(() => {
|
|
437
|
+
try {
|
|
438
|
+
if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
439
|
+
} catch {
|
|
440
|
+
/* give up */
|
|
441
|
+
}
|
|
442
|
+
}, GRACE_AFTER_TIMEOUT_MS);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const cleanup = () => {
|
|
447
|
+
if (watchdog) clearTimeout(watchdog);
|
|
448
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
449
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
450
|
+
};
|
|
451
|
+
const onAbort = () => killTree();
|
|
452
|
+
|
|
453
|
+
watchdog = setTimeout(() => {
|
|
454
|
+
timedOut = true;
|
|
455
|
+
killTree();
|
|
456
|
+
}, timeoutMin * 60_000);
|
|
457
|
+
|
|
458
|
+
if (signal) {
|
|
459
|
+
if (signal.aborted) killTree();
|
|
460
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const finish = (code: number | null) => {
|
|
464
|
+
if (settled) return;
|
|
465
|
+
settled = true;
|
|
466
|
+
cleanup();
|
|
467
|
+
resolveP({
|
|
468
|
+
exitCode: code ?? 0,
|
|
469
|
+
aborted: !!signal?.aborted,
|
|
470
|
+
timedOut,
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
proc.on("error", (err) => {
|
|
475
|
+
cleanup();
|
|
476
|
+
rejectP(err);
|
|
477
|
+
});
|
|
478
|
+
proc.on("close", finish);
|
|
479
|
+
proc.on("exit", finish);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
483
|
+
|
|
484
|
+
// Let the during-run bind poll finish (it bails immediately once agy
|
|
485
|
+
// has exited, so this rarely blocks).
|
|
486
|
+
await bindDuringRun;
|
|
487
|
+
|
|
488
|
+
details.exitCode = outcome.exitCode;
|
|
489
|
+
details.aborted = outcome.aborted;
|
|
490
|
+
details.timedOut = outcome.timedOut;
|
|
491
|
+
details.durationMs = Date.now() - start;
|
|
492
|
+
|
|
493
|
+
if (!isContinuation && !details.conversationId && snapshot) {
|
|
494
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
495
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
496
|
+
if (found) {
|
|
497
|
+
details.conversationId = found;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const text = out.trim();
|
|
505
|
+
|
|
506
|
+
if (outcome.aborted) {
|
|
507
|
+
return {
|
|
508
|
+
content: [
|
|
509
|
+
{
|
|
510
|
+
type: "text",
|
|
511
|
+
text: text
|
|
512
|
+
? `agy was aborted. Partial output:\n\n${text}`
|
|
513
|
+
: "agy was aborted before producing output.",
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
details,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (outcome.timedOut) {
|
|
521
|
+
const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
|
|
522
|
+
return {
|
|
523
|
+
content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
|
|
524
|
+
details,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (outcome.exitCode !== 0) {
|
|
529
|
+
const note = details.stderr.trim()
|
|
530
|
+
? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
|
|
531
|
+
: `agy exited with status ${outcome.exitCode}`;
|
|
532
|
+
return {
|
|
533
|
+
content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
|
|
534
|
+
details,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
onUpdate?.({ content: [{ type: "text", text: "" }], details: { ...details } });
|
|
539
|
+
const footer = details.conversationId
|
|
540
|
+
? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
|
|
541
|
+
: "";
|
|
542
|
+
return { content: [{ type: "text", text: text + footer }], details };
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
545
|
+
details.durationMs = Date.now() - start;
|
|
546
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
547
|
+
return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
interface AgyDetails {
|
|
554
|
+
model: string | null;
|
|
555
|
+
resolvedModel: string | null;
|
|
556
|
+
mode: AgyMode;
|
|
557
|
+
digest: boolean;
|
|
558
|
+
conversationId: string | null;
|
|
559
|
+
exitCode: number;
|
|
560
|
+
aborted: boolean;
|
|
561
|
+
timedOut: boolean;
|
|
562
|
+
durationMs: number;
|
|
563
|
+
stderr: string;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function emptyDetails(model: string | null = null, resolvedModel: string | null = null): AgyDetails {
|
|
567
|
+
return {
|
|
568
|
+
model,
|
|
569
|
+
resolvedModel,
|
|
570
|
+
mode: "accept-edits",
|
|
571
|
+
digest: false,
|
|
572
|
+
conversationId: null,
|
|
573
|
+
exitCode: 0,
|
|
574
|
+
aborted: false,
|
|
575
|
+
timedOut: false,
|
|
576
|
+
durationMs: 0,
|
|
577
|
+
stderr: "",
|
|
578
|
+
};
|
|
579
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Runtime config for the antigravity provider. Persisted at
|
|
2
|
+
// ~/.pi/agent/antigravity-bridge/config.json so the /agy command can
|
|
3
|
+
// toggle settings that take effect on the next turn.
|
|
4
|
+
//
|
|
5
|
+
// Knobs today:
|
|
6
|
+
// mode "accept-edits" (default) or "plan". Drives agy's --mode.
|
|
7
|
+
// skipPermissions true (default). Passes --dangerously-skip-permissions so
|
|
8
|
+
// commands don't hang on an unanswerable prompt in -p mode.
|
|
9
|
+
//
|
|
10
|
+
// Env overrides (AGY_MODE, AGY_SKIP_PERMISSIONS) win over the file so tests
|
|
11
|
+
// and one-off runs can force a setting without editing the file.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
const CONFIG_PATH = path.join(
|
|
18
|
+
os.homedir(),
|
|
19
|
+
".pi",
|
|
20
|
+
"agent",
|
|
21
|
+
"antigravity-bridge",
|
|
22
|
+
"config.json",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export type AgyMode = "accept-edits" | "plan";
|
|
26
|
+
export type ThinkingTier = "low" | "medium" | "high";
|
|
27
|
+
|
|
28
|
+
export interface AgyConfig {
|
|
29
|
+
mode: AgyMode;
|
|
30
|
+
/** Auto-approve all agy tool permission requests (--dangerously-skip-permissions).
|
|
31
|
+
* Required for non-interactive use: without it, any `run_command` triggers an
|
|
32
|
+
* interactive y/n prompt that hangs forever in `-p` mode. Defaults true.
|
|
33
|
+
* DANGEROUS: lets agy run arbitrary commands (including destructive ones)
|
|
34
|
+
* without review. Turn off only if you also set mode=plan (no execution). */
|
|
35
|
+
skipPermissions: boolean;
|
|
36
|
+
/** AskAntigravity tool: default model alias (flash/pro/gemini or exact). */
|
|
37
|
+
defaultModel: string;
|
|
38
|
+
/** AskAntigravity tool: default thinking tier when the alias names none. */
|
|
39
|
+
defaultThinking: ThinkingTier;
|
|
40
|
+
/** User declined the pi.invokeTool auto-patch consent prompt. When true,
|
|
41
|
+
* session_start silently skips the patch + MCP tool bridge until cleared
|
|
42
|
+
* (by /agy patch apply succeeding, or the patch otherwise becoming live). */
|
|
43
|
+
invokeToolPatchDeclined?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULTS: AgyConfig = {
|
|
47
|
+
mode: "accept-edits",
|
|
48
|
+
skipPermissions: true,
|
|
49
|
+
defaultModel: "flash",
|
|
50
|
+
defaultThinking: "medium",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Load config merged over defaults. Env vars override the file when set. */
|
|
54
|
+
export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
55
|
+
let file: Partial<AgyConfig> = {};
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
60
|
+
file = parsed as Partial<AgyConfig>;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
/* missing or corrupt - fall back to defaults */
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Env overrides file (matches the skipPermissions pattern).
|
|
67
|
+
// The naive OR `env === "plan" || file.mode === "plan"` would ignore an
|
|
68
|
+
// explicit AGY_MODE=accept-edits when the file says plan, violating the
|
|
69
|
+
// documented precedence. Check env first.
|
|
70
|
+
const mode: AgyMode =
|
|
71
|
+
process.env.AGY_MODE !== undefined
|
|
72
|
+
? process.env.AGY_MODE === "plan"
|
|
73
|
+
? "plan"
|
|
74
|
+
: "accept-edits"
|
|
75
|
+
: file.mode === "plan"
|
|
76
|
+
? "plan"
|
|
77
|
+
: "accept-edits";
|
|
78
|
+
|
|
79
|
+
const envPerm = process.env.AGY_SKIP_PERMISSIONS;
|
|
80
|
+
const skipPermissions =
|
|
81
|
+
envPerm !== undefined
|
|
82
|
+
? envPerm === "1" || envPerm.toLowerCase() === "true"
|
|
83
|
+
: file.skipPermissions ?? DEFAULTS.skipPermissions;
|
|
84
|
+
|
|
85
|
+
const defaultModelRaw =
|
|
86
|
+
process.env.AGY_DEFAULT_MODEL ?? file.defaultModel ?? DEFAULTS.defaultModel;
|
|
87
|
+
const defaultModel =
|
|
88
|
+
typeof defaultModelRaw === "string" ? defaultModelRaw.trim() || DEFAULTS.defaultModel : DEFAULTS.defaultModel;
|
|
89
|
+
|
|
90
|
+
const envThink = process.env.AGY_DEFAULT_THINKING;
|
|
91
|
+
const thinkRaw = (envThink ?? file.defaultThinking ?? DEFAULTS.defaultThinking).toLowerCase();
|
|
92
|
+
const defaultThinking: ThinkingTier =
|
|
93
|
+
thinkRaw === "low" || thinkRaw === "high" ? thinkRaw : "medium";
|
|
94
|
+
|
|
95
|
+
return { mode, skipPermissions, defaultModel, defaultThinking, invokeToolPatchDeclined: file.invokeToolPatchDeclined };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Atomically persist a config patch (temp + rename). */
|
|
99
|
+
export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFIG_PATH): AgyConfig {
|
|
100
|
+
const current = loadConfig(configPath);
|
|
101
|
+
const next: AgyConfig = { ...current, ...patch };
|
|
102
|
+
const dir = path.dirname(configPath);
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
const tmp = `${configPath}.${process.pid}.tmp`;
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
107
|
+
fs.renameSync(tmp, configPath);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(tmp);
|
|
111
|
+
} catch {
|
|
112
|
+
/* nothing to clean */
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { CONFIG_PATH };
|