@estebanforge/pi-antigravity-bridge 1.7.7 → 1.7.9

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,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.9] - 2026-10-06
6
+
7
+ ### Changed
8
+
9
+ - **Breaking: Claude models are effort-tiered now, and the ids moved to the Claude 5.5 family.** Google retired Claude 4.6 on Antigravity and ships Claude Opus 5.5 (2026-09-22) and Claude Sonnet 5.5 (2026-09-28) in its place, each with low/medium/high thinking tiers - the same shape Gemini already had. Claude joins the verified effort-capable families (probed live 2026-10: base slug + `--effort` accepted; a bare base is rejected with "requires --effort"), so `claude-opus-5-5` and `claude-sonnet-5-5` register as single toggleable models whose turns always send `--effort`, instead of per-tier fixed ids. The catalog fixtures mirror the live listing and the no-`agy models` fallback carries its head (3.8 flash, 3.1 pro, sonnet 5.5). A pattern pinned to `antigravity/claude-sonnet-4-6` (settings enabledModels, `--model`, or a scoped-model config) matches nothing after agy's upgrade and must move to `antigravity/claude-sonnet-5-5`; the bare base id itself resolves through the delegation tool to the nearest tier variant.
10
+ - **The `gpt-oss-120b-medium` family is dropped now, not next month.** Upstream announced its removal; nobody used the fixed-thinking single slug and it loses to the cheaper effort-tiered options, so the provider catalog and the delegation tool catalog both filter it before that date lands. The `gpt-oss` alias stops resolving: unrecognized input passes raw to agy and fails loudly there, instead of the tool offering a dying model.
11
+
12
+ ## [1.7.8] - 2026-09-30
13
+
14
+ ### Fixed
15
+
16
+ - **The system prompt no longer rides the prompt line on stream-json.** Probed on agy 1.2.14: the CLI silently drops any single prompt past a ~25KB input cap. The drop reports SUCCESS with an empty response, zero usage, and no model step, and a conversation whose turn was dropped never answers again - resuming it hangs with no result frame. The G10 system-prompt block shipped inline on the first prompt of every fresh conversation by default and routinely crossed the cap, so provider sessions answered boilerplate while the user's message never ran (issue #2). The block now stages as an agy agent file under the agents discovery root and rides `--agent`: the CLI reads agent files from disk, so the cap never applies, and the prompt line carries only the user message. Staging is content-addressed - one dir per distinct body, write-once, pid-marked, orphan-swept - so two conversations in one process can never overwrite each other's carrier (the round-2 review blocked the original per-process slot for exactly that cross-contamination). A user-configured `config.agent` always wins, with the inline block as fallback and a one-time warning. ACP keeps the inline block: the JSON-RPC transport has no cap and no `--agent` slot.
17
+ - **A dropped or dead turn now fails loudly instead of poisoning the session.** The stream-json driver settles every turn with model-output and result-frame evidence. An OK outcome with no model output - the exact signature of the silent over-cap drop - fails the turn with the cause and the remedy, and never stores the conversation binding; a deadline on a bound conversation with no result frame clears the binding so the next turn starts a fresh conversation instead of hanging forever on a dead one. Real terse turns (tool steps, thinking, any usage) are untouched; a deadline that recovered a withheld answer keeps settling OK.
18
+ - **Regression coverage pins the whole failure chain at both layers.** The fake agy binary grew a SILENT-DROP branch reproducing the exact wire signature; driver tests pin the evidence fields; provider tests cover the drop, the poisoned resume, a deadline-with-result keeping its binding, terse-turn false positives, carrier isolation across interleaved conversations, and deadline-flavored error copy. 1343 tests, tsc clean; round-2 adversarial review signed off after the carrier-isolation blocker fix.
19
+
20
+ Thanks @Jamsa for the report (#2).
21
+
5
22
  ## [1.7.7] - 2026-09-30
6
23
 
7
24
  ### Fixed
package/README.md CHANGED
@@ -119,7 +119,7 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
119
119
  | `webTools` | `off` (no web tools), `on` (register `agy_web_search` + `agy_read_url` as Pi tools, usable by ANY provider's model). Each call spawns a one-shot search-only `agy` agent (plan mode, bridge MCP inheritance off), gates the answer on an observed native `search_web`/`read_url_content` step, and spends Antigravity quota. Off by default: Antigravity sessions already have native web tools on both engines; these serve other providers | `off` |
120
120
  | `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
121
121
  | `digest` | `off` (stable prompts; agy's prompt cache hits) or `on` (inject a delta of pi-side context - compaction summaries, other-provider turns - into each agy prompt; the delta changes every turn, so agy re-bills the full context). Enable for mixed-provider sessions where agy must see pi-side context | `off` |
122
- | `systemPrompt` | `on` (prepend pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user) or `off` (agy-native behavior) | `on` |
122
+ | `systemPrompt` | `on` (send pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user. Delivery: stream-json stages it as an agy agent file via `--agent`, off the prompt line - the CLI silently drops single prompts past a ~25KB cap and the dropped turn kills the conversation; ACP prepends it to the first prompt) or `off` (agy-native behavior) | `on` |
123
123
  | `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. When it fires, the bridge kills the agy process mid-task ("ACP turn exceeded the 10m deadline" / "agy exceeded the 10m turn timeout") - it is a bridge cap, not a Google server limit. Default is TTY-aware: `0` (no cap) on an interactive pi - you are the backstop with Esc; `20` headless, where nobody can abort and one runaway turn blocks the serialized turn queue. Opt into a gate with 1-1440 (free type via `config.json` or `/agy timeout <1-1440|off>`, or the `/agy` picker's preset list); `0` disables explicitly; anything else falls back to the TTY-aware default. The 5m inactivity stall guard always still bounds a hung server | `0` TTY / `20` headless |
124
124
  | `inactivityTimeoutMin` | Silence cap, minutes, both engines: no stream-json stdout / no ACP session/update for this long fails the turn as a stall. `0` disables the guard | `5` |
125
125
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.7",
4
- "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
3
+ "version": "1.7.9",
4
+ "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -46,7 +46,7 @@
46
46
  ]
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-coding-agent": "*"
49
+ "@earendil-works/pi-coding-agent": "^1.0.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@earendil-works/pi-coding-agent": {
@@ -25,7 +25,7 @@
25
25
  import type {
26
26
  AgentToolResult,
27
27
  AgentToolUpdateCallback,
28
- ExtensionContext,
28
+ ExtensionToolContext,
29
29
  ToolDefinition,
30
30
  } from "@earendil-works/pi-coding-agent";
31
31
 
@@ -151,7 +151,7 @@ export function createShadowTool(base: AnyToolDefinition, policy: GatePolicy, op
151
151
  params: any,
152
152
  signal: AbortSignal | undefined,
153
153
  onUpdate: AgentToolUpdateCallback<any> | undefined,
154
- ctx: ExtensionContext,
154
+ ctx: ExtensionToolContext,
155
155
  ): Promise<AgentToolResult<unknown>> => {
156
156
  const p = (params ?? {}) as Record<string, unknown>;
157
157
  if (p[GATE_MARKER] !== true) {
package/src/ask-tool.ts CHANGED
@@ -56,16 +56,19 @@ const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
56
56
  // Static alias overlay for non-Gemini models agy may or may not surface.
57
57
  // Live catalog entries win on case-insensitive full-string equality; the
58
58
  // overlay resolves the alias when agy doesn't list it. Names are agy's stable
59
- // slugs (the same ids `agy models` prints and `--model` accepts).
59
+ // slugs (the same ids `agy models` prints and `--model` accepts). Claude ships
60
+ // tiered like Gemini now, so the overlay carries the full tier spread.
60
61
  const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
61
- { full: "claude-sonnet-4-6", family: "other", version: null, tier: null },
62
- { full: "claude-opus-4-6-thinking", family: "other", version: null, tier: null },
63
- { full: "gpt-oss-120b-medium", family: "other", version: null, tier: null },
62
+ { full: "claude-sonnet-5-5-low", family: "other", version: null, tier: "low" },
63
+ { full: "claude-sonnet-5-5-medium", family: "other", version: null, tier: "medium" },
64
+ { full: "claude-sonnet-5-5-high", family: "other", version: null, tier: "high" },
65
+ { full: "claude-opus-5-5-low", family: "other", version: null, tier: "low" },
66
+ { full: "claude-opus-5-5-medium", family: "other", version: null, tier: "medium" },
67
+ { full: "claude-opus-5-5-high", family: "other", version: null, tier: "high" },
64
68
  ];
65
69
  const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
66
- ["sonnet", "claude-sonnet-4-6"],
67
- ["opus", "claude-opus-4-6-thinking"],
68
- ["gpt-oss", "gpt-oss-120b-medium"],
70
+ ["sonnet", "claude-sonnet-5-5"],
71
+ ["opus", "claude-opus-5-5"],
69
72
  ]);
70
73
 
71
74
  // agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
@@ -164,19 +167,47 @@ function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): Thinki
164
167
  return sorted[0] ?? preferred;
165
168
  }
