@estebanforge/pi-ask-antigravity 1.2.11 → 1.2.13
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 +23 -0
- package/README.md +1 -1
- package/extensions/index.ts +151 -721
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [1.2.13] - 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Default `timeoutMinutes` is now 30 (was 10). Delegations carry feedback, research, review, and analysis work that legitimately runs long; a short default kills the run mid-task and the caller re-pays the whole prompt at a higher timeout. Callers still override per call, lower or higher.
|
|
12
|
+
|
|
13
|
+
## [1.2.12] - 2026-10-10
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Peer prompts now carry a `[TIME BUDGET]` notice matching the resolved timeout (default 10 minutes), placed before the plan-mode guard banners so the safety constraints stay the last text the model reads. `timeoutMinutes` values that cannot arm a real timer (non-positive, non-finite, above the 35791-minute ceiling) are rejected with a clear error instead of arming an instant-kill watchdog.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Project-scope config files resolve under `.pi/` again (`<project>/.pi/ask-*.json`): the shared-layer refactor passed the workspace root where the shared primitive expects the directory containing the file, so project configs would have been read from and written to the workspace root. Restores pre-refactor behavior.
|
|
22
|
+
- The live stdout accumulator applies the original 1MB valve again (past 1MB, keep the last 100k) — the shared-layer refactor had left it uncapped.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Internal dedup onto `pi-ask-shared` (behavior-preserving).** Config load/save composes the shared layered-config primitives (same homedir-based global path, same project-shadow routing, same atomic write); the spawn lifecycle (detached process group, SIGTERM→SIGKILL grace, watchdog, abort, settle-on-close) runs through the shared `runProcess`; the `agy models` line grammar, tiered alias resolution, plan-mode guard prompts, argv builder, and the SQLite conversation-id discovery technique moved to the shared peer adapter (`peers/antigravity.ts`). No user-facing behavior change: plan runs still never receive the skip-permissions flag (env-injected copies still stripped), conversation binding still disambiguates concurrent runs via the /proc FD scan, and the full test suite passes unchanged.
|
|
27
|
+
|
|
5
28
|
## [1.2.11] - 2026-10-07
|
|
6
29
|
|
|
7
30
|
### Added
|
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ Interactive picker for the default model and default thinking. If the project co
|
|
|
113
113
|
| `mode` | no | `plan` (review-shaped; not enforced, see the modes table) or `accept-edits` (agy applies edits, default). |
|
|
114
114
|
| `digest` | no | Prefix the prompt with `(Use compact digests, not full file contents.)`. Defaults on for `plan`, off for `accept-edits`. |
|
|
115
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). |
|
|
116
|
-
| `timeoutMinutes` | no | Hard cap on the run. Default
|
|
116
|
+
| `timeoutMinutes` | no | Hard cap on the agy run in minutes. Default 30. Above 0, at most 35791 (fractional minutes allowed). The prompt carries a matching `[TIME BUDGET]` notice; rejected values return a tool error without spawning. |
|
|
117
117
|
|
|
118
118
|
## Environment
|
|
119
119
|
|
package/extensions/index.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* so values containing spaces are not supported).
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { spawn } from "node:child_process";
|
|
31
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
32
32
|
import { randomBytes } from "node:crypto";
|
|
33
33
|
import * as fs from "node:fs";
|
|
34
34
|
import * as os from "node:os";
|
|
@@ -48,21 +48,48 @@ import { Type } from "typebox";
|
|
|
48
48
|
import {
|
|
49
49
|
BackgroundRunRegistry,
|
|
50
50
|
backgroundFlagText,
|
|
51
|
+
buildAgyArgs,
|
|
52
|
+
buildFinalPrompt,
|
|
53
|
+
buildTimeBudgetNotice,
|
|
54
|
+
MAX_TIMEOUT_MINUTES,
|
|
55
|
+
compareVersionsDesc,
|
|
56
|
+
CONVERSATIONS_DIR,
|
|
57
|
+
CONV_ID_RE,
|
|
58
|
+
configPaths,
|
|
51
59
|
createStopHandler,
|
|
52
60
|
createWakeSender,
|
|
61
|
+
DEFAULT_LINE_BUF_MAX_CHARS,
|
|
62
|
+
filterHiddenModels,
|
|
63
|
+
levelToTier,
|
|
64
|
+
loadLayeredRaw,
|
|
65
|
+
mergeCatalog,
|
|
66
|
+
newConversationId,
|
|
67
|
+
parseModelLine,
|
|
68
|
+
resolveAgyModel,
|
|
69
|
+
RunSpawnError,
|
|
70
|
+
runProcess,
|
|
71
|
+
saveLayeredConfig,
|
|
72
|
+
snapshotConversations,
|
|
53
73
|
summarizePrompt,
|
|
74
|
+
tryReadJson,
|
|
75
|
+
type Mode,
|
|
76
|
+
type ModelEntry,
|
|
77
|
+
type ResolvedModel,
|
|
78
|
+
type SaveResult,
|
|
79
|
+
type ThinkingTier,
|
|
54
80
|
} from "@estebanforge/pi-ask-shared";
|
|
55
81
|
|
|
56
82
|
// --- Constants -------------------------------------------------------------
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
// Feedback, research, review, and analysis delegations legitimately run
|
|
85
|
+
// long; a short default kills the run mid-task and the caller re-pays the
|
|
86
|
+
// whole prompt at a higher timeout. Callers opt down per call.
|
|
87
|
+
const DEFAULT_TIMEOUT_MIN = 30;
|
|
60
88
|
const STATUS_INTERVAL_MS = 1000;
|
|
61
89
|
const STATUS_TAIL_CHARS = 160;
|
|
62
90
|
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
63
91
|
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
64
92
|
const DISCOVERY_POLL_MS = 100;
|
|
65
|
-
const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "ask-antigravity.json");
|
|
66
93
|
|
|
67
94
|
// renderCall / renderResult preview limits (match pi-claude-bridge).
|
|
68
95
|
const PREVIEW_MAX_CHARS = 1000;
|
|
@@ -74,34 +101,11 @@ const DEFAULT_THINKING = "medium";
|
|
|
74
101
|
// non-interactive -p mode (accept-edits auto-approves edits, NOT commands).
|
|
75
102
|
const DEFAULT_SKIP_PERMISSIONS = true;
|
|
76
103
|
|
|
77
|
-
// Per-family fallback tier
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
pro: "high",
|
|
83
|
-
other: "medium",
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
|
|
87
|
-
|
|
88
|
-
/** pi thinking-level vocabulary -> agy tier, for the thinking/effort tool
|
|
89
|
-
* params. Unknown values fall to low (agy always thinks something). */
|
|
90
|
-
function levelToTier(level: string): ThinkingTier {
|
|
91
|
-
switch (level) {
|
|
92
|
-
case "minimal":
|
|
93
|
-
case "low":
|
|
94
|
-
return "low";
|
|
95
|
-
case "medium":
|
|
96
|
-
return "medium";
|
|
97
|
-
case "high":
|
|
98
|
-
case "xhigh":
|
|
99
|
-
case "max":
|
|
100
|
-
return "high";
|
|
101
|
-
default:
|
|
102
|
-
return "low";
|
|
103
|
-
}
|
|
104
|
-
}
|
|
104
|
+
// Per-family fallback tier, the pi->agy tier clamp (levelToTier), the static
|
|
105
|
+
// alias overlay, and the line grammar now live in the shared peer adapter
|
|
106
|
+
// (pi-ask-shared peers/antigravity.ts) alongside the argv builder and the
|
|
107
|
+
// conversation-discovery technique, so pi-antigravity-bridge and pi-peer-review
|
|
108
|
+
// reuse the exact same resolution contract.
|
|
105
109
|
|
|
106
110
|
// Mode = which agy tool-loop policy to apply. Distinct from the alias layer.
|
|
107
111
|
// "plan" → --mode plan (review-shaped; NOT a security boundary:
|
|
@@ -114,62 +118,6 @@ function levelToTier(level: string): ThinkingTier {
|
|
|
114
118
|
// Note: agy's --sandbox flag is an orthogonal shell-containment setting
|
|
115
119
|
// (not an "edit preview" mode), so it is not exposed here. Users who need
|
|
116
120
|
// it can pass it via the AGY_EXTRA_ARGS env var.
|
|
117
|
-
type Mode = "plan" | "accept-edits";
|
|
118
|
-
|
|
119
|
-
// Static alias overlay for non-Gemini models agy may or may not surface in
|
|
120
|
-
// `agy models` depending on plan. When the live catalog contains an entry
|
|
121
|
-
// whose full string equals the overlay target, the live entry wins. When it
|
|
122
|
-
// does not (older agy, missing model, plan gate), the overlay entry resolves
|
|
123
|
-
// the alias so the user can still type "sonnet" and get a working answer.
|
|
124
|
-
// "sonnet" -> claude-sonnet-5-5 (tiered low/medium/high like Gemini)
|
|
125
|
-
// "opus" -> claude-opus-5-5 (tiered low/medium/high like Gemini)
|
|
126
|
-
const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
|
|
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" },
|
|
133
|
-
];
|
|
134
|
-
|
|
135
|
-
// Short alias → overlay full string. Used by resolveModel to recognize
|
|
136
|
-
// friendly short names ("sonnet") that the family-parser (flash/pro) does
|
|
137
|
-
// not match. The overlay entries are also merged into the live catalog for
|
|
138
|
-
// exact-string passthrough, so this map only needs to cover the short names.
|
|
139
|
-
const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
|
|
140
|
-
["sonnet", "claude-sonnet-5-5"],
|
|
141
|
-
["opus", "claude-opus-5-5"],
|
|
142
|
-
]);
|
|
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
|
-
|
|
155
|
-
/** Merge the live catalog with the static alias overlay. Live entries win on
|
|
156
|
-
* case-insensitive full-string equality so an updated `agy models` listing
|
|
157
|
-
* always takes precedence over the hardcoded fallback. */
|
|
158
|
-
export function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
|
|
159
|
-
const seen = new Set(live.map((e) => e.full.toLowerCase()));
|
|
160
|
-
const merged = [...live];
|
|
161
|
-
for (const entry of STATIC_ALIAS_OVERLAY) {
|
|
162
|
-
if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
|
|
163
|
-
}
|
|
164
|
-
return merged;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// agy conversation ids are UUID DB-stems (e.g. "9e6fdc2f-f9f9-4096-95fc-7852528b50cc").
|
|
168
|
-
// Reject anything that isn't, so a leading-dash value can't misbind on agy's
|
|
169
|
-
// arg parser as the token after --conversation. First char must be
|
|
170
|
-
// alphanumeric (rejects leading-dash flag injection); hyphens allowed in the
|
|
171
|
-
// body because real UUIDs contain them.
|
|
172
|
-
const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
|
|
173
121
|
|
|
174
122
|
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.
|
|
175
123
|
|
|
@@ -191,24 +139,9 @@ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
|
|
|
191
139
|
|
|
192
140
|
// --- Types -----------------------------------------------------------------
|
|
193
141
|
|
|
194
|
-
type ThinkingTier = "low" | "medium" | "high";
|
|
195
142
|
type Family = "flash" | "pro" | "other";
|
|
196
143
|
|
|
197
|
-
|
|
198
|
-
full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
|
|
199
|
-
family: Family;
|
|
200
|
-
version: string | null; // "3.6"
|
|
201
|
-
tier: ThinkingTier | null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Argv-facing model resolution: the exact --model slug plus an optional
|
|
205
|
-
* --effort tier. Gemini bases split the tier out (the base slug alone is
|
|
206
|
-
* invalid without --effort); fixed-thinking families keep agy's exact slug
|
|
207
|
-
* and carry no effort. */
|
|
208
|
-
export interface ResolvedModel {
|
|
209
|
-
model: string;
|
|
210
|
-
effort?: ThinkingTier;
|
|
211
|
-
}
|
|
144
|
+
// --- Config ----------------------------------------------------------------
|
|
212
145
|
|
|
213
146
|
interface Config {
|
|
214
147
|
defaultModel: string;
|
|
@@ -218,41 +151,18 @@ interface Config {
|
|
|
218
151
|
skipPermissions: boolean;
|
|
219
152
|
}
|
|
220
153
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const da = pa[i] ?? 0;
|
|
230
|
-
const db = pb[i] ?? 0;
|
|
231
|
-
if (da !== db) return db - da; // descending
|
|
232
|
-
}
|
|
233
|
-
return 0;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// --- Config ----------------------------------------------------------------
|
|
237
|
-
|
|
238
|
-
function projectConfigPath(): string {
|
|
239
|
-
return path.join(process.cwd(), ".pi", "ask-antigravity.json");
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function tryReadJson(filePath: string): Record<string, unknown> {
|
|
243
|
-
if (!fs.existsSync(filePath)) return {};
|
|
244
|
-
try {
|
|
245
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
246
|
-
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
247
|
-
} catch {
|
|
248
|
-
return {};
|
|
249
|
-
}
|
|
154
|
+
/** Layered config paths: global is ~/.pi/agent (homedir, NOT getAgentDir —
|
|
155
|
+
* today's shipped resolution for this extension); project is <cwd>/.pi. */
|
|
156
|
+
function configPathsFor() {
|
|
157
|
+
return configPaths({
|
|
158
|
+
globalDir: path.join(os.homedir(), ".pi", "agent"),
|
|
159
|
+
projectDir: path.join(process.cwd(), ".pi"),
|
|
160
|
+
fileName: "ask-antigravity.json",
|
|
161
|
+
});
|
|
250
162
|
}
|
|
251
163
|
|
|
252
164
|
function loadConfig(): Config {
|
|
253
|
-
const
|
|
254
|
-
const project = tryReadJson(projectConfigPath());
|
|
255
|
-
const merged = { ...global, ...project };
|
|
165
|
+
const { merged } = loadLayeredRaw(configPathsFor());
|
|
256
166
|
|
|
257
167
|
const thinkingRaw = String(merged.defaultThinking ?? DEFAULT_THINKING).toLowerCase();
|
|
258
168
|
const thinking: ThinkingTier =
|
|
@@ -271,89 +181,19 @@ function loadConfig(): Config {
|
|
|
271
181
|
};
|
|
272
182
|
}
|
|
273
183
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
routedToProject: boolean;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** Persist a config patch. If the project config already defines any patched
|
|
281
|
-
* key, write to the PROJECT file so the change actually takes effect
|
|
282
|
-
* (project shadows global on load); otherwise write to global.
|
|
283
|
-
* Atomic: temp file + rename, with temp cleanup on failure. */
|
|
184
|
+
/** Persist a config patch via the shared all-or-nothing router: if any
|
|
185
|
+
* patched key is already project-defined, the whole patch goes to the
|
|
186
|
+
* project file (it shadows global on load). Atomic write inside. */
|
|
284
187
|
function saveConfig(patch: Partial<Config>): SaveResult {
|
|
285
|
-
|
|
286
|
-
const projectShadows = Object.keys(patch).some((k) => k in projectRaw);
|
|
287
|
-
const targetPath = projectShadows ? projectConfigPath() : GLOBAL_CONFIG_PATH;
|
|
288
|
-
|
|
289
|
-
const existing = tryReadJson(targetPath);
|
|
290
|
-
const next = { ...existing, ...patch };
|
|
291
|
-
const dir = path.dirname(targetPath);
|
|
292
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
293
|
-
|
|
294
|
-
const tmp = `${targetPath}.${process.pid}.tmp`;
|
|
295
|
-
try {
|
|
296
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
297
|
-
fs.renameSync(tmp, targetPath);
|
|
298
|
-
} catch (err) {
|
|
299
|
-
try {
|
|
300
|
-
fs.unlinkSync(tmp);
|
|
301
|
-
} catch {}
|
|
302
|
-
throw err;
|
|
303
|
-
}
|
|
304
|
-
return { path: targetPath, routedToProject: projectShadows };
|
|
188
|
+
return saveLayeredConfig(configPathsFor(), patch as Record<string, unknown>);
|
|
305
189
|
}
|
|
306
190
|
|
|
307
|
-
// --- Prompt assembly
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
* allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
|
|
314
|
-
* --sandbox does NOT relax the command gate, so the only input every user
|
|
315
|
-
* is guaranteed is the prompt itself. The guard steers the model to answer
|
|
316
|
-
* from that material. accept-edits runs keep their tools and never get it. */
|
|
317
|
-
export const PLAN_HEADLESS_GUARD = [
|
|
318
|
-
"",
|
|
319
|
-
"--- Headless session constraints ---",
|
|
320
|
-
"- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
|
|
321
|
-
"- Work only from the material provided in this prompt.",
|
|
322
|
-
"- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
|
|
323
|
-
].join("\n");
|
|
324
|
-
|
|
325
|
-
/** Guard for plan runs backed by the restricted reviewer agent. Difference
|
|
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. */
|
|
332
|
-
export const AGENT_REVIEW_GUARD = [
|
|
333
|
-
"",
|
|
334
|
-
"--- Review constraints ---",
|
|
335
|
-
"- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
|
|
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.",
|
|
337
|
-
"- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
|
|
338
|
-
].join("\n");
|
|
339
|
-
|
|
340
|
-
/** Assemble the prompt sent to agy: digest marker first (existing behavior),
|
|
341
|
-
* then the caller's prompt, then the plan-mode guard last - the position
|
|
342
|
-
* the model reads with the most recency. No user-config mutation: this is
|
|
343
|
-
* the only plan-mode lever the tool itself owns. */
|
|
344
|
-
export function buildFinalPrompt(
|
|
345
|
-
prompt: string,
|
|
346
|
-
mode: Mode,
|
|
347
|
-
digest: boolean,
|
|
348
|
-
/** True when the restricted reviewer agent is staged: its empty edit
|
|
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,
|
|
352
|
-
): string {
|
|
353
|
-
let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
|
|
354
|
-
if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
|
|
355
|
-
return out;
|
|
356
|
-
}
|
|
191
|
+
// --- Prompt assembly: shared peer adapter ----------------------------------
|
|
192
|
+
// The plan-mode guards (PLAN_HEADLESS_GUARD / AGENT_REVIEW_GUARD) and the
|
|
193
|
+
// prompt assembler (buildFinalPrompt) live in pi-ask-shared
|
|
194
|
+
// (peers/antigravity.ts) so the bridge's ask-tool and pi-peer-review consults
|
|
195
|
+
// inherit the identical guard discipline.
|
|
196
|
+
export { AGENT_REVIEW_GUARD, PLAN_HEADLESS_GUARD } from "@estebanforge/pi-ask-shared";
|
|
357
197
|
|
|
358
198
|
// --- Plan-mode reviewer agent ----------------------------------------------
|
|
359
199
|
|
|
@@ -451,204 +291,10 @@ function pidAlive(pid: number): boolean {
|
|
|
451
291
|
}
|
|
452
292
|
}
|
|
453
293
|
|
|
454
|
-
// --- Model parsing + alias resolution
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
// agy prints TWO columns: "<slug> <display label>". --model takes only the
|
|
459
|
-
// slug (col 1), so split it off; the label is display-only. A bare-slug line
|
|
460
|
-
// (no whitespace) splits to itself.
|
|
461
|
-
const full = line.trim().split(/\s+/)[0] ?? "";
|
|
462
|
-
if (!full) return null;
|
|
463
|
-
|
|
464
|
-
const lower = full.toLowerCase();
|
|
465
|
-
const family: Family = lower.includes("flash")
|
|
466
|
-
? "flash"
|
|
467
|
-
: lower.includes("pro")
|
|
468
|
-
? "pro"
|
|
469
|
-
: "other";
|
|
470
|
-
|
|
471
|
-
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
472
|
-
const version = versionMatch ? versionMatch[1] : null;
|
|
473
|
-
|
|
474
|
-
const tierMatch = lower.match(/-(low|medium|high)$/);
|
|
475
|
-
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
476
|
-
|
|
477
|
-
return { full, family, version, tier };
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** Pick the available tier closest in rank to the preferred one. Distance
|
|
481
|
-
* ties (e.g. Low/High around Medium) break toward the higher tier so
|
|
482
|
-
* "latest and greatest" wins when a family lacks the requested tier. */
|
|
483
|
-
function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
|
|
484
|
-
if (available.includes(preferred)) return preferred;
|
|
485
|
-
const sorted = [...available].sort((a, b) => {
|
|
486
|
-
const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
|
|
487
|
-
const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
|
|
488
|
-
return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
|
|
489
|
-
});
|
|
490
|
-
return sorted[0] ?? preferred;
|
|
491
|
-
}
|
|
492
|
-
|
|
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. */
|
|
500
|
-
function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
|
|
501
|
-
if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
|
|
502
|
-
return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
|
|
503
|
-
}
|
|
504
|
-
return { model: full };
|
|
505
|
-
}
|
|
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
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
|
|
536
|
-
* Returns null only when the family is unrecognized; the caller then passes
|
|
537
|
-
* the raw input straight to agy.
|
|
538
|
-
*/
|
|
539
|
-
export function resolveModel(
|
|
540
|
-
input: string,
|
|
541
|
-
entries: ModelEntry[],
|
|
542
|
-
defaultThinking: ThinkingTier,
|
|
543
|
-
/** Explicit thinking param (thinking/effort). Beats a tier embedded in
|
|
544
|
-
* the alias ("flash high") and the configured default; clamped to the
|
|
545
|
-
* family's real tiers, ignored for fixed-thinking families. */
|
|
546
|
-
preferredTier?: ThinkingTier,
|
|
547
|
-
): ResolvedModel | null {
|
|
548
|
-
const lower = input.toLowerCase().trim();
|
|
549
|
-
|
|
550
|
-
// 1. Exact full-string match (case-insensitive).
|
|
551
|
-
const exact = entries.find((e) => e.full.toLowerCase() === lower);
|
|
552
|
-
if (exact) return toResolved(exact.full, exact.tier);
|
|
553
|
-
|
|
554
|
-
// 1b. Static short alias ("sonnet" / "opus"). Checked
|
|
555
|
-
// before the family parser because none of these names contain
|
|
556
|
-
// "flash" or "pro" and would otherwise return null below. The
|
|
557
|
-
// resolved full string is then re-validated against the catalog
|
|
558
|
-
// in step 1's second pass on the next call, so renaming the
|
|
559
|
-
// overlay entry in code still wins on exact-string match.
|
|
560
|
-
// The case-insensitive lookup matches mergeCatalog's dedup logic
|
|
561
|
-
// so the "live entries win" guarantee holds even when agy lists
|
|
562
|
-
// the model under different casing than the overlay.
|
|
563
|
-
if (STATIC_SHORT_ALIAS.has(lower)) {
|
|
564
|
-
return resolveTieredBase(
|
|
565
|
-
STATIC_SHORT_ALIAS.get(lower) as string,
|
|
566
|
-
entries,
|
|
567
|
-
defaultThinking,
|
|
568
|
-
preferredTier,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// 2. Parse the alias.
|
|
573
|
-
let family: Family | null = lower.includes("flash")
|
|
574
|
-
? "flash"
|
|
575
|
-
: lower.includes("pro")
|
|
576
|
-
? "pro"
|
|
577
|
-
: null;
|
|
578
|
-
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
579
|
-
const version = versionMatch ? versionMatch[1] : null;
|
|
580
|
-
const tierMatch = lower.match(/\b(low|medium|high)\b/);
|
|
581
|
-
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
582
|
-
|
|
583
|
-
// "gemini" alone, "default", or empty -> default family (flash).
|
|
584
|
-
if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
|
|
585
|
-
family = "flash";
|
|
586
|
-
}
|
|
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
|
-
}
|
|
600
|
-
|
|
601
|
-
// 3. Filter by family.
|
|
602
|
-
let candidates = entries.filter((e) => e.family === family);
|
|
603
|
-
if (candidates.length === 0) return null;
|
|
604
|
-
|
|
605
|
-
// 4. Pin version if specified; otherwise pick the HIGHEST version
|
|
606
|
-
// (numeric compare, not lexical — see compareVersionsDesc).
|
|
607
|
-
if (version) {
|
|
608
|
-
const versioned = candidates.filter((e) => e.version === version);
|
|
609
|
-
if (versioned.length > 0) candidates = versioned;
|
|
610
|
-
} else {
|
|
611
|
-
// Prefer Google's official `gemini-*-latest` aliases (entries with no
|
|
612
|
-
// parseable version in their name, e.g. "Gemini Flash Latest") when
|
|
613
|
-
// the user did NOT pin a specific version. The alias is the
|
|
614
|
-
// versionless pointer Google intends for "the current release" and
|
|
615
|
-
// hot-swaps on every release, while versioned entries like
|
|
616
|
-
// "Gemini 3.6 Flash (Medium)" stay available via explicit pinning
|
|
617
|
-
// (e.g. "3.6 flash medium"). Falls back to the highest versioned
|
|
618
|
-
// entry if no alias is present in the catalog.
|
|
619
|
-
const aliases = candidates.filter((e) => e.version === null);
|
|
620
|
-
if (aliases.length > 0) {
|
|
621
|
-
candidates = aliases;
|
|
622
|
-
} else {
|
|
623
|
-
const versions = candidates
|
|
624
|
-
.map((e) => e.version)
|
|
625
|
-
.filter((v): v is string => v !== null)
|
|
626
|
-
.sort(compareVersionsDesc);
|
|
627
|
-
if (versions.length > 0) {
|
|
628
|
-
const top = versions[0];
|
|
629
|
-
const latest = candidates.filter((e) => e.version === top);
|
|
630
|
-
if (latest.length > 0) candidates = latest;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// 5. Pick tier: explicit > config default (if the family offers it) >
|
|
636
|
-
// family default. Pro has no Medium, so "pro" + default medium falls
|
|
637
|
-
// back to the Pro family default (High), not nearest-Medium.
|
|
638
|
-
const familyTiers = new Set(
|
|
639
|
-
candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
|
|
640
|
-
);
|
|
641
|
-
if (familyTiers.size === 0) return toResolved(candidates[0].full, null); // no tiers on any entry
|
|
642
|
-
|
|
643
|
-
const preferred =
|
|
644
|
-
preferredTier ??
|
|
645
|
-
tier ??
|
|
646
|
-
(familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
|
|
647
|
-
const chosenTier = nearestTier([...familyTiers], preferred);
|
|
648
|
-
const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
|
|
649
|
-
return toResolved(picked.full, picked.tier);
|
|
650
|
-
}
|
|
651
|
-
|
|
294
|
+
// --- Model parsing + alias resolution: shared peer adapter ---------------
|
|
295
|
+
// parseModelLine, the tier ladder, and resolveModel (family/version/tier
|
|
296
|
+
// resolution incl. the static sonnet/opus overlay) live in pi-ask-shared
|
|
297
|
+
// (peers/antigravity.ts); discoverModels below feeds them the live catalog.
|
|
652
298
|
// --- agy process helpers ---------------------------------------------------
|
|
653
299
|
|
|
654
300
|
function resolveAgy(): string {
|
|
@@ -750,150 +396,10 @@ function extraArgs(): string[] {
|
|
|
750
396
|
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
751
397
|
}
|
|
752
398
|
|
|
753
|
-
// --- Conversation discovery
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
const CONVERSATIONS_DIR =
|
|
759
|
-
process.env.AGY_CONVERSATIONS_DIR ||
|
|
760
|
-
path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
|
|
761
|
-
|
|
762
|
-
/** Snapshot the set of conversation ids (*.db stems) currently on disk. */
|
|
763
|
-
function snapshotConversations(dir: string): Set<string> {
|
|
764
|
-
const out = new Set<string>();
|
|
765
|
-
let entries: string[];
|
|
766
|
-
try {
|
|
767
|
-
entries = fs.readdirSync(dir);
|
|
768
|
-
} catch {
|
|
769
|
-
return out;
|
|
770
|
-
}
|
|
771
|
-
for (const f of entries) {
|
|
772
|
-
if (f.endsWith(".db")) out.add(f.slice(0, -3));
|
|
773
|
-
}
|
|
774
|
-
return out;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/** Resolve which of the `candidates` DB ids is held open by the process tree
|
|
778
|
-
* rooted at `rootPid`. Used to disambiguate concurrent agy runs. Returns the
|
|
779
|
-
* single matching id, or null when none/several are open or /proc is
|
|
780
|
-
* unavailable. Ported from pi-antigravity-bridge src/discovery.ts; keep in
|
|
781
|
-
* sync. */
|
|
782
|
-
type OpenDbResolver = (
|
|
783
|
-
rootPid: number,
|
|
784
|
-
dir: string,
|
|
785
|
-
candidates: Set<string>,
|
|
786
|
-
) => string | null;
|
|
787
|
-
|
|
788
|
-
function readProcStat(pid: number): { pid: number; ppid: number } | null {
|
|
789
|
-
let raw: string;
|
|
790
|
-
try {
|
|
791
|
-
raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
792
|
-
} catch {
|
|
793
|
-
return null;
|
|
794
|
-
}
|
|
795
|
-
const closeParen = raw.lastIndexOf(")");
|
|
796
|
-
if (closeParen < 0) return null;
|
|
797
|
-
const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
|
|
798
|
-
const ppid = Number(fields[1]);
|
|
799
|
-
if (!Number.isFinite(ppid)) return null;
|
|
800
|
-
return { pid, ppid };
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
function collectDescendants(rootPid: number): Set<number> {
|
|
804
|
-
const out = new Set<number>([rootPid]);
|
|
805
|
-
let entries: string[];
|
|
806
|
-
try {
|
|
807
|
-
entries = fs.readdirSync("/proc");
|
|
808
|
-
} catch {
|
|
809
|
-
return out;
|
|
810
|
-
}
|
|
811
|
-
const ppidOf = new Map<number, number>();
|
|
812
|
-
for (const e of entries) {
|
|
813
|
-
if (!/^\d+$/.test(e)) continue;
|
|
814
|
-
const s = readProcStat(Number(e));
|
|
815
|
-
if (s) ppidOf.set(s.pid, s.ppid);
|
|
816
|
-
}
|
|
817
|
-
let changed = true;
|
|
818
|
-
while (changed) {
|
|
819
|
-
changed = false;
|
|
820
|
-
for (const [pid, ppid] of ppidOf) {
|
|
821
|
-
if (out.has(pid)) continue;
|
|
822
|
-
if (out.has(ppid)) {
|
|
823
|
-
out.add(pid);
|
|
824
|
-
changed = true;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
return out;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function safeRealpath(p: string): string | null {
|
|
832
|
-
try {
|
|
833
|
-
return fs.realpathSync(p);
|
|
834
|
-
} catch {
|
|
835
|
-
return null;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
|
|
840
|
-
if (candidates.size <= 1) return null;
|
|
841
|
-
if (process.platform !== "linux") return null;
|
|
842
|
-
const dirResolved = safeRealpath(dir);
|
|
843
|
-
const tree = collectDescendants(rootPid);
|
|
844
|
-
const found = new Set<string>();
|
|
845
|
-
for (const pid of tree) {
|
|
846
|
-
let fds: string[];
|
|
847
|
-
try {
|
|
848
|
-
fds = fs.readdirSync(`/proc/${pid}/fd`);
|
|
849
|
-
} catch {
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
for (const fd of fds) {
|
|
853
|
-
let target: string;
|
|
854
|
-
try {
|
|
855
|
-
target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
856
|
-
} catch {
|
|
857
|
-
continue;
|
|
858
|
-
}
|
|
859
|
-
const base = path.basename(target);
|
|
860
|
-
if (!base.endsWith(".db")) continue;
|
|
861
|
-
if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
|
|
862
|
-
const id = base.slice(0, -3);
|
|
863
|
-
if (candidates.has(id)) found.add(id);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
if (found.size === 1) return [...found][0] ?? null;
|
|
867
|
-
return null;
|
|
868
|
-
};
|
|
869
|
-
|
|
870
|
-
interface BindOptions {
|
|
871
|
-
pid?: number;
|
|
872
|
-
resolveOpenDb?: OpenDbResolver;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
/** Find the conversation id created since `before`. Returns null when none
|
|
876
|
-
* appeared, or when several appeared and we cannot tie one to our process.
|
|
877
|
-
* Pass `opts.pid` (the spawned agy) to enable concurrent-run disambiguation
|
|
878
|
-
* via the process-tree FD scan. */
|
|
879
|
-
function newConversationId(
|
|
880
|
-
dir: string,
|
|
881
|
-
before: Set<string>,
|
|
882
|
-
opts: BindOptions = {},
|
|
883
|
-
): string | null {
|
|
884
|
-
const created = [...snapshotConversations(dir)].filter((id) => !before.has(id));
|
|
885
|
-
if (created.length === 0) return null;
|
|
886
|
-
if (created.length === 1) return created[0] ?? null;
|
|
887
|
-
// Ambiguous: try to authoritatively identify ours via the spawned
|
|
888
|
-
// process's open files. Fail safe to null when we can't pick exactly one.
|
|
889
|
-
if (opts.pid !== undefined) {
|
|
890
|
-
const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
|
|
891
|
-
const hit = resolve(opts.pid, dir, new Set(created));
|
|
892
|
-
if (hit && created.includes(hit)) return hit;
|
|
893
|
-
}
|
|
894
|
-
return null;
|
|
895
|
-
}
|
|
896
|
-
|
|
399
|
+
// --- Conversation discovery: shared peer adapter ---------------------------
|
|
400
|
+
// The .db snapshot / new-id pick / /proc FD disambiguation technique lives
|
|
401
|
+
// in pi-ask-shared (peers/antigravity.ts): snapshotConversations +
|
|
402
|
+
// newConversationId. CONVERSATIONS_DIR (env-overridable) is exported there.
|
|
897
403
|
// --- Full-context export (opt-in includeContext) --------------------------
|
|
898
404
|
// NOTE: duplicated per pi-ask-* package (each is self-contained). Duck-typed
|
|
899
405
|
// over role/content to tolerate AgentMessage's union + custom message types.
|
|
@@ -1004,8 +510,9 @@ interface ProcessRunOptions {
|
|
|
1004
510
|
signal?: AbortSignal;
|
|
1005
511
|
/** Blocking only: throttled progress partials. */
|
|
1006
512
|
onPartial?: (text: string) => void;
|
|
1007
|
-
/** Background only: hands over the tree-kill switch
|
|
1008
|
-
|
|
513
|
+
/** Background only: hands over the tree-kill switch plus the live child
|
|
514
|
+
* handle (pid for the FD-disambiguation poll) as soon as the child exists. */
|
|
515
|
+
onSpawn?: (kill: () => void, proc: ChildProcess) => void;
|
|
1009
516
|
}
|
|
1010
517
|
|
|
1011
518
|
interface ProcessRunOutcome {
|
|
@@ -1041,128 +548,51 @@ async function runAgyProcess(opts: ProcessRunOptions): Promise<ProcessRunOutcome
|
|
|
1041
548
|
: null;
|
|
1042
549
|
|
|
1043
550
|
try {
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
551
|
+
// Concurrent bind poll: while agy is ALIVE, the pid-based /proc FD
|
|
552
|
+
// resolver can disambiguate when a concurrent agy also drops a new
|
|
553
|
+
// .db. runProcess hands us the kill switch plus the spawned process
|
|
554
|
+
// through onSpawn; the pid is only useful while the tree lives, and
|
|
555
|
+
// the post-exit scan below is the fallback once it is gone.
|
|
1048
556
|
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
557
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
//
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
details.conversationId
|
|
1092
|
-
return;
|
|
558
|
+
const outcome = await runProcess({
|
|
559
|
+
binary,
|
|
560
|
+
args,
|
|
561
|
+
cwd: workdir,
|
|
562
|
+
timeoutMs: timeoutMin * 60_000,
|
|
563
|
+
signal,
|
|
564
|
+
// Raw chunks stream live so the status tail matches the old
|
|
565
|
+
// during-run accumulation. The 1MB valve lives in runProcess for
|
|
566
|
+
// stdoutRaw; this accumulator applies the original's own valve
|
|
567
|
+
// (past 1MB, keep the last 100k) so a pathological stream cannot
|
|
568
|
+
// balloon memory through the live tail.
|
|
569
|
+
onChunk: (chunk) => {
|
|
570
|
+
out += chunk;
|
|
571
|
+
if (out.length > DEFAULT_LINE_BUF_MAX_CHARS) out = out.slice(-100_000);
|
|
572
|
+
},
|
|
573
|
+
onSpawn: (kill, proc) => {
|
|
574
|
+
// Hand the kill switch to the background registry first, then
|
|
575
|
+
// start the bind poll against the live pid.
|
|
576
|
+
onSpawn?.(kill, proc);
|
|
577
|
+
if (!isContinuation && snapshot && proc.pid) {
|
|
578
|
+
bindDuringRun = (async () => {
|
|
579
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
580
|
+
if (details.conversationId) return;
|
|
581
|
+
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
582
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
583
|
+
pid: proc.pid,
|
|
584
|
+
});
|
|
585
|
+
if (found) {
|
|
586
|
+
details.conversationId = found;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
1093
590
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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);
|
|
591
|
+
})().catch(() => {
|
|
592
|
+
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
593
|
+
});
|
|
1119
594
|
}
|
|
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);
|
|
595
|
+
},
|
|
1166
596
|
});
|
|
1167
597
|
|
|
1168
598
|
if (statusInterval) clearInterval(statusInterval);
|
|
@@ -1171,6 +601,9 @@ async function runAgyProcess(opts: ProcessRunOptions): Promise<ProcessRunOutcome
|
|
|
1171
601
|
// has exited, so this rarely blocks).
|
|
1172
602
|
await bindDuringRun;
|
|
1173
603
|
|
|
604
|
+
// stderr feeds notes and wake messages, not forensics; runProcess
|
|
605
|
+
// applies the same 64k cap the inline accumulator used to.
|
|
606
|
+
details.stderr = outcome.stderr;
|
|
1174
607
|
details.exitCode = outcome.exitCode;
|
|
1175
608
|
details.aborted = outcome.aborted;
|
|
1176
609
|
details.timedOut = outcome.timedOut;
|
|
@@ -1192,6 +625,10 @@ async function runAgyProcess(opts: ProcessRunOptions): Promise<ProcessRunOutcome
|
|
|
1192
625
|
}
|
|
1193
626
|
|
|
1194
627
|
return { ...outcome, answerText: out.trim() };
|
|
628
|
+
} catch (err) {
|
|
629
|
+
// Preserve the extension's spawn-error contract (fixed message).
|
|
630
|
+
if (err instanceof RunSpawnError) throw new AgySpawnError("failed to spawn agy");
|
|
631
|
+
throw err;
|
|
1195
632
|
} finally {
|
|
1196
633
|
if (statusInterval) clearInterval(statusInterval);
|
|
1197
634
|
}
|
|
@@ -1331,7 +768,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1331
768
|
` defaultModel: ${config.defaultModel}`,
|
|
1332
769
|
` defaultThinking: ${config.defaultThinking}`,
|
|
1333
770
|
` permissions: ${config.skipPermissions ? "auto-approved" : "prompt"}`,
|
|
1334
|
-
` resolved: ${
|
|
771
|
+
` resolved: ${resolveAgyModel(config.defaultModel, discovered, config.defaultThinking)?.model ?? "(agy default)"}`,
|
|
1335
772
|
``,
|
|
1336
773
|
`Edit: ~/.pi/agent/ask-antigravity.json`,
|
|
1337
774
|
].join("\n"),
|
|
@@ -1342,7 +779,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1342
779
|
|
|
1343
780
|
// Resolve the display string for the current default model.
|
|
1344
781
|
const currentResolved =
|
|
1345
|
-
|
|
782
|
+
resolveAgyModel(config.defaultModel, discovered, config.defaultThinking)?.model ?? config.defaultModel;
|
|
1346
783
|
|
|
1347
784
|
const items: SettingItem[] = [
|
|
1348
785
|
{
|
|
@@ -1352,7 +789,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1352
789
|
"Friendly alias resolved to the latest matching agy model. 'flash' = latest Flash, 'pro' = latest Pro, 'gemini' = latest Flash.",
|
|
1353
790
|
currentValue: `${config.defaultModel} → ${currentResolved}`,
|
|
1354
791
|
values: MODEL_OPTIONS.map((m) => {
|
|
1355
|
-
const r =
|
|
792
|
+
const r = resolveAgyModel(m, discovered, config.defaultThinking)?.model ?? m;
|
|
1356
793
|
return `${m} → ${r}`;
|
|
1357
794
|
}),
|
|
1358
795
|
},
|
|
@@ -1499,7 +936,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1499
936
|
),
|
|
1500
937
|
timeoutMinutes: Type.Optional(
|
|
1501
938
|
Type.Number({
|
|
1502
|
-
description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.`,
|
|
939
|
+
description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}. Fractional minutes allowed; must be above 0 and at most ${MAX_TIMEOUT_MINUTES}. The prompt carries a matching [TIME BUDGET] notice.`,
|
|
1503
940
|
}),
|
|
1504
941
|
),
|
|
1505
942
|
includeContext: Type.Optional(
|
|
@@ -1523,8 +960,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1523
960
|
const cfg = loadConfig();
|
|
1524
961
|
const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
|
|
1525
962
|
const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
|
|
1526
|
-
const resolved =
|
|
1527
|
-
|
|
963
|
+
const resolved: ResolvedModel =
|
|
964
|
+
resolveAgyModel(
|
|
1528
965
|
requestedModel,
|
|
1529
966
|
discovered,
|
|
1530
967
|
cfg.defaultThinking,
|
|
@@ -1651,8 +1088,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1651
1088
|
};
|
|
1652
1089
|
}
|
|
1653
1090
|
const thinkingArg = (params.thinking as string | undefined) ?? (params.effort as string | undefined);
|
|
1654
|
-
const resolved =
|
|
1655
|
-
|
|
1091
|
+
const resolved: ResolvedModel =
|
|
1092
|
+
resolveAgyModel(
|
|
1656
1093
|
requestedModel,
|
|
1657
1094
|
discovered,
|
|
1658
1095
|
config.defaultThinking,
|
|
@@ -1681,6 +1118,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1681
1118
|
}
|
|
1682
1119
|
|
|
1683
1120
|
const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
|
|
1121
|
+
if (!(timeoutMin > 0 && Number.isFinite(timeoutMin) && timeoutMin <= MAX_TIMEOUT_MINUTES)) {
|
|
1122
|
+
// timeoutMinutes: 0 would arm runProcess's watchdog with a 0ms
|
|
1123
|
+
// timeout (Node clamps to 1ms) and kill the peer instantly.
|
|
1124
|
+
return {
|
|
1125
|
+
content: [{ type: "text", text: `timeoutMinutes must be a positive number of minutes up to ${MAX_TIMEOUT_MINUTES} (for example 0.5 for 30 seconds).` }],
|
|
1126
|
+
details: emptyDetails(requestedModel, resolved.model),
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1684
1129
|
|
|
1685
1130
|
// Continuity: if a conversationId is provided AND validates as an agy
|
|
1686
1131
|
// id (UUID-ish DB stem, never a leading-dash flag), resume it; otherwise
|
|
@@ -1716,8 +1161,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1716
1161
|
typeof params.digest === "boolean"
|
|
1717
1162
|
? params.digest
|
|
1718
1163
|
: mode === "plan";
|
|
1164
|
+
// Budget notice into the prompt BEFORE buildFinalPrompt: the plan-mode
|
|
1165
|
+
// guard banners must stay the final text (last-read recency), so the
|
|
1166
|
+
// notice can't go after them. The builder itself stays notice-free so
|
|
1167
|
+
// callers can't double-wrap.
|
|
1168
|
+
const budget = buildTimeBudgetNotice(timeoutMin * 60_000);
|
|
1719
1169
|
const finalPrompt: string = buildFinalPrompt(
|
|
1720
|
-
params.prompt,
|
|
1170
|
+
budget ? `${params.prompt}\n\n${budget}` : params.prompt,
|
|
1721
1171
|
mode,
|
|
1722
1172
|
useDigest,
|
|
1723
1173
|
reviewerAgent !== null,
|
|
@@ -1746,40 +1196,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1746
1196
|
? `The full pi conversation context (as markdown) is at: ${contextFile}\nRead that file first for context, then do the task below.\n\n---\n\n${finalPrompt}`
|
|
1747
1197
|
: finalPrompt;
|
|
1748
1198
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
args.push("--mode", mode);
|
|
1764
|
-
if (reviewerAgent) args.push("--agent", reviewerAgent.name);
|
|
1765
|
-
// accept-edits auto-approves file edits but NOT shell commands, so a
|
|
1766
|
-
// run_command would hang on an unanswerable y/n prompt in non-interactive
|
|
1767
|
-
// -p mode. Honor the shared permissions setting (same knob as the bridge).
|
|
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") {
|
|
1777
|
-
args.push("--dangerously-skip-permissions");
|
|
1778
|
-
}
|
|
1779
|
-
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
1780
|
-
args.push("--print-timeout", `${timeoutMin}m`);
|
|
1781
|
-
if (contextFile) args.push("--add-dir", askContextDir());
|
|
1782
|
-
args.push("-p", effectivePrompt);
|
|
1199
|
+
// Build argv via the shared peer adapter (see buildAgyArgs there for
|
|
1200
|
+
// the flag-order contract and the fail-closed plan-run rules).
|
|
1201
|
+
const args = buildAgyArgs({
|
|
1202
|
+
cwd,
|
|
1203
|
+
resolved,
|
|
1204
|
+
mode,
|
|
1205
|
+
reviewerAgentName: reviewerAgent?.name ?? null,
|
|
1206
|
+
skipPermissions: config.skipPermissions,
|
|
1207
|
+
conversationId: isContinuation ? (rawConvId as string) : null,
|
|
1208
|
+
timeoutMinutes: timeoutMin,
|
|
1209
|
+
addDirs: contextFile ? [askContextDir()] : [],
|
|
1210
|
+
extraArgs: extraArgs(),
|
|
1211
|
+
prompt: effectivePrompt,
|
|
1212
|
+
});
|
|
1783
1213
|
|
|
1784
1214
|
const details: AgyDetails = {
|
|
1785
1215
|
model: requestedModel,
|
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.13",
|
|
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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"type": "module",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@estebanforge/pi-ask-shared": "^0.1.
|
|
31
|
+
"@estebanforge/pi-ask-shared": "^0.1.2"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"extensions",
|