@estebanforge/pi-ask-antigravity 1.0.0
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/LICENSE +21 -0
- package/README.md +111 -0
- package/extensions/index.ts +857 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EstebanForge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @estebanforge/pi-ask-antigravity
|
|
2
|
+
|
|
3
|
+
A [Pi](https://github.com/earendil-works/pi-coding-agent) extension that exposes the **`AskAntigravity`** tool: delegate a self-contained sub-task to Google Antigravity's `agy` CLI and stream its response back into the Pi session.
|
|
4
|
+
|
|
5
|
+
It is the `AskClaude`-style delegation pattern, pointed at Gemini via `agy`. The tool answers to three names the CLI is known by — **gemini**, **antigravity**, and **agy** — surfaced in its description so the model maps any of them to this single tool.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pi install npm:@estebanforge/pi-ask-antigravity
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires the **`agy` CLI** installed and authenticated. If you don't have it, follow Google's [official install guide](https://antigravity.google/docs/cli/install) for your platform. Then run `agy` once to complete Google OAuth.
|
|
14
|
+
|
|
15
|
+
The extension resolves `agy` on `$PATH`, or via the `AGY_BIN` environment variable if you want to point at a specific binary.
|
|
16
|
+
|
|
17
|
+
## Two modes: one-shot vs continued conversation
|
|
18
|
+
|
|
19
|
+
One tool, two ways to use it — you (or the agent) decide per call:
|
|
20
|
+
|
|
21
|
+
- **One-shot (isolated)**: omit `conversationId`. agy starts fresh with no memory of prior calls. Use for independent questions.
|
|
22
|
+
- **Continued conversation**: pass the `conversationId` returned in a prior call's result (`details.conversationId`, also shown in the result footer). agy resumes that conversation with full context intact — use for follow-ups, multi-turn refinement, or "now do X based on what you just did".
|
|
23
|
+
|
|
24
|
+
How it works under the hood: `agy -p` does not print the conversation id, so on a fresh run the extension snapshots agy's conversations directory before spawn and discovers the single new `.db` file agy created (the one technique borrowed from [`antigravity-acp`](https://github.com/shubzkothekar/antigravity-acp)'s `scan.ts`). On a continued call it passes `--conversation <id>` and agy reuses it natively. agy holds all conversation state in its own SQLite DB; this extension is otherwise stateless.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Pi (orchestrator)
|
|
30
|
+
└─ AskAntigravity tool
|
|
31
|
+
└─ spawn: agy --add-dir <cwd> --model <resolved> [--conversation <id>] -p <prompt>
|
|
32
|
+
└─ agy runs its OWN tool loop (read / write / edit / exec) inside <cwd>
|
|
33
|
+
└─ stdout streamed as partial tool output
|
|
34
|
+
└─ returns agy's final answer text + conversationId (for follow-ups)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
No ACP server, no SQLite reverse-engineering, no `acpx`, no third-party binary. `agy -p` (print mode) prints its response to stdout, so we capture it directly.
|
|
38
|
+
|
|
39
|
+
## Model aliases
|
|
40
|
+
|
|
41
|
+
Verbose `agy models` strings (`Gemini 3.5 Flash (Medium)`) are hostile to natural requests. This extension resolves friendly aliases to the exact string:
|
|
42
|
+
|
|
43
|
+
| User says | Resolves to |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `flash` | latest Flash, default tier (Medium) |
|
|
46
|
+
| `pro` | latest Pro (Pro has no Medium → **High**) |
|
|
47
|
+
| `gemini` | alias for `flash` |
|
|
48
|
+
| `flash high` | latest Flash, High |
|
|
49
|
+
| `pro low` | latest Pro, Low |
|
|
50
|
+
| `3.5 flash` | pinned version, default tier |
|
|
51
|
+
| `3.1 pro` | pinned Pro version (→ High) |
|
|
52
|
+
| `Gemini 3.5 Flash (Medium)` | exact passthrough |
|
|
53
|
+
|
|
54
|
+
- **Latest** = highest version number available for the family.
|
|
55
|
+
- **Default tier**: Flash → Medium, Pro → High. Overridable per-config (below).
|
|
56
|
+
- When a tier is unavailable for a family, the nearest is chosen with ties broken toward the higher tier (so "latest and greatest" wins).
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
`~/.pi/agent/ask-antigravity.json` (global) merged over `.pi/ask-antigravity.json` (project):
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"defaultModel": "flash",
|
|
65
|
+
"defaultThinking": "medium"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Key | Default | Description |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `defaultModel` | `flash` | Alias or exact id used when the tool call omits `model`. |
|
|
72
|
+
| `defaultThinking` | `medium` | Tier used when an alias doesn't name one. Applies only if the family offers it; otherwise the family default (Pro → High). |
|
|
73
|
+
|
|
74
|
+
### `/agy` command
|
|
75
|
+
|
|
76
|
+
Interactive picker for the default model and default thinking. If the project config (`.pi/ask-antigravity.json`) already defines a key, the change is written there so it actually takes effect; otherwise it writes to the global config. Outside TUI (RPC/headless), prints a read-only status snapshot.
|
|
77
|
+
|
|
78
|
+
## Tool parameters
|
|
79
|
+
|
|
80
|
+
| Param | Required | Description |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `prompt` | yes | Self-contained task. agy cannot see the Pi conversation. |
|
|
83
|
+
| `cwd` | no | Workspace agy runs in. Defaults to the project root. |
|
|
84
|
+
| `model` | no | Alias or exact id. Omit for the configured default. |
|
|
85
|
+
| `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). |
|
|
86
|
+
| `skipPermissions` | no | Pass `--dangerously-skip-permissions`. Needed for mutating tasks. |
|
|
87
|
+
| `timeoutMinutes` | no | Hard cap on the run. Default 10. |
|
|
88
|
+
|
|
89
|
+
## Environment
|
|
90
|
+
|
|
91
|
+
| Env var | Purpose |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `AGY_BIN` | Path to an `agy` binary; skips `$PATH` lookup |
|
|
94
|
+
| `AGY_EXTRA_ARGS` | Extra args appended to every `agy` invocation (raw string) |
|
|
95
|
+
| `AGY_CONVERSATIONS_DIR` | Directory where agy writes its conversation SQLite DBs. Defaults to `~/.gemini/antigravity-cli/conversations`. Override only if agy uses a different path on your OS. |
|
|
96
|
+
|
|
97
|
+
## When to use it
|
|
98
|
+
|
|
99
|
+
- A second opinion from a different model family on the same problem.
|
|
100
|
+
- Gemini-specific reasoning, long-context synthesis.
|
|
101
|
+
- An isolated sub-task you want agy to complete end-to-end without driving each step.
|
|
102
|
+
|
|
103
|
+
agy mutates the workspace itself, so scope calls to sub-tasks where that is the desired contract, not where Pi is concurrently editing the same files.
|
|
104
|
+
|
|
105
|
+
## Terms of Service notice
|
|
106
|
+
|
|
107
|
+
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.**
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AskAntigravity — delegate a self-contained sub-task to Google Antigravity's
|
|
3
|
+
* `agy` CLI (the CLI for Gemini). The AskClaude-style delegation pattern,
|
|
4
|
+
* pointed at Gemini via agy.
|
|
5
|
+
*
|
|
6
|
+
* One self-contained tool. Spawns `agy -p`, streams its stdout as partial
|
|
7
|
+
* output, returns the final response. agy runs its OWN tool loop (read,
|
|
8
|
+
* write, edit, exec) inside the workspace.
|
|
9
|
+
*
|
|
10
|
+
* Model aliases: friendly names resolve to the exact `agy models` string.
|
|
11
|
+
* "flash" -> latest Flash, default tier (config)
|
|
12
|
+
* "flash high" -> latest Flash, high thinking
|
|
13
|
+
* "pro" -> latest Pro, default tier (config)
|
|
14
|
+
* "3.5 flash low" -> pinned version + tier
|
|
15
|
+
* "Gemini 3.5 Flash (Medium)" -> exact passthrough
|
|
16
|
+
*
|
|
17
|
+
* Config: ~/.pi/agent/ask-antigravity.json (global) merged over
|
|
18
|
+
* .pi/ask-antigravity.json (project). Editable via /agy.
|
|
19
|
+
*
|
|
20
|
+
* Two modes (agent decides per call):
|
|
21
|
+
* - omit conversationId -> one-shot, agy starts fresh
|
|
22
|
+
* - pass conversationId -> resume that agy conversation (full context)
|
|
23
|
+
* The id is discovered on fresh runs by snapshotting agy's conversations dir
|
|
24
|
+
* before spawn and diffing after (agy -p never prints it). This is the one
|
|
25
|
+
* technique borrowed from antigravity-acp's scan.ts.
|
|
26
|
+
*
|
|
27
|
+
* Env: AGY_BIN (binary path), AGY_EXTRA_ARGS (extra args; whitespace-split,
|
|
28
|
+
* so values containing spaces are not supported).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawn } from "node:child_process";
|
|
32
|
+
import * as fs from "node:fs";
|
|
33
|
+
import * as os from "node:os";
|
|
34
|
+
import * as path from "node:path";
|
|
35
|
+
import {
|
|
36
|
+
type AgentToolResult,
|
|
37
|
+
getSettingsListTheme,
|
|
38
|
+
type ExtensionAPI,
|
|
39
|
+
} from "@earendil-works/pi-coding-agent";
|
|
40
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
41
|
+
import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
|
|
42
|
+
import { Type } from "typebox";
|
|
43
|
+
|
|
44
|
+
// --- Constants -------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TIMEOUT_MIN = 10;
|
|
47
|
+
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
48
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
49
|
+
const STATUS_TAIL_CHARS = 160;
|
|
50
|
+
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
51
|
+
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
52
|
+
const DISCOVERY_POLL_MS = 100;
|
|
53
|
+
const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "ask-antigravity.json");
|
|
54
|
+
|
|
55
|
+
const DEFAULT_MODEL = "flash";
|
|
56
|
+
const DEFAULT_THINKING = "medium";
|
|
57
|
+
|
|
58
|
+
// Per-family fallback tier when none is specified and no config default.
|
|
59
|
+
// Flash defaults to medium (per spec); Pro only ships Low/High, so "latest
|
|
60
|
+
// and greatest" = high.
|
|
61
|
+
const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
|
|
62
|
+
flash: "medium",
|
|
63
|
+
pro: "high",
|
|
64
|
+
other: "medium",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
|
|
68
|
+
|
|
69
|
+
// agy conversation ids are UUID DB-stems (e.g. "9e6fdc2f-f9f9-4096-95fc-7852528b50cc").
|
|
70
|
+
// Reject anything that isn't, so a leading-dash value can't misbind on agy's
|
|
71
|
+
// arg parser as the token after --conversation.
|
|
72
|
+
const CONV_ID_RE = /^[A-Za-z0-9]{1,128}$/;
|
|
73
|
+
|
|
74
|
+
const AGY_DESCRIPTION = `Delegate a self-contained sub-task to Google Antigravity. agy is the CLI for Gemini, so this tool is reached under three equivalent names the user may use interchangeably: **gemini**, **antigravity**, and **agy**. When the user says "ask gemini", "ask antigravity", "ask agy", or otherwise refers to any of these, call THIS tool. agy runs its OWN tool loop: it can read, write, edit, and execute inside the workspace, then returns its final answer. Use for a second opinion from a different model family, Gemini-specific reasoning, or isolated sub-tasks you do not need to drive step-by-step. Provide a complete, self-contained task description; agy will not see this conversation.
|
|
75
|
+
|
|
76
|
+
TWO MODES (you choose):
|
|
77
|
+
- **One-shot (isolated)**: omit conversationId. agy starts fresh with no memory of prior calls. Use for independent questions.
|
|
78
|
+
- **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.`;
|
|
79
|
+
|
|
80
|
+
// --- Types -----------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
type ThinkingTier = "low" | "medium" | "high";
|
|
83
|
+
type Family = "flash" | "pro" | "other";
|
|
84
|
+
|
|
85
|
+
interface ModelEntry {
|
|
86
|
+
full: string; // exact agy string, e.g. "Gemini 3.5 Flash (Medium)"
|
|
87
|
+
family: Family;
|
|
88
|
+
version: string | null; // "3.5"
|
|
89
|
+
tier: ThinkingTier | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Config {
|
|
93
|
+
defaultModel: string;
|
|
94
|
+
defaultThinking: ThinkingTier;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Version helpers -------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/** Descending numeric version compare. "3.10" > "3.9" (lexical sort would
|
|
100
|
+
* wrongly rank "3.9" higher because '9' > '1'). */
|
|
101
|
+
function compareVersionsDesc(a: string, b: string): number {
|
|
102
|
+
const pa = a.split(".").map(Number);
|
|
103
|
+
const pb = b.split(".").map(Number);
|
|
104
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
105
|
+
const da = pa[i] ?? 0;
|
|
106
|
+
const db = pb[i] ?? 0;
|
|
107
|
+
if (da !== db) return db - da; // descending
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Config ----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function projectConfigPath(): string {
|
|
115
|
+
return path.join(process.cwd(), ".pi", "ask-antigravity.json");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tryReadJson(filePath: string): Record<string, unknown> {
|
|
119
|
+
if (!fs.existsSync(filePath)) return {};
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
122
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
123
|
+
} catch {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function loadConfig(): Config {
|
|
129
|
+
const global = tryReadJson(GLOBAL_CONFIG_PATH);
|
|
130
|
+
const project = tryReadJson(projectConfigPath());
|
|
131
|
+
const merged = { ...global, ...project };
|
|
132
|
+
|
|
133
|
+
const thinkingRaw = String(merged.defaultThinking ?? DEFAULT_THINKING).toLowerCase();
|
|
134
|
+
const thinking: ThinkingTier =
|
|
135
|
+
thinkingRaw === "low" || thinkingRaw === "high" ? thinkingRaw : "medium";
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
defaultModel: String(merged.defaultModel ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL,
|
|
139
|
+
defaultThinking: thinking,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface SaveResult {
|
|
144
|
+
path: string;
|
|
145
|
+
/** Keys whose effect is shadowed by a project config (info only). */
|
|
146
|
+
routedToProject: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Persist a config patch. If the project config already defines any patched
|
|
150
|
+
* key, write to the PROJECT file so the change actually takes effect
|
|
151
|
+
* (project shadows global on load); otherwise write to global.
|
|
152
|
+
* Atomic: temp file + rename, with temp cleanup on failure. */
|
|
153
|
+
function saveConfig(patch: Partial<Config>): SaveResult {
|
|
154
|
+
const projectRaw = tryReadJson(projectConfigPath());
|
|
155
|
+
const projectShadows = Object.keys(patch).some((k) => k in projectRaw);
|
|
156
|
+
const targetPath = projectShadows ? projectConfigPath() : GLOBAL_CONFIG_PATH;
|
|
157
|
+
|
|
158
|
+
const existing = tryReadJson(targetPath);
|
|
159
|
+
const next = { ...existing, ...patch };
|
|
160
|
+
const dir = path.dirname(targetPath);
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
const tmp = `${targetPath}.${process.pid}.tmp`;
|
|
164
|
+
try {
|
|
165
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
166
|
+
fs.renameSync(tmp, targetPath);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(tmp);
|
|
170
|
+
} catch {}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
return { path: targetPath, routedToProject: projectShadows };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Model parsing + alias resolution --------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Parse one `agy models` line into a structured entry. */
|
|
179
|
+
function parseModelLine(line: string): ModelEntry | null {
|
|
180
|
+
const full = line.trim();
|
|
181
|
+
if (!full) return null;
|
|
182
|
+
|
|
183
|
+
const lower = full.toLowerCase();
|
|
184
|
+
const family: Family = lower.includes("flash")
|
|
185
|
+
? "flash"
|
|
186
|
+
: lower.includes("pro")
|
|
187
|
+
? "pro"
|
|
188
|
+
: "other";
|
|
189
|
+
|
|
190
|
+
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
191
|
+
const version = versionMatch ? versionMatch[1] : null;
|
|
192
|
+
|
|
193
|
+
const tierMatch = lower.match(/\((low|medium|high)\)/);
|
|
194
|
+
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
195
|
+
|
|
196
|
+
return { full, family, version, tier };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Pick the available tier closest in rank to the preferred one. Distance
|
|
200
|
+
* ties (e.g. Low/High around Medium) break toward the higher tier so
|
|
201
|
+
* "latest and greatest" wins when a family lacks the requested tier. */
|
|
202
|
+
function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
|
|
203
|
+
if (available.includes(preferred)) return preferred;
|
|
204
|
+
const sorted = [...available].sort((a, b) => {
|
|
205
|
+
const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
|
|
206
|
+
const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
|
|
207
|
+
return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
|
|
208
|
+
});
|
|
209
|
+
return sorted[0] ?? preferred;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve a friendly alias / partial name to an exact agy model string.
|
|
214
|
+
* Returns null if resolution is not possible (caller passes input through
|
|
215
|
+
* to agy, which may resolve or fail on its own).
|
|
216
|
+
*/
|
|
217
|
+
function resolveModel(
|
|
218
|
+
input: string,
|
|
219
|
+
entries: ModelEntry[],
|
|
220
|
+
defaultThinking: ThinkingTier,
|
|
221
|
+
): string | null {
|
|
222
|
+
const lower = input.toLowerCase().trim();
|
|
223
|
+
|
|
224
|
+
// 1. Exact full-string match (case-insensitive).
|
|
225
|
+
const exact = entries.find((e) => e.full.toLowerCase() === lower);
|
|
226
|
+
if (exact) return exact.full;
|
|
227
|
+
|
|
228
|
+
// 2. Parse the alias.
|
|
229
|
+
let family: Family | null = lower.includes("flash")
|
|
230
|
+
? "flash"
|
|
231
|
+
: lower.includes("pro")
|
|
232
|
+
? "pro"
|
|
233
|
+
: null;
|
|
234
|
+
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
235
|
+
const version = versionMatch ? versionMatch[1] : null;
|
|
236
|
+
const tierMatch = lower.match(/\b(low|medium|high)\b/);
|
|
237
|
+
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
238
|
+
|
|
239
|
+
// "gemini" alone, "default", or empty -> default family (flash).
|
|
240
|
+
if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
|
|
241
|
+
family = "flash";
|
|
242
|
+
}
|
|
243
|
+
if (!family) return null; // unknown family -> let agy handle it
|
|
244
|
+
|
|
245
|
+
// 3. Filter by family.
|
|
246
|
+
let candidates = entries.filter((e) => e.family === family);
|
|
247
|
+
if (candidates.length === 0) return null;
|
|
248
|
+
|
|
249
|
+
// 4. Pin version if specified; otherwise pick the HIGHEST version
|
|
250
|
+
// (numeric compare, not lexical — see compareVersionsDesc).
|
|
251
|
+
if (version) {
|
|
252
|
+
const versioned = candidates.filter((e) => e.version === version);
|
|
253
|
+
if (versioned.length > 0) candidates = versioned;
|
|
254
|
+
} else {
|
|
255
|
+
const versions = candidates
|
|
256
|
+
.map((e) => e.version)
|
|
257
|
+
.filter((v): v is string => v !== null)
|
|
258
|
+
.sort(compareVersionsDesc);
|
|
259
|
+
if (versions.length > 0) {
|
|
260
|
+
const top = versions[0];
|
|
261
|
+
const latest = candidates.filter((e) => e.version === top || e.version === null);
|
|
262
|
+
if (latest.length > 0) candidates = latest;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 5. Pick tier: explicit > config default (if the family offers it) >
|
|
267
|
+
// family default. Pro has no Medium, so "pro" + default medium falls
|
|
268
|
+
// back to the Pro family default (High), not nearest-Medium.
|
|
269
|
+
const familyTiers = new Set(
|
|
270
|
+
candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
|
|
271
|
+
);
|
|
272
|
+
if (familyTiers.size === 0) return candidates[0].full; // no tiers on any entry
|
|
273
|
+
|
|
274
|
+
const preferred =
|
|
275
|
+
tier ??
|
|
276
|
+
(familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
|
|
277
|
+
const chosenTier = nearestTier([...familyTiers], preferred);
|
|
278
|
+
return (candidates.find((e) => e.tier === chosenTier) ?? candidates[0]).full;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- agy process helpers ---------------------------------------------------
|
|
282
|
+
|
|
283
|
+
function resolveAgy(): string {
|
|
284
|
+
return process.env.AGY_BIN || "agy";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
288
|
+
|
|
289
|
+
/** Query `agy models`. Returns [] on any failure (non-fatal). */
|
|
290
|
+
async function discoverModels(binary: string): Promise<ModelEntry[]> {
|
|
291
|
+
try {
|
|
292
|
+
const text = await new Promise<string>((resolve, reject) => {
|
|
293
|
+
const proc = spawn(binary, ["models"], {
|
|
294
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
295
|
+
shell: false,
|
|
296
|
+
});
|
|
297
|
+
// Decode at the stream level so multibyte codepoints split across
|
|
298
|
+
// pipe chunks don't corrupt.
|
|
299
|
+
proc.stdout?.setEncoding("utf8");
|
|
300
|
+
let out = "";
|
|
301
|
+
let done = false;
|
|
302
|
+
const finish = (v: string) => {
|
|
303
|
+
if (done) return;
|
|
304
|
+
done = true;
|
|
305
|
+
clearTimeout(watchdog);
|
|
306
|
+
resolve(v);
|
|
307
|
+
};
|
|
308
|
+
proc.stdout?.on("data", (d: string) => (out += d));
|
|
309
|
+
proc.on("error", (err) => {
|
|
310
|
+
clearTimeout(watchdog);
|
|
311
|
+
reject(err);
|
|
312
|
+
});
|
|
313
|
+
proc.on("close", (code) => finish(code === 0 ? out : ""));
|
|
314
|
+
// Bound the spawn so a hung agy (auth prompt, network stall) can't
|
|
315
|
+
// block extension load indefinitely.
|
|
316
|
+
const watchdog = setTimeout(() => {
|
|
317
|
+
try {
|
|
318
|
+
proc.kill("SIGKILL");
|
|
319
|
+
} catch {}
|
|
320
|
+
finish("");
|
|
321
|
+
}, DISCOVERY_TIMEOUT_MS);
|
|
322
|
+
});
|
|
323
|
+
return text
|
|
324
|
+
.split("\n")
|
|
325
|
+
.map(parseModelLine)
|
|
326
|
+
.filter((e): e is ModelEntry => e !== null);
|
|
327
|
+
} catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function extraArgs(): string[] {
|
|
333
|
+
const raw = process.env.AGY_EXTRA_ARGS;
|
|
334
|
+
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- Conversation discovery (the one technique borrowed from antigravity-acp) --
|
|
338
|
+
// agy -p does NOT print the conversation id, so for a fresh prompt we snapshot
|
|
339
|
+
// the conversations dir before spawn and pick the single new .db after. For a
|
|
340
|
+
// continued call we pass --conversation <id> and agy reuses it (no new file).
|
|
341
|
+
|
|
342
|
+
const CONVERSATIONS_DIR =
|
|
343
|
+
process.env.AGY_CONVERSATIONS_DIR ||
|
|
344
|
+
path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
|
|
345
|
+
|
|
346
|
+
/** Snapshot the set of conversation ids (*.db stems) currently on disk. */
|
|
347
|
+
function snapshotConversations(dir: string): Set<string> {
|
|
348
|
+
const out = new Set<string>();
|
|
349
|
+
let entries: string[];
|
|
350
|
+
try {
|
|
351
|
+
entries = fs.readdirSync(dir);
|
|
352
|
+
} catch {
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
for (const f of entries) {
|
|
356
|
+
if (f.endsWith(".db")) out.add(f.slice(0, -3));
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Find the single new conversation id created since `before`. Returns null if
|
|
362
|
+
* none, or if several appeared (can't safely pick which one is ours). */
|
|
363
|
+
function newConversationId(dir: string, before: Set<string>): string | null {
|
|
364
|
+
const created = [...snapshotConversations(dir)].filter((id) => !before.has(id));
|
|
365
|
+
if (created.length === 0) return null;
|
|
366
|
+
if (created.length > 1) return null; // ambiguous; refuse to bind
|
|
367
|
+
return created[0] ?? null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- Extension -------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
interface AgyDetails {
|
|
373
|
+
model: string | null;
|
|
374
|
+
resolvedModel: string | null;
|
|
375
|
+
conversationId: string | null;
|
|
376
|
+
exitCode: number;
|
|
377
|
+
aborted: boolean;
|
|
378
|
+
timedOut: boolean;
|
|
379
|
+
durationMs: number;
|
|
380
|
+
stderr: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export default async function (pi: ExtensionAPI) {
|
|
384
|
+
const binary = resolveAgy();
|
|
385
|
+
// Discovered once at load; frozen for the session (including the
|
|
386
|
+
// StringEnum model param). Run /reload after an `agy update` to refresh.
|
|
387
|
+
const discovered = await discoverModels(binary).catch(() => []);
|
|
388
|
+
|
|
389
|
+
// --- /agy: view / change default model + thinking ---------------------
|
|
390
|
+
|
|
391
|
+
// Friendly model options offered in the picker. Exact strings also work
|
|
392
|
+
// if typed, but the menu presents the common aliases.
|
|
393
|
+
const MODEL_OPTIONS = ["flash", "pro", "gemini"];
|
|
394
|
+
const THINKING_OPTIONS: ThinkingTier[] = ["low", "medium", "high"];
|
|
395
|
+
|
|
396
|
+
pi.registerCommand("agy", {
|
|
397
|
+
description:
|
|
398
|
+
"AskAntigravity config: show status, or open the model/thinking picker. Usage: /agy",
|
|
399
|
+
handler: async (_args, ctx) => {
|
|
400
|
+
const config = loadConfig();
|
|
401
|
+
|
|
402
|
+
// Headless / RPC fallback: print a status snapshot.
|
|
403
|
+
if (ctx.mode !== "tui") {
|
|
404
|
+
ctx.ui.notify(
|
|
405
|
+
[
|
|
406
|
+
`AskAntigravity config`,
|
|
407
|
+
` defaultModel: ${config.defaultModel}`,
|
|
408
|
+
` defaultThinking: ${config.defaultThinking}`,
|
|
409
|
+
` resolved: ${resolveModel(config.defaultModel, discovered, config.defaultThinking) ?? "(agy default)"}`,
|
|
410
|
+
``,
|
|
411
|
+
`Edit: ~/.pi/agent/ask-antigravity.json`,
|
|
412
|
+
].join("\n"),
|
|
413
|
+
"info",
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Resolve the display string for the current default model.
|
|
419
|
+
const currentResolved =
|
|
420
|
+
resolveModel(config.defaultModel, discovered, config.defaultThinking) ?? config.defaultModel;
|
|
421
|
+
|
|
422
|
+
const items: SettingItem[] = [
|
|
423
|
+
{
|
|
424
|
+
id: "defaultModel",
|
|
425
|
+
label: "Default model",
|
|
426
|
+
description:
|
|
427
|
+
"Friendly alias resolved to the latest matching agy model. 'flash' = latest Flash, 'pro' = latest Pro, 'gemini' = latest Flash.",
|
|
428
|
+
currentValue: `${config.defaultModel} → ${currentResolved}`,
|
|
429
|
+
values: MODEL_OPTIONS.map((m) => {
|
|
430
|
+
const r = resolveModel(m, discovered, config.defaultThinking) ?? m;
|
|
431
|
+
return `${m} → ${r}`;
|
|
432
|
+
}),
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: "defaultThinking",
|
|
436
|
+
label: "Default thinking",
|
|
437
|
+
description:
|
|
438
|
+
"Thinking tier used when the model alias doesn't name one. Pro has no Medium; it falls back to the nearest (Low or High).",
|
|
439
|
+
currentValue: config.defaultThinking,
|
|
440
|
+
values: THINKING_OPTIONS,
|
|
441
|
+
},
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
const pending: Partial<Config> = {};
|
|
445
|
+
|
|
446
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
447
|
+
const container = new Container();
|
|
448
|
+
container.addChild(
|
|
449
|
+
new Text(theme.fg("accent", theme.bold("AskAntigravity defaults")), 1, 1),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const settingsList = new SettingsList(
|
|
453
|
+
items,
|
|
454
|
+
Math.min(items.length + 4, 15),
|
|
455
|
+
getSettingsListTheme(),
|
|
456
|
+
(id, newValue) => {
|
|
457
|
+
if (id === "defaultModel") {
|
|
458
|
+
// Value is "alias → resolved"; keep the alias part.
|
|
459
|
+
const alias = newValue.split("→")[0].trim();
|
|
460
|
+
pending.defaultModel = alias;
|
|
461
|
+
} else if (id === "defaultThinking") {
|
|
462
|
+
pending.defaultThinking = newValue as ThinkingTier;
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
() => done(undefined),
|
|
466
|
+
);
|
|
467
|
+
container.addChild(settingsList);
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
render: (w: number) => container.render(w),
|
|
471
|
+
invalidate: () => container.invalidate(),
|
|
472
|
+
handleInput: (data: string) => {
|
|
473
|
+
settingsList.handleInput?.(data);
|
|
474
|
+
tui.requestRender();
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (Object.keys(pending).length === 0) return;
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const result = saveConfig(pending);
|
|
483
|
+
const changed = Object.entries(pending)
|
|
484
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
485
|
+
.join(", ");
|
|
486
|
+
const where = result.routedToProject
|
|
487
|
+
? "(written to project .pi/ask-antigravity.json — it shadows global)"
|
|
488
|
+
: "";
|
|
489
|
+
ctx.ui.notify(`Saved: ${changed}${where ? ` ${where}` : ""}`, "info");
|
|
490
|
+
} catch (err) {
|
|
491
|
+
ctx.ui.notify(
|
|
492
|
+
`Failed to save config: ${err instanceof Error ? err.message : String(err)}`,
|
|
493
|
+
"error",
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// --- Tool registration -------------------------------------------------
|
|
500
|
+
|
|
501
|
+
// Model param: free string (friendly alias OR exact). We surface the
|
|
502
|
+
// discovered models as a StringEnum when available so the model gets a
|
|
503
|
+
// bounded list, but also accept arbitrary input that resolves through
|
|
504
|
+
// the alias map. Description carries the alias grammar.
|
|
505
|
+
const modelParam = discovered.length
|
|
506
|
+
? Type.Optional(
|
|
507
|
+
StringEnum(
|
|
508
|
+
// Offer friendly aliases plus the full exact strings.
|
|
509
|
+
[...new Set([...MODEL_OPTIONS, ...discovered.map((e) => e.full)])],
|
|
510
|
+
{
|
|
511
|
+
description:
|
|
512
|
+
"Model alias or exact id. Friendly: 'flash' (latest Flash), 'pro' (latest Pro), 'gemini' (=flash). Add a tier: 'flash high', 'pro low'. Pin a version: '3.5 flash'. Exact: 'Gemini 3.5 Flash (Medium)'. Omit for the configured default.",
|
|
513
|
+
},
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
: Type.Optional(
|
|
517
|
+
Type.String({
|
|
518
|
+
description:
|
|
519
|
+
"Model alias or exact id. Friendly: 'flash', 'pro', 'gemini'. Add tier: 'flash high'. Omit for default.",
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
pi.registerTool({
|
|
524
|
+
name: "AskAntigravity",
|
|
525
|
+
label: "Ask Antigravity",
|
|
526
|
+
description: AGY_DESCRIPTION,
|
|
527
|
+
parameters: Type.Object({
|
|
528
|
+
prompt: Type.String({
|
|
529
|
+
description:
|
|
530
|
+
"Self-contained task for agy. Include all context agy needs; it cannot see this conversation.",
|
|
531
|
+
}),
|
|
532
|
+
cwd: Type.Optional(
|
|
533
|
+
Type.String({
|
|
534
|
+
description: "Absolute workspace path agy runs in. Defaults to the current project root.",
|
|
535
|
+
}),
|
|
536
|
+
),
|
|
537
|
+
model: modelParam,
|
|
538
|
+
skipPermissions: Type.Optional(
|
|
539
|
+
Type.Boolean({
|
|
540
|
+
description:
|
|
541
|
+
"Pass --dangerously-skip-permissions so agy auto-approves its own write/edit/exec tool calls. Required for tasks that mutate files. Use with care.",
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
conversationId: Type.Optional(
|
|
545
|
+
Type.String({
|
|
546
|
+
description:
|
|
547
|
+
"Omit for a one-shot (agy starts fresh). To CONTINUE a previous agy conversation with its context intact, pass the conversationId returned in that call's details. agy resumes that conversation.",
|
|
548
|
+
}),
|
|
549
|
+
),
|
|
550
|
+
timeoutMinutes: Type.Optional(
|
|
551
|
+
Type.Number({
|
|
552
|
+
description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.`,
|
|
553
|
+
}),
|
|
554
|
+
),
|
|
555
|
+
}),
|
|
556
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
557
|
+
// Circular-delegation guard (best-effort). This extension registers
|
|
558
|
+
// NO provider, so the check only fires if a future agy-as-provider
|
|
559
|
+
// extension registers a provider literally named antigravity/agy.
|
|
560
|
+
// Cheap insurance; harmless otherwise.
|
|
561
|
+
if (ctx.model?.provider === "antigravity" || ctx.model?.provider === "agy") {
|
|
562
|
+
return {
|
|
563
|
+
content: [
|
|
564
|
+
{
|
|
565
|
+
type: "text",
|
|
566
|
+
text: "Error: AskAntigravity cannot be used when the active provider is already agy/Antigravity — you're already running through it.",
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
details: {
|
|
570
|
+
model: null,
|
|
571
|
+
resolvedModel: null,
|
|
572
|
+
conversationId: null,
|
|
573
|
+
exitCode: 0,
|
|
574
|
+
aborted: false,
|
|
575
|
+
timedOut: false,
|
|
576
|
+
durationMs: 0,
|
|
577
|
+
stderr: "circular delegation blocked",
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const config = loadConfig();
|
|
583
|
+
const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
|
|
584
|
+
const resolved =
|
|
585
|
+
resolveModel(requestedModel, discovered, config.defaultThinking) ?? requestedModel;
|
|
586
|
+
|
|
587
|
+
const start = Date.now();
|
|
588
|
+
const cwd = params.cwd || ctx.cwd || process.cwd();
|
|
589
|
+
|
|
590
|
+
// Validate cwd up front for a clearer error than agy's ENOENT.
|
|
591
|
+
try {
|
|
592
|
+
const stat = fs.statSync(cwd);
|
|
593
|
+
if (!stat.isDirectory()) {
|
|
594
|
+
return {
|
|
595
|
+
content: [{ type: "text", text: `cwd is not a directory: ${cwd}` }],
|
|
596
|
+
details: emptyDetails(requestedModel, resolved),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
return {
|
|
601
|
+
content: [{ type: "text", text: `cwd does not exist: ${cwd}` }],
|
|
602
|
+
details: emptyDetails(requestedModel, resolved),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
|
|
607
|
+
|
|
608
|
+
// Continuity: if a conversationId is provided AND validates as an agy
|
|
609
|
+
// id (UUID-ish DB stem, never a leading-dash flag), resume it; otherwise
|
|
610
|
+
// snapshot the conversations dir so we can discover the new id agy
|
|
611
|
+
// creates (agy -p never prints it). This is the one mechanism
|
|
612
|
+
// borrowed from antigravity-acp's scan.ts. Validation rejects values
|
|
613
|
+
// that could misbind on agy's arg parser (e.g. --dangerously-skip-
|
|
614
|
+
// permissions passed as the token after --conversation).
|
|
615
|
+
const rawConvId = params.conversationId;
|
|
616
|
+
const isContinuation =
|
|
617
|
+
typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
|
|
618
|
+
const snapshot = isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR);
|
|
619
|
+
|
|
620
|
+
const args: string[] = ["--add-dir", cwd];
|
|
621
|
+
const extra = extraArgs();
|
|
622
|
+
if (extra.length) args.push(...extra);
|
|
623
|
+
if (resolved) args.push("--model", resolved);
|
|
624
|
+
if (params.skipPermissions) args.push("--dangerously-skip-permissions");
|
|
625
|
+
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
626
|
+
args.push("--print-timeout", `${timeoutMin}m`);
|
|
627
|
+
args.push("-p", params.prompt);
|
|
628
|
+
|
|
629
|
+
const details: AgyDetails = {
|
|
630
|
+
model: requestedModel,
|
|
631
|
+
resolvedModel: resolved,
|
|
632
|
+
conversationId: isContinuation ? (rawConvId as string) : null,
|
|
633
|
+
exitCode: 0,
|
|
634
|
+
aborted: false,
|
|
635
|
+
timedOut: false,
|
|
636
|
+
durationMs: 0,
|
|
637
|
+
stderr: "",
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
let out = "";
|
|
641
|
+
|
|
642
|
+
// Throttled status updates (claude-bridge pattern): emit a short
|
|
643
|
+
// status line on an interval instead of the full buffer on every
|
|
644
|
+
// stdout chunk, avoiding O(n²) re-renders on long runs.
|
|
645
|
+
const statusInterval = onUpdate
|
|
646
|
+
? setInterval(() => {
|
|
647
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
648
|
+
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
649
|
+
const text = tail
|
|
650
|
+
? `(running ${elapsed}s)\n…${tail}`
|
|
651
|
+
: `(running ${elapsed}s)`;
|
|
652
|
+
onUpdate({
|
|
653
|
+
content: [{ type: "text", text }],
|
|
654
|
+
details: { ...details, durationMs: Date.now() - start },
|
|
655
|
+
});
|
|
656
|
+
}, STATUS_INTERVAL_MS)
|
|
657
|
+
: null;
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const outcome = await new Promise<{
|
|
661
|
+
exitCode: number;
|
|
662
|
+
aborted: boolean;
|
|
663
|
+
timedOut: boolean;
|
|
664
|
+
}>((resolveP, rejectP) => {
|
|
665
|
+
// detached: true so we can signal the whole process group.
|
|
666
|
+
// agy spawns its own exec subprocesses in -p mode; a direct
|
|
667
|
+
// kill would orphan those grandchildren.
|
|
668
|
+
const proc = spawn(binary, args, {
|
|
669
|
+
cwd,
|
|
670
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
671
|
+
shell: false,
|
|
672
|
+
detached: true,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// Decode at the stream level so multibyte UTF-8 split across
|
|
676
|
+
// pipe chunks doesn't corrupt (Gemini output is non-ASCII).
|
|
677
|
+
proc.stdout?.setEncoding("utf8");
|
|
678
|
+
proc.stderr?.setEncoding("utf8");
|
|
679
|
+
|
|
680
|
+
proc.stdout?.on("data", (d: string) => {
|
|
681
|
+
out += d;
|
|
682
|
+
});
|
|
683
|
+
proc.stderr?.on("data", (d: string) => {
|
|
684
|
+
details.stderr += d;
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
688
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
689
|
+
let settled = false;
|
|
690
|
+
let timedOut = false;
|
|
691
|
+
|
|
692
|
+
// Kill the whole process group; SIGTERM first, then SIGKILL
|
|
693
|
+
// after a grace period if it hasn't exited.
|
|
694
|
+
const killTree = () => {
|
|
695
|
+
try {
|
|
696
|
+
if (proc.pid) process.kill(-proc.pid, "SIGTERM");
|
|
697
|
+
} catch {}
|
|
698
|
+
// Only arm the SIGKILL timer once.
|
|
699
|
+
if (!sigkillTimer) {
|
|
700
|
+
sigkillTimer = setTimeout(() => {
|
|
701
|
+
try {
|
|
702
|
+
if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
703
|
+
} catch {}
|
|
704
|
+
}, GRACE_AFTER_TIMEOUT_MS);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const cleanup = () => {
|
|
709
|
+
if (watchdog) clearTimeout(watchdog);
|
|
710
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
711
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const onAbort = () => killTree();
|
|
715
|
+
|
|
716
|
+
// Enforce the timeout cap ourselves (not just via
|
|
717
|
+
// --print-timeout, which agy could ignore or not support).
|
|
718
|
+
watchdog = setTimeout(() => {
|
|
719
|
+
timedOut = true;
|
|
720
|
+
killTree();
|
|
721
|
+
}, timeoutMin * 60_000);
|
|
722
|
+
|
|
723
|
+
if (signal) {
|
|
724
|
+
if (signal.aborted) killTree();
|
|
725
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const finish = (code: number | null) => {
|
|
729
|
+
if (settled) return;
|
|
730
|
+
settled = true;
|
|
731
|
+
cleanup();
|
|
732
|
+
// Distinguish an aborted run from a normal close: if the
|
|
733
|
+
// abort signal fired, treat as cancelled regardless of
|
|
734
|
+
// exit code (killTree produces a non-zero code, but be
|
|
735
|
+
// explicit and order-independent). Likewise surface a
|
|
736
|
+
// timeout distinctly from a genuine non-zero exit.
|
|
737
|
+
resolveP({
|
|
738
|
+
exitCode: code ?? 0,
|
|
739
|
+
aborted: !!signal?.aborted,
|
|
740
|
+
timedOut,
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
proc.on("error", (err) => {
|
|
745
|
+
cleanup();
|
|
746
|
+
rejectP(err);
|
|
747
|
+
});
|
|
748
|
+
proc.on("close", finish);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
752
|
+
|
|
753
|
+
details.exitCode = outcome.exitCode;
|
|
754
|
+
details.aborted = outcome.aborted;
|
|
755
|
+
details.timedOut = outcome.timedOut;
|
|
756
|
+
details.durationMs = Date.now() - start;
|
|
757
|
+
|
|
758
|
+
// For a fresh run, discover the conversation id agy just created
|
|
759
|
+
// (agy -p never prints it). Retry briefly since agy may flush its
|
|
760
|
+
// SQLite DB a moment after the process closes. A continuation run
|
|
761
|
+
// reuses the provided id (already set on details).
|
|
762
|
+
if (!isContinuation && !details.conversationId && snapshot) {
|
|
763
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
764
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
765
|
+
if (found) {
|
|
766
|
+
details.conversationId = found;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const text = out.trim();
|
|
774
|
+
|
|
775
|
+
// Aborted: return a distinct result so the caller knows it was
|
|
776
|
+
// cancelled, not a silent success.
|
|
777
|
+
if (outcome.aborted) {
|
|
778
|
+
return {
|
|
779
|
+
content: [
|
|
780
|
+
{
|
|
781
|
+
type: "text",
|
|
782
|
+
text: text
|
|
783
|
+
? `agy was aborted. Partial output:\n\n${text}`
|
|
784
|
+
: "agy was aborted before producing output.",
|
|
785
|
+
},
|
|
786
|
+
],
|
|
787
|
+
details,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Timeout: distinct from a genuine non-zero exit (the watchdog
|
|
792
|
+
// killed the tree because the configured cap elapsed).
|
|
793
|
+
if (outcome.timedOut) {
|
|
794
|
+
const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
|
|
795
|
+
return {
|
|
796
|
+
content: [
|
|
797
|
+
{ type: "text", text: text ? `${text}\n\n[${note}]` : note },
|
|
798
|
+
],
|
|
799
|
+
details,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Non-zero exit: surface the failure even when partial text
|
|
804
|
+
// exists, instead of returning silent success.
|
|
805
|
+
if (outcome.exitCode !== 0) {
|
|
806
|
+
const note = details.stderr.trim()
|
|
807
|
+
? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
|
|
808
|
+
: `agy exited with status ${outcome.exitCode}`;
|
|
809
|
+
return {
|
|
810
|
+
content: [
|
|
811
|
+
{ type: "text", text: text ? `${text}\n\n[${note}]` : note },
|
|
812
|
+
],
|
|
813
|
+
details,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Success. Clear the last partial status line (claude-bridge
|
|
818
|
+
// idiom) so the running-tail preview doesn't linger under the final
|
|
819
|
+
// answer, then append a conversation footer so the orchestrating model
|
|
820
|
+
// can see (and thread) the id without inspecting details.
|
|
821
|
+
onUpdate?.({
|
|
822
|
+
content: [{ type: "text", text: "" }],
|
|
823
|
+
details: { ...details },
|
|
824
|
+
});
|
|
825
|
+
const footer = details.conversationId
|
|
826
|
+
? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
|
|
827
|
+
: "";
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
content: [{ type: "text", text: text + footer }],
|
|
831
|
+
details,
|
|
832
|
+
};
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
835
|
+
details.durationMs = Date.now() - start;
|
|
836
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
837
|
+
return {
|
|
838
|
+
content: [{ type: "text", text: `failed to run agy: ${msg}` }],
|
|
839
|
+
details,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function emptyDetails(model: string | null, resolvedModel: string | null): AgyDetails {
|
|
847
|
+
return {
|
|
848
|
+
model,
|
|
849
|
+
resolvedModel,
|
|
850
|
+
conversationId: null,
|
|
851
|
+
exitCode: 0,
|
|
852
|
+
aborted: false,
|
|
853
|
+
timedOut: false,
|
|
854
|
+
durationMs: 0,
|
|
855
|
+
stderr: "",
|
|
856
|
+
};
|
|
857
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@estebanforge/pi-ask-antigravity",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"antigravity",
|
|
9
|
+
"agy",
|
|
10
|
+
"gemini",
|
|
11
|
+
"google",
|
|
12
|
+
"delegation",
|
|
13
|
+
"tool"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "EstebanForge",
|
|
18
|
+
"email": "esteban@attitude.cl",
|
|
19
|
+
"url": "https://actitud.xyz"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/EstebanForge/pi-ask-antigravity.git"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"files": [
|
|
27
|
+
"extensions",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./extensions"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
45
|
+
"@earendil-works/pi-ai": "^0.80.2",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.80.2",
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"typebox": "^1.1.38",
|
|
49
|
+
"typescript": "^5.8.0"
|
|
50
|
+
}
|
|
51
|
+
}
|