166
169
 
167
- /** Build the argv-facing resolution from a picked catalog entry. Gemini bases
168
- * (slugs starting "gemini-") accept a separate --effort, so split the tier
169
- * suffix out of the slug: the base alone (gemini-3.6-flash) is what --model
170
- * wants, and the tier goes to --effort. Fixed-thinking families keep agy's
171
- * exact slug even when it carries a -medium suffix (gpt-oss-120b-medium):
172
- * agy rejects --effort for them, so the suffix stays part of the slug. */
170
+ /** Build the argv-facing resolution from a picked catalog entry. Gemini and
171
+ * Claude bases (slugs starting "gemini-"/"claude-") accept a separate
172
+ * --effort, so split the tier suffix out of the slug: the base alone
173
+ * (claude-sonnet-5-5) is what --model wants, and the tier goes to --effort
174
+ * (agy rejects a bare base: "requires --effort"). Only unverified families
175
+ * keep agy's exact slug whole, so an unknown suffix can never trigger an
176
+ * unsupported --effort. */
173
177
  function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
174
- if (tier && full.toLowerCase().startsWith("gemini-")) {
178
+ if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
175
179
  return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
176
180
  }
177
181
  return { model: full };
178
182
  }
179
183
 
184
+ /** Resolve a tiered base id - a short-alias target ("claude-sonnet-5-5") or a
185
+ * bare base slug the provider itself advertises - to the catalog variant
186
+ * nearest the requested tier. A bare base alone is invalid upstream
187
+ * ("requires --effort"), so the pick always rides the split in toResolved;
188
+ * with no variants in the catalog, the overlay/exact entry or the raw base
189
+ * passes through unchanged. */
190
+ function resolveTieredBase(
191
+ base: string,
192
+ entries: ModelEntry[],
193
+ defaultThinking: ThinkingTier,
194
+ preferredTier: ThinkingTier | undefined,
195
+ ): ResolvedModel {
196
+ const needle = base.toLowerCase();
197
+ const variants = entries.filter((e) => e.full.toLowerCase().startsWith(`${needle}-`));
198
+ if (variants.length > 0) {
199
+ const tiers = variants.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null);
200
+ const preferred =
201
+ preferredTier ??
202
+ (tiers.includes(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER.other);
203
+ const chosen = nearestTier(tiers, preferred);
204
+ const picked = variants.find((e) => e.tier === chosen) ?? variants[0];
205
+ return toResolved(picked.full, picked.tier);
206
+ }
207
+ const fromCatalog = entries.find((e) => e.full.toLowerCase() === needle);
208
+ return toResolved(fromCatalog?.full ?? base, fromCatalog?.tier ?? null);
209
+ }
210
+
180
211
  /** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
181
212
  * Returns null only when the family is unrecognized; the caller then passes
182
213
  * the raw input straight to agy. */
