@estebanforge/pi-antigravity-bridge 1.1.1 → 1.2.3

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 CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.3] - 2026-08-10
6
+
7
+ ### Fixed
8
+
9
+ - **Model parsing now handles agy's real two-column output.** `agy models`
10
+ prints `<slug> <display label>` per line, and `--model` accepts only the
11
+ slug. `entriesFromRaw` (provider path) applied its slug regex to the whole
12
+ line, so every real line was rejected and the provider always fell back to
13
+ the hardcoded catalog; it now splits column 1 and requires a hyphen, which
14
+ also drops banner words split out of column 1. The AskAntigravity resolver
15
+ swallowed slug + label into `--model`, which agy rejected; it now returns a
16
+ `{model, effort?}` shape that sends Gemini-family bases' base slug to
17
+ `--model` and their tier to `--effort`, while fixed-thinking families
18
+ (Claude, GPT-OSS) keep the exact slug with no `--effort` (agy rejects it for
19
+ them). Matches the provider's collapse + clamping path. Tests rewritten to
20
+ the verified live `agy models` fixture.
21
+
22
+ ## [1.1.2] - 2026-08-10
23
+
24
+ ### Fixed
25
+
26
+ - **MCP bridge startup messages no longer pin above the input.** pi's TUI
27
+ captures extension stderr and pins it above the editor for the whole session,
28
+ which left the `[antigravity-bridge mcp] bridge-config-written` and
29
+ `listening` lines stuck on screen. The lifecycle logger now routes through
30
+ `ctx.ui.notify` (an ephemeral toast that fades); headless print/json modes
31
+ fall back to stderr. Error and diagnostic events use the same channel, so
32
+ they no longer pin either.
33
+
5
34
  ## [1.1.1] - 2026-08-06
6
35
 
7
36
  ### Changed
