@estebanforge/pi-ask-antigravity 1.2.9 → 1.2.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,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.11] - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - **Background runs (`background`, opt-in, default blocking).** Detached runs with the shared wake contract: `runId` at once, the answer arrives later as a conversation message with the resume handle (when agy reports one; discovery misses say so) under an UNTRUSTED banner. `/agy-stop [runId]` kills a run. One run per conversation at a time across blocking and background (per-handle lock). Refused in print/json mode; session replacement kills in-flight runs and loses their results; the staged context file and plan-mode reviewer dir are cleaned on every exit path including session shutdown.
10
+
11
+
12
+ ### Fixed
13
+
14
+ - **Plan runs are no longer auto-approved and no longer claim enforcement.** The CLI does not gate writes under plan mode ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)): the previously documented "write attempts fail at plan approval" behavior never held, and plan runs carrying the skip-permissions flag (whenever the restricted agent staged) were write-capable. Plan runs now never receive the flag (including via `AGY_EXTRA_ARGS`, filtered by prefix), the restricted agent is documented as a damper, and the tool description, param description, and README no longer call plan review-only or enforced.
15
+
16
+ ## [1.2.10] - 2026-10-06
17
+
18
+ ### Changed
19
+
20
+ - **Breaking: model aliases track agy's Claude 5.5 tiered catalog.** `sonnet`/`opus` now resolve to `claude-sonnet-5-5`/`claude-opus-5-5` with the tier riding `--effort` (nearest-tier pick against the requested thinking level), replacing the retired fixed `claude-sonnet-4-6`/`claude-opus-4-6-thinking` slugs Google removed from Antigravity. The static overlay carries the full low/medium/high spread so the aliases still resolve when the live catalog omits Claude, and the bare base id (`claude-sonnet-5-5`) resolves to the nearest tier variant because a bare base is rejected upstream. Exact tiered ids (`claude-sonnet-5-5-low`) split to base + effort exactly like Gemini's.
21
+ - **The `gpt-oss` alias and its overlay entry are removed** ahead of upstream's announced removal of that family: nobody used the fixed-thinking slug and it loses to the cheaper tiered options. The alias now resolves to nothing (the raw string passes to agy and fails there) instead of the tool offering a dying model.
22
+
5
23
  ## [1.2.9] - 2026-09-28
6
24
 
7
25
  ### Fixed
package/README.md CHANGED
@@ -49,21 +49,20 @@ Verbose `agy models` strings (`Gemini 3.5 Flash (Medium)`) are hostile to natura
49
49
  | `pro low` | latest Pro, Low |
50
50
  | `3.5 flash` | pinned version, default tier |
51
51
  | `3.1 pro` | pinned Pro version (→ High) |
52
- | `sonnet` | Claude Sonnet 4.6 (Thinking) |
53
- | `opus` | Claude Opus 4.6 (Thinking) |
54
- | `gpt-oss` | GPT-OSS 120B (Medium) |
52
+ | `sonnet` | Claude Sonnet 5.5, default tier (Medium) |
53
+ | `opus` | Claude Opus 5.5, default tier (Medium) |
55
54
  | `Gemini 3.5 Flash (Medium)` | exact passthrough |
56
55
 
57
56
  - **Latest** = highest version number available for the family.
58
57
  - **Default tier**: Flash → Medium, Pro → High. Overridable per-config (below).
59
58
  - When a tier is unavailable for a family, the nearest is chosen with ties broken toward the higher tier (so "latest and greatest" wins).
60
- - The non-Gemini aliases (`sonnet`, `opus`, `gpt-oss`) are a static overlay merged with the live `agy models` catalog. Live entries always win on case-insensitive full-string equality, so an updated `agy` listing takes precedence over the hardcoded fallback.
59
+ - The non-Gemini aliases (`sonnet`, `opus`) are a static overlay (the full low/medium/high tier spread) merged with the live `agy models` catalog. Live entries always win on case-insensitive full-string equality, so an updated `agy` listing takes precedence over the hardcoded fallback. A bare base id (`claude-sonnet-5-5`) resolves to the nearest tier variant; the retired `gpt-oss` family resolves to nothing.
61
60
 
62
61
  ## Execution modes (`mode`)
63
62
 
64
63
  | Mode | Flag | Use when |
65
64
  | --- | --- | --- |
66
- | `plan` | `--mode plan` | agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: the skip-permissions flag is never passed in plan mode, so write and command attempts fail visibly at plan approval instead of executing. |
65
+ | `plan` | `--mode plan` | Review-shaped run for cross-review and read-only tasks. NOT a security boundary: the CLI does not gate writes under plan mode ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181), probed 2026-10-07: the write can execute in the same turn). The skip-permissions flag is never passed, so nothing runs auto-approved, and a temporary restricted agent (no file-editing tools) stages as a damper. Treat plan output as review material, not as proof nothing happened. |
67
66
  | `accept-edits` (default) | `--mode accept-edits` | agy applies edits directly inside the workspace. |
68
67
 
69
68
  For agy's orthogonal `--sandbox` shell-containment flag, set `AGY_EXTRA_ARGS=--sandbox` in your environment. The extension does not expose it as a `mode` value because `--sandbox` controls command containment, not edit persistence.
@@ -111,7 +110,7 @@ Interactive picker for the default model and default thinking. If the project co
111
110
  | `prompt` | yes | Self-contained task. agy cannot see the Pi conversation. |
112
111
  | `cwd` | no | Workspace agy runs in. Defaults to the project root. |
113
112
  | `model` | no | Alias or exact id. Omit for the configured default. |
114
- | `mode` | no | `plan` (review-only; enforced via plan approval) or `accept-edits` (agy applies edits, default). |
113
+ | `mode` | no | `plan` (review-shaped; not enforced, see the modes table) or `accept-edits` (agy applies edits, default). |
115
114
  | `digest` | no | Prefix the prompt with `(Use compact digests, not full file contents.)`. Defaults on for `plan`, off for `accept-edits`. |
116
115
  | `conversationId` | no | Omit for a one-shot (agy starts fresh). Pass the id from a prior call's result (`details.conversationId`) to resume that agy conversation with full context. See [Two modes](#two-modes-one-shot-vs-continued-conversation). |
117
116
  | `timeoutMinutes` | no | Hard cap on the run. Default 10. |
@@ -136,6 +135,10 @@ agy mutates the workspace itself, so scope calls to sub-tasks where that is the
136
135
 
137
136
  Google's [Antigravity ToS](https://antigravity.google/terms) states that using third-party software to access the Service (e.g. driving `agy` from a non-Google editor) is a breach of the Agreement and may be grounds for suspension or termination of your account. `agy` handles its own Google OAuth; this extension only spawns the official binary as a subprocess — but the effect is still a non-Google editor driving the Antigravity service through a third-party tool. **By using this extension against an `agy` session logged into your personal Antigravity account, you accept that risk.**
138
137
 
138
+ ## Background runs
139
+
140
+ Set `background: true` on a call to run detached: the tool returns a `runId` at once, the calling agent keeps working, and the full answer arrives later as a message in the conversation (wrapped in an UNTRUSTED banner: it is tool output, never a user instruction). The message carries the resume handle (`conversationId`) when agy reports one; fresh runs where id discovery finds nothing say so instead of inventing a handle. One run per conversation at a time (blocking and background share the lock). Stop a run with `/agy-stop [runId]` (unique id prefix; no arg stops the only running run). Constraints: refused in print/json mode (the process exits before the message could arrive); a `/new`, `/resume`, `/fork`, or `/reload` kills in-flight runs and their results are lost; at most 4 runs at once; failure wakes (timeout, crash) carry up to 400 chars of peer output as the reason, also banner-marked.
141
+
139
142
  ## License
140
143
 
141
144
  MIT