@@ -195,9 +226,12 @@ export function resolveModel(
195
226
  if (exact) return toResolved(exact.full, exact.tier);
196
227
 
197
228
  if (STATIC_SHORT_ALIAS.has(lower)) {
198
- const target = STATIC_SHORT_ALIAS.get(lower) as string;
199
- const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
200
- return toResolved(fromCatalog?.full ?? target, fromCatalog?.tier ?? null);
229
+ return resolveTieredBase(
230
+ STATIC_SHORT_ALIAS.get(lower) as string,
231
+ entries,
232
+ defaultThinking,
233
+ preferredTier,
234
+ );
201
235
  }
202
236
 
203
237
  let family: Family | null = lower.includes("flash")
@@ -213,7 +247,19 @@ export function resolveModel(
213
247
  if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
214
248
  family = "flash";
215
249
  }
216
- if (!family) return null;
250
+ if (!family) {
251
+ // A bare base slug the provider itself advertises ("claude-sonnet-5-5",
252
+ // the id behind the antigravity/ entry in pi's picker) is invalid
253
+ // upstream without an effort: resolve it to the nearest tier variant
254
+ // exactly like the short aliases.
255
+ if (
256
+ entries.some((e) => e.full.toLowerCase().startsWith(`${lower}-`)) ||
257
+ entries.some((e) => e.full.toLowerCase() === lower)
258
+ ) {
259
+ return resolveTieredBase(lower, entries, defaultThinking, preferredTier);
260
+ }
261
+ return null;
262
+ }
217
263
 
218
264
  let candidates = entries.filter((e) => e.family === family);
219
265
  if (candidates.length === 0) return null;
@@ -252,11 +298,18 @@ export function resolveModel(
252
298
  return toResolved(picked.full, picked.tier);
253
299
  }
254
300
 
301
+ /** Families dropped outright even while `agy models` still lists them (same
302
+ * policy as models.ts HIDDEN_FAMILY_RE): never offered, never resolved. */
303
+ const HIDDEN_FAMILY_RE = /^gpt-oss-/;
304
+
255
305
  /** Parse raw `agy models` text into tool-catalog entries (all families, plus
256
- * the static sonnet/opus/gpt-oss overlay). Pure: no spawn. */
306
+ * the static sonnet/opus overlay). Pure: no spawn. */
257
307
  export function toolModelsFromRaw(raw: string): ModelEntry[] {
258
308
  return mergeCatalog(
259
- raw.split("\n").map(parseModelLine).filter((e): e is ModelEntry => e !== null),
309
+ raw
310
+ .split("\n")
311
+ .map(parseModelLine)
312
+ .filter((e): e is ModelEntry => e !== null && !HIDDEN_FAMILY_RE.test(e.full)),
260
313
  );
261
314
  }
262
315
 
@@ -90,6 +90,20 @@ export interface TurnOutcome {
90
90
  usage?: AgyUsage;
91
91
  finished: boolean;
92
92
  aborted: boolean;
93
+ /** The model demonstrably produced output this turn: an agent_response
94
+ * or tool step was observed, or the settled response/usage is non-empty.
95
+ * Stream-json sets it on every outcome; ACP leaves it undefined.
96
+ * false + status OK is agy's silent over-cap drop signature (issue #2:
97
+ * prompts past ~25KB are discarded with SUCCESS, no error). */
98
+ modelOutputSeen?: boolean;
99
+ /** Stream-json only: a result frame arrived for this turn. Undefined on
100
+ * ACP. A deadline kill or process exit before the result frame means the
101
+ * turn never committed. */
102
+ sawResult?: boolean;
103
+ /** Stream-json only: the turn ended via the overall/inactivity deadline
104
+ * guard (not a user abort, not a process crash). With sawResult false on
105
+ * a resumed conversation this marks the conversation as poisoned. */
106
+ deadline?: "timeout" | "stall";
93
107
  }
94
108
 
