@estebanforge/pi-antigravity-bridge 1.1.2 → 1.2.4

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,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.4] - 2026-08-13
6
+
7
+ ### Added
8
+
9
+ - **Model, thinking tier, and mode shown next to the tool name.** The
10
+ `AskAntigravity` tool now renders
11
+ `AskAntigravity [model=gemini-3.6-flash, thinking=high]` with a prompt
12
+ preview while a delegation runs, plus a tidy result row
13
+ (`✓ AskAntigravity 12.3s`) with an expandable body. Built on pi's
14
+ `renderCall`/`renderResult` hooks; the values shown are the resolved
15
+ config defaults (model alias + tier), not just the args the caller passed.
16
+ `AgyDetails` gained a `thinking` field.
17
+ - **Opt-in full-context delegation (`includeContext`).** New boolean param
18
+ (default `false`, isolated one-shot unchanged). When `true`, the current pi
19
+ conversation is exported as resolved markdown to
20
+ `~/.pi/extensions-data/estebanforge/pi-antigravity-bridge/` and the prompt
21
+ tells agy to read it first. The run passes `--add-dir` for that folder so
22
+ the sandbox can read it; the temp file is removed after the run.
23
+
24
+ ## [1.2.3] - 2026-08-10
25
+
26
+ ### Fixed
27
+
28
+ - **Model parsing now handles agy's real two-column output.** `agy models`
29
+ prints `<slug> <display label>` per line, and `--model` accepts only the
30
+ slug. `entriesFromRaw` (provider path) applied its slug regex to the whole
31
+ line, so every real line was rejected and the provider always fell back to
32
+ the hardcoded catalog; it now splits column 1 and requires a hyphen, which
33
+ also drops banner words split out of column 1. The AskAntigravity resolver
34
+ swallowed slug + label into `--model`, which agy rejected; it now returns a
35
+ `{model, effort?}` shape that sends Gemini-family bases' base slug to
36
+ `--model` and their tier to `--effort`, while fixed-thinking families
37
+ (Claude, GPT-OSS) keep the exact slug with no `--effort` (agy rejects it for
38
+ them). Matches the provider's collapse + clamping path. Tests rewritten to
39
+ the verified live `agy models` fixture.
40
+
5
41
  ## [1.1.2] - 2026-08-10
6
42
 
7
43
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.1.2",
3
+ "version": "1.2.4",
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
@@ -15,6 +15,9 @@ import { spawn } from "node:child_process";
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { buildSessionContext, getAgentDir, keyHint } from "@earendil-works/pi-coding-agent";
19
+ import { Text } from "@earendil-works/pi-tui";
20
+ import { contentText } from "@earendil-works/pi-ai";
18
21
  import { Type } from "typebox";
19
22
  import {
20
23
  CONVERSATIONS_DIR,
@@ -30,6 +33,10 @@ const DEFAULT_TIMEOUT_MIN = 10;
30
33
  const GRACE_AFTER_TIMEOUT_MS = 5000;
31
34
  const STATUS_INTERVAL_MS = 1000;
32
35
  const STATUS_TAIL_CHARS = 160;
36
+
37
+ // renderCall / renderResult preview limits (match pi-claude-bridge).
38
+ const PREVIEW_MAX_CHARS = 1000;
39
+ const PREVIEW_MAX_LINES = 6;
33
40
  const DISCOVERY_POLL_ATTEMPTS = 5;
34
41
  const DISCOVERY_POLL_MS = 100;
35
42
 
@@ -84,6 +91,15 @@ interface ModelEntry {
84
91
  tier: ThinkingTier | null;
85
92
  }
86
93
 
94
+ /** Argv-facing model resolution: the exact --model slug plus an optional
95
+ * --effort tier. Gemini bases split the tier out (the base slug alone is
96
+ * invalid without --effort); fixed-thinking families keep agy's exact slug
97
+ * and carry no effort. */
98
+ interface ResolvedModel {
99
+ model: string;
100
+ effort?: ThinkingTier;
101
+ }
102
+
87
103
  // --- Version helpers -------------------------------------------------------
88
104
 
89
105
  /** Descending numeric version compare (3.10 > 3.9, not lexical). */
@@ -110,7 +126,10 @@ function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
110
126
  }