@@ -45,6 +45,13 @@ import {
45
45
  import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
46
46
  import { contentText } from "@earendil-works/pi-ai";
47
47
  import { Type } from "typebox";
48
+ import {
49
+ BackgroundRunRegistry,
50
+ backgroundFlagText,
51
+ createStopHandler,
52
+ createWakeSender,
53
+ summarizePrompt,
54
+ } from "@estebanforge/pi-ask-shared";
48
55
 
49
56
  // --- Constants -------------------------------------------------------------
50
57
 
@@ -97,9 +104,11 @@ function levelToTier(level: string): ThinkingTier {
97
104
  }
98
105
 
99
106
  // Mode = which agy tool-loop policy to apply. Distinct from the alias layer.
100
- // "plan" → --mode plan (no edits; review-only — the skip-
101
- // permissions flag is withheld in plan
102
- // mode so the plan-approval gate holds)
107
+ // "plan" → --mode plan (review-shaped; NOT a security boundary:
108
+ // the CLI does not gate writes under
109
+ // plan, upstream #1181. The skip-
110
+ // permissions flag is still withheld so
111
+ // nothing runs auto-approved.)
103
112
  // "accept-edits" → --mode accept-edits (agy applies edits)
104
113
  //
105
114
  // Note: agy's --sandbox flag is an orthogonal shell-containment setting
@@ -112,13 +121,15 @@ type Mode = "plan" | "accept-edits";
112
121
  // whose full string equals the overlay target, the live entry wins. When it
113
122
  // does not (older agy, missing model, plan gate), the overlay entry resolves
114
123
  // the alias so the user can still type "sonnet" and get a working answer.
115
- // "sonnet" -> claude-sonnet-4-6
116
- // "opus" -> claude-opus-4-6-thinking
117
- // "gpt-oss" -> gpt-oss-120b-medium
124
+ // "sonnet" -> claude-sonnet-5-5 (tiered low/medium/high like Gemini)
125
+ // "opus" -> claude-opus-5-5 (tiered low/medium/high like Gemini)
118
126
  const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
119
- { full: "claude-sonnet-4-6", family: "other", version: null, tier: null },
120
- { full: "claude-opus-4-6-thinking", family: "other", version: null, tier: null },
121
- { full: "gpt-oss-120b-medium", family: "other", version: null, tier: null },
127
+ { full: "claude-sonnet-5-5-low", family: "other", version: null, tier: "low" },
128
+ { full: "claude-sonnet-5-5-medium", family: "other", version: null, tier: "medium" },
129
+ { full: "claude-sonnet-5-5-high", family: "other", version: null, tier: "high" },
130
+ { full: "claude-opus-5-5-low", family: "other", version: null, tier: "low" },
131
+ { full: "claude-opus-5-5-medium", family: "other", version: null, tier: "medium" },
132
+ { full: "claude-opus-5-5-high", family: "other", version: null, tier: "high" },
122
133
  ];
123
134
 
124
135
  // Short alias → overlay full string. Used by resolveModel to recognize
@@ -126,11 +137,21 @@ const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
126
137
  // not match. The overlay entries are also merged into the live catalog for
127
138
  // exact-string passthrough, so this map only needs to cover the short names.
128
139
  const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
129
- ["sonnet", "claude-sonnet-4-6"],
130
- ["opus", "claude-opus-4-6-thinking"],
131
- ["gpt-oss", "gpt-oss-120b-medium"],
140
+ ["sonnet", "claude-sonnet-5-5"],
141
+ ["opus", "claude-opus-5-5"],
132
142
  ]);
133
143
 
144
+ /** Families dropped outright even while `agy models` still lists them: the
145
+ * fixed-thinking single slug loses to the cheaper effort-tiered options and
146
+ * its removal upstream is already announced, so we stop offering it now. */
147
+ const HIDDEN_FAMILY_RE = /^gpt-oss-/;
148
+
149
+ /** Drop families we refuse to offer. Exported pure so tests exercise the
150
+ * same filter the live parse runs; discoverModels applies it per load. */
151
+ export function filterHiddenModels(entries: ModelEntry[]): ModelEntry[] {
152
+ return entries.filter((e) => !HIDDEN_FAMILY_RE.test(e.full));
153
+ }
154
+
134
155
  /** Merge the live catalog with the static alias overlay. Live entries win on
135
156
  * case-insensitive full-string equality so an updated `agy models` listing
136
157
  * always takes precedence over the hardcoded fallback. */
@@ -157,7 +178,7 @@ TWO MODES (you choose):
157
178
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact — use for follow-ups, multi-turn refinement, or when the user says "ask agy to follow up / continue / now do X based on what you just did". Thread the id from each result into the next call.
158
179
 
159
180
  EXECUTION MODES (param: mode):