95
109
  export interface TurnHandle {
package/src/driver.ts CHANGED
@@ -67,6 +67,10 @@ interface ActiveTurn {
67
67
  * transcript steps to those written after this. */
68
68
  startedAt: number;
69
69
  sawResult: boolean;
70
+ /** An agent_response or tool step arrived this turn. Distinguishes a
71
+ * real (if terse) turn from agy's silent over-cap drop, which settles
72
+ * SUCCESS with no model step at all (issue #2). */
73
+ sawModelStep: boolean;
70
74
  /** Text-dedupe guard state (delta vs cumulative response_text). */
71
75
  cumulativeText: boolean | undefined;
72
76
  /** Open bridge parks. Each one suspends the stdout idle timer: agy is
@@ -120,6 +124,20 @@ function nowIso(): string {
120
124
  return new Date().toISOString().slice(11, 19);
121
125
  }
122
126
 
127
+ /** True when any usage counter carries a real value. The silent over-cap
128
+ * drop reports all-zero usage (no model call happened); a turn that burned
129
+ * any tokens demonstrably reached the model. */
130
+ function hasTokenEvidence(usage: AgyUsage | undefined): boolean {
131
+ if (!usage) return false;
132
+ return (
133
+ (usage.input_tokens ?? 0) > 0 ||
134
+ (usage.output_tokens ?? 0) > 0 ||
135
+ (usage.thinking_tokens ?? 0) > 0 ||
136
+ (usage.cache_read_tokens ?? 0) > 0 ||
137
+ (usage.total_tokens ?? 0) > 0
138
+ );
139
+ }
140
+
123
141
  /** True when `next` is a cumulative resend of `accumulated` (it repeats every
124
142
  * byte already streamed) rather than a fresh delta. Exported for tests. */
125
143
  export function isCumulativeResend(accumulated: string, next: string): boolean {
@@ -287,6 +305,7 @@ export class StreamDriver implements TurnDriver {
287
305
  outcome,
288
306
  response: "",
289
307
  sawResult: false,
308
+ sawModelStep: false,
290
309
  cumulativeText: undefined,
291
310
  parks: 0,
292
311
  startedAt: Date.now(),
@@ -500,6 +519,7 @@ export class StreamDriver implements TurnDriver {
500
519
  emit(turn, { type: "usage", usage: s.usage });
501
520
  }
502
521
  if (s.step_type === "agent_response") {
522
+ turn.sawModelStep = true;
503
523
  const text =
504
524
  typeof s.text_delta === "string"
505
525
  ? s.text_delta
@@ -513,6 +533,7 @@ export class StreamDriver implements TurnDriver {
513
533
  break;
514
534
  }
515
535
  if (s.step_type === "tool") {
536
+ turn.sawModelStep = true;
516
537
  const name = s.tool_name ?? s.tool_info?.name ?? "tool";
517
538
  const args =
518
539
  s.tool_info?.parameters && typeof s.tool_info.parameters === "object"
@@ -614,9 +635,22 @@ export class StreamDriver implements TurnDriver {
614
635
  this.#state = this.#child ? "ready" : "dead";
615
636
  for (const wake of turn.wake) wake();
616
637
  turn.wake = [];
617
- turn.resolve(outcome);
638
+ // Evidence fields every consumer can rely on. modelOutputSeen separates a
639
+ // real (if terse) turn from agy's silent over-cap drop, which settles
640
+ // SUCCESS with NO model step, empty response, and zero usage (issue #2).
641
+ const usage = outcome.usage ?? turn.usage;
642
+ const enriched: TurnOutcome = {
643
+ ...outcome,
644
+ usage,
645
+ sawResult: turn.sawResult,
646
+ modelOutputSeen:
647
+ turn.sawModelStep ||
648
+ outcome.response.length > 0 ||
649
+ hasTokenEvidence(usage),
650
+ };
651
+ turn.resolve(enriched);
618
652
  try {
619
- this.#onTurnEnd?.(outcome);
653
+ this.#onTurnEnd?.(enriched);
620
654
  } catch {
621
655
  /* listener errors must not break settling */
622
656
  }
@@ -630,7 +664,7 @@ export class StreamDriver implements TurnDriver {
630
664
  * One found, the turn settles OK with the withheld answer instead of
631
665
  * discarding finished work; either way the next turn respawns into a
632
666
  * known state. */
633
- async #turnDeadlineGuard(turn: ActiveTurn, label: string, message: string): Promise<void> {
667
+ async #turnDeadlineGuard(turn: ActiveTurn, label: "timeout" | "stall", message: string): Promise<void> {
634
668
  if (turn.closed) return;
635
669
  this.#log(`${label}:${turn.id}`);
636
670
  this.#killChild();
@@ -650,13 +684,14 @@ export class StreamDriver implements TurnDriver {
650
684
  usage: turn.usage,
651
685
  finished: true,
652
686
  aborted: false,
687
+ deadline: label,
653
688
  });
654
689
  return;
655
690
  }
656
- this.#failTurn(turn, message);
691
+ this.#failTurn(turn, message, label);
657
692
  }
658
693
 
659
- #failTurn(turn: ActiveTurn, message: string): void {
694
+ #failTurn(turn: ActiveTurn, message: string, deadline?: "timeout" | "stall"): void {
660
695
  this.#settle(turn, {
661
696
  conversationId: turn.conversationId,
662
697
  status: "ERROR",
@@ -665,6 +700,7 @@ export class StreamDriver implements TurnDriver {
665
700
  usage: turn.usage,
666
701
  finished: true,
667
702
  aborted: false,
703
+ deadline,
668
704
  });
669
705
  }
670
706
 
package/src/models.ts CHANGED
@@ -2,20 +2,21 @@
2
2
  // they appear in the /model picker as antigravity/<slug>.
3
3
  //
4
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)
5
+ // ONLY the slug (col 1); the label is display-only. Verified live 2026-10:
6
+ // gemini-3.8-flash-high Gemini 3.8 Flash (High)
7
+ // gemini-3.8-flash-medium Gemini 3.8 Flash (Medium) (+ -low)
8
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
+ // claude-sonnet-5-5-high Claude Sonnet 5.5 (High) (+ -medium, -low)
11
10
  //
12
- // Gemini models are collapsed to a BASE slug (gemini-3.6-flash) and exposed
13
- // with a thinking-effort toggle whose levels match exactly the tiers agy
14
- // offers that base (verified: Pro rejects medium). The picked level is sent as
15
- // agy --effort; a base slug is INVALID on its own, so effort is always passed.
16
- // Claude and GPT-OSS keep agy's exact slug with no toggle: their thinking is
17
- // fixed and agy rejects --effort for them. Google's Antigravity subscription
18
- // bills all of these through agy.
11
+ // Gemini AND Claude models are collapsed to a BASE slug (gemini-3.8-flash,
12
+ // claude-sonnet-5-5) and exposed with a thinking-effort toggle whose levels
13
+ // match exactly the tiers agy offers that base (verified: Pro rejects medium;
14
+ // Claude 5.5 accepts low/medium/high). The picked level is sent as agy
15
+ // --effort; a bare base slug is INVALID on its own (agy: "requires --effort"),
16
+ // so effort is always passed. Only unverified families keep agy's exact slug
17
+ // with no toggle, so an unknown future family degrades safely; the retired
18
+ // gpt-oss family is dropped outright (HIDDEN_FAMILY_RE). Google's Antigravity
19
+ // subscription bills all of these through agy.
19
20
 
20
21
  import { spawn } from "node:child_process";
21
22
  import fs from "node:fs";
@@ -81,8 +82,9 @@ export function toAgyEffort(
81
82
  }
82
83
 
83
84
  /** Split an agy slug into (base, tier). tier is null when the slug has no
84
- * -high/-medium/-low suffix (claude-sonnet-4-6, gpt-oss-120b-medium's "medium"
85
- * IS its suffix here, claude-opus-4-6-thinking is not a tier). */
85
+ * -high/-medium/-low suffix - a shape only unverified families should
86
+ * produce now, and those stay whole. Every Claude slug carries a tier
87
+ * suffix and collapses to its base. */
86
88
  const TIER_RE = /^(.+)-(high|medium|low)$/;
87
89
 
88
90
  /** agy emits clean slug ids (gemini-3.6-flash-high). Validate col1 of each
@@ -97,14 +99,21 @@ const MODEL_LINE_RE = /^[A-Za-z0-9][A-Za-z0-9._]*-[A-Za-z0-9._-]*$/;
97
99
  * to a base slug with a thinking toggle. Any other family stays as agy's exact
98
100
  * qualified slug (always valid on its own), so an unknown or fixed-thinking
99
101
  * family degrades safely instead of forcing an unsupported --effort. Add a
100
- * family here only after confirming base+--effort is accepted for it. */
101
- const EFFORT_CAPABLE_FAMILIES: readonly RegExp[] = [/^gemini-/];
102
+ * family here only after confirming base+--effort is accepted for it.
103
+ * Claude 5.5 verified live 2026-10: base+--effort accepted; a bare base is
104
+ * rejected ("requires --effort"). */
105
+ const EFFORT_CAPABLE_FAMILIES: readonly RegExp[] = [/^gemini-/, /^claude-/];
106
+
107
+ /** Families dropped outright, even while `agy models` still lists them: the
108
+ * fixed-thinking single slug loses to the cheaper effort-tiered options and
109
+ * its removal upstream is already announced, so we stop offering it now. */
110
+ const HIDDEN_FAMILY_RE = /^gpt-oss-/;
102
111
 
103
112
  export interface AgyModelEntry {
104
- /** Exact agy --model string to pass: a base slug ("gemini-3.6-flash") when
105
- * effort-driven, else agy's full qualified slug ("claude-opus-4-6-thinking"). */
113
+ /** Exact agy --model string to pass: a base slug ("gemini-3.8-flash") when
114
+ * effort-driven, else agy's full qualified slug (unverified family). */
106
115
  full: string;
107
- /** pi model id, e.g. "gemini-3-6-flash". */
116
+ /** pi model id, e.g. "gemini-3-8-flash". */
108
117
  id: string;
109
118
  /** Present iff this is an effort-driven base slug. Lists the tiers agy
110
119
  * accepts for it; pi's thinking toggle picks among them and we always pass
@@ -284,11 +293,12 @@ export async function loadModelCatalogRaw(
284
293
 
285
294
  /** Parse raw `agy models` text into the provider's model entries.
286
295
  *
287
- * Effort-driven Gemini bases (>= 2 tier variants in the catalog) collapse to
288
- * one BASE-slug entry carrying the tiers they accept. Models with 0 or 1 tier
289
- * variants keep agy's exact qualified slug: a single suffix (gpt-oss-120b-
290
- * medium) or none (claude-sonnet-4-6) means fixed thinking, where --effort is
291
- * unsupported. Insertion order of first-seen bases is preserved. */
296
+ * Effort-driven bases (>= 2 tier variants in the catalog, verified
297
+ * effort-capable family) collapse to one BASE-slug entry carrying the tiers
298
+ * they accept. Models with 0 or 1 tier variants keep agy's exact qualified
299
+ * slug: without a verified effort-capable family match, --effort is never
300
+ * risked. Hidden families are dropped outright. Insertion order of
301
+ * first-seen bases is preserved. */
292
302
  export function entriesFromRaw(raw: string): AgyModelEntry[] {
293
303
  // agy prints TWO columns: "<slug> <display label>". --model takes only the
294
304
  // slug, so split col1 and validate THAT; the label is display-only. A
@@ -298,6 +308,7 @@ export function entriesFromRaw(raw: string): AgyModelEntry[] {
298
308
  for (const line of raw.split("\n")) {
299
309
  const slug = line.trim().split(/\s+/)[0] ?? "";
300
310
  if (!slug || !MODEL_LINE_RE.test(slug)) continue;
311
+ if (HIDDEN_FAMILY_RE.test(slug)) continue;
301
312
  const m = TIER_RE.exec(slug);
302
313
  const base = m ? (m[1] as string) : slug;
303
314
  const tier = m ? (m[2] as AgyEffort) : null;
@@ -365,8 +376,8 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
365
376
  // sentinel that no built-in provider claims, so it can never collide.
366
377
  baseUrl: "agy-bridge://antigravity",
367
378
  // reasoning=true only for effort-driven bases => pi shows the toggle.
368
- // Fixed models (Claude/GPT-OSS) get no toggle: their thinking can't be
369
- // changed and agy rejects --effort for them.
379
+ // Unverified families get no toggle: --effort support is unknown, so
380
+ // none is ever sent.
370
381
  reasoning: effortDriven,
371
382
  ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
372
383
  // Input advertising comes from the caller (engine-dependent): the ACP
@@ -386,9 +397,9 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
386
397
  * can still select a model and get a clear runtime error instead of an empty
387
398
  * list. Update these when agy ships new Gemini versions. */
388
399
  export const FALLBACK_MODELS: AgyModelEntry[] = [
389
- { full: "gemini-3.7-flash", id: "gemini-3-7-flash", efforts: ["low", "medium", "high"] },
400
+ { full: "gemini-3.8-flash", id: "gemini-3-8-flash", efforts: ["low", "medium", "high"] },
390
401
  { full: "gemini-3.1-pro", id: "gemini-3-1-pro", efforts: ["low", "high"] },
391
- { full: "claude-sonnet-4-6", id: "claude-sonnet-4-6" },
402
+ { full: "claude-sonnet-5-5", id: "claude-sonnet-5-5", efforts: ["low", "medium", "high"] },
392
403
  ];
393
404
 
394
405
 
package/src/provider.ts CHANGED
@@ -43,9 +43,22 @@ import { GATE_MARKER, mapNativeToShadow, stripMarkerFields } from "./approval-ga
43
43
  import type { ApprovalDecision, ApprovalPayload, ApprovalParkApi } from "./mcp-server.js";
44
44
  import path from "node:path";
45
45
  import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
46
+ import { stageSyspromptAgent } from "./sysprompt-agent.js";
46
47
 
47
48
  const DEFAULT_TIMEOUT_MIN = 10;
48
49
 
50
+ /** Warned once per process: config.agent set while the system prompt needs
51
+ * the staged carrier agent (issue #2 inline-block fallback). */
52
+ let warnedUserAgentConflict = false;
53
+
54
+ /** conversationId -> staged carrier agent name. Gate-closed turns look the
55
+ * name up here so a conversation's agent field never flips: a change would
56
+ * recycle the agy process mid-conversation (profile drift), and a respawn
57
+ * must reload THAT conversation's own staged instructions. Content-addressed
58
+ * staging (one dir per body) keeps sibling conversations from overwriting
59
+ * each other's files. */
60
+ const stagedAgentByConversation = new Map<string, string>();
61
+
49
62
  /** Zero-usage helper. agy doesn't expose token counts; pi's cost math gets
50
63
  * zeros (we're not billing through this provider). */
51
64
  function zeroUsage(): Usage {
@@ -1182,6 +1195,10 @@ async function runTurnDriver(
1182
1195
  }
1183
1196
 
1184
1197
  let handle: TurnHandle;
1198
+ // Name of the carrier agent staged for THIS turn's fresh conversation, if
1199
+ // any. Recorded into stagedAgentByConversation once the outcome binds the
1200
+ // conversation id, so later gate-closed turns pass the same agent.
1201
+ let stagedAgentName: string | undefined;
1185
1202
  if (isContinuation) {
1186
1203
  const active = deps.driver.reentry();
1187
1204
  if (!active) {
@@ -1225,12 +1242,44 @@ async function runTurnDriver(
1225
1242
  // The uri is suffixed per turn so a deduping server cannot serve stale
1226
1243
  // content on turn 2+.
1227
1244
  const embeddedDigest = deps.engine === "acp" && digest ? digest : undefined;
1228
- // Fresh conversation only: agy stores the block in its own history, so
1229
- // re-sending it every turn would bloat each prompt and bust the cache.
1230
- const sysPrompt =
1231
- config.systemPrompt && !existing?.conversationId
1232
- ? getCurrentSystemPrompt(context.messages) || undefined
1233
- : undefined;
1245
+ // G10, fresh conversations only (bound conversations ride agy's own
1246
+ // history; re-sending would bloat every prompt and bust the cache).
1247
+ // stream-json delivery is a staged agent file, NOT prompt text: agy's
1248
+ // stream-json path silently drops any turn past a ~25KB input cap and
1249
+ // the dropped turn leaves the conversation permanently unresponsive
1250
+ // (issue #2, probed 2026-09-30), and the block routinely crossed it.
1251
+ // ACP keeps the inline block (JSON-RPC transport; --agent is a CLI
1252
+ // feature). A user-configured agent always wins: never clobber it.
1253
+ const gateOpen = config.systemPrompt && !existing?.conversationId;
1254
+ const rawSysPrompt = gateOpen ? getCurrentSystemPrompt(context.messages) || undefined : undefined;
1255
+ let sysPrompt = rawSysPrompt;
1256
+ let agent = config.agent;
1257
+ if (deps.engine !== "acp") {
1258
+ if (rawSysPrompt && !config.agent) {
1259
+ const stagedName = stageSyspromptAgent(
1260
+ [SYSTEM_PROMPT_PREAMBLE, rawSysPrompt, TOOL_PRIORITY_NOTE].join("\n\n"),
1261
+ );
1262
+ if (stagedName) {
1263
+ agent = stagedName;
1264
+ sysPrompt = undefined;
1265
+ stagedAgentName = stagedName;
1266
+ }
1267
+ // Staging failed: inline fallback. The silent-drop guard below
1268
+ // keeps the resulting cap failure visible instead of quiet.
1269
+ } else if (rawSysPrompt && config.agent && !warnedUserAgentConflict) {
1270
+ warnedUserAgentConflict = true;
1271
+ deps.log?.(
1272
+ "sysprompt-agent-conflict",
1273
+ { agent: config.agent, effect: "system prompt ships inline in the prompt (over-cap drop risk)" },
1274
+ "warn",
1275
+ );
1276
+ } else if (!rawSysPrompt && !config.agent && existing?.conversationId) {
1277
+ // Gate closed (bound conversation): pass the carrier staged for
1278
+ // THIS conversation, if any, so the driver profile never drifts on
1279
+ // "agent" and a respawned process reloads its own instructions.
1280
+ agent = stagedAgentByConversation.get(existing.conversationId);
1281
+ }
1282
+ }
1234
1283
  const fullPrompt =
1235
1284
  late.length > 0
1236
1285
  ? buildLateResultPrompt(late, prompt || undefined)
@@ -1242,12 +1291,12 @@ async function runTurnDriver(
1242
1291
  effort,
1243
1292
  mode: config.mode,
1244
1293
  skipPermissions: config.skipPermissions,
1245
- agent: config.agent,
1246
1294
  timeoutMin: config.turnTimeoutMin,
1247
1295
  inactivityMin: config.inactivityTimeoutMin,
1248
1296
  conversationId: existing?.conversationId ?? null,
1249
1297
  prompt: fullPrompt,
1250
1298
  images: images.length > 0 ? images : undefined,
1299
+ agent,
1251
1300
  contextBlock: embeddedDigest
1252
1301
  ? {
1253
1302
  uri: `urn:pi-bridge:context-digest/${messageCount}`,
@@ -1282,6 +1331,49 @@ async function runTurnDriver(
1282
1331
  }
1283
1332
 
1284
1333
  const outcome = await handle.outcome;
1334
+ // Issue #2 recovery paths. agy's stream-json prompt path silently drops an
1335
+ // over-cap turn (SUCCESS, empty response, zero usage, NO model step) and a
1336
+ // dropped turn leaves the conversation permanently unresponsive, so both
1337
+ // failures clear the binding instead of settling a quiet empty reply —
1338
+ // settling OK here would persist a dead conversation for every later turn
1339
+ // (probe 2026-09-30: resume of a dropped-turn conversation never returns
1340
+ // a result frame). stream-json only: the ACP driver leaves the evidence
1341
+ // fields undefined.
1342
+ const silentDrop = outcome.status === "OK" && outcome.modelOutputSeen === false;
1343
+ const poisonedResume =
1344
+ outcome.status === "ERROR" &&
1345
+ outcome.deadline !== undefined &&
1346
+ outcome.sawResult === false &&
1347
+ Boolean(existing?.conversationId);
1348
+ if (silentDrop || poisonedResume) {
1349
+ if (existing?.conversationId || outcome.conversationId) {
1350
+ store.delete(key);
1351
+ if (outcome.conversationId) stagedAgentByConversation.delete(outcome.conversationId);
1352
+ }
1353
+ deps.log?.(
1354
+ "turn-error",
1355
+ {
1356
+ reason: silentDrop ? "silent-overcap-drop" : "poisoned-conversation",
1357
+ conversation: outcome.conversationId ?? null,
1358
+ },
1359
+ "error",
1360
+ );
1361
+ // A deadline with no model output is a stall/timeout, not the over-cap
1362
+ // signature: label it by its real cause (the deadline guard's recovered-
1363
+ // answer probe can settle OK with an empty response after the kill).
1364
+ const dropCause = outcome.deadline
1365
+ ? "the turn hit its time limit and produced no model output"
1366
+ : "the prompt was silently dropped (agy discards prompts past ~25KB with no error)";
1367
+ finalize(
1368
+ stream,
1369
+ blocks,
1370
+ "error",
1371
+ silentDrop
1372
+ ? `agy returned success but produced no model output: ${dropCause}. Common cause: a very large pasted prompt. The conversation binding was cleared; retry with a smaller prompt.`
1373
+ : "agy stopped responding on the resumed conversation (no response frame before the deadline; the conversation is dead). The binding was cleared, so the next antigravity turn starts a fresh conversation.",
1374
+ );
1375
+ return;
1376
+ }
1285
1377
  if (outcome.conversationId) {
1286
1378
  // A different id than the stored one means the driver recreated the
1287
1379
  // conversation under us (ACP session-load fallback, stream-json CLI
@@ -1293,12 +1385,14 @@ async function runTurnDriver(
1293
1385
  // documented residual; recovery starts on the next turn.
1294
1386
  if (existing?.conversationId && outcome.conversationId !== existing.conversationId) {
1295
1387
  store.delete(key);
1388
+ stagedAgentByConversation.delete(existing.conversationId);
1296
1389
  } else if (outcome.status === "OK" && !outcome.aborted) {
1297
1390
  store.set(key, {
1298
1391
  conversationId: outcome.conversationId,
1299
1392
  lastStepIdx: -1,
1300
1393
  lastMessageCount: messageCount,
1301
1394
  });
1395
+ if (stagedAgentName) stagedAgentByConversation.set(outcome.conversationId, stagedAgentName);
1302
1396
  }
1303
1397
  // A failed or aborted turn on the SAME conversation keeps the pre-turn
1304
1398
  // record: whether the prompt actually landed in agy's DB is not
@@ -0,0 +1,109 @@
1
+ // G10 delivery for the stream-json engine: staged system-prompt agent.
2
+ //
3
+ // agy's stream-json prompt path silently drops any turn past a ~25KB input
4
+ // cap (probed 2026-09-30, issue #2: status SUCCESS, empty response, zero
5
+ // usage, no error — and the dropped turn leaves the conversation permanently
6
+ // unresponsive to later input). The pi system prompt block used to ride the
7
+ // first prompt of every fresh conversation and routinely crossed that cap,
8
+ // killing the whole session. It now stages as an agy agent file discovered
9
+ // under ~/.gemini/config/agents and passed via --agent: the CLI reads agent
10
+ // files from disk, off the prompt line, so the cap never applies.
11
+ //
12
+ // Frontmatter keeps agy's DEFAULTS on purpose (no excludeDefaultComponents,
13
+ // no tools list, no inheritMcp override): provider sessions need the normal
14
+ // toolset and the bridge MCP config for G9 round-trips. Contrast the ask-tool
15
+ // reviewer agent, which strips everything for sandboxing.
16
+ //
17
+ // Lifecycle: one agent dir per DISTINCT body (content-addressed by sha256);
18
+ // two conversations with different system prompts in one process stage two
19
+ // dirs and never overwrite each other, so a driver respawn mid-conversation
20
+ // always reloads that conversation's own instructions. Same body -> same dir
21
+ // (write-once; identical bytes are never rewritten). The name stays stable
22
+ // across the turns of a conversation so the driver's profile comparison
23
+ // never recycles on "agent" drift. The .pid marker lets sweepStaleWebAgents
24
+ // prune orphans (same doctrine as the web delegates and the ask-tool
25
+ // reviewer).
26
+
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { sweepStaleWebAgents } from "./web-tools.js";
32
+
33
+ export const SYS_AGENT_PREFIX = "pi-bridge-sys-";
34
+
35
+ /** AGY_AGENTS_ROOT override for tests and sandboxes (same contract as the
36
+ * ask-tool reviewer root). */
37
+ export function syspromptAgentsRoot(): string {
38
+ return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
39
+ }
40
+
41
+ /** agent.md for the carrier. The body is the pi system prompt composed by the
42
+ * provider (preamble + transcript system prompt + tool-priority note). */
43
+ export function syspromptAgentMd(name: string, body: string): string {
44
+ return [
45
+ "---",
46
+ `name: ${name}`,
47
+ "description: Pi bridge system-prompt carrier: pi operating instructions plus project AGENTS.md context",
48
+ "mainAgent: true",
49
+ "model: inherit",
50
+ "---",
51
+ "",
52
+ body,
53
+ "",
54
+ ].join("\n");
55
+ }
56
+
57
+ interface Staged {
58
+ root: string;
59
+ name: string;
60
+ dir: string;
61
+ }
62
+
63
+ /** Content-addressed staging cache: body sha256 -> staged dir. Two
64
+ * conversations with different system prompts in one process must never
65
+ * share a carrier file (a rewrite would leak one conversation's instructions
66
+ * into the other's respawned agy process). */
67
+ const stagedByHash = new Map<string, Staged>();
68
+
69
+ function bodyHash(body: string): string {
70
+ return createHash("sha256").update(body).digest("hex").slice(0, 12);
71
+ }
72
+
73
+ /** Stage the carrier agent with `body` as its instructions. Content-addressed:
74
+ * the same body reuses its dir (no rewrite), a new body gets a fresh dir.
75
+ * Returns the agent name for --agent, or null when staging failed — the
76
+ * caller then falls back to the legacy inline prompt block, where the
77
+ * silent-drop guard keeps the cap risk visible. */
78
+ export function stageSyspromptAgent(
79
+ body: string,
80
+ opts: { agentsRoot?: string } = {},
81
+ ): string | null {
82
+ try {
83
+ const root = opts.agentsRoot ?? syspromptAgentsRoot();
84
+ const hash = bodyHash(body);
85
+ const cached = stagedByHash.get(hash);
86
+ if (cached && cached.root === root) return cached.name;
87
+ sweepStaleWebAgents(root, Date.now(), SYS_AGENT_PREFIX);
88
+ fs.mkdirSync(root, { recursive: true });
89
+ // Nonce: two pi processes staging the same body must not fight over one
90
+ // dir; the pid plus nonce keeps every staging attempt self-contained.
91
+ const name = `${SYS_AGENT_PREFIX}${hash}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
92
+ const dir = path.join(root, name);
93
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ fs.writeFileSync(path.join(dir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
95
+ fs.writeFileSync(path.join(dir, "agent.md"), syspromptAgentMd(name, body), {
96
+ mode: 0o600,
97
+ });
98
+ const entry: Staged = { root, name, dir };
99
+ stagedByHash.set(hash, entry);
100
+ return entry.name;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Test hook: forget the staging cache without touching the filesystem. */
107
+ export function resetSyspromptAgentForTests(): void {
108
+ stagedByHash.clear();
109
+ }