@estebanforge/pi-antigravity-bridge 1.7.10 → 1.7.11

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,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.11] - 2026-10-10
6
+
7
+ ### Added
8
+
9
+ - AskAntigravity prompts now carry a `[TIME BUDGET]` notice matching the resolved timeout (default 10 minutes), placed before the plan-mode guard banners so the safety constraints stay the last text the model reads. `timeoutMinutes` values that cannot arm a real timer (non-positive, non-finite, above the 35791-minute ceiling) are rejected with a clear error instead of arming an instant-kill watchdog.
10
+
11
+ ### Changed
12
+
13
+ - **AskAntigravity dedup onto `pi-ask-shared` (behavior-preserving).** The tool's copy of the agy model grammar, tiered alias resolution, plan-mode guard prompts, prompt assembly, and argv construction now imports the shared peer adapter (new dependency `@estebanforge/pi-ask-shared`); `src/discovery.ts` is deleted — its conversation-bind technique (snapshot/diff + /proc FD scan, including the `onAmbiguous` hook, now upstream in the shared adapter) was the last consumer. The process block is deliberately kept bridge-local: it settles on close AND exit, leaves stdout/stderr uncapped, and threads the MCP-entry suppression release into cleanup — all three diverge from the shared `runProcess` contract on purpose. Shared `newConversationId` gained the `onAmbiguous` callback and exports `procTreeOpenDbResolver` (both absorbed from discovery.ts; `conversationDbPath` was unused and died with the module).
14
+
5
15
  ## [1.7.10] - 2026-10-07
6
16
 
7
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.10",
3
+ "version": "1.7.11",
4
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",
@@ -54,6 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
+ "@estebanforge/pi-ask-shared": "^0.1.2",
57
58
  "@modelcontextprotocol/sdk": "^1.29.0"
58
59
  }
59
60
  }
package/src/ask-tool.ts CHANGED
@@ -21,10 +21,21 @@ import { Text } from "@earendil-works/pi-tui";
21
21
  import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
22
22
  import { Type } from "typebox";
23
23
  import {
24
+ buildAgyArgs,
25
+ buildFinalPrompt,
26
+ buildTimeBudgetNotice,
27
+ MAX_TIMEOUT_MINUTES,
24
28
  CONVERSATIONS_DIR,
29
+ CONV_ID_RE,
30
+ filterHiddenModels,
31
+ mergeCatalog,
25
32
  newConversationId,
33
+ parseModelLine,
34
+ resolveAgyModel,
26
35
  snapshotConversations,
27
- } from "./discovery.js";
36
+ type ModelEntry,
37
+ type Mode,
38
+ } from "@estebanforge/pi-ask-shared";
28
39
  import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
29
40
  import { redactText } from "./redact.js";
30
41
  import { acquireBridgeSuppression } from "./mcp-registration.js";
@@ -33,49 +44,6 @@ import { sweepStaleWebAgents, webAgentsRoot } from "./web-tools.js";
33
44
 
34
45
  // --- Constants -------------------------------------------------------------
35
46
 
