@estebanforge/pi-antigravity-bridge 1.0.0 → 1.1.1

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,52 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.1.1] - 2026-08-06
6
+
7
+ ### Changed
8
+ - **Dependencies updated.** Raised the `pi-coding-agent`, `pi-ai`, `pi-tui` dev pins to `^0.84.0`. Audited against the pi v0.84.0 breaking changes; the custom `streamSimple` provider still matches pi-ai 0.84.0's stream-event contract, and `tsc --noEmit` passes.
9
+
10
+ ## [1.1.0] - 2026-07-31
11
+
12
+ ### Added
13
+
14
+ - **Full agy catalog in the picker, grouped like agy's own.** Gemini models
15
+ collapse to base entries (gemini-3.6-flash, gemini-3.1-pro) with a
16
+ thinking-effort toggle; Claude (Sonnet/Opus) and GPT-OSS appear as fixed
17
+ entries with no toggle, since their thinking cannot be changed. The earlier
18
+ Gemini-only filter is gone. Google's Antigravity subscription bills all of
19
+ these through agy, so routing Claude here uses the agy quota you already pay
20
+ for (if you also run pi-claude-bridge you will simply see two Claude
21
+ entries).
22
+ - **Reasoning-effort bridging (`agy --effort`).** For an effort-driven Gemini
23
+ base, pi's thinking-effort toggle drives agy's `--effort` (agy 1.1.5+), and
24
+ the toggle only offers the tiers that base actually accepts (Flash:
25
+ low/medium/high; Pro: low/high), so it can never request a tier agy rejects.
26
+ The level is clamped to the base's supported tiers and always passed (a base
27
+ slug is invalid on its own). Fixed models never receive `--effort` (agy
28
+ rejects it for them). Behavior verified by local experiments against agy
29
+ 1.1.9.
30
+
31
+ ### Changed
32
+
33
+ - **Breaking: Gemini model ids changed shape.** Effort is no longer part of the
34
+ model id (`antigravity/gemini-3-6-flash-medium` →
35
+ `antigravity/gemini-3-6-flash`); it is now chosen via pi's thinking-effort
36
+ toggle. A persisted default model, a `--model antigravity/...` flag, or a
37
+ scoped-model pattern set before upgrading will need re-selection.
38
+ Claude/GPT-OSS ids keep their qualified shape (e.g.
39
+ `antigravity/claude-sonnet-4-6`).
40
+ - **Requires agy >= 1.1.5.** Base slugs and the `--effort` flag landed there;
41
+ older agy rejects every Gemini entry with a flag error.
42
+
43
+ ### Fixed
44
+
45
+ - **AskAntigravity model resolution** now parses agy's stable-slug catalog
46
+ (`gemini-3.6-flash-high`, `claude-sonnet-4-6`) instead of the legacy human
47
+ names. The `sonnet`/`opus`/`gpt-oss` aliases and the `/agy thinking` tier had
48
+ silently resolved to invalid `--model` values since agy switched to slugs;
49
+ they now resolve to exact valid slugs.
50
+
5
51
  ## [1.0.0] - 2026-07-29
6
52
 
7
53
  First release. A streaming Gemini model provider for pi, built on Google's
@@ -83,6 +83,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
83
83
  name: m.name,
84
84
  api: m.api,
85
85
  reasoning: m.reasoning,
86
+ thinkingLevelMap: m.thinkingLevelMap,
86
87
  input: m.input,
87
88
  cost: m.cost,
88
89
  contextWindow: m.contextWindow,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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",
@@ -28,7 +28,7 @@
28
28
  "type": "module",
29
29
  "scripts": {
30
30
  "build": "tsc --noEmit",
31
- "test": "tsx --test tests/*.test.ts",
31
+ "test": "vitest run",
32
32
  "prepublishOnly": "npm run build && npm test",
33
33
  "run-agy": "tsx scripts/run-agy.ts",
34
34
  "decode-db": "tsx scripts/decode-db.ts",
@@ -55,13 +55,14 @@
55
55
  }
56
56
  },