160
- - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs execute in a temporary restricted agent whose toolset has NO file-editing tools (hard block); the reviewer may still execute arbitrary shell commands, kept read-only by the prompt guard alone. Inline the material to review - a plan run cannot fetch it.
181
+ - **plan**: agy proposes a plan for review-shaped tasks. NOT a security boundary: the CLI does not gate writes under plan mode (upstream google-antigravity/antigravity-cli#1181, probed 2026-10-07: the write can execute in the same turn). The run additionally stages a temporary restricted agent whose toolset has no file-editing tools, as a damper, and the skip-permissions flag is never passed. Inline the material to review - a plan run cannot fetch it.
161
182
  - **accept-edits** (default): agy applies edits directly inside the workspace.
162
183
  - For agy's orthogonal \`--sandbox\` shell-containment flag, set the \`AGY_EXTRA_ARGS=--sandbox\` env var.
163
184
 
@@ -302,15 +323,17 @@ export const PLAN_HEADLESS_GUARD = [
302
323
  ].join("\n");
303
324
 
304
325
  /** Guard for plan runs backed by the restricted reviewer agent. Difference
305
- * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
306
- * agent's commandExecutionPolicy auto-approves them headless, probed
307
- * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
308
- * and keeps the answer-from-material discipline. */
326
+ * to PLAN_HEADLESS_GUARD: read commands are the intended analysis path
327
+ * here, so the guard forbids FILE MUTATION - by tool or by shell - and
328
+ * keeps the answer-from-material discipline. The agent is a damper, not
329
+ * enforcement (upstream #1181); a headless command attempt can still be
330
+ * auto-denied, which ends the run, so the guard steers toward view_file
331
+ * and staged material first. */
309
332
  export const AGENT_REVIEW_GUARD = [
310
333
  "",
311
334
  "--- Review constraints ---",
312
335
  "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
313
- "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
336
+ "- 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.",
314
337
  "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
315
338
  ].join("\n");
316
339
 
@@ -323,11 +346,12 @@ export function buildFinalPrompt(
323
346
  mode: Mode,
324
347
  digest: boolean,
325
348
  /** True when the restricted reviewer agent is staged: its empty edit
326
- * toolset is the hard block, so the prompt may ALLOW read-only commands. */
327
- agentEnforced = false,
349
+ * toolset is a damper (the CLI does not enforce review-only, upstream
350
+ * #1181), so the prompt may ALLOW read-only commands. */
351
+ agentDamper = false,
328
352
  ): string {
329
353
  let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
330
- if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
354
+ if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
331
355
  return out;
332
356
  }
333
357
 
@@ -342,12 +366,12 @@ export function askAgentsRoot(): string {
342
366
  return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
343
367
  }
344
368
 
345
- /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
346
- * with no file-editing tool present the model cannot emit an edit call at
347
- * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
348
- * edits, with and without the skip flag). commandExecutionPolicy auto is
349
- * what lets read commands run headless with no user allow rules. Never add
350
- * a write-capable tool here. */
369
+ /** agent.md for the plan-mode reviewer. The tools list is a damper, not
370
+ * enforcement: with no file-editing tool present the model cannot emit an
371
+ * edit call through the agent toolset, but the CLI does not enforce
372
+ * review-only (upstream #1181 - writes bypass the permission system and
373
+ * the toolset is not honored reliably on 1.3.x). Prefer view_file for
374
+ * reads here too. Never add a write-capable tool. */
351
375
  export function reviewerAgentMd(name: string): string {
352
376
  return [
353
377
  "---",
@@ -466,19 +490,47 @@ function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): Thinki
466
490
  return sorted[0] ?? preferred;
467
491
  }
468
492
 
469
- /** Build the argv-facing resolution from a picked catalog entry. Gemini bases
470
- * (slugs starting "gemini-") accept a separate --effort, so split the tier
471
- * suffix out of the slug: the base alone (gemini-3.6-flash) is what --model
472
- * wants, and the tier goes to --effort. Fixed-thinking families keep agy's
473
- * exact slug even when it carries a -medium suffix (gpt-oss-120b-medium):
474
- * agy rejects --effort for them, so the suffix stays part of the slug. */
493
+ /** Build the argv-facing resolution from a picked catalog entry. Gemini and
494
+ * Claude bases (slugs starting "gemini-"/"claude-") accept a separate
495
+ * --effort, so split the tier suffix out of the slug: the base alone
496
+ * (claude-sonnet-5-5) is what --model wants, and the tier goes to --effort
497
+ * (agy rejects a bare base: "requires --effort"). Only unverified families
498
+ * keep agy's exact slug whole, so an unknown suffix can never trigger an
499
+ * unsupported --effort. */
475
500
  function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
476
- if (tier && full.toLowerCase().startsWith("gemini-")) {
501
+ if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
477
502
  return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
478
503
  }
479
504
  return { model: full };
480
505
  }
481
506
 
507
+ /** Resolve a tiered base id - a short-alias target ("claude-sonnet-5-5") or a
508
+ * bare base slug the provider itself advertises - to the catalog variant
509
+ * nearest the requested tier. A bare base alone is invalid upstream
510
+ * ("requires --effort"), so the pick always rides the split in toResolved;
511
+ * with no variants in the catalog, the overlay/exact entry or the raw base
512
+ * passes through unchanged. */
513
+ function resolveTieredBase(
514
+ base: string,
515
+ entries: ModelEntry[],
516
+ defaultThinking: ThinkingTier,
517
+ preferredTier: ThinkingTier | undefined,
518
+ ): ResolvedModel {
519
+ const needle = base.toLowerCase();
520
+ const variants = entries.filter((e) => e.full.toLowerCase().startsWith(`${needle}-`));
521
+ if (variants.length > 0) {
522
+ const tiers = variants.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null);
523
+ const preferred =
524
+ preferredTier ??
525
+ (tiers.includes(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER.other);
526
+ const chosen = nearestTier(tiers, preferred);
527
+ const picked = variants.find((e) => e.tier === chosen) ?? variants[0];
528
+ return toResolved(picked.full, picked.tier);
529
+ }
530
+ const fromCatalog = entries.find((e) => e.full.toLowerCase() === needle);
531
+ return toResolved(fromCatalog?.full ?? base, fromCatalog?.tier ?? null);
532
+ }
533
+
482
534
  /**
483
535
  * Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
484
536
  * Returns null only when the family is unrecognized; the caller then passes
@@ -499,7 +551,7 @@ export function resolveModel(
499
551
  const exact = entries.find((e) => e.full.toLowerCase() === lower);
500
552
  if (exact) return toResolved(exact.full, exact.tier);
501
553
 
502
- // 1b. Static short alias ("sonnet" / "opus" / "gpt-oss"). Checked
554
+ // 1b. Static short alias ("sonnet" / "opus"). Checked
503
555
  // before the family parser because none of these names contain
504
556
  // "flash" or "pro" and would otherwise return null below. The
505
557
  // resolved full string is then re-validated against the catalog
@@ -509,9 +561,12 @@ export function resolveModel(
509
561
  // so the "live entries win" guarantee holds even when agy lists
510
562
  // the model under different casing than the overlay.
511
563
  if (STATIC_SHORT_ALIAS.has(lower)) {
512
- const target = STATIC_SHORT_ALIAS.get(lower) as string;
513
- const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
514
- return toResolved(fromCatalog?.full ?? target, fromCatalog?.tier ?? null);
564
+ return resolveTieredBase(
565
+ STATIC_SHORT_ALIAS.get(lower) as string,
566
+ entries,
567
+ defaultThinking,
568
+ preferredTier,
569
+ );
515
570
  }
516
571
 
517
572
  // 2. Parse the alias.
@@ -529,7 +584,19 @@ export function resolveModel(
529
584
  if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
530
585
  family = "flash";
531
586
  }
532
- if (!family) return null; // unknown family -> let agy handle it
587
+ if (!family) {
588
+ // A bare base slug the provider itself advertises ("claude-sonnet-5-5",
589
+ // the id behind the antigravity/ entry in pi's picker) is invalid
590
+ // upstream without an effort: resolve it to the nearest tier variant
591
+ // exactly like the short aliases.
592
+ if (
593
+ entries.some((e) => e.full.toLowerCase().startsWith(`${lower}-`)) ||
594
+ entries.some((e) => e.full.toLowerCase() === lower)
595
+ ) {
596
+ return resolveTieredBase(lower, entries, defaultThinking, preferredTier);
597
+ }
598
+ return null; // unknown family -> let agy handle it
599
+ }
533
600
 
534
601
  // 3. Filter by family.
535
602
  let candidates = entries.filter((e) => e.family === family);
@@ -670,10 +737,9 @@ async function discoverModels(binary: string): Promise<ModelEntry[]> {
670
737
  finish("");
671
738
  }, DISCOVERY_TIMEOUT_MS);
672
739
  });
673
- return text
674
- .split("\n")
675
- .map(parseModelLine)
676
- .filter((e): e is ModelEntry => e !== null);
740
+ return filterHiddenModels(
741
+ text.split("\n").map(parseModelLine).filter((e): e is ModelEntry => e !== null),
742
+ );
677
743
  } catch {
678
744
  return [];
679
745
  }
@@ -914,6 +980,276 @@ interface AgyDetails {
914
980
  stderr: string;
915
981
  }
916
982
 
983
+
984
+ // --- Background-mode plumbing ----------------------------------------------
985
+ // Shared shape for both call styles: blocking awaits it, background lets it
986
+ // run detached and pushes the outcome through the wake sender on close.
987
+
988
+ const STDOUT_BUF_MAX_CHARS = 1_000_000;
989
+
990
+ /** Spawn failure (binary vanished between resolve and spawn). */
991
+ class AgySpawnError extends Error {}
992
+
993
+ interface ProcessRunOptions {
994
+ binary: string;
995
+ args: string[];
996
+ workdir: string;
997
+ timeoutMin: number;
998
+ startAt: number;
999
+ /** Mutated in place: conversationId discovery, stderr, durationMs. */
1000
+ details: AgyDetails;
1001
+ isContinuation: boolean;
1002
+ snapshot: ReturnType<typeof snapshotConversations> | null;
1003
+ /** Blocking passes pi's per-run signal; background passes none on purpose: a later Esc must not kill a detached run. */
1004
+ signal?: AbortSignal;
1005
+ /** Blocking only: throttled progress partials. */
1006
+ onPartial?: (text: string) => void;
1007
+ /** Background only: hands over the tree-kill switch as soon as the child exists. */
1008
+ onSpawn?: (kill: () => void) => void;
1009
+ }
1010
+
1011
+ interface ProcessRunOutcome {
1012
+ exitCode: number;
1013
+ aborted: boolean;
1014
+ timedOut: boolean;
1015
+ /** Full stdout, trimmed. Empty means the headless auto-deny failure mode. */
1016
+ answerText: string;
1017
+ }
1018
+
1019
+ /** Tool-result shape this extension returns; keeps the background helper's literals narrow. */
1020
+ interface AskToolResult {
1021
+ content: Array<{ type: "text"; text: string }>;
1022
+ details: AgyDetails;
1023
+ }
1024
+
1025
+ /**
1026
+ * Spawns agy -p, accumulates stdout, runs conversation-id discovery (during
1027
+ * the run via the /proc FD resolver, then the post-exit scan fallback), and
1028
+ * resolves when the process tree closes.
1029
+ */
1030
+ async function runAgyProcess(opts: ProcessRunOptions): Promise<ProcessRunOutcome> {
1031
+ const { binary, args, workdir, timeoutMin, startAt, details, isContinuation, snapshot, signal, onPartial, onSpawn } = opts;
1032
+ let out = "";
1033
+
1034
+ const statusInterval = onPartial
1035
+ ? setInterval(() => {
1036
+ const elapsed = Math.floor((Date.now() - startAt) / 1000);
1037
+ const tail = out.slice(-STATUS_TAIL_CHARS);
1038
+ const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
1039
+ onPartial(text);
1040
+ }, STATUS_INTERVAL_MS)
1041
+ : null;
1042
+
1043
+ try {
1044
+ // Bind the conversation id DURING the run (agy is alive then) so the
1045
+ // pid-based /proc FD resolver can disambiguate when a concurrent agy
1046
+ // also drops a new .db. Awaited after the run; the post-exit loop
1047
+ // below is the fallback for runs that exit before the poll binds.
1048
+ let bindDuringRun: Promise<void> = Promise.resolve();
1049
+ const outcome = await new Promise<{
1050
+ exitCode: number;
1051
+ aborted: boolean;
1052
+ timedOut: boolean;
1053
+ }>((resolveP, rejectP) => {
1054
+ // detached: true so we can signal the whole process group.
1055
+ // agy spawns its own exec subprocesses in -p mode; a direct
1056
+ // kill would orphan those grandchildren.
1057
+ const proc = spawn(binary, args, {
1058
+ cwd: workdir,
1059
+ stdio: ["ignore", "pipe", "pipe"],
1060
+ shell: false,
1061
+ detached: true,
1062
+ });
1063
+
1064
+ // Decode at the stream level so multibyte UTF-8 split across
1065
+ // pipe chunks doesn't corrupt (Gemini output is non-ASCII).
1066
+ proc.stdout?.setEncoding("utf8");
1067
+ proc.stderr?.setEncoding("utf8");
1068
+
1069
+ proc.stdout?.on("data", (d: string) => {
1070
+ out += d;
1071
+ // Safety valve for pathological output; keeps the tail.
1072
+ if (out.length > STDOUT_BUF_MAX_CHARS) out = out.slice(-100_000);
1073
+ });
1074
+ proc.stderr?.on("data", (d: string) => {
1075
+ // Cap: stderr feeds notes and wake messages, not forensics.
1076
+ if (details.stderr.length < 64_000) details.stderr += d;
1077
+ });
1078
+
1079
+ // Concurrent bind: poll for the new id while agy is alive. The FD
1080
+ // resolver needs a live process tree, so this stops (and the
1081
+ // post-exit fallback below takes over) once agy has exited.
1082
+ if (!isContinuation && snapshot && proc.pid) {
1083
+ bindDuringRun = (async () => {
1084
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
1085
+ if (details.conversationId) return;
1086
+ if (proc.exitCode !== null) return; // agy gone: scan useless now
1087
+ const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
1088
+ pid: proc.pid,
1089
+ });
1090
+ if (found) {
1091
+ details.conversationId = found;
1092
+ return;
1093
+ }
1094
+ await sleep(DISCOVERY_POLL_MS);
1095
+ }
1096
+ })().catch(() => {
1097
+ /* best-effort: a bind error must never fail an otherwise-OK turn */
1098
+ });
1099
+ }
1100
+
1101
+ let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
1102
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
1103
+ let settled = false;
1104
+ let timedOut = false;
1105
+
1106
+ // Kill the whole process group; SIGTERM first, then SIGKILL
1107
+ // after a grace period if it hasn't exited.
1108
+ const killTree = () => {
1109
+ try {
1110
+ if (proc.pid) process.kill(-proc.pid, "SIGTERM");
1111
+ } catch {}
1112
+ // Only arm the SIGKILL timer once.
1113
+ if (!sigkillTimer) {
1114
+ sigkillTimer = setTimeout(() => {
1115
+ try {
1116
+ if (proc.pid) process.kill(-proc.pid, "SIGKILL");
1117
+ } catch {}
1118
+ }, GRACE_AFTER_TIMEOUT_MS);
1119
+ }
1120
+ };
1121
+
1122
+ // Hand the kill switch to the background registry before any terminal event can fire.
1123
+ onSpawn?.(killTree);
1124
+
1125
+ const cleanup = () => {
1126
+ if (watchdog) clearTimeout(watchdog);
1127
+ if (sigkillTimer) clearTimeout(sigkillTimer);
1128
+ if (signal) signal.removeEventListener("abort", onAbort);
1129
+ };
1130
+
1131
+ const onAbort = () => killTree();
1132
+
1133
+ // Enforce the timeout cap ourselves (not just via
1134
+ // --print-timeout, which agy could ignore or not support).
1135
+ watchdog = setTimeout(() => {
1136
+ timedOut = true;
1137
+ killTree();
1138
+ }, timeoutMin * 60_000);
1139
+
1140
+ if (signal) {
1141
+ if (signal.aborted) killTree();
1142
+ else signal.addEventListener("abort", onAbort, { once: true });
1143
+ }
1144
+
1145
+ const finish = (code: number | null) => {
1146
+ if (settled) return;
1147
+ settled = true;
1148
+ cleanup();
1149
+ // Distinguish an aborted run from a normal close: if the
1150
+ // abort signal fired, treat as cancelled regardless of
1151
+ // exit code (killTree produces a non-zero code, but be
1152
+ // explicit and order-independent). Likewise surface a
1153
+ // timeout distinctly from a genuine non-zero exit.
1154
+ resolveP({
1155
+ exitCode: code ?? 0,
1156
+ aborted: !!signal?.aborted,
1157
+ timedOut,
1158
+ });
1159
+ };
1160
+
1161
+ proc.on("error", () => {
1162
+ cleanup();
1163
+ rejectP(new AgySpawnError("failed to spawn agy"));
1164
+ });
1165
+ proc.on("close", finish);
1166
+ });
1167
+
1168
+ if (statusInterval) clearInterval(statusInterval);
1169
+
1170
+ // Let the during-run bind poll finish (it bails immediately once agy
1171
+ // has exited, so this rarely blocks).
1172
+ await bindDuringRun;
1173
+
1174
+ details.exitCode = outcome.exitCode;
1175
+ details.aborted = outcome.aborted;
1176
+ details.timedOut = outcome.timedOut;
1177
+ details.durationMs = Date.now() - startAt;
1178
+
1179
+ // For a fresh run, discover the conversation id agy just created
1180
+ // (agy -p never prints it). Retry briefly since agy may flush its
1181
+ // SQLite DB a moment after the process closes. A continuation run
1182
+ // reuses the provided id (already set on details).
1183
+ if (!isContinuation && !details.conversationId && snapshot) {
1184
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
1185
+ const found = newConversationId(CONVERSATIONS_DIR, snapshot);
1186
+ if (found) {
1187
+ details.conversationId = found;
1188
+ break;
1189
+ }
1190
+ await sleep(DISCOVERY_POLL_MS);
1191
+ }
1192
+ }
1193
+
1194
+ return { ...outcome, answerText: out.trim() };
1195
+ } finally {
1196
+ if (statusInterval) clearInterval(statusInterval);
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Builds the user/model-facing answer text for every terminal outcome.
1202
+ * Pure except the empty-output marker it stamps on details.
1203
+ */
1204
+ function shapeFinalText(details: AgyDetails, outcome: ProcessRunOutcome, timeoutMin: number): string {
1205
+ const text = outcome.answerText;
1206
+
1207
+ // Aborted: a distinct result so the caller knows it was cancelled, not a silent success.
1208
+ if (outcome.aborted) {
1209
+ return text ? `agy was aborted. Partial output:\n\n${text}` : "agy was aborted before producing output.";
1210
+ }
1211
+
1212
+ // Timeout: distinct from a genuine non-zero exit (the watchdog
1213
+ // killed the tree because the configured cap elapsed).
1214
+ if (outcome.timedOut) {
1215
+ const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
1216
+ return text ? `${text}\n\n[${note}]` : note;
1217
+ }
1218
+
1219
+ // Non-zero exit: surface the failure even when partial text exists.
1220
+ if (outcome.exitCode !== 0) {
1221
+ const note = details.stderr.trim()
1222
+ ? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
1223
+ : `agy exited with status ${outcome.exitCode}`;
1224
+ return text ? `${text}\n\n[${note}]` : note;
1225
+ }
1226
+
1227
+ // Exit 0 with nothing on stdout is still a failure for the
1228
+ // caller: headless agy auto-denies a permission-gated tool call
1229
+ // (e.g. the command gate in plan mode), prints the reason only to
1230
+ // stderr, and ends cleanly. Falling through to the success path
1231
+ // here returned just the conversation footer, which read as an
1232
+ // empty success (silent-failure bug found 2026-09-25).
1233
+ if (!text) {
1234
+ details.empty = true;
1235
+ const note = [
1236
+ "agy exited cleanly but produced no output.",
1237
+ details.stderr.trim() ? `stderr: ${details.stderr.trim()}` : null,
1238
+ "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
1239
+ ]
1240
+ .filter(Boolean)
1241
+ .join(" ");
1242
+ return note;
1243
+ }
1244
+
1245
+ // Success. Conversation footer lets the orchestrating model thread the id.
1246
+ const footer = details.conversationId
1247
+ ? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
1248
+ : "";
1249
+
1250
+ return text + footer;
1251
+ }
1252
+
917
1253
  export default async function (pi: ExtensionAPI) {
918
1254
  // If pi-antigravity-bridge is installed, it owns the AskAntigravity tool
919
1255
  // (and the provider). Stay silent and register nothing to avoid a
@@ -933,10 +1269,47 @@ export default async function (pi: ExtensionAPI) {
933
1269
  // Discovered once at load; frozen for the session. Run /reload after an
934
1270
  // `agy update` to refresh. Failure is non-fatal: resolveModel falls back
935
1271
  // to passthrough so exact slugs typed by the user still work. The static
936
- // alias overlay (sonnet / opus / gpt-oss) is merged on top so those
1272
+ // alias overlay (sonnet / opus) is merged on top so those
937
1273
  // aliases resolve even when agy doesn't surface them in the live catalog.
938
1274
  const discovered = mergeCatalog(await discoverModels(binary).catch(() => []));
939
1275
 
1276
+ // --- Background-run state (one set per extension load) -------------------
1277
+ // Pi wipes module state on /new, /resume, /fork and /reload: an in-flight
1278
+ // background run is killed and its result is never delivered. Accepted
1279
+ // trade-off, documented in the README.
1280
+ const registry = new BackgroundRunRegistry({ toolName: "ask-antigravity" });
1281
+ const kills = new Map<string, () => void>();
1282
+ // Staged-artifact cleanup (contextFile + reviewer agent dir), keyed by run:
1283
+ // the session_shutdown backstop runs these when a hard exit would skip the
1284
+ // close-path finally.
1285
+ const cleanups = new Map<string, () => void>();
1286
+ // One background run per conversationId at a time: two runs resuming the
1287
+ // same conversation interleave their turns inside agy's SQLite state.
1288
+ const busyHandles = new Set<string>();
1289
+ const sendWake = createWakeSender(pi, {
1290
+ customType: "ask-antigravity-result",
1291
+ isDisposed: () => registry.isDisposed(),
1292
+ });
1293
+ const stopBackground = createStopHandler(registry, {
1294
+ kill: (run) => kills.get(run.runId)?.(),
1295
+ onStopped: (run) =>
1296
+ sendWake({
1297
+ toolLabel: "agy",
1298
+ runId: run.runId,
1299
+ ok: false,
1300
+ elapsedS: Math.round((Date.now() - run.startedAt) / 1000),
1301
+ error: "stopped via /agy-stop",
1302
+ }),
1303
+ });
1304
+ pi.on("session_shutdown", () => {
1305
+ // Latch first so close-handler wakes stay silent, then kill children,
1306
+ // then sweep staged artifacts (hard exit would skip the close-path cleanup).
1307
+ for (const run of registry.dispose()) kills.get(run.runId)?.();
1308
+ for (const fn of cleanups.values()) fn();
1309
+ cleanups.clear();
1310
+ kills.clear();
1311
+ });
1312
+
940
1313
  // --- /agy: view / change default model + thinking ---------------------
941
1314
 
942
1315
  // Friendly model options offered in the picker. Exact strings also work
@@ -1107,7 +1480,7 @@ export default async function (pi: ExtensionAPI) {
1107
1480
  ],
1108
1481
  {
1109
1482
  description:
1110
- "agy execution mode. 'plan' = review-only (--mode plan in a temporary restricted agent: file-editing tools are hard-blocked; arbitrary shell commands remain prompt-guarded only; with skipPermissions on, commands still run for analysis). 'accept-edits' = agy applies edits directly (--mode accept-edits, default). For agy's orthogonal --sandbox shell-containment flag, set the AGY_EXTRA_ARGS env var.",
1483
+ "agy execution mode. 'plan' = plan-shaped run: the CLI does not enforce review-only (see the tool description); the staged reviewer agent's toolset omits file-editing tools as a damper, and the skip-permissions flag is never passed. 'accept-edits' = agy applies edits directly (--mode accept-edits, default). For agy's orthogonal --sandbox shell-containment flag, set the AGY_EXTRA_ARGS env var.",
1111
1484
  default: "accept-edits",
1112
1485
  },
1113
1486
  ),
@@ -1135,6 +1508,12 @@ export default async function (pi: ExtensionAPI) {
1135
1508
  "When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
1136
1509
  }),
1137
1510
  ),