36
- const DEFAULT_TIMEOUT_MIN = 10;
37
- const GRACE_AFTER_TIMEOUT_MS = 5000;
38
- const STATUS_INTERVAL_MS = 1000;
39
- const STATUS_TAIL_CHARS = 160;
40
-
41
- // renderCall / renderResult preview limits (match pi-claude-bridge).
42
- const PREVIEW_MAX_CHARS = 1000;
43
- const PREVIEW_MAX_LINES = 6;
44
- const DISCOVERY_POLL_ATTEMPTS = 5;
45
- const DISCOVERY_POLL_MS = 100;
46
-
47
- // Per-family fallback tier when none is specified and no config default.
48
- const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
49
- flash: "medium",
50
- pro: "high",
51
- other: "medium",
52
- };
53
-
54
- const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
55
-
56
- // Static alias overlay for non-Gemini models agy may or may not surface.
57
- // Live catalog entries win on case-insensitive full-string equality; the
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). Claude ships
60
- // tiered like Gemini now, so the overlay carries the full tier spread.
61
- const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
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" },
68
- ];
69
- const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
70
- ["sonnet", "claude-sonnet-5-5"],
71
- ["opus", "claude-opus-5-5"],
72
- ]);
73
-
74
- // agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
75
- // leading-dash value can't misbind on agy's arg parser; hyphens allowed in the
76
- // body (real UUIDs contain them).
77
- const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
78
-
79
47
  const AGY_DESCRIPTION = `Delegate a self-contained sub-task to Google Antigravity. agy is the CLI for Gemini, so this tool is reached under three equivalent names the user may use interchangeably: **gemini**, **antigravity**, and **agy**. When the user says "ask gemini", "ask antigravity", "ask agy", or otherwise refers to any of these, call THIS tool. agy runs its OWN tool loop: it can read, write, edit, and execute inside the workspace, then returns its final answer. Use for a second opinion from a different model family, Gemini-specific reasoning, or isolated sub-tasks you do not need to drive step-by-step. Provide a complete, self-contained task description; agy will not see this conversation.
80
48
 
81
49
  TWO MODES (you choose):
@@ -93,223 +61,28 @@ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
93
61
  - Values (pi vocabulary): minimal|low|medium|high|xhigh|max. Clamped to agy's low|medium|high; unknown values fall back to low. "peer review on high thinking" -> thinking: "high".
94
62
  - An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
95
63
 
96
- // --- Types -----------------------------------------------------------------
97
-
98
- type Family = "flash" | "pro" | "other";
99
-
100
- interface ModelEntry {
101
- full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
102
- family: Family;
103
- version: string | null; // "3.6"
104
- tier: ThinkingTier | null;
105
- }
106
-
107
- /** Argv-facing model resolution: the exact --model slug plus an optional
108
- * --effort tier. Gemini bases split the tier out (the base slug alone is
109
- * invalid without --effort); fixed-thinking families keep agy's exact slug
110
- * and carry no effort. */
111
- interface ResolvedModel {
112
- model: string;
113
- effort?: ThinkingTier;
114
- }
115
-
116
- // --- Version helpers -------------------------------------------------------
117
-
118
- /** Descending numeric version compare (3.10 > 3.9, not lexical). */
119
- function compareVersionsDesc(a: string, b: string): number {
120
- const pa = a.split(".").map(Number);
121
- const pb = b.split(".").map(Number);
122
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
123
- const da = pa[i] ?? 0;
124
- const db = pb[i] ?? 0;
125
- if (da !== db) return db - da;
126
- }
127
- return 0;
128
- }
129
-
130
- // --- Model catalog ---------------------------------------------------------
131
-
132
- function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
133
- const seen = new Set(live.map((e) => e.full.toLowerCase()));
134
- const merged = [...live];
135
- for (const entry of STATIC_ALIAS_OVERLAY) {
136
- if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
137
- }
138
- return merged;
139
- }
140
-
141
- function parseModelLine(line: string): ModelEntry | null {
142
- // agy prints TWO columns: "<slug> <display label>". --model takes only the
143
- // slug (col 1), so split it off; the label is display-only and must never
144
- // reach --model. A bare-slug line (no whitespace) splits to itself.
145
- const full = line.trim().split(/\s+/)[0] ?? "";
146
- if (!full) return null;
147
- const lower = full.toLowerCase();
148
- const family: Family = lower.includes("flash")
149
- ? "flash"
150
- : lower.includes("pro")
151
- ? "pro"
152
- : "other";
153
- const versionMatch = lower.match(/(\d+\.\d+)/);
154
- const version = versionMatch ? versionMatch[1] : null;
155
- const tierMatch = lower.match(/-(low|medium|high)$/);
156
- const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
157
- return { full, family, version, tier };
158
- }
159
-
160
- function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
161
- if (available.includes(preferred)) return preferred;
162
- const sorted = [...available].sort((a, b) => {
163
- const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
164
- const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
165
- return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
166
- });
167
- return sorted[0] ?? preferred;
168
- }
169
-
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. */
177
- function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
178
- if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
179
- return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
180
- }
181
- return { model: full };
182
- }
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
-
211
- /** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
212
- * Returns null only when the family is unrecognized; the caller then passes
213
- * the raw input straight to agy. */
214
- export function resolveModel(
215
- input: string,
216
- entries: ModelEntry[],
217
- defaultThinking: ThinkingTier,
218
- /** Explicit thinking param (thinking/effort). Beats a tier embedded in
219
- * the alias ("flash high") and the configured default; clamped to the
220
- * family's real tiers, ignored for fixed-thinking families. */
221
- preferredTier?: ThinkingTier,
222
- ): ResolvedModel | null {
223
- const lower = input.toLowerCase().trim();
224
-
225
- const exact = entries.find((e) => e.full.toLowerCase() === lower);
226
- if (exact) return toResolved(exact.full, exact.tier);
227
-
228
- if (STATIC_SHORT_ALIAS.has(lower)) {
229
- return resolveTieredBase(
230
- STATIC_SHORT_ALIAS.get(lower) as string,
231
- entries,
232
- defaultThinking,
233
- preferredTier,
234
- );
235
- }
236
-
237
- let family: Family | null = lower.includes("flash")
238
- ? "flash"
239
- : lower.includes("pro")
240
- ? "pro"
241
- : null;
242
- const versionMatch = lower.match(/(\d+\.\d+)/);
243
- const version = versionMatch ? versionMatch[1] : null;
244
- const tierMatch = lower.match(/\b(low|medium|high)\b/);
245
- const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
246
-
247
- if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
248
- family = "flash";
249
- }
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
- }
263
-
264
- let candidates = entries.filter((e) => e.family === family);
265
- if (candidates.length === 0) return null;
266
-
267
- if (version) {
268
- const versioned = candidates.filter((e) => e.version === version);
269
- if (versioned.length > 0) candidates = versioned;
270
- } else {
271
- const aliases = candidates.filter((e) => e.version === null);
272
- if (aliases.length > 0) {
273
- candidates = aliases;
274
- } else {
275
- const versions = candidates
276
- .map((e) => e.version)
277
- .filter((v): v is string => v !== null)
278
- .sort(compareVersionsDesc);
279
- if (versions.length > 0) {
280
- const top = versions[0];
281
- const latest = candidates.filter((e) => e.version === top);
282
- if (latest.length > 0) candidates = latest;
283
- }
284
- }
285
- }
286
-
287
- const familyTiers = new Set(
288
- candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
289
- );
290
- if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
291
-
292
- const preferred =
293
- preferredTier ??
294
- tier ??
295
- (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
296
- const chosenTier = nearestTier([...familyTiers], preferred);
297
- const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
298
- return toResolved(picked.full, picked.tier);
299
- }
64
+ const DEFAULT_TIMEOUT_MIN = 10;
65
+ const GRACE_AFTER_TIMEOUT_MS = 5000;
66
+ const STATUS_INTERVAL_MS = 1000;
67
+ const STATUS_TAIL_CHARS = 160;
300
68
 
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-/;
69
+ // renderCall / renderResult preview limits (match pi-claude-bridge).
70
+ const PREVIEW_MAX_CHARS = 1000;
71
+ const PREVIEW_MAX_LINES = 6;
72
+ const DISCOVERY_POLL_ATTEMPTS = 5;
73
+ const DISCOVERY_POLL_MS = 100;
304
74
 
305
- /** Parse raw `agy models` text into tool-catalog entries (all families, plus
306
- * the static sonnet/opus overlay). Pure: no spawn. */
75
+ /** Parse raw `agy models` text into tool-catalog entries: the shared line
76
+ * grammar + hidden-family filter + static sonnet/opus overlay. Pure: no
77
+ * spawn. */
307
78
  export function toolModelsFromRaw(raw: string): ModelEntry[] {
308
79
  return mergeCatalog(
309
- raw
310
- .split("\n")
311
- .map(parseModelLine)
312
- .filter((e): e is ModelEntry => e !== null && !HIDDEN_FAMILY_RE.test(e.full)),
80
+ filterHiddenModels(
81
+ raw
82
+ .split("\n")
83
+ .map(parseModelLine)
84
+ .filter((e): e is ModelEntry => e !== null),
85
+ ),
313
86
  );
314
87
  }
315
88
 
@@ -325,56 +98,11 @@ function extraArgs(): string[] {
325
98
  return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
326
99
  }
327
100
 
328
- // --- Prompt assembly -------------------------------------------------------
329
-
330
- /** Guard for plan runs backed by the restricted reviewer agent. Difference
331
- * to PLAN_HEADLESS_GUARD: read commands are the intended analysis path
332
- * here, so the guard forbids FILE MUTATION - by tool or by shell - and
333
- * keeps the answer-from-material discipline. The agent is a damper, not
334
- * enforcement (upstream #1181); a headless command attempt can still be
335
- * auto-denied, which ends the run, so the guard steers toward view_file
336
- * and staged material first. */
337
- export const AGENT_REVIEW_GUARD = [
338
- "",
339
- "--- Review constraints ---",
340
- "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
341
- "- Prefer the staged material and view_file for reading. If you run a read-only command (git log, git diff, rg, ls) and it comes back denied, the run ends without an answer: state what you could not check instead of retrying commands.",
342
- "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
343
- ].join("\n");
344
-
345
- /** Appended to every headless plan-mode prompt. `agy -p` cannot answer
346
- * permission prompts: in plan mode a run_command attempt is soft-denied and
347
- * the turn ends AT the denial (exit 0, empty stdout, notice only on stderr -
348
- * the empty-output branch in execute). Probed 2026-09-28 on agy 1.2.12:
349
- * allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
350
- * --sandbox does NOT relax the command gate, so the only input every user
351
- * is guaranteed is the prompt itself. The guard steers the model to answer
352
- * from that material. accept-edits runs keep their tools and never get it. */
353
- export const PLAN_HEADLESS_GUARD = [
354
- "",
355
- "--- Headless session constraints ---",
356
- "- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
357
- "- Work only from the material provided in this prompt.",
358
- "- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
359
- ].join("\n");
360
-
361
- /** Assemble the prompt sent to agy: digest marker first (existing behavior),
362
- * then the caller's prompt, then the plan-mode guard last - the position
363
- * the model reads with the most recency. No user-config mutation: this is
364
- * the only plan-mode lever the tool itself owns. */
365
- export function buildFinalPrompt(
366
- prompt: string,
367
- mode: AgyMode,
368
- digest: boolean,
369
- /** True when the restricted reviewer agent is staged: its empty edit
370
- * toolset is a damper (the CLI does not enforce review-only, upstream
371
- * #1181), so the prompt may ALLOW read-only commands. */
372
- agentDamper = false,
373
- ): string {
374
- let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
375
- if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
376
- return out;
377
- }
101
+ // --- Prompt assembly: shared peer adapter ----------------------------------
102
+ // PLAN_HEADLESS_GUARD / AGENT_REVIEW_GUARD / buildFinalPrompt live in
103
+ // pi-ask-shared (peers/antigravity.ts); the bridge inherits the identical
104
+ // guard discipline. Re-exported for the bridge's own tests.
105
+ export { AGENT_REVIEW_GUARD, buildFinalPrompt, PLAN_HEADLESS_GUARD } from "@estebanforge/pi-ask-shared";
378
106
 
379
107
  // --- Plan-mode reviewer agent ----------------------------------------------
380
108
 
@@ -505,7 +233,7 @@ export async function registerAskAntigravityTool(
505
233
  }),
506
234
  ),
507
235
  timeoutMinutes: Type.Optional(
508
- Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.` }),
236
+ Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}. Fractional minutes allowed; must be above 0 and at most ${MAX_TIMEOUT_MINUTES}. The prompt carries a matching [TIME BUDGET] notice.` }),
509
237
  ),
510
238
  includeContext: Type.Optional(
511
239
  Type.Boolean({
@@ -527,7 +255,7 @@ export async function registerAskAntigravityTool(
527
255
  const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
528
256
  const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
529
257
  const resolved =
530
- resolveModel(
258
+ resolveAgyModel(
531
259
  requestedModel,
532
260
  entries,
533
261
  cfg.defaultThinking,
@@ -674,7 +402,7 @@ export async function registerAskAntigravityTool(
674
402
  ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER)
675
403
  : undefined;
676
404
  const resolved =
677
- resolveModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
405
+ resolveAgyModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
678
406
  model: requestedModel,
679
407
  };
680
408
 
@@ -696,11 +424,19 @@ export async function registerAskAntigravityTool(
696
424
  }
697
425
 
698
426
  const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
427
+ if (!(timeoutMin > 0 && Number.isFinite(timeoutMin) && timeoutMin <= MAX_TIMEOUT_MINUTES)) {
428
+ // timeoutMinutes: 0 would arm runProcess's watchdog with a 0ms
429
+ // timeout (Node clamps to 1ms) and kill the peer instantly.
430
+ return {
431
+ content: [{ type: "text", text: `timeoutMinutes must be a positive number of minutes up to ${MAX_TIMEOUT_MINUTES} (for example 0.5 for 30 seconds).` }],
432
+ details: emptyDetails(requestedModel, resolved.model),
433
+ };
434
+ }
699
435
 
700
436
  const rawConvId = params.conversationId;
701
437
  const isContinuation =
702
438
  typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
703
- const snapshot = isContinuation ? null : snapshotConversations();
439
+ const snapshot = isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR);
704
440
 
705
441
  const mode: AgyMode =
706
442
  (params.mode as AgyMode | undefined) ?? (config.mode === "plan" ? "plan" : "accept-edits");
@@ -721,8 +457,13 @@ export async function registerAskAntigravityTool(
721
457
  }
722
458
  const useDigest: boolean =
723
459
  typeof params.digest === "boolean" ? params.digest : mode === "plan";
460
+ // Budget notice into the prompt BEFORE buildFinalPrompt: the plan-mode
461
+ // guard banners must stay the final text (last-read recency), so the
462
+ // notice can't go after them. The builder itself stays notice-free so
463
+ // callers can't double-wrap.
464
+ const budget = buildTimeBudgetNotice(timeoutMin * 60_000);
724
465
  const finalPrompt: string = buildFinalPrompt(
725
- params.prompt,
466
+ budget ? `${params.prompt}\n\n${budget}` : params.prompt,
726
467
  mode,
727
468
  useDigest,
728
469
  reviewerAgent !== null,
@@ -751,45 +492,27 @@ export async function registerAskAntigravityTool(
751
492
  ? `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}`
752
493
  : finalPrompt;
753
494
 
754
- const args: string[] = ["--add-dir", cwd];
755
495
  log?.(
756
496
  "ask-start",
757
497
  { model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
758
498
  "info",
759
499
  );
760
- const extraRaw = extraArgs();
761
- // Fail-closed on every plan run: AGY_EXTRA_ARGS lands before the mode
762
- // flags, so an env-injected skip flag would re-arm exactly what the
763
- // never-flag-on-plan rule withholds (upstream #1181: an auto-approved
764
- // plan run is write-capable). The reviewer agent is a damper, not a
765
- // license for the flag.
766
- const extra =
767
- mode === "plan"
768
- ? extraRaw.filter((a) => !a.startsWith("--dangerously-skip-permissions"))
769
- : extraRaw;
770
- if (extra.length) args.push(...extra);
771
- if (resolved.model) args.push("--model", resolved.model);
772
- if (resolved.effort) args.push("--effort", resolved.effort);
773
- args.push("--mode", mode);
774
- if (reviewerAgent) args.push("--agent", reviewerAgent.name);
775
- // Honor the shared permissions setting (same knob as the provider).
776
- // accept-edits: the flag keeps commands from hanging on an
777
- // unanswerable prompt in -p mode. Plan runs NEVER get the flag: it
778
- // auto-approves every permission request, and the CLI does not gate
779
- // writes under plan mode (upstream
780
- // google-antigravity/antigravity-cli#1181, probed 2026-10-07), so an
781
- // auto-approved plan run is a write-capable run. The reviewer agent's
782
- // toolset is a damper, not a guarantee; the 2026-09-28 "safe with the
783
- // restricted toolset" probe predates the #1181 evidence. Without the
784
- // flag, command attempts fail visibly (headless auto-deny) instead of
785
- // running approved.
786
- if (config.skipPermissions !== false && mode !== "plan") {
787
- args.push("--dangerously-skip-permissions");
788
- }
789
- if (isContinuation) args.push("--conversation", rawConvId as string);
790
- args.push("--print-timeout", `${timeoutMin}m`);
791
- if (contextFile) args.push("--add-dir", askContextDir());
792
- args.push("-p", effectivePrompt);
500
+ // Build argv via the shared peer adapter (see buildAgyArgs there for
501
+ // the flag-order contract and the fail-closed plan-run rules). The
502
+ // bridge's permissions gate is truthy (`!== false`), so an unset
503
+ // config counts as enabled - same knob as the provider.
504
+ const args = buildAgyArgs({
505
+ cwd,
506
+ resolved,
507
+ mode,
508
+ reviewerAgentName: reviewerAgent?.name ?? null,
509
+ skipPermissions: config.skipPermissions !== false,
510
+ conversationId: isContinuation ? (rawConvId as string) : null,
511
+ timeoutMinutes: timeoutMin,
512
+ addDirs: contextFile ? [askContextDir()] : [],
513
+ extraArgs: extraArgs(),
514
+ prompt: effectivePrompt,
515
+ });
793
516
 
794
517
  const details: AgyDetails = {
795
518
  model: requestedModel,
package/src/discovery.ts DELETED
@@ -1,199 +0,0 @@
1
- // Discover the agy conversation id that `agy -p` creates but never prints.
2
- //
3
- // agy's print mode writes its steps to a fresh SQLite DB at
4
- // ~/.gemini/antigravity-cli/conversations/<uuid>.db. It does NOT echo the id.
5
- // The only reliable bind is to snapshot the *.db stems in that dir before
6
- // spawn, then diff after: exactly one new file = ours. If several appear we
7
- // refuse to bind (can't tell which is ours) - same approach as antigravity-acp
8
- // (scan.ts), agy-acp (scan.ts), and pi-ask-antigravity.
9
- //
10
- // CONCURRENCY: when another agy (or subagent) starts in parallel, more than
11
- // one new .db can land in the dir during our turn. mtime filtering does NOT
12
- // disambiguate two *active* concurrent runs (both DBs are >= turn-start), and
13
- // prompt-content matching is fragile (the user-message step_type 98 payload
14
- // is undocumented and deeply nested; a field-number shift would silently
15
- // misbind). The authoritative signal is which candidate .db OUR spawned agy
16
- // process tree holds open right now - resolved via /proc/<pid>/fd on Linux.
17
- // When that signal is unavailable (no pid, non-Linux, process already exited,
18
- // or the scan itself is ambiguous) we fail safe to null, preserving the
19
- // original "refuse to bind" behavior rather than guessing.
20
-
21
- import fs from "node:fs";
22
- import os from "node:os";
23
- import path from "node:path";
24
-
25
- /** Default conversations dir. Override with AGY_CONVERSATIONS_DIR. */
26
- export const CONVERSATIONS_DIR =
27
- process.env.AGY_CONVERSATIONS_DIR ||
28
- path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
29
-
30
- /** Snapshot the set of conversation ids (*.db stems) currently on disk.
31
- * Empty set (not throw) when the dir is missing - agy will create it. */
32
- export function snapshotConversations(dir: string = CONVERSATIONS_DIR): Set<string> {
33
- const out = new Set<string>();
34
- let entries: string[];
35
- try {
36
- entries = fs.readdirSync(dir);
37
- } catch {
38
- return out;
39
- }
40
- for (const f of entries) {
41
- if (f.endsWith(".db")) out.add(f.slice(0, -3));
42
- }
43
- return out;
44
- }
45
-
46
- /** Resolve which of the `candidates` DB ids is held open by the process tree
47
- * rooted at `rootPid`. Used to disambiguate concurrent agy runs.
48
- *
49
- * Returns the single matching id, or null when none / several are open, when
50
- * /proc is unavailable, or when reading fails. Injectable so tests can stub
51
- * it without touching the filesystem. */
52
- export type OpenDbResolver = (
53
- rootPid: number,
54
- dir: string,
55
- candidates: Set<string>,
56
- ) => string | null;
57
-
58
- /** Read pid + ppid from /proc/<pid>/stat. Returns null when the entry is gone
59
- * (process exited) or unparseable. comm may contain spaces and parens, so we
60
- * split on the LAST ')' rather than naively tokenizing. */
61
- function readProcStat(pid: number): { pid: number; ppid: number } | null {
62
- let raw: string;
63
- try {
64
- raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
65
- } catch {
66
- return null;
67
- }
68
- const closeParen = raw.lastIndexOf(")");
69
- if (closeParen < 0) return null;
70
- // After "pid (comm)" come: state ppid pgrp session ...
71
- const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
72
- const ppid = Number(fields[1]);
73
- if (!Number.isFinite(ppid)) return null;
74
- return { pid, ppid };
75
- }
76
-
77
- /** Collect rootPid and every descendant pid by walking /proc/<pid>/stat
78
- * parent links. Single pass over /proc building a pid->ppid map, then
79
- * iterated to closure. */
80
- function collectDescendants(rootPid: number): Set<number> {
81
- const out = new Set<number>([rootPid]);
82
- let entries: string[];
83
- try {
84
- entries = fs.readdirSync("/proc");
85
- } catch {
86
- return out;
87
- }
88
- const ppidOf = new Map<number, number>();
89
- for (const e of entries) {
90
- if (!/^\d+$/.test(e)) continue;
91
- const s = readProcStat(Number(e));
92
- if (s) ppidOf.set(s.pid, s.ppid);
93
- }
94
- let changed = true;
95
- while (changed) {
96
- changed = false;
97
- for (const [pid, ppid] of ppidOf) {
98
- if (out.has(pid)) continue;
99
- if (out.has(ppid)) {
100
- out.add(pid);
101
- changed = true;
102
- }
103
- }
104
- }
105
- return out;
106
- }
107
-
108
- /** Linux /proc implementation of OpenDbResolver. Scans every FD symlink in
109
- * the root process's tree and returns the one candidate .db that is open.
110
- * Non-authoritative when zero or >1 candidates are open - caller fails safe. */
111
- export const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
112
- if (candidates.size <= 1) return null; // nothing to disambiguate
113
- if (process.platform !== "linux") return null; // /proc/<pid>/fd is Linux-only
114
- const dirResolved = safeRealpath(dir);
115
- const tree = collectDescendants(rootPid);
116
- const found = new Set<string>();
117
- for (const pid of tree) {
118
- let fds: string[];
119
- try {
120
- fds = fs.readdirSync(`/proc/${pid}/fd`);
121
- } catch {
122
- continue; // process gone or fd dir unreadable
123
- }
124
- for (const fd of fds) {
125
- let target: string;
126
- try {
127
- target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
128
- } catch {
129
- continue;
130
- }
131
- // agy also holds .db-wal / .db-shm open; only the base .db matters.
132
- const base = path.basename(target);
133
- if (!base.endsWith(".db")) continue;
134
- if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
135
- const id = base.slice(0, -3);
136
- if (candidates.has(id)) found.add(id);
137
- }
138
- }
139
- if (found.size === 1) return [...found][0] ?? null;
140
- return null;
141
- };
142
-
143
- /** fs.realpathSync that swallows ENOENT (dir may not be canonicalized yet). */
144
- function safeRealpath(p: string): string | null {
145
- try {
146
- return fs.realpathSync(p);
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- /** Options for {@link newConversationId}. */
153
- export interface BindOptions {
154
- /** Pid of the spawned agy (the process we want to bind to). When set and
155
- * multiple candidate DBs exist, the process-tree FD resolver tries to
156
- * pick ours. Omit to keep the legacy fail-safe behavior. */
157
- pid?: number;
158
- /** Override resolver (tests). Defaults to the Linux /proc scanner. */
159
- resolveOpenDb?: OpenDbResolver;
160
- /** Invoked when the snapshot is ambiguous (more than one new DB since
161
- * `before`) and the resolver could not authoritatively pick ours. Lets a
162
- * caller bound its retry budget to the genuinely-ambiguous case only, so
163
- * the ordinary "agy hasn't created its DB yet" wait is not counted
164
- * against it. Not invoked when there is nothing new yet, or when exactly
165
- * one new DB binds, or when the resolver succeeds. */
166
- onAmbiguous?: () => void;
167
- }
168
-
169
- /** Find the conversation id created since `before`. Returns null when none
170
- * appeared, or when several appeared and we cannot authoritatively tie one
171
- * to our process. Pass `opts.pid` to enable concurrent-run disambiguation. */
172
- export function newConversationId(
173
- dir: string,
174
- before: Set<string>,
175
- opts: BindOptions = {},
176
- ): string | null {
177
- const after = snapshotConversations(dir);
178
- const created = [...after].filter((id) => !before.has(id));
179
- if (created.length === 0) return null;
180
- if (created.length === 1) return created[0] ?? null;
181
- // Ambiguous: more than one new DB since the snapshot. Try to authoritatively
182
- // identify ours via the spawned process's open files. Fail safe to null
183
- // (refuse to bind) when the resolver can't pick exactly one.
184
- if (opts.pid !== undefined) {
185
- const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
186
- const hit = resolve(opts.pid, dir, new Set(created));
187
- if (hit && created.includes(hit)) return hit;
188
- }
189
- opts.onAmbiguous?.();
190
- return null;
191
- }
192
-
193
- /** Path to the DB file for a given conversation id. */
194
- export function conversationDbPath(
195
- id: string,
196
- dir: string = CONVERSATIONS_DIR,
197
- ): string {
198
- return path.join(dir, `${id}.db`);
199
- }