@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 +18 -0
- package/README.md +9 -6
- package/extensions/index.ts +681 -278
- package/package.json +5 -2
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
|
|
53
|
-
| `opus` | Claude Opus
|
|
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
|
|
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` |
|
|
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-
|
|
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
|
package/extensions/index.ts
CHANGED
|
@@ -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 (
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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-
|
|
116
|
-
// "opus" -> claude-opus-
|
|
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-
|
|
120
|
-
{ full: "claude-
|
|
121
|
-
{ full: "
|
|
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-
|
|
130
|
-
["opus", "claude-opus-
|
|
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
|
|
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
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
"-
|
|
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
|
|
327
|
-
|
|
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 +=
|
|
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
|
|
346
|
-
* with no file-editing tool present the model cannot emit an
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* a write-capable tool
|
|
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
|
|
470
|
-
* (slugs starting "gemini-") accept a separate
|
|
471
|
-
* suffix out of the slug: the base alone
|
|
472
|
-
* wants, and the tier goes to --effort
|
|
473
|
-
*
|
|
474
|
-
* agy
|
|
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()
|
|
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"
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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)
|
|
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
|
|
674
|
-
.split("\n")
|
|
675
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
1320
|
-
// alone was observed failing once - a sub-agent still edited
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
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-
|
|
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
|
|
1371
|
-
//
|
|
1372
|
-
// plan
|
|
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"
|
|
1375
|
-
? extraRaw.filter((a) => a
|
|
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
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1392
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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:
|
|
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
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1610
|
-
|
|
1611
|
-
if (outcome.exitCode !== 0) {
|
|
1612
|
-
|
|
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
|
|
1624
|
-
//
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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": {
|