111
127
 
112
128
  function parseModelLine(line: string): ModelEntry | null {
113
- const full = line.trim();
129
+ // agy prints TWO columns: "<slug> <display label>". --model takes only the
130
+ // slug (col 1), so split it off; the label is display-only and must never
131
+ // reach --model. A bare-slug line (no whitespace) splits to itself.
132
+ const full = line.trim().split(/\s+/)[0] ?? "";
114
133
  if (!full) return null;
115
134
  const lower = full.toLowerCase();
116
135
  const family: Family = lower.includes("flash")
@@ -135,21 +154,36 @@ function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): Thinki
135
154
  return sorted[0] ?? preferred;
136
155
  }
137
156
 
138
- /** Resolve a friendly alias / partial name to an exact agy model string. */
157
+ /** Build the argv-facing resolution from a picked catalog entry. Gemini bases
158
+ * (slugs starting "gemini-") accept a separate --effort, so split the tier
159
+ * suffix out of the slug: the base alone (gemini-3.6-flash) is what --model
160
+ * wants, and the tier goes to --effort. Fixed-thinking families keep agy's
161
+ * exact slug even when it carries a -medium suffix (gpt-oss-120b-medium):
162
+ * agy rejects --effort for them, so the suffix stays part of the slug. */
163
+ function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
164
+ if (tier && full.toLowerCase().startsWith("gemini-")) {
165
+ return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
166
+ }
167
+ return { model: full };
168
+ }
169
+
170
+ /** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
171
+ * Returns null only when the family is unrecognized; the caller then passes
172
+ * the raw input straight to agy. */
139
173
  export function resolveModel(
140
174
  input: string,
141
175
  entries: ModelEntry[],
142
176
  defaultThinking: ThinkingTier,
143
- ): string | null {
177
+ ): ResolvedModel | null {
144
178
  const lower = input.toLowerCase().trim();
145
179
 
146
180
  const exact = entries.find((e) => e.full.toLowerCase() === lower);
147
- if (exact) return exact.full;
181
+ if (exact) return toResolved(exact.full, exact.tier);
148
182
 
149
183
  if (STATIC_SHORT_ALIAS.has(lower)) {
150
184
  const target = STATIC_SHORT_ALIAS.get(lower) as string;
151
185
  const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
152
- return fromCatalog ? fromCatalog.full : target;
186
+ return toResolved(fromCatalog?.full ?? target, fromCatalog?.tier ?? null);
153
187
  }
154
188
 
155
189
  let family: Family | null = lower.includes("flash")
@@ -193,13 +227,14 @@ export function resolveModel(
193
227
  const familyTiers = new Set(
194
228
  candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
195
229
  );
196
- if (familyTiers.size === 0) return candidates[0].full;
230
+ if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
197
231
 
198
232
  const preferred =
199
233
  tier ??
200
234
  (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
201
235
  const chosenTier = nearestTier([...familyTiers], preferred);
202
- return (candidates.find((e) => e.tier === chosenTier) ?? candidates[0]).full;
236
+ const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
237
+ return toResolved(picked.full, picked.tier);
203
238
  }
204
239
 
205
240
  /** Parse raw `agy models` text into tool-catalog entries (all families, plus
@@ -274,7 +309,78 @@ export async function registerAskAntigravityTool(
274
309
  timeoutMinutes: Type.Optional(
275
310
  Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.` }),
276
311
  ),
312
+ includeContext: Type.Optional(
313
+ Type.Boolean({
314
+ description:
315
+ "When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
316
+ }),
317
+ ),
277
318
  }),
319
+ renderCall(args, theme, _context) {
320
+ // Show RESOLVED model/thinking/mode (config defaults applied) so the
321
+ // row identifies what will actually run, not just explicit args.
322
+ const cfg = loadConfig();
323
+ const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
324
+ const resolved =
325
+ resolveModel(requestedModel, entries, cfg.defaultThinking) ?? { model: requestedModel };
326
+ const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
327
+ const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
328
+ const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
329
+ const isContinue =
330
+ typeof args.conversationId === "string" && CONV_ID_RE.test(args.conversationId);
331
+
332
+ const tags: string[] = [`model=${resolved.model}`, `thinking=${thinking}`];
333
+ if (mode !== "accept-edits") tags.push(`mode=${mode}`);
334
+ if (useDigest) tags.push("digest");
335
+ if (isContinue) tags.push("continue");
336
+ if (args.includeContext) tags.push("context=full");
337
+
338
+ let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
339
+ text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
340
+
341
+ const prompt = String(args.prompt ?? "");
342
+ const truncated = prompt.length > PREVIEW_MAX_CHARS ? prompt.slice(0, PREVIEW_MAX_CHARS) : prompt;
343
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
344
+ text += theme.fg("muted", `"${lines.join("\n")}"`);
345
+ if (prompt.length > PREVIEW_MAX_CHARS || prompt.split("\n").length > PREVIEW_MAX_LINES) {
346
+ text += theme.fg("dim", " …");
347
+ }
348
+ return new Text(text, 0, 0);
349
+ },
350
+ renderResult(result, { expanded, isPartial }, theme) {
351
+ const d = result.details as AgyDetails | undefined;
352
+ if (isPartial) {
353
+ const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
354
+ return new Text(theme.fg("mdLink", "◉ AskAntigravity ") + theme.fg("muted", status), 0, 0);
355
+ }
356
+
357
+ const body = result.content[0]?.type === "text" ? result.content[0].text : "";
358
+ const errored = d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut;
359
+
360
+ let text = errored
361
+ ? theme.fg("error", "✗ AskAntigravity error")
362
+ : theme.fg("mdLink", "✓ AskAntigravity");
363
+
364
+ const rTags: string[] = [];
365
+ if (d?.resolvedModel || d?.model) rTags.push(`model=${d?.resolvedModel ?? d?.model}`);
366
+ if (d?.thinking) rTags.push(`thinking=${d.thinking}`);
367
+ if (d?.mode && d.mode !== "accept-edits") rTags.push(`mode=${d.mode}`);
368
+ if (d?.includeContext) rTags.push("context=full");
369
+ if (rTags.length) text += ` ${theme.fg("accent", `[${rTags.join(", ")}]`)}`;
370
+ if (d?.durationMs) text += ` ${theme.fg("dim", `${(d.durationMs / 1000).toFixed(1)}s`)}`;
371
+
372
+ if (expanded) {
373
+ if (body) text += `\n${theme.fg("toolOutput", body)}`;
374
+ } else {
375
+ const truncated = body.length > PREVIEW_MAX_CHARS ? body.slice(0, PREVIEW_MAX_CHARS) : body;
376
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
377
+ if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
378
+ if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) {
379
+ text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
380
+ }
381
+ }
382
+ return new Text(text, 0, 0);
383
+ },
278
384
  async execute(toolCallId, params, signal, onUpdate, ctx) {
279
385
  // Circular-delegation guard: refuse if already running through the
280
386
  // antigravity provider.
@@ -304,7 +410,9 @@ export async function registerAskAntigravityTool(
304
410
  };
305
411
  }
306
412
  const resolved =
307
- resolveModel(requestedModel, entries, config.defaultThinking) ?? requestedModel;
413
+ resolveModel(requestedModel, entries, config.defaultThinking) ?? {
414
+ model: requestedModel,
415
+ };
308
416
 
309
417
  const start = Date.now();
310
418
  const cwd = params.cwd || ctx.cwd || process.cwd();
@@ -313,13 +421,13 @@ export async function registerAskAntigravityTool(
313
421
  if (!stat.isDirectory()) {
314
422
  return {
315
423
  content: [{ type: "text", text: `cwd is not a directory: ${cwd}` }],
316
- details: emptyDetails(requestedModel, resolved),
424
+ details: emptyDetails(requestedModel, resolved.model),
317
425
  };
318
426
  }
319
427
  } catch {
320
428
  return {
321
429
  content: [{ type: "text", text: `cwd does not exist: ${cwd}` }],
322
- details: emptyDetails(requestedModel, resolved),
430
+ details: emptyDetails(requestedModel, resolved.model),
323
431
  };
324
432
  }
325
433
 
@@ -337,10 +445,34 @@ export async function registerAskAntigravityTool(
337
445
  ? `(Use compact digests, not full file contents.)\n${params.prompt}`
338
446
  : params.prompt;
339
447
 
448
+ // Opt-in full-context export (isolated stays the default).
449
+ let contextFile: string | null = null;
450
+ if (params.includeContext) {
451
+ try {
452
+ const { messages } = buildSessionContext(ctx.sessionManager.getBranch());
453
+ if (messages.length) {
454
+ const md = renderAgentMessagesMarkdown(messages);
455
+ const ctxDir = askContextDir();
456
+ fs.mkdirSync(ctxDir, { recursive: true });
457
+ contextFile = path.join(
458
+ ctxDir,
459
+ `.ask-context-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.md`,
460
+ );
461
+ fs.writeFileSync(contextFile, md, { mode: 0o600 });
462
+ }
463
+ } catch {
464
+ contextFile = null;
465
+ }
466
+ }
467
+ const effectivePrompt = contextFile
468
+ ? `The full pi conversation context (as markdown) is at: ${contextFile}\nRead that file first for context, then do the task below.\n\n---\n\n${finalPrompt}`
469
+ : finalPrompt;
470
+
340
471
  const args: string[] = ["--add-dir", cwd];
341
472
  const extra = extraArgs();
342
473
  if (extra.length) args.push(...extra);
343
- if (resolved) args.push("--model", resolved);
474
+ if (resolved.model) args.push("--model", resolved.model);
475
+ if (resolved.effort) args.push("--effort", resolved.effort);
344
476
  args.push("--mode", mode);
345
477
  // Honor the shared permissions setting (same knob as the provider). Non-
346
478
  // interactive -p can't answer a permission prompt, so when this is off
@@ -348,14 +480,17 @@ export async function registerAskAntigravityTool(
348
480
  if (config.skipPermissions !== false) args.push("--dangerously-skip-permissions");
349
481
  if (isContinuation) args.push("--conversation", rawConvId as string);
350
482
  args.push("--print-timeout", `${timeoutMin}m`);
351
- args.push("-p", finalPrompt);
483
+ if (contextFile) args.push("--add-dir", askContextDir());
484
+ args.push("-p", effectivePrompt);
352
485
 
353
486
  const details: AgyDetails = {
354
487
  model: requestedModel,
355
- resolvedModel: resolved,
488
+ resolvedModel: resolved.model,
489
+ thinking: resolved.effort ?? config.defaultThinking,
356
490
  mode,
357
491
  digest: useDigest,
358
492
  conversationId: isContinuation ? (rawConvId as string) : null,
493
+ includeContext: contextFile !== null,
359
494
  exitCode: 0,
360
495
  aborted: false,
361
496
  timedOut: false,
@@ -547,16 +682,91 @@ export async function registerAskAntigravityTool(
547
682
  const msg = err instanceof Error ? err.message : String(err);
548
683
  return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
549
684
  }
685
+ finally {
686
+ if (contextFile) {
687
+ try {
688
+ fs.unlinkSync(contextFile);
689
+ } catch {}
690
+ }
691
+ }
550
692
  },
551
693
  });
552
694
  }
553
695
 
696
+ // --- Full-context export (opt-in includeContext) --------------------------
697
+ // NOTE: duplicated per pi-ask-* / bridge package (each is self-contained).
698
+ // Duck-typed over role/content to tolerate AgentMessage's union + custom types.
699
+
700
+ // Tool-call inputs and tool-result bodies are clamped so the exported
701
+ // transcript stays reviewable; the agent can re-read any source file by path.
702
+ // User/assistant prose is kept in full (that IS the conversation).
703
+ const CONTEXT_BLOCK_MAX_CHARS = 2000;
704
+
705
+ function clampBlock(text: unknown, limit = CONTEXT_BLOCK_MAX_CHARS): string {
706
+ const t = String(text ?? "");
707
+ return t.length > limit ? `${t.slice(0, limit)}\n…[truncated, ${t.length - limit} more chars]` : t;
708
+ }
709
+
710
+ /** Render resolved pi AgentMessages to a readable markdown transcript.
711
+ * Pure: no IO. Caller writes the returned string to a temp file. */
712
+ function renderAgentMessagesMarkdown(messages: readonly unknown[]): string {
713
+ const lines: string[] = [
714
+ "# Pi conversation context",
715
+ "",
716
+ `_Exported for full-context delegation. ${messages.length} message(s)._`,
717
+ "",
718
+ ];
719
+ for (const raw of messages) {
720
+ const m = raw as { role?: string; content?: unknown };
721
+ const role = m.role ?? "message";
722
+ const content = m.content;
723
+ if (role === "assistant") {
724
+ const blocks = (Array.isArray(content) ? content : []) as ReadonlyArray<{
725
+ type: string;
726
+ text?: string;
727
+ name?: string;
728
+ input?: unknown;
729
+ }>;
730
+ const text = blocks
731
+ .filter((b) => b.type === "text")
732
+ .map((b) => b.text ?? "")
733
+ .join("\n");
734
+ if (text.trim()) lines.push("## Assistant", "", text, "");
735
+ for (const b of blocks) {
736
+ if (b.type === "toolCall" || b.type === "tool_use") {
737
+ const input = clampBlock(
738
+ typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? ""),
739
+ 500,
740
+ );
741
+ lines.push(`> tool call: ${b.name ?? "(unknown)"}(${input})`, "");
742
+ }
743
+ }
744
+ } else if (role === "toolResult" || role === "tool_result" || role === "tool") {
745
+ const text = contentText(content as any);
746
+ if (text.trim()) lines.push("## Tool result", "", clampBlock(text), "");
747
+ } else {
748
+ const text = contentText(content as any);
749
+ if (text.trim()) lines.push(`## ${role}`, "", clampBlock(text), "");
750
+ }
751
+ }
752
+ return lines.join("\n");
753
+ }
754
+
755
+ /** Centralized scratch dir for full-context exports, following the
756
+ * ~/.pi/extensions-data/<author>/<extension>/ convention (see pi-token-cost-ledger).
757
+ * Derived from getAgentDir() so rebranded distros resolve correctly. */
758
+ function askContextDir(): string {
759
+ return path.join(path.dirname(getAgentDir()), "extensions-data", "estebanforge", "pi-antigravity-bridge");
760
+ }
761
+
554
762
  interface AgyDetails {
555
763
  model: string | null;
556
764
  resolvedModel: string | null;
765
+ thinking: ThinkingTier | null;
557
766
  mode: AgyMode;
558
767
  digest: boolean;
559
768
  conversationId: string | null;
769
+ includeContext: boolean;
560
770
  exitCode: number;
561
771
  aborted: boolean;
562
772
  timedOut: boolean;
@@ -564,12 +774,19 @@ interface AgyDetails {
564
774
  stderr: string;
565
775
  }
566
776
 
567
- function emptyDetails(model: string | null = null, resolvedModel: string | null = null): AgyDetails {
777
+ function emptyDetails(
778
+ model: string | null = null,
779
+ resolvedModel: string | null = null,
780
+ thinking: ThinkingTier | null = null,
781
+ includeContext: boolean = false,
782
+ ): AgyDetails {
568
783
  return {
569
784
  model,
570
785
  resolvedModel,
786
+ thinking,
571
787
  mode: "accept-edits",
572
788
  digest: false,
789
+ includeContext,
573
790
  conversationId: null,
574
791
  exitCode: 0,
575
792
  aborted: false,
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[] = [];