57
57
  "devDependencies": {
58
- "@earendil-works/pi-ai": "^0.82.1",
59
- "@earendil-works/pi-coding-agent": "^0.82.1",
60
- "@earendil-works/pi-tui": "^0.82.1",
58
+ "@earendil-works/pi-ai": "^0.84.0",
59
+ "@earendil-works/pi-coding-agent": "^0.84.0",
60
+ "@earendil-works/pi-tui": "^0.84.0",
61
61
  "@types/node": "^22.0.0",
62
62
  "tsx": "^4.19.0",
63
63
  "typebox": "^1.1.38",
64
- "typescript": "^5.8.0"
64
+ "typescript": "^5.8.0",
65
+ "vitest": "^4.1.7"
65
66
  },
66
67
  "dependencies": {
67
68
  "@modelcontextprotocol/sdk": "^1.29.0"
package/src/ask-tool.ts CHANGED
@@ -43,16 +43,17 @@ const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
43
43
 
44
44
  // Static alias overlay for non-Gemini models agy may or may not surface.
45
45
  // Live catalog entries win on case-insensitive full-string equality; the
46
- // overlay resolves the alias when agy doesn't list it.
46
+ // overlay resolves the alias when agy doesn't list it. Names are agy's stable
47
+ // slugs (the same ids `agy models` prints and `--model` accepts).
47
48
  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 },
49
+ { full: "claude-sonnet-4-6", family: "other", version: null, tier: null },
50
+ { full: "claude-opus-4-6-thinking", family: "other", version: null, tier: null },
51
+ { full: "gpt-oss-120b-medium", family: "other", version: null, tier: null },
51
52
  ];
52
53
  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)"],
54
+ ["sonnet", "claude-sonnet-4-6"],
55
+ ["opus", "claude-opus-4-6-thinking"],
56
+ ["gpt-oss", "gpt-oss-120b-medium"],
56
57
  ]);
57
58
 
58
59
  // agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
@@ -77,7 +78,7 @@ COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request com
77
78
  type Family = "flash" | "pro" | "other";
78
79
 