@@ -106,24 +106,31 @@ export default async function (pi: ExtensionAPI): Promise<void> {
106
106
  // unchanged (other instances do not break). Started on session_start so it is
107
107
  // live before the provider's first turn; torn down on session_shutdown.
108
108
  let mcpHandle: McpServerHandle | null = null;
109
- const mcpLog = (s: string, d?: unknown) => {
110
- // Quiet by default: surface only lifecycle/error events to stderr.
111
- // Lifecycle + error events only. Per-turn success events (list-tools /
112
- // call-tool) are deliberately NOT surfaced: writing to stderr during an
113
- // active turn corrupts the pi TUI / construct daemon rendering (spinner
114
- // gets stuck, hint text leaks into the display). Use a status-bar API for
115
- // in-turn visibility instead.
116
- const surfaced = new Set([
117
- "listening", "capability-missing", "http-error", "closed",
118
- "bridge-config-written", "bridge-config-removed", "bridge-config-write-failed",
119
- "call-tool-fail", "transport-error", "handleRequest-error",
120
- "request-error", "request-handler-error", "unauthorized", "self-patch-error",
121
- ]);
122
- if (surfaced.has(s)) {
123
- console.error(`[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`);
124
- }
125
- };
126
109
  pi.on("session_start", async (_event, ctx) => {
110
+ // Bridge lifecycle/error logger. Routes through ctx.ui.notify (an
111
+ // ephemeral toast that fades) instead of stderr: pi's TUI captures stderr
112
+ // and pins it above the input for the whole session, which left the
113
+ // startup "bridge-config-written" / "listening" lines stuck on screen all
114
+ // session. Headless modes (print/json, hasUI === false) have no toast, so
115
+ // fall back to stderr there. Per-turn success events (list-tools /
116
+ // call-tool) stay silent either way.
117
+ const mcpLog = (s: string, d?: unknown) => {
118
+ const surfaced = new Set([
119
+ "listening", "capability-missing", "http-error", "closed",
120
+ "bridge-config-written", "bridge-config-removed", "bridge-config-write-failed",
121
+ "call-tool-fail", "transport-error", "handleRequest-error",
122
+ "request-error", "request-handler-error", "unauthorized", "self-patch-error",
123
+ ]);
124
+ if (!surfaced.has(s)) return;
125
+ const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
126
+ if (ctx.hasUI) {
127
+ const ok = s === "listening" || s === "bridge-config-written"
128
+ || s === "bridge-config-removed" || s === "closed";
129
+ ctx.ui.notify(msg, ok ? "info" : "warning");
130
+ } else {
131
+ console.error(msg);
132
+ }
133
+ };
127
134
  // Decide the pi.invokeTool patch + MCP tool bridge path. The patch is only
128
135
  // needed for the bridge; the provider and AskAntigravity tool always work.
129
136
  // The bridge can only start when the patch is LIVE in this process; a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.1.1",
3
+ "version": "1.2.3",
4
4
  "description": "Streaming Gemini provider for pi, built on the agy CLI. Registers antigravity/* models in pi's /model picker via SQLite polling + protobuf decode of agy's conversation DBs.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask-tool.ts CHANGED
@@ -84,6 +84,15 @@ interface ModelEntry {
84
84
  tier: ThinkingTier | null;
85
85
  }
86
86
 
87
+ /** Argv-facing model resolution: the exact --model slug plus an optional
88
+ * --effort tier. Gemini bases split the tier out (the base slug alone is
89
+ * invalid without --effort); fixed-thinking families keep agy's exact slug
90
+ * and carry no effort. */
91
+ interface ResolvedModel {
92
+ model: string;
93
+ effort?: ThinkingTier;
94
+ }
95
+
87
96
  // --- Version helpers -------------------------------------------------------
88
97
 
89
98
  /** Descending numeric version compare (3.10 > 3.9, not lexical). */
@@ -110,7 +119,10 @@ function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
110
119
  }
111
120
 
112
121
  function parseModelLine(line: string): ModelEntry | null {
113
- const full = line.trim();
122
+ // agy prints TWO columns: "<slug> <display label>". --model takes only the
123
+ // slug (col 1), so split it off; the label is display-only and must never
124
+ // reach --model. A bare-slug line (no whitespace) splits to itself.
125
+ const full = line.trim().split(/\s+/)[0] ?? "";
114
126
  if (!full) return null;
115
127
  const lower = full.toLowerCase();
116
128
  const family: Family = lower.includes("flash")
@@ -135,21 +147,36 @@ function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): Thinki
135
147
  return sorted[0] ?? preferred;
136
148
  }
137
149
 
138
- /** Resolve a friendly alias / partial name to an exact agy model string. */
150
+ /** Build the argv-facing resolution from a picked catalog entry. Gemini bases
151
+ * (slugs starting "gemini-") accept a separate --effort, so split the tier
152
+ * suffix out of the slug: the base alone (gemini-3.6-flash) is what --model
153
+ * wants, and the tier goes to --effort. Fixed-thinking families keep agy's
154
+ * exact slug even when it carries a -medium suffix (gpt-oss-120b-medium):
155
+ * agy rejects --effort for them, so the suffix stays part of the slug. */
156
+ function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
157
+ if (tier && full.toLowerCase().startsWith("gemini-")) {
158
+ return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
159
+ }
160
+ return { model: full };
161
+ }
162
+
163
+ /** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
164
+ * Returns null only when the family is unrecognized; the caller then passes
165
+ * the raw input straight to agy. */
139
166
  export function resolveModel(
140
167
  input: string,
141
168
  entries: ModelEntry[],
142
169
  defaultThinking: ThinkingTier,
143
- ): string | null {
170
+ ): ResolvedModel | null {
144
171
  const lower = input.toLowerCase().trim();
145
172
 
146
173
  const exact = entries.find((e) => e.full.toLowerCase() === lower);
147
- if (exact) return exact.full;
174
+ if (exact) return toResolved(exact.full, exact.tier);
148
175
 
149
176
  if (STATIC_SHORT_ALIAS.has(lower)) {
150
177
  const target = STATIC_SHORT_ALIAS.get(lower) as string;
151
178
  const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
152
- return fromCatalog ? fromCatalog.full : target;
179
+ return toResolved(fromCatalog?.full ?? target, fromCatalog?.tier ?? null);
153
180
  }
154
181
 
155
182
  let family: Family | null = lower.includes("flash")
@@ -193,13 +220,14 @@ export function resolveModel(
193
220
  const familyTiers = new Set(
194
221
  candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
195
222
  );
196
- if (familyTiers.size === 0) return candidates[0].full;
223
+ if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
197
224
 
198
225
  const preferred =
199
226
  tier ??
200
227
  (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
201
228
  const chosenTier = nearestTier([...familyTiers], preferred);
202
- return (candidates.find((e) => e.tier === chosenTier) ?? candidates[0]).full;
229
+ const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
230
+ return toResolved(picked.full, picked.tier);
203
231
  }
204
232
 
205
233
  /** Parse raw `agy models` text into tool-catalog entries (all families, plus
@@ -304,7 +332,9 @@ export async function registerAskAntigravityTool(
304
332
  };
305
333
  }
306
334
  const resolved =
307
- resolveModel(requestedModel, entries, config.defaultThinking) ?? requestedModel;
335
+ resolveModel(requestedModel, entries, config.defaultThinking) ?? {
336
+ model: requestedModel,
337
+ };
308
338
 
309
339
  const start = Date.now();
310
340
  const cwd = params.cwd || ctx.cwd || process.cwd();
@@ -313,13 +343,13 @@ export async function registerAskAntigravityTool(
313
343
  if (!stat.isDirectory()) {
314
344
  return {
315
345
  content: [{ type: "text", text: `cwd is not a directory: ${cwd}` }],
316
- details: emptyDetails(requestedModel, resolved),
346
+ details: emptyDetails(requestedModel, resolved.model),
317
347
  };
318
348
  }
319
349
  } catch {
320
350
  return {
321
351
  content: [{ type: "text", text: `cwd does not exist: ${cwd}` }],
322
- details: emptyDetails(requestedModel, resolved),
352
+ details: emptyDetails(requestedModel, resolved.model),
323
353
  };
324
354
  }
325
355
 
@@ -340,7 +370,8 @@ export async function registerAskAntigravityTool(
340
370
  const args: string[] = ["--add-dir", cwd];
341
371
  const extra = extraArgs();
342
372
  if (extra.length) args.push(...extra);
343
- if (resolved) args.push("--model", resolved);
373
+ if (resolved.model) args.push("--model", resolved.model);
374
+ if (resolved.effort) args.push("--effort", resolved.effort);
344
375
  args.push("--mode", mode);
345
376
  // Honor the shared permissions setting (same knob as the provider). Non-
346
377
  // interactive -p can't answer a permission prompt, so when this is off
@@ -352,7 +383,7 @@ export async function registerAskAntigravityTool(
352
383
 
353
384
  const details: AgyDetails = {
354
385
  model: requestedModel,
355
- resolvedModel: resolved,
386
+ resolvedModel: resolved.model,
356
387
  mode,
357
388
  digest: useDigest,
358
389
  conversationId: isContinuation ? (rawConvId as string) : null,
package/src/models.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  // Discover models from `agy models` and project them into pi's Model shape so
2
2
  // they appear in the /model picker as antigravity/<slug>.
3
3
  //
4
- // agy lists effort-qualified slugs one per line, e.g.:
5
- // gemini-3.6-flash-high / -medium / -low
6
- // gemini-3.1-pro-high / -low (Pro has NO medium variant)
7
- // claude-sonnet-4-6 (fixed thinking, no effort tiers)
8
- // gpt-oss-120b-medium (fixed, no effort tiers)
4
+ // agy prints TWO columns per line: "<slug> <display label>". --model takes
5
+ // ONLY the slug (col 1); the label is display-only. Verified live, e.g.:
6
+ // gemini-3.6-flash-high Gemini 3.6 Flash (High)
7
+ // gemini-3.6-flash-medium Gemini 3.6 Flash (Medium) (+ -low)
8
+ // gemini-3.1-pro-high Gemini 3.1 Pro (High) (Pro has NO medium)
9
+ // claude-sonnet-4-6 Claude Sonnet 4.6 (Thinking) (fixed, no tiers)
10
+ // gpt-oss-120b-medium GPT-OSS 120B (Medium) (fixed, no tiers)
9
11
  //
10
12
  // Gemini models are collapsed to a BASE slug (gemini-3.6-flash) and exposed
11
13
  // with a thinking-effort toggle whose levels match exactly the tiers agy
@@ -34,11 +36,13 @@ const EFFORT_RANK: Record<AgyEffort, number> = { low: 0, medium: 1, high: 2 };
34
36
  * IS its suffix here, claude-opus-4-6-thinking is not a tier). */
35
37
  const TIER_RE = /^(.+)-(high|medium|low)$/;
36
38
 
37
- /** agy emits clean slug ids (gemini-3.6-flash-high). Reject anything else so a
38
- * banner / auth / "Fetching models…" line can't register as a model, and a
39
- * leading-dash token (e.g. "-high") can't reach agy's flag parser as
40
- * --model. First char must be alphanumeric (no leading dash, no dot). */
41
- const MODEL_LINE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
39
+ /** agy emits clean slug ids (gemini-3.6-flash-high). Validate col1 of each
40
+ * line so a banner / auth / "Fetching models…" line can't register as a
41
+ * model, and a leading-dash token (e.g. "-high") can't reach agy's flag
42
+ * parser as --model. First char must be alphanumeric; the slug must contain
43
+ * at least one hyphen (every real agy slug does: family-version-name), which
44
+ * also drops a prose banner word split out of col1 ("Available"). */
45
+ const MODEL_LINE_RE = /^[A-Za-z0-9][A-Za-z0-9._]*-[A-Za-z0-9._-]*$/;
42
46
 
43
47
  /** Model families VERIFIED to accept base-slug + --effort. Only these collapse
44
48
  * to a base slug with a thinking toggle. Any other family stays as agy's exact
@@ -210,22 +214,23 @@ export async function loadModelCatalogRaw(
210
214
  * medium) or none (claude-sonnet-4-6) means fixed thinking, where --effort is
211
215
  * unsupported. Insertion order of first-seen bases is preserved. */
212
216
  export function entriesFromRaw(raw: string): AgyModelEntry[] {
213
- const lines = raw
214
- .split("\n")
215
- .map((line) => line.trim())
216
- .filter((line) => line.length > 0)
217
- .filter((line) => MODEL_LINE_RE.test(line));
217
+ // agy prints TWO columns: "<slug> <display label>". --model takes only the
218
+ // slug, so split col1 and validate THAT; the label is display-only. A
219
+ // bare-slug line (no whitespace) splits to itself, so this also tolerates
220
+ // the legacy one-column shape.
218
221
  const groups = new Map<string, { lines: string[]; tiers: Set<AgyEffort> }>();
219
- for (const line of lines) {
220
- const m = TIER_RE.exec(line);
221
- const base = m ? (m[1] as string) : line;
222
+ for (const line of raw.split("\n")) {
223
+ const slug = line.trim().split(/\s+/)[0] ?? "";
224
+ if (!slug || !MODEL_LINE_RE.test(slug)) continue;
225
+ const m = TIER_RE.exec(slug);
226
+ const base = m ? (m[1] as string) : slug;
222
227
  const tier = m ? (m[2] as AgyEffort) : null;
223
228
  let g = groups.get(base);
224
229
  if (!g) {
225
230
  g = { lines: [], tiers: new Set<AgyEffort>() };
226
231
  groups.set(base, g);
227
232
  }
228
- g.lines.push(line);
233
+ g.lines.push(slug);
229
234
  if (tier) g.tiers.add(tier);
230
235
  }
231
236
  const entries: AgyModelEntry[] = [];