1511
+ background: Type.Optional(
1512
+ Type.Boolean({
1513
+ description: backgroundFlagText("conversationId"),
1514
+ default: false,
1515
+ }),
1516
+ ),
1138
1517
  }),
1139
1518
  renderCall(args, theme, _context) {
1140
1519
  // Show RESOLVED model/thinking/mode (config defaults applied) so the
@@ -1162,6 +1541,7 @@ export default async function (pi: ExtensionAPI) {
1162
1541
  if (useDigest) tags.push("digest");
1163
1542
  if (isContinue) tags.push("continue");
1164
1543
  if (args.includeContext) tags.push("context=full");
1544
+ if (args.background) tags.push("bg");
1165
1545
 
1166
1546
  let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
1167
1547
  text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
@@ -1316,10 +1696,11 @@ export default async function (pi: ExtensionAPI) {
1316
1696
 
1317
1697
  const mode: Mode = (params.mode as Mode | undefined) ?? "accept-edits";
1318
1698
  // Plan runs stage a restricted reviewer agent: its tools list has NO
1319
- // file-editing tool, which is the hard edit block (the prompt guard
1320
- // alone was observed failing once - a sub-agent still edited files).
1321
- // Staging failure degrades to the legacy fallback: no agent, no skip
1322
- // flag, the stricter command-forbidding guard.
1699
+ // file-editing tool, a toolset-level damper on edits (the prompt
1700
+ // guard alone was observed failing once - a sub-agent still edited
1701
+ // files; the CLI itself does not enforce review-only, upstream
1702
+ // #1181). Staging failure degrades to the legacy fallback: no agent,
1703
+ // no skip flag, the stricter command-forbidding guard.
1323
1704
  let reviewerAgent: { name: string; dir: string } | null = null;
1324
1705
  if (mode === "plan") {
1325
1706
  try {
@@ -1328,7 +1709,7 @@ export default async function (pi: ExtensionAPI) {
1328
1709
  reviewerAgent = null;
1329
1710
  }
1330
1711
  }
1331
- // digest default: on for plan (review-only contexts where full file
1712
+ // digest default: on for plan (review-shaped contexts where full file
1332
1713
  // contents are noise), off for accept-edits (agy applies edits and
1333
1714
  // may need richer context for diffs).
1334
1715
  const useDigest: boolean =
@@ -1367,12 +1748,14 @@ export default async function (pi: ExtensionAPI) {
1367
1748
 
1368
1749
  const args: string[] = ["--add-dir", cwd];
1369
1750
  const extraRaw = extraArgs();
1370
- // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
1371
- // mode flags, so an env-injected skip flag would re-arm what the
1372
- // plan-mode rule strips. Only the enforced agent makes the flag safe.
1751
+ // Fail-closed on every plan run: AGY_EXTRA_ARGS lands before the mode
1752
+ // flags, so an env-injected skip flag would re-arm exactly what the
1753
+ // never-flag-on-plan rule withholds (upstream #1181: an auto-approved
1754
+ // plan run is write-capable). The reviewer agent is a damper, not a
1755
+ // license for the flag.
1373
1756
  const extra =
1374
- mode === "plan" && !reviewerAgent
1375
- ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
1757
+ mode === "plan"
1758
+ ? extraRaw.filter((a) => !a.startsWith("--dangerously-skip-permissions"))
1376
1759
  : extraRaw;
1377
1760
  if (extra.length) args.push(...extra);
1378
1761
  if (resolved.model) args.push("--model", resolved.model);
@@ -1382,14 +1765,15 @@ export default async function (pi: ExtensionAPI) {
1382
1765
  // accept-edits auto-approves file edits but NOT shell commands, so a
1383
1766
  // run_command would hang on an unanswerable y/n prompt in non-interactive
1384
1767
  // -p mode. Honor the shared permissions setting (same knob as the bridge).
1385
- // Plan runs WITH the reviewer agent: the flag is safe again because the
1386
- // agent's toolset has no file-editing tool - the 2026-09-25 write
1387
- // incident rode edit tools that existed then; probed 2026-09-28 the
1388
- // restricted toolset reports "none" for edits even with the flag. The
1389
- // flag is what lets analysis commands (builds, test runners) run for
1390
- // users without allow rules. Plan fallback (staging failed): strict
1391
- // legacy behavior, no flag - the prompt guard is all that is left.
1392
- if (config.skipPermissions && (mode !== "plan" || reviewerAgent !== null)) {
1768
+ // Plan runs NEVER get the flag: it auto-approves every permission
1769
+ // request, and the CLI does not gate writes under plan mode (upstream
1770
+ // google-antigravity/antigravity-cli#1181, probed 2026-10-07), so an
1771
+ // auto-approved plan run is a write-capable run. The reviewer agent's
1772
+ // toolset is a damper, not a guarantee; the 2026-09-28 "safe with the
1773
+ // restricted toolset" probe predates the #1181 evidence. Without the
1774
+ // flag, command attempts fail visibly (headless auto-deny) instead of
1775
+ // running approved.
1776
+ if (config.skipPermissions && mode !== "plan") {
1393
1777
  args.push("--dangerously-skip-permissions");
1394
1778
  }
1395
1779
  if (isContinuation) args.push("--conversation", rawConvId as string);
@@ -1412,261 +1796,130 @@ export default async function (pi: ExtensionAPI) {
1412
1796
  stderr: "",
1413
1797
  };
1414
1798
 
1415
- let out = "";
1416
-
1417
- // Throttled status updates (claude-bridge pattern): emit a short
1418
- // status line on an interval instead of the full buffer on every
1419
- // stdout chunk, avoiding O(n²) re-renders on long runs.
1420
- const statusInterval = onUpdate
1421
- ? setInterval(() => {
1422
- const elapsed = Math.floor((Date.now() - start) / 1000);
1423
- const tail = out.slice(-STATUS_TAIL_CHARS);
1424
- const text = tail
1425
- ? `(running ${elapsed}s)\n…${tail}`
1426
- : `(running ${elapsed}s)`;
1427
- onUpdate({
1428
- content: [{ type: "text", text }],
1429
- details: { ...details, durationMs: Date.now() - start },
1430
- });
1431
- }, STATUS_INTERVAL_MS)
1432
- : null;
1433
-
1434
- try {
1435
- // Bind the conversation id DURING the run (agy is alive then) so the
1436
- // pid-based /proc FD resolver can disambiguate when a concurrent agy
1437
- // also drops a new .db. Awaited after the run; the post-exit loop
1438
- // below is the fallback for runs that exit before the poll binds.
1439
- let bindDuringRun: Promise<void> = Promise.resolve();
1440
- const outcome = await new Promise<{
1441
- exitCode: number;
1442
- aborted: boolean;
1443
- timedOut: boolean;
1444
- }>((resolveP, rejectP) => {
1445
- // detached: true so we can signal the whole process group.
1446
- // agy spawns its own exec subprocesses in -p mode; a direct
1447
- // kill would orphan those grandchildren.
1448
- const proc = spawn(binary, args, {
1449
- cwd,
1450
- stdio: ["ignore", "pipe", "pipe"],
1451
- shell: false,
1452
- detached: true,
1453
- });
1454
-
1455
- // Decode at the stream level so multibyte UTF-8 split across
1456
- // pipe chunks doesn't corrupt (Gemini output is non-ASCII).
1457
- proc.stdout?.setEncoding("utf8");
1458
- proc.stderr?.setEncoding("utf8");
1459
-
1460
- proc.stdout?.on("data", (d: string) => {
1461
- out += d;
1462
- });
1463
- proc.stderr?.on("data", (d: string) => {
1464
- details.stderr += d;
1465
- });
1466
-
1467
- // Concurrent bind: poll for the new id while agy is alive. The FD
1468
- // resolver needs a live process tree, so this stops (and the
1469
- // post-exit fallback below takes over) once agy has exited.
1470
- if (!isContinuation && snapshot && proc.pid) {
1471
- bindDuringRun = (async () => {
1472
- for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
1473
- if (details.conversationId) return;
1474
- if (proc.exitCode !== null) return; // agy gone: scan useless now
1475
- const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
1476
- pid: proc.pid,
1477
- });
1478
- if (found) {
1479
- details.conversationId = found;
1480
- return;
1481
- }
1482
- await sleep(DISCOVERY_POLL_MS);
1483
- }
1484
- })().catch(() => {
1485
- /* best-effort: a bind error must never fail an otherwise-OK turn */
1486
- });
1487
- }
1488
-
1489
- let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
1490
- let watchdog: ReturnType<typeof setTimeout> | undefined;
1491
- let settled = false;
1492
- let timedOut = false;
1493
-
1494
- // Kill the whole process group; SIGTERM first, then SIGKILL
1495
- // after a grace period if it hasn't exited.
1496
- const killTree = () => {
1799
+ // Background: hand the run to the registry and return at once. The
1800
+ // result travels back through pi.sendMessage (wake), not this call.
1801
+ if (params.background) {
1802
+ if (ctx.mode === "print" || ctx.mode === "json") {
1803
+ if (contextFile) {
1497
1804
  try {
1498
- if (proc.pid) process.kill(-proc.pid, "SIGTERM");
1805
+ fs.unlinkSync(contextFile);
1499
1806
  } catch {}
1500
- // Only arm the SIGKILL timer once.
1501
- if (!sigkillTimer) {
1502
- sigkillTimer = setTimeout(() => {
1503
- try {
1504
- if (proc.pid) process.kill(-proc.pid, "SIGKILL");
1505
- } catch {}
1506
- }, GRACE_AFTER_TIMEOUT_MS);
1507
- }
1508
- };
1509
-
1510
- const cleanup = () => {
1511
- if (watchdog) clearTimeout(watchdog);
1512
- if (sigkillTimer) clearTimeout(sigkillTimer);
1513
- if (signal) signal.removeEventListener("abort", onAbort);
1514
- };
1515
-
1516
- const onAbort = () => killTree();
1517
-
1518
- // Enforce the timeout cap ourselves (not just via
1519
- // --print-timeout, which agy could ignore or not support).
1520
- watchdog = setTimeout(() => {
1521
- timedOut = true;
1522
- killTree();
1523
- }, timeoutMin * 60_000);
1524
-
1525
- if (signal) {
1526
- if (signal.aborted) killTree();
1527
- else signal.addEventListener("abort", onAbort, { once: true });
1528
1807
  }
1529
-
1530
- const finish = (code: number | null) => {
1531
- if (settled) return;
1532
- settled = true;
1533
- cleanup();
1534
- // Distinguish an aborted run from a normal close: if the
1535
- // abort signal fired, treat as cancelled regardless of
1536
- // exit code (killTree produces a non-zero code, but be
1537
- // explicit and order-independent). Likewise surface a
1538
- // timeout distinctly from a genuine non-zero exit.
1539
- resolveP({
1540
- exitCode: code ?? 0,
1541
- aborted: !!signal?.aborted,
1542
- timedOut,
1543
- });
1544
- };
1545
-
1546
- proc.on("error", (err) => {
1547
- cleanup();
1548
- rejectP(err);
1549
- });
1550
- proc.on("close", finish);
1551
- });
1552
-
1553
- if (statusInterval) clearInterval(statusInterval);
1554
-
1555
- // Let the during-run bind poll finish (it bails immediately once agy
1556
- // has exited, so this rarely blocks).
1557
- await bindDuringRun;
1558
-
1559
- details.exitCode = outcome.exitCode;
1560
- details.aborted = outcome.aborted;
1561
- details.timedOut = outcome.timedOut;
1562
- details.durationMs = Date.now() - start;
1563
-
1564
- // For a fresh run, discover the conversation id agy just created
1565
- // (agy -p never prints it). Retry briefly since agy may flush its
1566
- // SQLite DB a moment after the process closes. A continuation run
1567
- // reuses the provided id (already set on details).
1568
- if (!isContinuation && !details.conversationId && snapshot) {
1569
- for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
1570
- const found = newConversationId(CONVERSATIONS_DIR, snapshot);
1571
- if (found) {
1572
- details.conversationId = found;
1573
- break;
1574
- }
1575
- await sleep(DISCOVERY_POLL_MS);
1808
+ if (reviewerAgent) {
1809
+ try {
1810
+ fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
1811
+ } catch {}
1576
1812
  }
1577
- }
1578
-
1579
- const text = out.trim();
1580
-
1581
- // Aborted: return a distinct result so the caller knows it was
1582
- // cancelled, not a silent success.
1583
- if (outcome.aborted) {
1584
1813
  return {
1585
1814
  content: [
1586
1815
  {
1587
1816
  type: "text",
1588
- text: text
1589
- ? `agy was aborted. Partial output:\n\n${text}`
1590
- : "agy was aborted before producing output.",
1817
+ text: "background is not available in print/json mode: the process exits before the result message can arrive. Call again without background.",
1591
1818
  },
1592
1819
  ],
1593
- details,
1820
+ details: { ...details },
1594
1821
  };
1595
1822
  }
1823
+ return startBackgroundRun({
1824
+ summary: params.prompt,
1825
+ binary,
1826
+ args,
1827
+ workdir: cwd,
1828
+ timeoutMin,
1829
+ startAt: start,
1830
+ details,
1831
+ contextFile,
1832
+ reviewerAgent,
1833
+ modelLabel: resolved.model ?? requestedModel,
1834
+ isContinuation,
1835
+ conversationId: isContinuation ? (rawConvId as string) : undefined,
1836
+ });
1837
+ }
1596
1838
 
1597
- // Timeout: distinct from a genuine non-zero exit (the watchdog
1598
- // killed the tree because the configured cap elapsed).
1599
- if (outcome.timedOut) {
1600
- const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
1839
+ // One run per conversationId at a time, blocking included: a blocking
1840
+ // resume and a background resume of the same conversation would
1841
+ // interleave turns inside agy's session state. Declared out here so
1842
+ // the finally can see it; set only when THIS call takes the lock.
1843
+ let lockAcquired = false;
1844
+ try {
1845
+ if (isContinuation && busyHandles.has(rawConvId as string)) {
1846
+ if (contextFile) {
1847
+ try {
1848
+ fs.unlinkSync(contextFile);
1849
+ } catch {}
1850
+ }
1851
+ if (reviewerAgent) {
1852
+ try {
1853
+ fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
1854
+ } catch {}
1855
+ }
1601
1856
  return {
1602
1857
  content: [
1603
- { type: "text", text: text ? `${text}\n\n[${note}]` : note },
1858
+ {
1859
+ type: "text",
1860
+ text: `conversation ${rawConvId} already has a run in flight. Wait for its result, or stop it with /agy-stop.`,
1861
+ },
1604
1862
  ],
1605
- details,
1863
+ details: emptyDetails(requestedModel, resolved.model),
1606
1864
  };
1607
1865
  }
1866
+ // Track acquisition: the finally must only release a lock THIS call
1867
+ // took, never the one held by the run that caused a refusal.
1868
+ lockAcquired = isContinuation;
1869
+ if (isContinuation) busyHandles.add(rawConvId as string);
1870
+ const outcome = await runAgyProcess({
1871
+ binary,
1872
+ args,
1873
+ workdir: cwd,
1874
+ timeoutMin,
1875
+ startAt: start,
1876
+ details,
1877
+ isContinuation,
1878
+ snapshot,
1879
+ signal,
1880
+ onPartial: onUpdate
1881
+ ? (text) => {
1882
+ onUpdate({
1883
+ content: [{ type: "text", text }],
1884
+ details: { ...details, durationMs: Date.now() - start },
1885
+ });
1886
+ }
1887
+ : undefined,
1888
+ });
1608
1889
 
1609
- // Non-zero exit: surface the failure even when partial text
1610
- // exists, instead of returning silent success.
1611
- if (outcome.exitCode !== 0) {
1612
- const note = details.stderr.trim()
1613
- ? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
1614
- : `agy exited with status ${outcome.exitCode}`;
1615
- return {
1616
- content: [
1617
- { type: "text", text: text ? `${text}\n\n[${note}]` : note },
1618
- ],
1619
- details,
1620
- };
1890
+ const finalText = shapeFinalText(details, outcome, timeoutMin);
1891
+
1892
+ if (outcome.aborted || outcome.timedOut || outcome.exitCode !== 0) {
1893
+ return { content: [{ type: "text", text: finalText }], details };
1621
1894
  }
1622
1895
 
1623
- // Exit 0 with nothing on stdout is still a failure for the
1624
- // caller: headless agy auto-denies a permission-gated tool call
1625
- // (e.g. the command gate in plan mode), prints the reason only to
1626
- // stderr, and ends cleanly. Falling through to the success path
1627
- // here returned just the conversation footer, which read as an
1628
- // empty success (silent-failure bug found 2026-09-25).
1629
- if (!text) {
1630
- details.empty = true;
1631
- const note = [
1632
- "agy exited cleanly but produced no output.",
1633
- details.stderr.trim() ? `stderr: ${details.stderr.trim()}` : null,
1634
- "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
1635
- ]
1636
- .filter(Boolean)
1637
- .join(" ");
1638
- return {
1639
- content: [{ type: "text", text: note }],
1640
- details,
1641
- };
1896
+ // Exit 0 with no output keeps its dedicated failure result
1897
+ // (headless auto-deny); see shapeFinalText for the note.
1898
+ if (!outcome.answerText) {
1899
+ return { content: [{ type: "text", text: finalText }], details };
1642
1900
  }
1643
1901
 
1644
1902
  // Success. Clear the last partial status line (claude-bridge
1645
1903
  // idiom) so the running-tail preview doesn't linger under the final
1646
- // answer, then append a conversation footer so the orchestrating model
1647
- // can see (and thread) the id without inspecting details.
1904
+ // answer.
1648
1905
  onUpdate?.({
1649
1906
  content: [{ type: "text", text: "" }],
1650
1907
  details: { ...details },
1651
1908
  });
1652
- const footer = details.conversationId
1653
- ? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
1654
- : "";
1655
1909
 
1656
1910
  return {
1657
- content: [{ type: "text", text: text + footer }],
1911
+ content: [{ type: "text", text: finalText }],
1658
1912
  details,
1659
1913
  };
1660
1914
  } catch (err) {
1661
- if (statusInterval) clearInterval(statusInterval);
1662
1915
  details.durationMs = Date.now() - start;
1663
1916
  const msg = err instanceof Error ? err.message : String(err);
1664
1917
  return {
1665
1918
  content: [{ type: "text", text: `failed to run agy: ${msg}` }],
1666
1919
  details,
1667
1920
  };
1668
- }
1669
- finally {
1921
+ } finally {
1922
+ if (isContinuation && lockAcquired) busyHandles.delete(rawConvId as string);
1670
1923
  if (contextFile) {
1671
1924
  try {
1672
1925
  fs.unlinkSync(contextFile);
@@ -1680,6 +1933,156 @@ export default async function (pi: ExtensionAPI) {
1680
1933
  }
1681
1934
  },
1682
1935
  });
1936
+ // --- /agy-stop: kill a background run ----------------------------------
1937
+
1938
+ pi.registerCommand("agy-stop", {
1939
+ description: "Stop a background AskAntigravity run (id prefix, or the only running one). Usage: /agy-stop [runId]",
1940
+ handler: async (args, ctx) => {
1941
+ const message = await stopBackground(args ?? "");
1942
+ if (ctx.hasUI) ctx.ui.notify(message, "info");
1943
+ },
1944
+ });
1945
+
1946
+ const startBackgroundRun = (o: {
1947
+ summary: string;
1948
+ binary: string;
1949
+ args: string[];
1950
+ workdir: string;
1951
+ timeoutMin: number;
1952
+ startAt: number;
1953
+ details: AgyDetails;
1954
+ contextFile: string | null;
1955
+ reviewerAgent: { name: string; dir: string } | null;
1956
+ modelLabel: string;
1957
+ isContinuation: boolean;
1958
+ conversationId?: string;
1959
+ }): AskToolResult => {
1960
+ registry.sweep();
1961
+ // Per-handle lock: two background runs resuming the same conversation
1962
+ // would interleave turns inside agy's session state.
1963
+ if (o.conversationId && busyHandles.has(o.conversationId)) {
1964
+ return {
1965
+ content: [
1966
+ {
1967
+ type: "text",
1968
+ text: `background refused: conversation ${o.conversationId} already has a background run. Wait for its wake message, or use blocking mode.`,
1969
+ },
1970
+ ],
1971
+ details: { ...o.details },
1972
+ };
1973
+ }
1974
+ if (o.conversationId) busyHandles.add(o.conversationId);
1975
+ let run;
1976
+ try {
1977
+ run = registry.start(summarizePrompt(o.summary));
1978
+ } catch (err) {
1979
+ if (o.conversationId) busyHandles.delete(o.conversationId);
1980
+ if (o.contextFile) {
1981
+ try {
1982
+ fs.unlinkSync(o.contextFile);
1983
+ } catch {}
1984
+ }
1985
+ if (o.reviewerAgent) {
1986
+ try {
1987
+ fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
1988
+ } catch {}
1989
+ }
1990
+ const msg = err instanceof Error ? err.message : String(err);
1991
+ return {
1992
+ content: [
1993
+ {
1994
+ type: "text",
1995
+ text: `background refused: ${msg}. Use blocking (omit background) or free a slot with /agy-stop.`,
1996
+ },
1997
+ ],
1998
+ details: { ...o.details },
1999
+ };
2000
+ }
2001
+ const runId = run.runId;
2002
+ const cleanupStaging = () => {
2003
+ // Both staged artifacts must outlive the execute() return in
2004
+ // background mode: agy reads them mid-run.
2005
+ if (o.contextFile) {
2006
+ try {
2007
+ fs.unlinkSync(o.contextFile);
2008
+ } catch {}
2009
+ }
2010
+ if (o.reviewerAgent) {
2011
+ try {
2012
+ fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
2013
+ } catch {}
2014
+ }
2015
+ };
2016
+ cleanups.set(runId, cleanupStaging);
2017
+ void runAgyProcess({
2018
+ binary: o.binary,
2019
+ args: o.args,
2020
+ workdir: o.workdir,
2021
+ timeoutMin: o.timeoutMin,
2022
+ startAt: o.startAt,
2023
+ details: o.details,
2024
+ isContinuation: o.isContinuation,
2025
+ snapshot: o.isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR),
2026
+ onSpawn: (kill) => {
2027
+ kills.set(runId, kill);
2028
+ },
2029
+ })
2030
+ .then((outcome) => {
2031
+ kills.delete(runId);
2032
+ const finalText = shapeFinalText(o.details, outcome, o.timeoutMin);
2033
+ const elapsedS = Math.round((Date.now() - o.startAt) / 1000);
2034
+ const handle = o.details.conversationId ? `conversationId=${o.details.conversationId}` : undefined;
2035
+ const failed = outcome.timedOut || outcome.exitCode !== 0 || !outcome.answerText;
2036
+ // The settle latch makes /agy-stop and shutdown win races
2037
+ // against this handler: a false return means their wake went out.
2038
+ const settled = registry.settle(runId, {
2039
+ status: failed ? "failed" : "done",
2040
+ output: finalText,
2041
+ handle,
2042
+ error: outcome.timedOut
2043
+ ? `timeout after ${o.timeoutMin}m`
2044
+ : outcome.exitCode !== 0
2045
+ ? `exit status ${outcome.exitCode}`
2046
+ : !outcome.answerText
2047
+ ? "agy exited cleanly but produced no output"
2048
+ : undefined,
2049
+ });
2050
+ if (!settled) return;
2051
+ if (outcome.timedOut) {
2052
+ const partial = outcome.answerText ? ` Partial output: ${outcome.answerText.slice(0, 400)}` : "";
2053
+ sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: `timeout after ${o.timeoutMin}m.${partial}`, handle });
2054
+ } else if (outcome.exitCode !== 0) {
2055
+ const reason = o.details.stderr.trim().slice(0, 400) || `exit status ${outcome.exitCode}`;
2056
+ sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: reason, handle });
2057
+ } else if (!outcome.answerText) {
2058
+ sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: "agy exited cleanly but produced no output (headless auto-deny?)", handle });
2059
+ } else {
2060
+ sendWake({ toolLabel: "agy", runId, ok: true, elapsedS, handle }, finalText);
2061
+ }
2062
+ })
2063
+ .catch((err) => {
2064
+ kills.delete(runId);
2065
+ const msg = err instanceof Error ? err.message : String(err);
2066
+ // Settle first: an /agy-stop that won the race owns the wake.
2067
+ const settled = registry.settle(runId, { status: "failed", error: msg });
2068
+ if (!settled) return;
2069
+ sendWake({ toolLabel: "agy", runId, ok: false, error: `failed to run agy: ${msg}` });
2070
+ })
2071
+ .finally(() => {
2072
+ cleanups.delete(runId);
2073
+ cleanupStaging();
2074
+ if (o.conversationId) busyHandles.delete(o.conversationId);
2075
+ });
2076
+ return {
2077
+ content: [
2078
+ {
2079
+ type: "text",
2080
+ text: `Background run ${runId} started (model=${o.modelLabel}). The result arrives as a message when agy finishes; the resume handle (conversationId) comes with it when agy reports one. Do not poll. Continue with other work or end the turn.`,
2081
+ },
2082
+ ],
2083
+ details: { ...o.details },
2084
+ };
2085
+ };
1683
2086
  }
1684
2087
 
1685
2088
  function emptyDetails(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-ask-antigravity",
3
- "version": "1.2.9",
3
+ "version": "1.2.11",
4
4
  "description": "Pi tool that delegates a self-contained sub-task to Google Antigravity's agy CLI (the CLI for Gemini). Friendly model aliases (flash/pro/gemini), configurable default model + thinking, and the AskAntigravity delegation tool.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,6 +27,9 @@
27
27
  "access": "public"
28
28
  },
29
29
  "type": "module",
30
+ "dependencies": {
31
+ "@estebanforge/pi-ask-shared": "^0.1.0"
32
+ },
30
33
  "files": [
31
34
  "extensions",
32
35
  "README.md",
@@ -39,7 +42,7 @@
39
42
  ]
40
43
  },
41
44
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": "*"
45
+ "@earendil-works/pi-coding-agent": "^1.0.0"
43
46
  },
44
47
  "peerDependenciesMeta": {
45
48
  "@earendil-works/pi-coding-agent": {