79
80
  interface ModelEntry {
80
- full: string; // exact agy string, e.g. "Gemini 3.6 Flash (Medium)"
81
+ full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
81
82
  family: Family;
82
83
  version: string | null; // "3.6"
83
84
  tier: ThinkingTier | null;
@@ -119,7 +120,7 @@ function parseModelLine(line: string): ModelEntry | null {
119
120
  : "other";
120
121
  const versionMatch = lower.match(/(\d+\.\d+)/);
121
122
  const version = versionMatch ? versionMatch[1] : null;
122
- const tierMatch = lower.match(/\((low|medium|high)\)/);
123
+ const tierMatch = lower.match(/-(low|medium|high)$/);
123
124
  const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
124
125
  return { full, family, version, tier };
125
126
  }
@@ -248,7 +249,7 @@ export async function registerAskAntigravityTool(
248
249
  model: Type.Optional(
249
250
  Type.String({
250
251
  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
+ "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
  }),
253
254
  ),
254
255
  mode: Type.Optional(
package/src/mcp-server.ts CHANGED
@@ -292,7 +292,7 @@ export async function startMcpServer(
292
292
 
293
293
  const makeServer = (signal: AbortSignal) => {
294
294
  const s = new Server(
295
- { name: "pi-antigravity-bridge", version: "1.0.0" },
295
+ { name: "pi-antigravity-bridge", version: "1.1.0" },
296
296
  { capabilities: { tools: {} } },
297
297
  );
298
298
  s.setRequestHandler(ListToolsRequestSchema, listHandler);
package/src/models.ts CHANGED
@@ -1,29 +1,62 @@
1
- // Discover Gemini models from `agy models` and project them into pi's Model
2
- // shape so they appear in the /model picker as antigravity/<slug>.
1
+ // Discover models from `agy models` and project them into pi's Model shape so
2
+ // they appear in the /model picker as antigravity/<slug>.
3
3
  //
4
- // agy prints one model per line, e.g.:
5
- // Gemini 3.6 Flash (Medium)
6
- // Gemini 3.1 Pro (High)
7
- // Claude Sonnet 4.6 (Thinking)
8
- // GPT-OSS 120B (Medium)
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)
9
9
  //
10
- // We keep ONLY Gemini models here - Claude and GPT-OSS belong to other
11
- // providers (pi-claude-bridge, etc.). Driving them through agy would double-
12
- // bill and conflict with the user's other subscriptions.
10
+ // Gemini models are collapsed to a BASE slug (gemini-3.6-flash) and exposed
11
+ // with a thinking-effort toggle whose levels match exactly the tiers agy
12
+ // offers that base (verified: Pro rejects medium). The picked level is sent as
13
+ // agy --effort; a base slug is INVALID on its own, so effort is always passed.
14
+ // Claude and GPT-OSS keep agy's exact slug with no toggle: their thinking is
15
+ // fixed and agy rejects --effort for them. Google's Antigravity subscription
16
+ // bills all of these through agy.
13
17
 
14
18
  import { spawn } from "node:child_process";
15
19
  import fs from "node:fs";
16
20
  import os from "node:os";
17
21
  import path from "node:path";
18
- import type { Api, Model } from "@earendil-works/pi-ai";
22
+ import type { Api, Model, ThinkingLevelMap } from "@earendil-works/pi-ai";
19
23
 
20
24
  const DISCOVERY_TIMEOUT_MS = 8_000;
21
25
 
26
+ /** Reasoning-effort tiers agy accepts via --effort. */
27
+ export type AgyEffort = "low" | "medium" | "high";
28
+
29
+ /** low < medium < high, for sorting/clamping. */
30
+ const EFFORT_RANK: Record<AgyEffort, number> = { low: 0, medium: 1, high: 2 };
31
+
32
+ /** Split an agy slug into (base, tier). tier is null when the slug has no
33
+ * -high/-medium/-low suffix (claude-sonnet-4-6, gpt-oss-120b-medium's "medium"
34
+ * IS its suffix here, claude-opus-4-6-thinking is not a tier). */
35
+ const TIER_RE = /^(.+)-(high|medium|low)$/;
36
+
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._-]*$/;
42
+
43
+ /** Model families VERIFIED to accept base-slug + --effort. Only these collapse
44
+ * to a base slug with a thinking toggle. Any other family stays as agy's exact
45
+ * qualified slug (always valid on its own), so an unknown or fixed-thinking
46
+ * family degrades safely instead of forcing an unsupported --effort. Add a
47
+ * family here only after confirming base+--effort is accepted for it. */
48
+ const EFFORT_CAPABLE_FAMILIES: readonly RegExp[] = [/^gemini-/];
49
+
22
50
  export interface AgyModelEntry {
23
- /** Exact agy string, e.g. "Gemini 3.6 Flash (Medium)". */
51
+ /** Exact agy --model string to pass: a base slug ("gemini-3.6-flash") when
52
+ * effort-driven, else agy's full qualified slug ("claude-opus-4-6-thinking"). */
24
53
  full: string;
25
- /** pi model id, e.g. "gemini-3-6-flash-medium". */
54
+ /** pi model id, e.g. "gemini-3-6-flash". */
26
55
  id: string;
56
+ /** Present iff this is an effort-driven base slug. Lists the tiers agy
57
+ * accepts for it; pi's thinking toggle picks among them and we always pass
58
+ * --effort. Absent => fixed model, never pass --effort. */
59
+ efforts?: AgyEffort[];
27
60
  }
28
61
 
29
62
  /** Spawn `agy models` and return its raw stdout text. Returns "" on any
@@ -169,33 +202,50 @@ export async function loadModelCatalogRaw(
169
202
  return raw;
170
203
  }
171
204
 
172
- /** Parse raw `agy models` text into the provider's slugified Gemini entries. */
205
+ /** Parse raw `agy models` text into the provider's model entries.
206
+ *
207
+ * Effort-driven Gemini bases (>= 2 tier variants in the catalog) collapse to
208
+ * one BASE-slug entry carrying the tiers they accept. Models with 0 or 1 tier
209
+ * variants keep agy's exact qualified slug: a single suffix (gpt-oss-120b-
210
+ * medium) or none (claude-sonnet-4-6) means fixed thinking, where --effort is
211
+ * unsupported. Insertion order of first-seen bases is preserved. */
173
212
  export function entriesFromRaw(raw: string): AgyModelEntry[] {
174
- return raw
213
+ const lines = raw
175
214
  .split("\n")
176
215
  .map((line) => line.trim())
177
216
  .filter((line) => line.length > 0)
178
- .filter(isGeminiModel)
179
- .map((full) => ({ full, id: slugify(full) }))
180
- .filter((e): e is AgyModelEntry => e.id.length > 0);
217
+ .filter((line) => MODEL_LINE_RE.test(line));
218
+ 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
+ const tier = m ? (m[2] as AgyEffort) : null;
223
+ let g = groups.get(base);
224
+ if (!g) {
225
+ g = { lines: [], tiers: new Set<AgyEffort>() };
226
+ groups.set(base, g);
227
+ }
228
+ g.lines.push(line);
229
+ if (tier) g.tiers.add(tier);
230
+ }
231
+ const entries: AgyModelEntry[] = [];
232
+ for (const [base, g] of groups) {
233
+ const efforts = [...g.tiers].sort((a, b) => EFFORT_RANK[a] - EFFORT_RANK[b]);
234
+ if (efforts.length >= 2 && EFFORT_CAPABLE_FAMILIES.some((re) => re.test(base))) {
235
+ entries.push({ full: base, id: slugify(base), efforts });
236
+ } else {
237
+ for (const line of g.lines) entries.push({ full: line, id: slugify(line) });
238
+ }
239
+ }
240
+ return entries.filter((e) => e.id.length > 0);
181
241
  }
182
242
 
183
- /** Run `agy models`, return parsed Gemini entries. Returns [] on any failure
243
+ /** Run `agy models`, return parsed model entries. Returns [] on any failure
184
244
  * (non-fatal - the provider falls back to a hardcoded set). */
185
245
  export async function discoverAgyModels(binary: string): Promise<AgyModelEntry[]> {
186
246
  return entriesFromRaw(await spawnAgyModelsRaw(binary));
187
247
  }
188
248
 
189
- /** Gemini models only. Case-insensitive: the name must contain "gemini" and
190
- * NOT be a Claude/GPT-OSS entry (defensive - agy could rename lines). */
191
- function isGeminiModel(line: string): boolean {
192
- const l = line.toLowerCase();
193
- if (!l.includes("gemini")) return false;
194
- if (l.includes("claude")) return false;
195
- if (l.includes("gpt")) return false;
196
- return true;
197
- }
198
-
199
249
  /** "Gemini 3.6 Flash (Medium)" -> "gemini-3-6-flash-medium".
200
250
  * Lowercase, non-alphanumerics -> "-", collapsed, trimmed. */
201
251
  export function slugify(full: string): string {
@@ -205,17 +255,22 @@ export function slugify(full: string): string {
205
255
  .replace(/^-+|-+$/g, "");
206
256
  }
207
257
 
258
+ /** Build the thinkingLevelMap for an effort-driven base: hide "off" and
259
+ * "minimal" always (agy has no no-thinking mode; a base REQUIRES an effort),
260
+ * and hide any of low/medium/high the base doesn't offer (e.g. Pro has no
261
+ * medium). pi's getSupportedThinkingLevels treats a null value as hidden, so
262
+ * the toggle then shows exactly agy's slider stops. */
263
+ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
264
+ const map: Record<string, string | null> = { off: null, minimal: null };
265
+ for (const level of ["low", "medium", "high"] as const) {
266
+ if (!efforts.includes(level)) map[level] = null;
267
+ }
268
+ return map as ThinkingLevelMap;
269
+ }
270
+
208
271
  /** Project an agy entry to pi's Model shape. */
209
272
  export function toPiModel(entry: AgyModelEntry): Model<Api> {
210
- const tier = /\(high\)/i.test(entry.full)
211
- ? "high"
212
- : /\(low\)/i.test(entry.full)
213
- ? "low"
214
- : "medium";
215
- // "reasoning" gates pi's thinking-effort UI. Gemini reasons at every tier,
216
- // but we only expose the toggle for High to avoid implying control we
217
- // don't actually bridge to agy.
218
- const reasoning = tier === "high";
273
+ const effortDriven = !!entry.efforts && entry.efforts.length > 0;
219
274
  return {
220
275
  id: entry.id,
221
276
  name: entry.full,
@@ -225,7 +280,11 @@ export function toPiModel(entry: AgyModelEntry): Model<Api> {
225
280
  // pi requires non-empty values. The "agy-bridge" api string is a custom
226
281
  // sentinel that no built-in provider claims, so it can never collide.
227
282
  baseUrl: "agy-bridge://antigravity",
228
- reasoning,
283
+ // reasoning=true only for effort-driven bases => pi shows the toggle.
284
+ // Fixed models (Claude/GPT-OSS) get no toggle: their thinking can't be
285
+ // changed and agy rejects --effort for them.
286
+ reasoning: effortDriven,
287
+ ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
229
288
  // agy's -p prompt is text-only. Advertising image input would let pi
230
289
  // offer image attach, but extractUserPrompt silently drops image blocks,
231
290
  // so the user would be misled. Keep input text-only until agy supports
@@ -244,18 +303,9 @@ export function toPiModel(entry: AgyModelEntry): Model<Api> {
244
303
  * can still select a model and get a clear runtime error instead of an empty
245
304
  * list. Update these when agy ships new Gemini versions. */
246
305
  export const FALLBACK_MODELS: AgyModelEntry[] = [
247
- { full: "Gemini 3.6 Flash (Medium)", id: "gemini-3-6-flash-medium" },
248
- { full: "Gemini 3.6 Flash (High)", id: "gemini-3-6-flash-high" },
249
- { full: "Gemini 3.1 Pro (High)", id: "gemini-3-1-pro-high" },
306
+ { full: "gemini-3.6-flash", id: "gemini-3-6-flash", efforts: ["low", "medium", "high"] },
307
+ { full: "gemini-3.1-pro", id: "gemini-3-1-pro", efforts: ["low", "high"] },
308
+ { full: "claude-sonnet-4-6", id: "claude-sonnet-4-6" },
250
309
  ];
251
310
 
252
- /** Resolve a pi model id back to the exact agy string. O(n) over a small list
253
- * - the provider calls this once per turn. Returns null on miss (caller
254
- * falls back to passthrough, agy will likely reject). */
255
- export function resolveAgyString(
256
- piModelId: string,
257
- entries: AgyModelEntry[],
258
- ): string | null {
259
- const found = entries.find((e) => e.id === piModelId);
260
- return found ? found.full : null;
261
- }
311
+
package/src/provider.ts CHANGED
@@ -25,11 +25,12 @@ import {
25
25
  type Message,
26
26
  type Model,
27
27
  type SimpleStreamOptions,
28
+ type ThinkingLevel,
28
29
  type Usage,
29
30
  } from "@earendil-works/pi-ai";
30
31
  import type { Api } from "@earendil-works/pi-ai";
31
32
  import { runAgyTurn, type AgyEvent, type AgyRunOptions } from "./runner.js";
32
- import { resolveAgyString, type AgyModelEntry } from "./models.js";
33
+ import { type AgyEffort, type AgyModelEntry } from "./models.js";
33
34
  import { SessionStore } from "./sessions.js";
34
35
  import { loadConfig } from "./config.js";
35
36
  import path from "node:path";
@@ -246,6 +247,46 @@ export interface StreamSimpleDeps {
246
247
  runAgyTurn?: typeof runAgyTurn;
247
248
  }
248
249
 
250
+ /** pi thinking-effort order mirrors agy's, for clamping. */
251
+ const AGY_EFFORT_ORDER: readonly AgyEffort[] = ["low", "medium", "high"];
252
+
253
+ /** Map pi's thinking level onto one of the tiers `efforts` the base actually
254
+ * supports, clamping to the nearest available. agy rejects an effort tier a
255
+ * base doesn't list (e.g. medium on Pro), so we never emit one. A base slug is
256
+ * invalid without --effort, so when pi sends no level we default to the
257
+ * middle tier (or the highest available). */
258
+ export function toAgyEffort(
259
+ reasoning: ThinkingLevel | undefined,
260
+ efforts: readonly AgyEffort[],
261
+ ): AgyEffort {
262
+ let candidate: AgyEffort;
263
+ switch (reasoning) {
264
+ case "minimal":
265
+ case "low":
266
+ candidate = "low";
267
+ break;
268
+ case "medium":
269
+ candidate = "medium";
270
+ break;
271
+ case "high":
272
+ case "xhigh":
273
+ case "max":
274
+ candidate = "high";
275
+ break;
276
+ default:
277
+ candidate = efforts[0] ?? "low";
278
+ }
279
+ if (efforts.includes(candidate)) return candidate;
280
+ const i = AGY_EFFORT_ORDER.indexOf(candidate);
281
+ for (let j = i; j < AGY_EFFORT_ORDER.length; j++) {
282
+ if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
283
+ }
284
+ for (let j = i - 1; j >= 0; j--) {
285
+ if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
286
+ }
287
+ return efforts[0] ?? "low";
288
+ }
289
+
249
290
  /** Build the streamSimple closure. Captures the model catalog + session store
250
291
  * resolved at extension load. */
251
292
  export function createStreamSimple(
@@ -311,20 +352,27 @@ async function runTurn(
311
352
  const digest = buildContextDigest(context.messages, watermark);
312
353
  const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
313
354
 
314
- // Resolve the pi model id to the exact agy string. Fall through to the id
315
- // itself on miss - agy will likely reject, but the error reaches the user
355
+ // Resolve the pi model id to its catalog entry. On a miss, fall through to
356
+ // the id itself - agy will likely reject, but the error reaches the user
316
357
  // instead of a silent no-op.
317
- const agyModel = resolveAgyString(model.id, entries) ?? model.id;
358
+ const entry = entries.find((e) => e.id === model.id) ?? null;
359
+ const agyModel = entry?.full ?? model.id;
318
360
 
319
361
  // Runtime config (mode, permissions). Loaded fresh each turn so /agy
320
362
  // toggles take effect immediately without a reload.
321
363
  const config = loadConfig();
322
364
 
365
+ // Effort-driven bases always need --effort (a base slug is invalid on its
366
+ // own); fixed models never get it (agy rejects --effort for them). For an
367
+ // effort-driven base we clamp pi's level to the tiers agy offers it.
368
+ const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
369
+
323
370
  const runOpts: AgyRunOptions = {
324
371
  cwd,
325
372
  model: agyModel,
326
373
  mode: config.mode,
327
374
  skipPermissions: config.skipPermissions,
375
+ effort,
328
376
  prompt: fullPrompt,
329
377
  conversationId: existing?.conversationId ?? null,
330
378
  baseStepIdx: existing?.lastStepIdx ?? -1,
package/src/runner.ts CHANGED
@@ -88,6 +88,9 @@ export interface AgyRunOptions {
88
88
  /** Exact agy model string, e.g. "Gemini 3.6 Flash (Medium)". Caller
89
89
  * resolves aliases; the runner passes this through verbatim. */
90
90
  model?: string;
91
+ /** Reasoning effort passed to agy --effort (low|medium|high). Omit to let
92
+ * the model slug's own effort stand (requires agy >= 1.1.5). */
93
+ effort?: "low" | "medium" | "high";
91
94
  /** agy execution mode. accept-edits = agy applies edits; plan = review-only. */
92
95
  mode?: "accept-edits" | "plan";
93
96
  /** Pass --dangerously-skip-permissions so commands don't hang on an
@@ -168,6 +171,7 @@ export async function runAgyTurn(
168
171
  // this dir, so its agy stays plain (no recursion).
169
172
  if (bridgeMcpConfigExists()) args.push("--add-dir", bridgeMcpConfigDir());
170
173
  if (opts.model) args.push("--model", opts.model);
174
+ if (opts.effort) args.push("--effort", opts.effort);
171
175
  args.push("--mode", mode);
172
176
  // Without this, any run_command triggers an interactive permission prompt
173
177
  // that hangs forever in non-interactive print mode (no TTY to answer y/n).