@estebanforge/pi-ask-antigravity 1.2.10 → 1.2.12
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 +26 -0
- package/README.md +7 -3
- package/extensions/index.ts +601 -825
- package/package.json +4 -1
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";
|
|
@@ -45,17 +45,48 @@ import {
|
|
|
45
45
|
import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
|
|
46
46
|
import { contentText } from "@earendil-works/pi-ai";
|
|
47
47
|
import { Type } from "typebox";
|
|
48
|
+
import {
|
|
49
|
+
BackgroundRunRegistry,
|
|
50
|
+
backgroundFlagText,
|
|
51
|
+
buildAgyArgs,
|
|
52
|
+
buildFinalPrompt,
|
|
53
|
+
buildTimeBudgetNotice,
|
|
54
|
+
MAX_TIMEOUT_MINUTES,
|
|
55
|
+
compareVersionsDesc,
|
|
56
|
+
CONVERSATIONS_DIR,
|
|
57
|
+
CONV_ID_RE,
|
|
58
|
+
configPaths,
|
|
59
|
+
createStopHandler,
|
|
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,
|
|
73
|
+
summarizePrompt,
|
|
74
|
+
tryReadJson,
|
|
75
|
+
type Mode,
|
|
76
|
+
type ModelEntry,
|
|
77
|
+
type ResolvedModel,
|
|
78
|
+
type SaveResult,
|
|
79
|
+
type ThinkingTier,
|
|
80
|
+
} from "@estebanforge/pi-ask-shared";
|
|
48
81
|
|
|
49
82
|
// --- Constants -------------------------------------------------------------
|
|
50
83
|
|
|
51
84
|
const DEFAULT_TIMEOUT_MIN = 10;
|
|
52
|
-
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
53
85
|
const STATUS_INTERVAL_MS = 1000;
|
|
54
86
|
const STATUS_TAIL_CHARS = 160;
|
|
55
87
|
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
56
88
|
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
57
89
|
const DISCOVERY_POLL_MS = 100;
|
|
58
|
-
const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "ask-antigravity.json");
|
|
59
90
|
|
|
60
91
|
// renderCall / renderResult preview limits (match pi-claude-bridge).
|
|
61
92
|
const PREVIEW_MAX_CHARS = 1000;
|
|
@@ -67,100 +98,23 @@ const DEFAULT_THINKING = "medium";
|
|
|
67
98
|
// non-interactive -p mode (accept-edits auto-approves edits, NOT commands).
|
|
68
99
|
const DEFAULT_SKIP_PERMISSIONS = true;
|
|
69
100
|
|
|
70
|
-
// Per-family fallback tier
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
pro: "high",
|
|
76
|
-
other: "medium",
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
|
|
80
|
-
|
|
81
|
-
/** pi thinking-level vocabulary -> agy tier, for the thinking/effort tool
|
|
82
|
-
* params. Unknown values fall to low (agy always thinks something). */
|
|
83
|
-
function levelToTier(level: string): ThinkingTier {
|
|
84
|
-
switch (level) {
|
|
85
|
-
case "minimal":
|
|
86
|
-
case "low":
|
|
87
|
-
return "low";
|
|
88
|
-
case "medium":
|
|
89
|
-
return "medium";
|
|
90
|
-
case "high":
|
|
91
|
-
case "xhigh":
|
|
92
|
-
case "max":
|
|
93
|
-
return "high";
|
|
94
|
-
default:
|
|
95
|
-
return "low";
|
|
96
|
-
}
|
|
97
|
-
}
|
|
101
|
+
// Per-family fallback tier, the pi->agy tier clamp (levelToTier), the static
|
|
102
|
+
// alias overlay, and the line grammar now live in the shared peer adapter
|
|
103
|
+
// (pi-ask-shared peers/antigravity.ts) alongside the argv builder and the
|
|
104
|
+
// conversation-discovery technique, so pi-antigravity-bridge and pi-unblock
|
|
105
|
+
// reuse the exact same resolution contract.
|
|
98
106
|
|
|
99
107
|
// Mode = which agy tool-loop policy to apply. Distinct from the alias layer.
|
|
100
|
-
// "plan" → --mode plan (
|
|
101
|
-
//
|
|
102
|
-
//
|
|
108
|
+
// "plan" → --mode plan (review-shaped; NOT a security boundary:
|
|
109
|
+
// the CLI does not gate writes under
|
|
110
|
+
// plan, upstream #1181. The skip-
|
|
111
|
+
// permissions flag is still withheld so
|
|
112
|
+
// nothing runs auto-approved.)
|
|
103
113
|
// "accept-edits" → --mode accept-edits (agy applies edits)
|
|
104
114
|
//
|
|
105
115
|
// Note: agy's --sandbox flag is an orthogonal shell-containment setting
|
|
106
116
|
// (not an "edit preview" mode), so it is not exposed here. Users who need
|
|
107
117
|
// it can pass it via the AGY_EXTRA_ARGS env var.
|
|
108
|
-
type Mode = "plan" | "accept-edits";
|
|
109
|
-
|
|
110
|
-
// Static alias overlay for non-Gemini models agy may or may not surface in
|
|
111
|
-
// `agy models` depending on plan. When the live catalog contains an entry
|
|
112
|
-
// whose full string equals the overlay target, the live entry wins. When it
|
|
113
|
-
// does not (older agy, missing model, plan gate), the overlay entry resolves
|
|
114
|
-
// the alias so the user can still type "sonnet" and get a working answer.
|
|
115
|
-
// "sonnet" -> claude-sonnet-5-5 (tiered low/medium/high like Gemini)
|
|
116
|
-
// "opus" -> claude-opus-5-5 (tiered low/medium/high like Gemini)
|
|
117
|
-
const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
|
|
118
|
-
{ full: "claude-sonnet-5-5-low", family: "other", version: null, tier: "low" },
|
|
119
|
-
{ full: "claude-sonnet-5-5-medium", family: "other", version: null, tier: "medium" },
|
|
120
|
-
{ full: "claude-sonnet-5-5-high", family: "other", version: null, tier: "high" },
|
|
121
|
-
{ full: "claude-opus-5-5-low", family: "other", version: null, tier: "low" },
|
|
122
|
-
{ full: "claude-opus-5-5-medium", family: "other", version: null, tier: "medium" },
|
|
123
|
-
{ full: "claude-opus-5-5-high", family: "other", version: null, tier: "high" },
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
// Short alias → overlay full string. Used by resolveModel to recognize
|
|
127
|
-
// friendly short names ("sonnet") that the family-parser (flash/pro) does
|
|
128
|
-
// not match. The overlay entries are also merged into the live catalog for
|
|
129
|
-
// exact-string passthrough, so this map only needs to cover the short names.
|
|
130
|
-
const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
|
|
131
|
-
["sonnet", "claude-sonnet-5-5"],
|
|
132
|
-
["opus", "claude-opus-5-5"],
|
|
133
|
-
]);
|
|
134
|
-
|
|
135
|
-
/** Families dropped outright even while `agy models` still lists them: the
|
|
136
|
-
* fixed-thinking single slug loses to the cheaper effort-tiered options and
|
|
137
|
-
* its removal upstream is already announced, so we stop offering it now. */
|
|
138
|
-
const HIDDEN_FAMILY_RE = /^gpt-oss-/;
|
|
139
|
-
|
|
140
|
-
/** Drop families we refuse to offer. Exported pure so tests exercise the
|
|
141
|
-
* same filter the live parse runs; discoverModels applies it per load. */
|
|
142
|
-
export function filterHiddenModels(entries: ModelEntry[]): ModelEntry[] {
|
|
143
|
-
return entries.filter((e) => !HIDDEN_FAMILY_RE.test(e.full));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Merge the live catalog with the static alias overlay. Live entries win on
|
|
147
|
-
* case-insensitive full-string equality so an updated `agy models` listing
|
|
148
|
-
* always takes precedence over the hardcoded fallback. */
|
|
149
|
-
export function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
|
|
150
|
-
const seen = new Set(live.map((e) => e.full.toLowerCase()));
|
|
151
|
-
const merged = [...live];
|
|
152
|
-
for (const entry of STATIC_ALIAS_OVERLAY) {
|
|
153
|
-
if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
|
|
154
|
-
}
|
|
155
|
-
return merged;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// agy conversation ids are UUID DB-stems (e.g. "9e6fdc2f-f9f9-4096-95fc-7852528b50cc").
|
|
159
|
-
// Reject anything that isn't, so a leading-dash value can't misbind on agy's
|
|
160
|
-
// arg parser as the token after --conversation. First char must be
|
|
161
|
-
// alphanumeric (rejects leading-dash flag injection); hyphens allowed in the
|
|
162
|
-
// body because real UUIDs contain them.
|
|
163
|
-
const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
|
|
164
118
|
|
|
165
119
|
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.
|
|
166
120
|
|
|
@@ -169,7 +123,7 @@ TWO MODES (you choose):
|
|
|
169
123
|
- **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.
|
|
170
124
|
|
|
171
125
|
EXECUTION MODES (param: mode):
|
|
172
|
-
- **plan**: agy
|
|
126
|
+
- **plan**: agy proposes a plan for review-shaped tasks. NOT a security boundary: the CLI does not gate writes under plan mode (upstream google-antigravity/antigravity-cli#1181, probed 2026-10-07: the write can execute in the same turn). The run additionally stages a temporary restricted agent whose toolset has no file-editing tools, as a damper, and the skip-permissions flag is never passed. Inline the material to review - a plan run cannot fetch it.
|
|
173
127
|
- **accept-edits** (default): agy applies edits directly inside the workspace.
|
|
174
128
|
- For agy's orthogonal \`--sandbox\` shell-containment flag, set the \`AGY_EXTRA_ARGS=--sandbox\` env var.
|
|
175
129
|
|
|
@@ -182,24 +136,9 @@ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
|
|
|
182
136
|
|
|
183
137
|
// --- Types -----------------------------------------------------------------
|
|
184
138
|
|
|
185
|
-
type ThinkingTier = "low" | "medium" | "high";
|
|
186
139
|
type Family = "flash" | "pro" | "other";
|
|
187
140
|
|
|
188
|
-
|
|
189
|
-
full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
|
|
190
|
-
family: Family;
|
|
191
|
-
version: string | null; // "3.6"
|
|
192
|
-
tier: ThinkingTier | null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Argv-facing model resolution: the exact --model slug plus an optional
|
|
196
|
-
* --effort tier. Gemini bases split the tier out (the base slug alone is
|
|
197
|
-
* invalid without --effort); fixed-thinking families keep agy's exact slug
|
|
198
|
-
* and carry no effort. */
|
|
199
|
-
export interface ResolvedModel {
|
|
200
|
-
model: string;
|
|
201
|
-
effort?: ThinkingTier;
|
|
202
|
-
}
|
|
141
|
+
// --- Config ----------------------------------------------------------------
|
|
203
142
|
|
|
204
143
|
interface Config {
|
|
205
144
|
defaultModel: string;
|
|
@@ -209,41 +148,18 @@ interface Config {
|
|
|
209
148
|
skipPermissions: boolean;
|
|
210
149
|
}
|
|
211
150
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const da = pa[i] ?? 0;
|
|
221
|
-
const db = pb[i] ?? 0;
|
|
222
|
-
if (da !== db) return db - da; // descending
|
|
223
|
-
}
|
|
224
|
-
return 0;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// --- Config ----------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
function projectConfigPath(): string {
|
|
230
|
-
return path.join(process.cwd(), ".pi", "ask-antigravity.json");
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function tryReadJson(filePath: string): Record<string, unknown> {
|
|
234
|
-
if (!fs.existsSync(filePath)) return {};
|
|
235
|
-
try {
|
|
236
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
237
|
-
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
238
|
-
} catch {
|
|
239
|
-
return {};
|
|
240
|
-
}
|
|
151
|
+
/** Layered config paths: global is ~/.pi/agent (homedir, NOT getAgentDir —
|
|
152
|
+
* today's shipped resolution for this extension); project is <cwd>/.pi. */
|
|
153
|
+
function configPathsFor() {
|
|
154
|
+
return configPaths({
|
|
155
|
+
globalDir: path.join(os.homedir(), ".pi", "agent"),
|
|
156
|
+
projectDir: path.join(process.cwd(), ".pi"),
|
|
157
|
+
fileName: "ask-antigravity.json",
|
|
158
|
+
});
|
|
241
159
|
}
|
|
242
160
|
|
|
243
161
|
function loadConfig(): Config {
|
|
244
|
-
const
|
|
245
|
-
const project = tryReadJson(projectConfigPath());
|
|
246
|
-
const merged = { ...global, ...project };
|
|
162
|
+
const { merged } = loadLayeredRaw(configPathsFor());
|
|
247
163
|
|
|
248
164
|
const thinkingRaw = String(merged.defaultThinking ?? DEFAULT_THINKING).toLowerCase();
|
|
249
165
|
const thinking: ThinkingTier =
|
|
@@ -262,86 +178,19 @@ function loadConfig(): Config {
|
|
|
262
178
|
};
|
|
263
179
|
}
|
|
264
180
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
routedToProject: boolean;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Persist a config patch. If the project config already defines any patched
|
|
272
|
-
* key, write to the PROJECT file so the change actually takes effect
|
|
273
|
-
* (project shadows global on load); otherwise write to global.
|
|
274
|
-
* Atomic: temp file + rename, with temp cleanup on failure. */
|
|
181
|
+
/** Persist a config patch via the shared all-or-nothing router: if any
|
|
182
|
+
* patched key is already project-defined, the whole patch goes to the
|
|
183
|
+
* project file (it shadows global on load). Atomic write inside. */
|
|
275
184
|
function saveConfig(patch: Partial<Config>): SaveResult {
|
|
276
|
-
|
|
277
|
-
const projectShadows = Object.keys(patch).some((k) => k in projectRaw);
|
|
278
|
-
const targetPath = projectShadows ? projectConfigPath() : GLOBAL_CONFIG_PATH;
|
|
279
|
-
|
|
280
|
-
const existing = tryReadJson(targetPath);
|
|
281
|
-
const next = { ...existing, ...patch };
|
|
282
|
-
const dir = path.dirname(targetPath);
|
|
283
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
284
|
-
|
|
285
|
-
const tmp = `${targetPath}.${process.pid}.tmp`;
|
|
286
|
-
try {
|
|
287
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
288
|
-
fs.renameSync(tmp, targetPath);
|
|
289
|
-
} catch (err) {
|
|
290
|
-
try {
|
|
291
|
-
fs.unlinkSync(tmp);
|
|
292
|
-
} catch {}
|
|
293
|
-
throw err;
|
|
294
|
-
}
|
|
295
|
-
return { path: targetPath, routedToProject: projectShadows };
|
|
185
|
+
return saveLayeredConfig(configPathsFor(), patch as Record<string, unknown>);
|
|
296
186
|
}
|
|
297
187
|
|
|
298
|
-
// --- Prompt assembly
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
* allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
|
|
305
|
-
* --sandbox does NOT relax the command gate, so the only input every user
|
|
306
|
-
* is guaranteed is the prompt itself. The guard steers the model to answer
|
|
307
|
-
* from that material. accept-edits runs keep their tools and never get it. */
|
|
308
|
-
export const PLAN_HEADLESS_GUARD = [
|
|
309
|
-
"",
|
|
310
|
-
"--- Headless session constraints ---",
|
|
311
|
-
"- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
|
|
312
|
-
"- Work only from the material provided in this prompt.",
|
|
313
|
-
"- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
|
|
314
|
-
].join("\n");
|
|
315
|
-
|
|
316
|
-
/** Guard for plan runs backed by the restricted reviewer agent. Difference
|
|
317
|
-
* to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
|
|
318
|
-
* agent's commandExecutionPolicy auto-approves them headless, probed
|
|
319
|
-
* 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
|
|
320
|
-
* and keeps the answer-from-material discipline. */
|
|
321
|
-
export const AGENT_REVIEW_GUARD = [
|
|
322
|
-
"",
|
|
323
|
-
"--- Review constraints ---",
|
|
324
|
-
"- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
|
|
325
|
-
"- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
|
|
326
|
-
"- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
|
|
327
|
-
].join("\n");
|
|
328
|
-
|
|
329
|
-
/** Assemble the prompt sent to agy: digest marker first (existing behavior),
|
|
330
|
-
* then the caller's prompt, then the plan-mode guard last - the position
|
|
331
|
-
* the model reads with the most recency. No user-config mutation: this is
|
|
332
|
-
* the only plan-mode lever the tool itself owns. */
|
|
333
|
-
export function buildFinalPrompt(
|
|
334
|
-
prompt: string,
|
|
335
|
-
mode: Mode,
|
|
336
|
-
digest: boolean,
|
|
337
|
-
/** True when the restricted reviewer agent is staged: its empty edit
|
|
338
|
-
* toolset is the hard block, so the prompt may ALLOW read-only commands. */
|
|
339
|
-
agentEnforced = false,
|
|
340
|
-
): string {
|
|
341
|
-
let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
|
|
342
|
-
if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
|
|
343
|
-
return out;
|
|
344
|
-
}
|
|
188
|
+
// --- Prompt assembly: shared peer adapter ----------------------------------
|
|
189
|
+
// The plan-mode guards (PLAN_HEADLESS_GUARD / AGENT_REVIEW_GUARD) and the
|
|
190
|
+
// prompt assembler (buildFinalPrompt) live in pi-ask-shared
|
|
191
|
+
// (peers/antigravity.ts) so the bridge's ask-tool and pi-unblock consults
|
|
192
|
+
// inherit the identical guard discipline.
|
|
193
|
+
export { AGENT_REVIEW_GUARD, PLAN_HEADLESS_GUARD } from "@estebanforge/pi-ask-shared";
|
|
345
194
|
|
|
346
195
|
// --- Plan-mode reviewer agent ----------------------------------------------
|
|
347
196
|
|
|
@@ -354,12 +203,12 @@ export function askAgentsRoot(): string {
|
|
|
354
203
|
return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
|
|
355
204
|
}
|
|
356
205
|
|
|
357
|
-
/** agent.md for the plan-mode reviewer. The tools list is
|
|
358
|
-
* with no file-editing tool present the model cannot emit an
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* a write-capable tool
|
|
206
|
+
/** agent.md for the plan-mode reviewer. The tools list is a damper, not
|
|
207
|
+
* enforcement: with no file-editing tool present the model cannot emit an
|
|
208
|
+
* edit call through the agent toolset, but the CLI does not enforce
|
|
209
|
+
* review-only (upstream #1181 - writes bypass the permission system and
|
|
210
|
+
* the toolset is not honored reliably on 1.3.x). Prefer view_file for
|
|
211
|
+
* reads here too. Never add a write-capable tool. */
|
|
363
212
|
export function reviewerAgentMd(name: string): string {
|
|
364
213
|
return [
|
|
365
214
|
"---",
|
|
@@ -439,204 +288,10 @@ function pidAlive(pid: number): boolean {
|
|
|
439
288
|
}
|
|
440
289
|
}
|
|
441
290
|
|
|
442
|
-
// --- Model parsing + alias resolution
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
// agy prints TWO columns: "<slug> <display label>". --model takes only the
|
|
447
|
-
// slug (col 1), so split it off; the label is display-only. A bare-slug line
|
|
448
|
-
// (no whitespace) splits to itself.
|
|
449
|
-
const full = line.trim().split(/\s+/)[0] ?? "";
|
|
450
|
-
if (!full) return null;
|
|
451
|
-
|
|
452
|
-
const lower = full.toLowerCase();
|
|
453
|
-
const family: Family = lower.includes("flash")
|
|
454
|
-
? "flash"
|
|
455
|
-
: lower.includes("pro")
|
|
456
|
-
? "pro"
|
|
457
|
-
: "other";
|
|
458
|
-
|
|
459
|
-
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
460
|
-
const version = versionMatch ? versionMatch[1] : null;
|
|
461
|
-
|
|
462
|
-
const tierMatch = lower.match(/-(low|medium|high)$/);
|
|
463
|
-
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
464
|
-
|
|
465
|
-
return { full, family, version, tier };
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/** Pick the available tier closest in rank to the preferred one. Distance
|
|
469
|
-
* ties (e.g. Low/High around Medium) break toward the higher tier so
|
|
470
|
-
* "latest and greatest" wins when a family lacks the requested tier. */
|
|
471
|
-
function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
|
|
472
|
-
if (available.includes(preferred)) return preferred;
|
|
473
|
-
const sorted = [...available].sort((a, b) => {
|
|
474
|
-
const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
|
|
475
|
-
const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
|
|
476
|
-
return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
|
|
477
|
-
});
|
|
478
|
-
return sorted[0] ?? preferred;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/** Build the argv-facing resolution from a picked catalog entry. Gemini and
|
|
482
|
-
* Claude bases (slugs starting "gemini-"/"claude-") accept a separate
|
|
483
|
-
* --effort, so split the tier suffix out of the slug: the base alone
|
|
484
|
-
* (claude-sonnet-5-5) is what --model wants, and the tier goes to --effort
|
|
485
|
-
* (agy rejects a bare base: "requires --effort"). Only unverified families
|
|
486
|
-
* keep agy's exact slug whole, so an unknown suffix can never trigger an
|
|
487
|
-
* unsupported --effort. */
|
|
488
|
-
function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
|
|
489
|
-
if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
|
|
490
|
-
return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
|
|
491
|
-
}
|
|
492
|
-
return { model: full };
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** Resolve a tiered base id - a short-alias target ("claude-sonnet-5-5") or a
|
|
496
|
-
* bare base slug the provider itself advertises - to the catalog variant
|
|
497
|
-
* nearest the requested tier. A bare base alone is invalid upstream
|
|
498
|
-
* ("requires --effort"), so the pick always rides the split in toResolved;
|
|
499
|
-
* with no variants in the catalog, the overlay/exact entry or the raw base
|
|
500
|
-
* passes through unchanged. */
|
|
501
|
-
function resolveTieredBase(
|
|
502
|
-
base: string,
|
|
503
|
-
entries: ModelEntry[],
|
|
504
|
-
defaultThinking: ThinkingTier,
|
|
505
|
-
preferredTier: ThinkingTier | undefined,
|
|
506
|
-
): ResolvedModel {
|
|
507
|
-
const needle = base.toLowerCase();
|
|
508
|
-
const variants = entries.filter((e) => e.full.toLowerCase().startsWith(`${needle}-`));
|
|
509
|
-
if (variants.length > 0) {
|
|
510
|
-
const tiers = variants.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null);
|
|
511
|
-
const preferred =
|
|
512
|
-
preferredTier ??
|
|
513
|
-
(tiers.includes(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER.other);
|
|
514
|
-
const chosen = nearestTier(tiers, preferred);
|
|
515
|
-
const picked = variants.find((e) => e.tier === chosen) ?? variants[0];
|
|
516
|
-
return toResolved(picked.full, picked.tier);
|
|
517
|
-
}
|
|
518
|
-
const fromCatalog = entries.find((e) => e.full.toLowerCase() === needle);
|
|
519
|
-
return toResolved(fromCatalog?.full ?? base, fromCatalog?.tier ?? null);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
|
|
524
|
-
* Returns null only when the family is unrecognized; the caller then passes
|
|
525
|
-
* the raw input straight to agy.
|
|
526
|
-
*/
|
|
527
|
-
export function resolveModel(
|
|
528
|
-
input: string,
|
|
529
|
-
entries: ModelEntry[],
|
|
530
|
-
defaultThinking: ThinkingTier,
|
|
531
|
-
/** Explicit thinking param (thinking/effort). Beats a tier embedded in
|
|
532
|
-
* the alias ("flash high") and the configured default; clamped to the
|
|
533
|
-
* family's real tiers, ignored for fixed-thinking families. */
|
|
534
|
-
preferredTier?: ThinkingTier,
|
|
535
|
-
): ResolvedModel | null {
|
|
536
|
-
const lower = input.toLowerCase().trim();
|
|
537
|
-
|
|
538
|
-
// 1. Exact full-string match (case-insensitive).
|
|
539
|
-
const exact = entries.find((e) => e.full.toLowerCase() === lower);
|
|
540
|
-
if (exact) return toResolved(exact.full, exact.tier);
|
|
541
|
-
|
|
542
|
-
// 1b. Static short alias ("sonnet" / "opus"). Checked
|
|
543
|
-
// before the family parser because none of these names contain
|
|
544
|
-
// "flash" or "pro" and would otherwise return null below. The
|
|
545
|
-
// resolved full string is then re-validated against the catalog
|
|
546
|
-
// in step 1's second pass on the next call, so renaming the
|
|
547
|
-
// overlay entry in code still wins on exact-string match.
|
|
548
|
-
// The case-insensitive lookup matches mergeCatalog's dedup logic
|
|
549
|
-
// so the "live entries win" guarantee holds even when agy lists
|
|
550
|
-
// the model under different casing than the overlay.
|
|
551
|
-
if (STATIC_SHORT_ALIAS.has(lower)) {
|
|
552
|
-
return resolveTieredBase(
|
|
553
|
-
STATIC_SHORT_ALIAS.get(lower) as string,
|
|
554
|
-
entries,
|
|
555
|
-
defaultThinking,
|
|
556
|
-
preferredTier,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// 2. Parse the alias.
|
|
561
|
-
let family: Family | null = lower.includes("flash")
|
|
562
|
-
? "flash"
|
|
563
|
-
: lower.includes("pro")
|
|
564
|
-
? "pro"
|
|
565
|
-
: null;
|
|
566
|
-
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
567
|
-
const version = versionMatch ? versionMatch[1] : null;
|
|
568
|
-
const tierMatch = lower.match(/\b(low|medium|high)\b/);
|
|
569
|
-
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
570
|
-
|
|
571
|
-
// "gemini" alone, "default", or empty -> default family (flash).
|
|
572
|
-
if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
|
|
573
|
-
family = "flash";
|
|
574
|
-
}
|
|
575
|
-
if (!family) {
|
|
576
|
-
// A bare base slug the provider itself advertises ("claude-sonnet-5-5",
|
|
577
|
-
// the id behind the antigravity/ entry in pi's picker) is invalid
|
|
578
|
-
// upstream without an effort: resolve it to the nearest tier variant
|
|
579
|
-
// exactly like the short aliases.
|
|
580
|
-
if (
|
|
581
|
-
entries.some((e) => e.full.toLowerCase().startsWith(`${lower}-`)) ||
|
|
582
|
-
entries.some((e) => e.full.toLowerCase() === lower)
|
|
583
|
-
) {
|
|
584
|
-
return resolveTieredBase(lower, entries, defaultThinking, preferredTier);
|
|
585
|
-
}
|
|
586
|
-
return null; // unknown family -> let agy handle it
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// 3. Filter by family.
|
|
590
|
-
let candidates = entries.filter((e) => e.family === family);
|
|
591
|
-
if (candidates.length === 0) return null;
|
|
592
|
-
|
|
593
|
-
// 4. Pin version if specified; otherwise pick the HIGHEST version
|
|
594
|
-
// (numeric compare, not lexical — see compareVersionsDesc).
|
|
595
|
-
if (version) {
|
|
596
|
-
const versioned = candidates.filter((e) => e.version === version);
|
|
597
|
-
if (versioned.length > 0) candidates = versioned;
|
|
598
|
-
} else {
|
|
599
|
-
// Prefer Google's official `gemini-*-latest` aliases (entries with no
|
|
600
|
-
// parseable version in their name, e.g. "Gemini Flash Latest") when
|
|
601
|
-
// the user did NOT pin a specific version. The alias is the
|
|
602
|
-
// versionless pointer Google intends for "the current release" and
|
|
603
|
-
// hot-swaps on every release, while versioned entries like
|
|
604
|
-
// "Gemini 3.6 Flash (Medium)" stay available via explicit pinning
|
|
605
|
-
// (e.g. "3.6 flash medium"). Falls back to the highest versioned
|
|
606
|
-
// entry if no alias is present in the catalog.
|
|
607
|
-
const aliases = candidates.filter((e) => e.version === null);
|
|
608
|
-
if (aliases.length > 0) {
|
|
609
|
-
candidates = aliases;
|
|
610
|
-
} else {
|
|
611
|
-
const versions = candidates
|
|
612
|
-
.map((e) => e.version)
|
|
613
|
-
.filter((v): v is string => v !== null)
|
|
614
|
-
.sort(compareVersionsDesc);
|
|
615
|
-
if (versions.length > 0) {
|
|
616
|
-
const top = versions[0];
|
|
617
|
-
const latest = candidates.filter((e) => e.version === top);
|
|
618
|
-
if (latest.length > 0) candidates = latest;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// 5. Pick tier: explicit > config default (if the family offers it) >
|
|
624
|
-
// family default. Pro has no Medium, so "pro" + default medium falls
|
|
625
|
-
// back to the Pro family default (High), not nearest-Medium.
|
|
626
|
-
const familyTiers = new Set(
|
|
627
|
-
candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
|
|
628
|
-
);
|
|
629
|
-
if (familyTiers.size === 0) return toResolved(candidates[0].full, null); // no tiers on any entry
|
|
630
|
-
|
|
631
|
-
const preferred =
|
|
632
|
-
preferredTier ??
|
|
633
|
-
tier ??
|
|
634
|
-
(familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
|
|
635
|
-
const chosenTier = nearestTier([...familyTiers], preferred);
|
|
636
|
-
const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
|
|
637
|
-
return toResolved(picked.full, picked.tier);
|
|
638
|
-
}
|
|
639
|
-
|
|
291
|
+
// --- Model parsing + alias resolution: shared peer adapter ---------------
|
|
292
|
+
// parseModelLine, the tier ladder, and resolveModel (family/version/tier
|
|
293
|
+
// resolution incl. the static sonnet/opus overlay) live in pi-ask-shared
|
|
294
|
+
// (peers/antigravity.ts); discoverModels below feeds them the live catalog.
|
|
640
295
|
// --- agy process helpers ---------------------------------------------------
|
|
641
296
|
|
|
642
297
|
function resolveAgy(): string {
|
|
@@ -738,150 +393,10 @@ function extraArgs(): string[] {
|
|
|
738
393
|
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
739
394
|
}
|
|
740
395
|
|
|
741
|
-
// --- Conversation discovery
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
const CONVERSATIONS_DIR =
|
|
747
|
-
process.env.AGY_CONVERSATIONS_DIR ||
|
|
748
|
-
path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
|
|
749
|
-
|
|
750
|
-
/** Snapshot the set of conversation ids (*.db stems) currently on disk. */
|
|
751
|
-
function snapshotConversations(dir: string): Set<string> {
|
|
752
|
-
const out = new Set<string>();
|
|
753
|
-
let entries: string[];
|
|
754
|
-
try {
|
|
755
|
-
entries = fs.readdirSync(dir);
|
|
756
|
-
} catch {
|
|
757
|
-
return out;
|
|
758
|
-
}
|
|
759
|
-
for (const f of entries) {
|
|
760
|
-
if (f.endsWith(".db")) out.add(f.slice(0, -3));
|
|
761
|
-
}
|
|
762
|
-
return out;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/** Resolve which of the `candidates` DB ids is held open by the process tree
|
|
766
|
-
* rooted at `rootPid`. Used to disambiguate concurrent agy runs. Returns the
|
|
767
|
-
* single matching id, or null when none/several are open or /proc is
|
|
768
|
-
* unavailable. Ported from pi-antigravity-bridge src/discovery.ts; keep in
|
|
769
|
-
* sync. */
|
|
770
|
-
type OpenDbResolver = (
|
|
771
|
-
rootPid: number,
|
|
772
|
-
dir: string,
|
|
773
|
-
candidates: Set<string>,
|
|
774
|
-
) => string | null;
|
|
775
|
-
|
|
776
|
-
function readProcStat(pid: number): { pid: number; ppid: number } | null {
|
|
777
|
-
let raw: string;
|
|
778
|
-
try {
|
|
779
|
-
raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
780
|
-
} catch {
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
783
|
-
const closeParen = raw.lastIndexOf(")");
|
|
784
|
-
if (closeParen < 0) return null;
|
|
785
|
-
const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
|
|
786
|
-
const ppid = Number(fields[1]);
|
|
787
|
-
if (!Number.isFinite(ppid)) return null;
|
|
788
|
-
return { pid, ppid };
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function collectDescendants(rootPid: number): Set<number> {
|
|
792
|
-
const out = new Set<number>([rootPid]);
|
|
793
|
-
let entries: string[];
|
|
794
|
-
try {
|
|
795
|
-
entries = fs.readdirSync("/proc");
|
|
796
|
-
} catch {
|
|
797
|
-
return out;
|
|
798
|
-
}
|
|
799
|
-
const ppidOf = new Map<number, number>();
|
|
800
|
-
for (const e of entries) {
|
|
801
|
-
if (!/^\d+$/.test(e)) continue;
|
|
802
|
-
const s = readProcStat(Number(e));
|
|
803
|
-
if (s) ppidOf.set(s.pid, s.ppid);
|
|
804
|
-
}
|
|
805
|
-
let changed = true;
|
|
806
|
-
while (changed) {
|
|
807
|
-
changed = false;
|
|
808
|
-
for (const [pid, ppid] of ppidOf) {
|
|
809
|
-
if (out.has(pid)) continue;
|
|
810
|
-
if (out.has(ppid)) {
|
|
811
|
-
out.add(pid);
|
|
812
|
-
changed = true;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
return out;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function safeRealpath(p: string): string | null {
|
|
820
|
-
try {
|
|
821
|
-
return fs.realpathSync(p);
|
|
822
|
-
} catch {
|
|
823
|
-
return null;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
|
|
828
|
-
if (candidates.size <= 1) return null;
|
|
829
|
-
if (process.platform !== "linux") return null;
|
|
830
|
-
const dirResolved = safeRealpath(dir);
|
|
831
|
-
const tree = collectDescendants(rootPid);
|
|
832
|
-
const found = new Set<string>();
|
|
833
|
-
for (const pid of tree) {
|
|
834
|
-
let fds: string[];
|
|
835
|
-
try {
|
|
836
|
-
fds = fs.readdirSync(`/proc/${pid}/fd`);
|
|
837
|
-
} catch {
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
for (const fd of fds) {
|
|
841
|
-
let target: string;
|
|
842
|
-
try {
|
|
843
|
-
target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
844
|
-
} catch {
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
const base = path.basename(target);
|
|
848
|
-
if (!base.endsWith(".db")) continue;
|
|
849
|
-
if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
|
|
850
|
-
const id = base.slice(0, -3);
|
|
851
|
-
if (candidates.has(id)) found.add(id);
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
if (found.size === 1) return [...found][0] ?? null;
|
|
855
|
-
return null;
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
interface BindOptions {
|
|
859
|
-
pid?: number;
|
|
860
|
-
resolveOpenDb?: OpenDbResolver;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/** Find the conversation id created since `before`. Returns null when none
|
|
864
|
-
* appeared, or when several appeared and we cannot tie one to our process.
|
|
865
|
-
* Pass `opts.pid` (the spawned agy) to enable concurrent-run disambiguation
|
|
866
|
-
* via the process-tree FD scan. */
|
|
867
|
-
function newConversationId(
|
|
868
|
-
dir: string,
|
|
869
|
-
before: Set<string>,
|
|
870
|
-
opts: BindOptions = {},
|
|
871
|
-
): string | null {
|
|
872
|
-
const created = [...snapshotConversations(dir)].filter((id) => !before.has(id));
|
|
873
|
-
if (created.length === 0) return null;
|
|
874
|
-
if (created.length === 1) return created[0] ?? null;
|
|
875
|
-
// Ambiguous: try to authoritatively identify ours via the spawned
|
|
876
|
-
// process's open files. Fail safe to null when we can't pick exactly one.
|
|
877
|
-
if (opts.pid !== undefined) {
|
|
878
|
-
const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
|
|
879
|
-
const hit = resolve(opts.pid, dir, new Set(created));
|
|
880
|
-
if (hit && created.includes(hit)) return hit;
|
|
881
|
-
}
|
|
882
|
-
return null;
|
|
883
|
-
}
|
|
884
|
-
|
|
396
|
+
// --- Conversation discovery: shared peer adapter ---------------------------
|
|
397
|
+
// The .db snapshot / new-id pick / /proc FD disambiguation technique lives
|
|
398
|
+
// in pi-ask-shared (peers/antigravity.ts): snapshotConversations +
|
|
399
|
+
// newConversationId. CONVERSATIONS_DIR (env-overridable) is exported there.
|
|
885
400
|
// --- Full-context export (opt-in includeContext) --------------------------
|
|
886
401
|
// NOTE: duplicated per pi-ask-* package (each is self-contained). Duck-typed
|
|
887
402
|
// over role/content to tolerate AgentMessage's union + custom message types.
|
|
@@ -968,6 +483,207 @@ interface AgyDetails {
|
|
|
968
483
|
stderr: string;
|
|
969
484
|
}
|
|
970
485
|
|
|
486
|
+
|
|
487
|
+
// --- Background-mode plumbing ----------------------------------------------
|
|
488
|
+
// Shared shape for both call styles: blocking awaits it, background lets it
|
|
489
|
+
// run detached and pushes the outcome through the wake sender on close.
|
|
490
|
+
|
|
491
|
+
const STDOUT_BUF_MAX_CHARS = 1_000_000;
|
|
492
|
+
|
|
493
|
+
/** Spawn failure (binary vanished between resolve and spawn). */
|
|
494
|
+
class AgySpawnError extends Error {}
|
|
495
|
+
|
|
496
|
+
interface ProcessRunOptions {
|
|
497
|
+
binary: string;
|
|
498
|
+
args: string[];
|
|
499
|
+
workdir: string;
|
|
500
|
+
timeoutMin: number;
|
|
501
|
+
startAt: number;
|
|
502
|
+
/** Mutated in place: conversationId discovery, stderr, durationMs. */
|
|
503
|
+
details: AgyDetails;
|
|
504
|
+
isContinuation: boolean;
|
|
505
|
+
snapshot: ReturnType<typeof snapshotConversations> | null;
|
|
506
|
+
/** Blocking passes pi's per-run signal; background passes none on purpose: a later Esc must not kill a detached run. */
|
|
507
|
+
signal?: AbortSignal;
|
|
508
|
+
/** Blocking only: throttled progress partials. */
|
|
509
|
+
onPartial?: (text: string) => void;
|
|
510
|
+
/** Background only: hands over the tree-kill switch plus the live child
|
|
511
|
+
* handle (pid for the FD-disambiguation poll) as soon as the child exists. */
|
|
512
|
+
onSpawn?: (kill: () => void, proc: ChildProcess) => void;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
interface ProcessRunOutcome {
|
|
516
|
+
exitCode: number;
|
|
517
|
+
aborted: boolean;
|
|
518
|
+
timedOut: boolean;
|
|
519
|
+
/** Full stdout, trimmed. Empty means the headless auto-deny failure mode. */
|
|
520
|
+
answerText: string;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Tool-result shape this extension returns; keeps the background helper's literals narrow. */
|
|
524
|
+
interface AskToolResult {
|
|
525
|
+
content: Array<{ type: "text"; text: string }>;
|
|
526
|
+
details: AgyDetails;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Spawns agy -p, accumulates stdout, runs conversation-id discovery (during
|
|
531
|
+
* the run via the /proc FD resolver, then the post-exit scan fallback), and
|
|
532
|
+
* resolves when the process tree closes.
|
|
533
|
+
*/
|
|
534
|
+
async function runAgyProcess(opts: ProcessRunOptions): Promise<ProcessRunOutcome> {
|
|
535
|
+
const { binary, args, workdir, timeoutMin, startAt, details, isContinuation, snapshot, signal, onPartial, onSpawn } = opts;
|
|
536
|
+
let out = "";
|
|
537
|
+
|
|
538
|
+
const statusInterval = onPartial
|
|
539
|
+
? setInterval(() => {
|
|
540
|
+
const elapsed = Math.floor((Date.now() - startAt) / 1000);
|
|
541
|
+
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
542
|
+
const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
|
|
543
|
+
onPartial(text);
|
|
544
|
+
}, STATUS_INTERVAL_MS)
|
|
545
|
+
: null;
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
// Concurrent bind poll: while agy is ALIVE, the pid-based /proc FD
|
|
549
|
+
// resolver can disambiguate when a concurrent agy also drops a new
|
|
550
|
+
// .db. runProcess hands us the kill switch plus the spawned process
|
|
551
|
+
// through onSpawn; the pid is only useful while the tree lives, and
|
|
552
|
+
// the post-exit scan below is the fallback once it is gone.
|
|
553
|
+
let bindDuringRun: Promise<void> = Promise.resolve();
|
|
554
|
+
|
|
555
|
+
const outcome = await runProcess({
|
|
556
|
+
binary,
|
|
557
|
+
args,
|
|
558
|
+
cwd: workdir,
|
|
559
|
+
timeoutMs: timeoutMin * 60_000,
|
|
560
|
+
signal,
|
|
561
|
+
// Raw chunks stream live so the status tail matches the old
|
|
562
|
+
// during-run accumulation. The 1MB valve lives in runProcess for
|
|
563
|
+
// stdoutRaw; this accumulator applies the original's own valve
|
|
564
|
+
// (past 1MB, keep the last 100k) so a pathological stream cannot
|
|
565
|
+
// balloon memory through the live tail.
|
|
566
|
+
onChunk: (chunk) => {
|
|
567
|
+
out += chunk;
|
|
568
|
+
if (out.length > DEFAULT_LINE_BUF_MAX_CHARS) out = out.slice(-100_000);
|
|
569
|
+
},
|
|
570
|
+
onSpawn: (kill, proc) => {
|
|
571
|
+
// Hand the kill switch to the background registry first, then
|
|
572
|
+
// start the bind poll against the live pid.
|
|
573
|
+
onSpawn?.(kill, proc);
|
|
574
|
+
if (!isContinuation && snapshot && proc.pid) {
|
|
575
|
+
bindDuringRun = (async () => {
|
|
576
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
577
|
+
if (details.conversationId) return;
|
|
578
|
+
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
579
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
580
|
+
pid: proc.pid,
|
|
581
|
+
});
|
|
582
|
+
if (found) {
|
|
583
|
+
details.conversationId = found;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
587
|
+
}
|
|
588
|
+
})().catch(() => {
|
|
589
|
+
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
596
|
+
|
|
597
|
+
// Let the during-run bind poll finish (it bails immediately once agy
|
|
598
|
+
// has exited, so this rarely blocks).
|
|
599
|
+
await bindDuringRun;
|
|
600
|
+
|
|
601
|
+
// stderr feeds notes and wake messages, not forensics; runProcess
|
|
602
|
+
// applies the same 64k cap the inline accumulator used to.
|
|
603
|
+
details.stderr = outcome.stderr;
|
|
604
|
+
details.exitCode = outcome.exitCode;
|
|
605
|
+
details.aborted = outcome.aborted;
|
|
606
|
+
details.timedOut = outcome.timedOut;
|
|
607
|
+
details.durationMs = Date.now() - startAt;
|
|
608
|
+
|
|
609
|
+
// For a fresh run, discover the conversation id agy just created
|
|
610
|
+
// (agy -p never prints it). Retry briefly since agy may flush its
|
|
611
|
+
// SQLite DB a moment after the process closes. A continuation run
|
|
612
|
+
// reuses the provided id (already set on details).
|
|
613
|
+
if (!isContinuation && !details.conversationId && snapshot) {
|
|
614
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
615
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
616
|
+
if (found) {
|
|
617
|
+
details.conversationId = found;
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return { ...outcome, answerText: out.trim() };
|
|
625
|
+
} catch (err) {
|
|
626
|
+
// Preserve the extension's spawn-error contract (fixed message).
|
|
627
|
+
if (err instanceof RunSpawnError) throw new AgySpawnError("failed to spawn agy");
|
|
628
|
+
throw err;
|
|
629
|
+
} finally {
|
|
630
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Builds the user/model-facing answer text for every terminal outcome.
|
|
636
|
+
* Pure except the empty-output marker it stamps on details.
|
|
637
|
+
*/
|
|
638
|
+
function shapeFinalText(details: AgyDetails, outcome: ProcessRunOutcome, timeoutMin: number): string {
|
|
639
|
+
const text = outcome.answerText;
|
|
640
|
+
|
|
641
|
+
// Aborted: a distinct result so the caller knows it was cancelled, not a silent success.
|
|
642
|
+
if (outcome.aborted) {
|
|
643
|
+
return text ? `agy was aborted. Partial output:\n\n${text}` : "agy was aborted before producing output.";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Timeout: distinct from a genuine non-zero exit (the watchdog
|
|
647
|
+
// killed the tree because the configured cap elapsed).
|
|
648
|
+
if (outcome.timedOut) {
|
|
649
|
+
const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
|
|
650
|
+
return text ? `${text}\n\n[${note}]` : note;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Non-zero exit: surface the failure even when partial text exists.
|
|
654
|
+
if (outcome.exitCode !== 0) {
|
|
655
|
+
const note = details.stderr.trim()
|
|
656
|
+
? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
|
|
657
|
+
: `agy exited with status ${outcome.exitCode}`;
|
|
658
|
+
return text ? `${text}\n\n[${note}]` : note;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Exit 0 with nothing on stdout is still a failure for the
|
|
662
|
+
// caller: headless agy auto-denies a permission-gated tool call
|
|
663
|
+
// (e.g. the command gate in plan mode), prints the reason only to
|
|
664
|
+
// stderr, and ends cleanly. Falling through to the success path
|
|
665
|
+
// here returned just the conversation footer, which read as an
|
|
666
|
+
// empty success (silent-failure bug found 2026-09-25).
|
|
667
|
+
if (!text) {
|
|
668
|
+
details.empty = true;
|
|
669
|
+
const note = [
|
|
670
|
+
"agy exited cleanly but produced no output.",
|
|
671
|
+
details.stderr.trim() ? `stderr: ${details.stderr.trim()}` : null,
|
|
672
|
+
"Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
|
|
673
|
+
]
|
|
674
|
+
.filter(Boolean)
|
|
675
|
+
.join(" ");
|
|
676
|
+
return note;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Success. Conversation footer lets the orchestrating model thread the id.
|
|
680
|
+
const footer = details.conversationId
|
|
681
|
+
? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
|
|
682
|
+
: "";
|
|
683
|
+
|
|
684
|
+
return text + footer;
|
|
685
|
+
}
|
|
686
|
+
|
|
971
687
|
export default async function (pi: ExtensionAPI) {
|
|
972
688
|
// If pi-antigravity-bridge is installed, it owns the AskAntigravity tool
|
|
973
689
|
// (and the provider). Stay silent and register nothing to avoid a
|
|
@@ -991,6 +707,43 @@ export default async function (pi: ExtensionAPI) {
|
|
|
991
707
|
// aliases resolve even when agy doesn't surface them in the live catalog.
|
|
992
708
|
const discovered = mergeCatalog(await discoverModels(binary).catch(() => []));
|
|
993
709
|
|
|
710
|
+
// --- Background-run state (one set per extension load) -------------------
|
|
711
|
+
// Pi wipes module state on /new, /resume, /fork and /reload: an in-flight
|
|
712
|
+
// background run is killed and its result is never delivered. Accepted
|
|
713
|
+
// trade-off, documented in the README.
|
|
714
|
+
const registry = new BackgroundRunRegistry({ toolName: "ask-antigravity" });
|
|
715
|
+
const kills = new Map<string, () => void>();
|
|
716
|
+
// Staged-artifact cleanup (contextFile + reviewer agent dir), keyed by run:
|
|
717
|
+
// the session_shutdown backstop runs these when a hard exit would skip the
|
|
718
|
+
// close-path finally.
|
|
719
|
+
const cleanups = new Map<string, () => void>();
|
|
720
|
+
// One background run per conversationId at a time: two runs resuming the
|
|
721
|
+
// same conversation interleave their turns inside agy's SQLite state.
|
|
722
|
+
const busyHandles = new Set<string>();
|
|
723
|
+
const sendWake = createWakeSender(pi, {
|
|
724
|
+
customType: "ask-antigravity-result",
|
|
725
|
+
isDisposed: () => registry.isDisposed(),
|
|
726
|
+
});
|
|
727
|
+
const stopBackground = createStopHandler(registry, {
|
|
728
|
+
kill: (run) => kills.get(run.runId)?.(),
|
|
729
|
+
onStopped: (run) =>
|
|
730
|
+
sendWake({
|
|
731
|
+
toolLabel: "agy",
|
|
732
|
+
runId: run.runId,
|
|
733
|
+
ok: false,
|
|
734
|
+
elapsedS: Math.round((Date.now() - run.startedAt) / 1000),
|
|
735
|
+
error: "stopped via /agy-stop",
|
|
736
|
+
}),
|
|
737
|
+
});
|
|
738
|
+
pi.on("session_shutdown", () => {
|
|
739
|
+
// Latch first so close-handler wakes stay silent, then kill children,
|
|
740
|
+
// then sweep staged artifacts (hard exit would skip the close-path cleanup).
|
|
741
|
+
for (const run of registry.dispose()) kills.get(run.runId)?.();
|
|
742
|
+
for (const fn of cleanups.values()) fn();
|
|
743
|
+
cleanups.clear();
|
|
744
|
+
kills.clear();
|
|
745
|
+
});
|
|
746
|
+
|
|
994
747
|
// --- /agy: view / change default model + thinking ---------------------
|
|
995
748
|
|
|
996
749
|
// Friendly model options offered in the picker. Exact strings also work
|
|
@@ -1012,7 +765,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1012
765
|
` defaultModel: ${config.defaultModel}`,
|
|
1013
766
|
` defaultThinking: ${config.defaultThinking}`,
|
|
1014
767
|
` permissions: ${config.skipPermissions ? "auto-approved" : "prompt"}`,
|
|
1015
|
-
` resolved: ${
|
|
768
|
+
` resolved: ${resolveAgyModel(config.defaultModel, discovered, config.defaultThinking)?.model ?? "(agy default)"}`,
|
|
1016
769
|
``,
|
|
1017
770
|
`Edit: ~/.pi/agent/ask-antigravity.json`,
|
|
1018
771
|
].join("\n"),
|
|
@@ -1023,7 +776,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1023
776
|
|
|
1024
777
|
// Resolve the display string for the current default model.
|
|
1025
778
|
const currentResolved =
|
|
1026
|
-
|
|
779
|
+
resolveAgyModel(config.defaultModel, discovered, config.defaultThinking)?.model ?? config.defaultModel;
|
|
1027
780
|
|
|
1028
781
|
const items: SettingItem[] = [
|
|
1029
782
|
{
|
|
@@ -1033,7 +786,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1033
786
|
"Friendly alias resolved to the latest matching agy model. 'flash' = latest Flash, 'pro' = latest Pro, 'gemini' = latest Flash.",
|
|
1034
787
|
currentValue: `${config.defaultModel} → ${currentResolved}`,
|
|
1035
788
|
values: MODEL_OPTIONS.map((m) => {
|
|
1036
|
-
const r =
|
|
789
|
+
const r = resolveAgyModel(m, discovered, config.defaultThinking)?.model ?? m;
|
|
1037
790
|
return `${m} → ${r}`;
|
|
1038
791
|
}),
|
|
1039
792
|
},
|
|
@@ -1161,7 +914,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1161
914
|
],
|
|
1162
915
|
{
|
|
1163
916
|
description:
|
|
1164
|
-
"agy execution mode. 'plan' = review-only (
|
|
917
|
+
"agy execution mode. 'plan' = plan-shaped run: the CLI does not enforce review-only (see the tool description); the staged reviewer agent's toolset omits file-editing tools as a damper, and the skip-permissions flag is never passed. 'accept-edits' = agy applies edits directly (--mode accept-edits, default). For agy's orthogonal --sandbox shell-containment flag, set the AGY_EXTRA_ARGS env var.",
|
|
1165
918
|
default: "accept-edits",
|
|
1166
919
|
},
|
|
1167
920
|
),
|
|
@@ -1180,7 +933,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1180
933
|
),
|
|
1181
934
|
timeoutMinutes: Type.Optional(
|
|
1182
935
|
Type.Number({
|
|
1183
|
-
description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.`,
|
|
936
|
+
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.`,
|
|
1184
937
|
}),
|
|
1185
938
|
),
|
|
1186
939
|
includeContext: Type.Optional(
|
|
@@ -1189,6 +942,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1189
942
|
"When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
|
|
1190
943
|
}),
|
|
1191
944
|
),
|
|
945
|
+
background: Type.Optional(
|
|
946
|
+
Type.Boolean({
|
|
947
|
+
description: backgroundFlagText("conversationId"),
|
|
948
|
+
default: false,
|
|
949
|
+
}),
|
|
950
|
+
),
|
|
1192
951
|
}),
|
|
1193
952
|
renderCall(args, theme, _context) {
|
|
1194
953
|
// Show RESOLVED model/thinking/mode (config defaults applied) so the
|
|
@@ -1198,8 +957,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1198
957
|
const cfg = loadConfig();
|
|
1199
958
|
const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
|
|
1200
959
|
const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
|
|
1201
|
-
const resolved =
|
|
1202
|
-
|
|
960
|
+
const resolved: ResolvedModel =
|
|
961
|
+
resolveAgyModel(
|
|
1203
962
|
requestedModel,
|
|
1204
963
|
discovered,
|
|
1205
964
|
cfg.defaultThinking,
|
|
@@ -1216,6 +975,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1216
975
|
if (useDigest) tags.push("digest");
|
|
1217
976
|
if (isContinue) tags.push("continue");
|
|
1218
977
|
if (args.includeContext) tags.push("context=full");
|
|
978
|
+
if (args.background) tags.push("bg");
|
|
1219
979
|
|
|
1220
980
|
let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
|
|
1221
981
|
text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
|
|
@@ -1325,8 +1085,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1325
1085
|
};
|
|
1326
1086
|
}
|
|
1327
1087
|
const thinkingArg = (params.thinking as string | undefined) ?? (params.effort as string | undefined);
|
|
1328
|
-
const resolved =
|
|
1329
|
-
|
|
1088
|
+
const resolved: ResolvedModel =
|
|
1089
|
+
resolveAgyModel(
|
|
1330
1090
|
requestedModel,
|
|
1331
1091
|
discovered,
|
|
1332
1092
|
config.defaultThinking,
|
|
@@ -1355,6 +1115,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1355
1115
|
}
|
|
1356
1116
|
|
|
1357
1117
|
const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
|
|
1118
|
+
if (!(timeoutMin > 0 && Number.isFinite(timeoutMin) && timeoutMin <= MAX_TIMEOUT_MINUTES)) {
|
|
1119
|
+
// timeoutMinutes: 0 would arm runProcess's watchdog with a 0ms
|
|
1120
|
+
// timeout (Node clamps to 1ms) and kill the peer instantly.
|
|
1121
|
+
return {
|
|
1122
|
+
content: [{ type: "text", text: `timeoutMinutes must be a positive number of minutes up to ${MAX_TIMEOUT_MINUTES} (for example 0.5 for 30 seconds).` }],
|
|
1123
|
+
details: emptyDetails(requestedModel, resolved.model),
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1358
1126
|
|
|
1359
1127
|
// Continuity: if a conversationId is provided AND validates as an agy
|
|
1360
1128
|
// id (UUID-ish DB stem, never a leading-dash flag), resume it; otherwise
|
|
@@ -1370,10 +1138,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1370
1138
|
|
|
1371
1139
|
const mode: Mode = (params.mode as Mode | undefined) ?? "accept-edits";
|
|
1372
1140
|
// Plan runs stage a restricted reviewer agent: its tools list has NO
|
|
1373
|
-
// file-editing tool,
|
|
1374
|
-
// alone was observed failing once - a sub-agent still edited
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1141
|
+
// file-editing tool, a toolset-level damper on edits (the prompt
|
|
1142
|
+
// guard alone was observed failing once - a sub-agent still edited
|
|
1143
|
+
// files; the CLI itself does not enforce review-only, upstream
|
|
1144
|
+
// #1181). Staging failure degrades to the legacy fallback: no agent,
|
|
1145
|
+
// no skip flag, the stricter command-forbidding guard.
|
|
1377
1146
|
let reviewerAgent: { name: string; dir: string } | null = null;
|
|
1378
1147
|
if (mode === "plan") {
|
|
1379
1148
|
try {
|
|
@@ -1382,15 +1151,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1382
1151
|
reviewerAgent = null;
|
|
1383
1152
|
}
|
|
1384
1153
|
}
|
|
1385
|
-
// digest default: on for plan (review-
|
|
1154
|
+
// digest default: on for plan (review-shaped contexts where full file
|
|
1386
1155
|
// contents are noise), off for accept-edits (agy applies edits and
|
|
1387
1156
|
// may need richer context for diffs).
|
|
1388
1157
|
const useDigest: boolean =
|
|
1389
1158
|
typeof params.digest === "boolean"
|
|
1390
1159
|
? params.digest
|
|
1391
1160
|
: mode === "plan";
|
|
1161
|
+
// Budget notice into the prompt BEFORE buildFinalPrompt: the plan-mode
|
|
1162
|
+
// guard banners must stay the final text (last-read recency), so the
|
|
1163
|
+
// notice can't go after them. The builder itself stays notice-free so
|
|
1164
|
+
// callers can't double-wrap.
|
|
1165
|
+
const budget = buildTimeBudgetNotice(timeoutMin * 60_000);
|
|
1392
1166
|
const finalPrompt: string = buildFinalPrompt(
|
|
1393
|
-
params.prompt,
|
|
1167
|
+
budget ? `${params.prompt}\n\n${budget}` : params.prompt,
|
|
1394
1168
|
mode,
|
|
1395
1169
|
useDigest,
|
|
1396
1170
|
reviewerAgent !== null,
|
|
@@ -1419,37 +1193,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1419
1193
|
? `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}`
|
|
1420
1194
|
: finalPrompt;
|
|
1421
1195
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
// accept-edits auto-approves file edits but NOT shell commands, so a
|
|
1437
|
-
// run_command would hang on an unanswerable y/n prompt in non-interactive
|
|
1438
|
-
// -p mode. Honor the shared permissions setting (same knob as the bridge).
|
|
1439
|
-
// Plan runs WITH the reviewer agent: the flag is safe again because the
|
|
1440
|
-
// agent's toolset has no file-editing tool - the 2026-09-25 write
|
|
1441
|
-
// incident rode edit tools that existed then; probed 2026-09-28 the
|
|
1442
|
-
// restricted toolset reports "none" for edits even with the flag. The
|
|
1443
|
-
// flag is what lets analysis commands (builds, test runners) run for
|
|
1444
|
-
// users without allow rules. Plan fallback (staging failed): strict
|
|
1445
|
-
// legacy behavior, no flag - the prompt guard is all that is left.
|
|
1446
|
-
if (config.skipPermissions && (mode !== "plan" || reviewerAgent !== null)) {
|
|
1447
|
-
args.push("--dangerously-skip-permissions");
|
|
1448
|
-
}
|
|
1449
|
-
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
1450
|
-
args.push("--print-timeout", `${timeoutMin}m`);
|
|
1451
|
-
if (contextFile) args.push("--add-dir", askContextDir());
|
|
1452
|
-
args.push("-p", effectivePrompt);
|
|
1196
|
+
// Build argv via the shared peer adapter (see buildAgyArgs there for
|
|
1197
|
+
// the flag-order contract and the fail-closed plan-run rules).
|
|
1198
|
+
const args = buildAgyArgs({
|
|
1199
|
+
cwd,
|
|
1200
|
+
resolved,
|
|
1201
|
+
mode,
|
|
1202
|
+
reviewerAgentName: reviewerAgent?.name ?? null,
|
|
1203
|
+
skipPermissions: config.skipPermissions,
|
|
1204
|
+
conversationId: isContinuation ? (rawConvId as string) : null,
|
|
1205
|
+
timeoutMinutes: timeoutMin,
|
|
1206
|
+
addDirs: contextFile ? [askContextDir()] : [],
|
|
1207
|
+
extraArgs: extraArgs(),
|
|
1208
|
+
prompt: effectivePrompt,
|
|
1209
|
+
});
|
|
1453
1210
|
|
|
1454
1211
|
const details: AgyDetails = {
|
|
1455
1212
|
model: requestedModel,
|
|
@@ -1466,261 +1223,130 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1466
1223
|
stderr: "",
|
|
1467
1224
|
};
|
|
1468
1225
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
const statusInterval = onUpdate
|
|
1475
|
-
? setInterval(() => {
|
|
1476
|
-
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
1477
|
-
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
1478
|
-
const text = tail
|
|
1479
|
-
? `(running ${elapsed}s)\n…${tail}`
|
|
1480
|
-
: `(running ${elapsed}s)`;
|
|
1481
|
-
onUpdate({
|
|
1482
|
-
content: [{ type: "text", text }],
|
|
1483
|
-
details: { ...details, durationMs: Date.now() - start },
|
|
1484
|
-
});
|
|
1485
|
-
}, STATUS_INTERVAL_MS)
|
|
1486
|
-
: null;
|
|
1487
|
-
|
|
1488
|
-
try {
|
|
1489
|
-
// Bind the conversation id DURING the run (agy is alive then) so the
|
|
1490
|
-
// pid-based /proc FD resolver can disambiguate when a concurrent agy
|
|
1491
|
-
// also drops a new .db. Awaited after the run; the post-exit loop
|
|
1492
|
-
// below is the fallback for runs that exit before the poll binds.
|
|
1493
|
-
let bindDuringRun: Promise<void> = Promise.resolve();
|
|
1494
|
-
const outcome = await new Promise<{
|
|
1495
|
-
exitCode: number;
|
|
1496
|
-
aborted: boolean;
|
|
1497
|
-
timedOut: boolean;
|
|
1498
|
-
}>((resolveP, rejectP) => {
|
|
1499
|
-
// detached: true so we can signal the whole process group.
|
|
1500
|
-
// agy spawns its own exec subprocesses in -p mode; a direct
|
|
1501
|
-
// kill would orphan those grandchildren.
|
|
1502
|
-
const proc = spawn(binary, args, {
|
|
1503
|
-
cwd,
|
|
1504
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1505
|
-
shell: false,
|
|
1506
|
-
detached: true,
|
|
1507
|
-
});
|
|
1508
|
-
|
|
1509
|
-
// Decode at the stream level so multibyte UTF-8 split across
|
|
1510
|
-
// pipe chunks doesn't corrupt (Gemini output is non-ASCII).
|
|
1511
|
-
proc.stdout?.setEncoding("utf8");
|
|
1512
|
-
proc.stderr?.setEncoding("utf8");
|
|
1513
|
-
|
|
1514
|
-
proc.stdout?.on("data", (d: string) => {
|
|
1515
|
-
out += d;
|
|
1516
|
-
});
|
|
1517
|
-
proc.stderr?.on("data", (d: string) => {
|
|
1518
|
-
details.stderr += d;
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
// Concurrent bind: poll for the new id while agy is alive. The FD
|
|
1522
|
-
// resolver needs a live process tree, so this stops (and the
|
|
1523
|
-
// post-exit fallback below takes over) once agy has exited.
|
|
1524
|
-
if (!isContinuation && snapshot && proc.pid) {
|
|
1525
|
-
bindDuringRun = (async () => {
|
|
1526
|
-
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
1527
|
-
if (details.conversationId) return;
|
|
1528
|
-
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
1529
|
-
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
1530
|
-
pid: proc.pid,
|
|
1531
|
-
});
|
|
1532
|
-
if (found) {
|
|
1533
|
-
details.conversationId = found;
|
|
1534
|
-
return;
|
|
1535
|
-
}
|
|
1536
|
-
await sleep(DISCOVERY_POLL_MS);
|
|
1537
|
-
}
|
|
1538
|
-
})().catch(() => {
|
|
1539
|
-
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
1540
|
-
});
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1544
|
-
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
1545
|
-
let settled = false;
|
|
1546
|
-
let timedOut = false;
|
|
1547
|
-
|
|
1548
|
-
// Kill the whole process group; SIGTERM first, then SIGKILL
|
|
1549
|
-
// after a grace period if it hasn't exited.
|
|
1550
|
-
const killTree = () => {
|
|
1226
|
+
// Background: hand the run to the registry and return at once. The
|
|
1227
|
+
// result travels back through pi.sendMessage (wake), not this call.
|
|
1228
|
+
if (params.background) {
|
|
1229
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
1230
|
+
if (contextFile) {
|
|
1551
1231
|
try {
|
|
1552
|
-
|
|
1232
|
+
fs.unlinkSync(contextFile);
|
|
1553
1233
|
} catch {}
|
|
1554
|
-
// Only arm the SIGKILL timer once.
|
|
1555
|
-
if (!sigkillTimer) {
|
|
1556
|
-
sigkillTimer = setTimeout(() => {
|
|
1557
|
-
try {
|
|
1558
|
-
if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
1559
|
-
} catch {}
|
|
1560
|
-
}, GRACE_AFTER_TIMEOUT_MS);
|
|
1561
|
-
}
|
|
1562
|
-
};
|
|
1563
|
-
|
|
1564
|
-
const cleanup = () => {
|
|
1565
|
-
if (watchdog) clearTimeout(watchdog);
|
|
1566
|
-
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
1567
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1568
|
-
};
|
|
1569
|
-
|
|
1570
|
-
const onAbort = () => killTree();
|
|
1571
|
-
|
|
1572
|
-
// Enforce the timeout cap ourselves (not just via
|
|
1573
|
-
// --print-timeout, which agy could ignore or not support).
|
|
1574
|
-
watchdog = setTimeout(() => {
|
|
1575
|
-
timedOut = true;
|
|
1576
|
-
killTree();
|
|
1577
|
-
}, timeoutMin * 60_000);
|
|
1578
|
-
|
|
1579
|
-
if (signal) {
|
|
1580
|
-
if (signal.aborted) killTree();
|
|
1581
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1582
1234
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
cleanup();
|
|
1588
|
-
// Distinguish an aborted run from a normal close: if the
|
|
1589
|
-
// abort signal fired, treat as cancelled regardless of
|
|
1590
|
-
// exit code (killTree produces a non-zero code, but be
|
|
1591
|
-
// explicit and order-independent). Likewise surface a
|
|
1592
|
-
// timeout distinctly from a genuine non-zero exit.
|
|
1593
|
-
resolveP({
|
|
1594
|
-
exitCode: code ?? 0,
|
|
1595
|
-
aborted: !!signal?.aborted,
|
|
1596
|
-
timedOut,
|
|
1597
|
-
});
|
|
1598
|
-
};
|
|
1599
|
-
|
|
1600
|
-
proc.on("error", (err) => {
|
|
1601
|
-
cleanup();
|
|
1602
|
-
rejectP(err);
|
|
1603
|
-
});
|
|
1604
|
-
proc.on("close", finish);
|
|
1605
|
-
});
|
|
1606
|
-
|
|
1607
|
-
if (statusInterval) clearInterval(statusInterval);
|
|
1608
|
-
|
|
1609
|
-
// Let the during-run bind poll finish (it bails immediately once agy
|
|
1610
|
-
// has exited, so this rarely blocks).
|
|
1611
|
-
await bindDuringRun;
|
|
1612
|
-
|
|
1613
|
-
details.exitCode = outcome.exitCode;
|
|
1614
|
-
details.aborted = outcome.aborted;
|
|
1615
|
-
details.timedOut = outcome.timedOut;
|
|
1616
|
-
details.durationMs = Date.now() - start;
|
|
1617
|
-
|
|
1618
|
-
// For a fresh run, discover the conversation id agy just created
|
|
1619
|
-
// (agy -p never prints it). Retry briefly since agy may flush its
|
|
1620
|
-
// SQLite DB a moment after the process closes. A continuation run
|
|
1621
|
-
// reuses the provided id (already set on details).
|
|
1622
|
-
if (!isContinuation && !details.conversationId && snapshot) {
|
|
1623
|
-
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
1624
|
-
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
1625
|
-
if (found) {
|
|
1626
|
-
details.conversationId = found;
|
|
1627
|
-
break;
|
|
1628
|
-
}
|
|
1629
|
-
await sleep(DISCOVERY_POLL_MS);
|
|
1235
|
+
if (reviewerAgent) {
|
|
1236
|
+
try {
|
|
1237
|
+
fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
|
|
1238
|
+
} catch {}
|
|
1630
1239
|
}
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
const text = out.trim();
|
|
1634
|
-
|
|
1635
|
-
// Aborted: return a distinct result so the caller knows it was
|
|
1636
|
-
// cancelled, not a silent success.
|
|
1637
|
-
if (outcome.aborted) {
|
|
1638
1240
|
return {
|
|
1639
1241
|
content: [
|
|
1640
1242
|
{
|
|
1641
1243
|
type: "text",
|
|
1642
|
-
text:
|
|
1643
|
-
? `agy was aborted. Partial output:\n\n${text}`
|
|
1644
|
-
: "agy was aborted before producing output.",
|
|
1244
|
+
text: "background is not available in print/json mode: the process exits before the result message can arrive. Call again without background.",
|
|
1645
1245
|
},
|
|
1646
1246
|
],
|
|
1647
|
-
details,
|
|
1247
|
+
details: { ...details },
|
|
1648
1248
|
};
|
|
1649
1249
|
}
|
|
1250
|
+
return startBackgroundRun({
|
|
1251
|
+
summary: params.prompt,
|
|
1252
|
+
binary,
|
|
1253
|
+
args,
|
|
1254
|
+
workdir: cwd,
|
|
1255
|
+
timeoutMin,
|
|
1256
|
+
startAt: start,
|
|
1257
|
+
details,
|
|
1258
|
+
contextFile,
|
|
1259
|
+
reviewerAgent,
|
|
1260
|
+
modelLabel: resolved.model ?? requestedModel,
|
|
1261
|
+
isContinuation,
|
|
1262
|
+
conversationId: isContinuation ? (rawConvId as string) : undefined,
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1650
1265
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1266
|
+
// One run per conversationId at a time, blocking included: a blocking
|
|
1267
|
+
// resume and a background resume of the same conversation would
|
|
1268
|
+
// interleave turns inside agy's session state. Declared out here so
|
|
1269
|
+
// the finally can see it; set only when THIS call takes the lock.
|
|
1270
|
+
let lockAcquired = false;
|
|
1271
|
+
try {
|
|
1272
|
+
if (isContinuation && busyHandles.has(rawConvId as string)) {
|
|
1273
|
+
if (contextFile) {
|
|
1274
|
+
try {
|
|
1275
|
+
fs.unlinkSync(contextFile);
|
|
1276
|
+
} catch {}
|
|
1277
|
+
}
|
|
1278
|
+
if (reviewerAgent) {
|
|
1279
|
+
try {
|
|
1280
|
+
fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
|
|
1281
|
+
} catch {}
|
|
1282
|
+
}
|
|
1655
1283
|
return {
|
|
1656
1284
|
content: [
|
|
1657
|
-
{
|
|
1285
|
+
{
|
|
1286
|
+
type: "text",
|
|
1287
|
+
text: `conversation ${rawConvId} already has a run in flight. Wait for its result, or stop it with /agy-stop.`,
|
|
1288
|
+
},
|
|
1658
1289
|
],
|
|
1659
|
-
details,
|
|
1290
|
+
details: emptyDetails(requestedModel, resolved.model),
|
|
1660
1291
|
};
|
|
1661
1292
|
}
|
|
1293
|
+
// Track acquisition: the finally must only release a lock THIS call
|
|
1294
|
+
// took, never the one held by the run that caused a refusal.
|
|
1295
|
+
lockAcquired = isContinuation;
|
|
1296
|
+
if (isContinuation) busyHandles.add(rawConvId as string);
|
|
1297
|
+
const outcome = await runAgyProcess({
|
|
1298
|
+
binary,
|
|
1299
|
+
args,
|
|
1300
|
+
workdir: cwd,
|
|
1301
|
+
timeoutMin,
|
|
1302
|
+
startAt: start,
|
|
1303
|
+
details,
|
|
1304
|
+
isContinuation,
|
|
1305
|
+
snapshot,
|
|
1306
|
+
signal,
|
|
1307
|
+
onPartial: onUpdate
|
|
1308
|
+
? (text) => {
|
|
1309
|
+
onUpdate({
|
|
1310
|
+
content: [{ type: "text", text }],
|
|
1311
|
+
details: { ...details, durationMs: Date.now() - start },
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
: undefined,
|
|
1315
|
+
});
|
|
1662
1316
|
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
if (outcome.exitCode !== 0) {
|
|
1666
|
-
|
|
1667
|
-
? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
|
|
1668
|
-
: `agy exited with status ${outcome.exitCode}`;
|
|
1669
|
-
return {
|
|
1670
|
-
content: [
|
|
1671
|
-
{ type: "text", text: text ? `${text}\n\n[${note}]` : note },
|
|
1672
|
-
],
|
|
1673
|
-
details,
|
|
1674
|
-
};
|
|
1317
|
+
const finalText = shapeFinalText(details, outcome, timeoutMin);
|
|
1318
|
+
|
|
1319
|
+
if (outcome.aborted || outcome.timedOut || outcome.exitCode !== 0) {
|
|
1320
|
+
return { content: [{ type: "text", text: finalText }], details };
|
|
1675
1321
|
}
|
|
1676
1322
|
|
|
1677
|
-
// Exit 0 with
|
|
1678
|
-
//
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
// here returned just the conversation footer, which read as an
|
|
1682
|
-
// empty success (silent-failure bug found 2026-09-25).
|
|
1683
|
-
if (!text) {
|
|
1684
|
-
details.empty = true;
|
|
1685
|
-
const note = [
|
|
1686
|
-
"agy exited cleanly but produced no output.",
|
|
1687
|
-
details.stderr.trim() ? `stderr: ${details.stderr.trim()}` : null,
|
|
1688
|
-
"Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
|
|
1689
|
-
]
|
|
1690
|
-
.filter(Boolean)
|
|
1691
|
-
.join(" ");
|
|
1692
|
-
return {
|
|
1693
|
-
content: [{ type: "text", text: note }],
|
|
1694
|
-
details,
|
|
1695
|
-
};
|
|
1323
|
+
// Exit 0 with no output keeps its dedicated failure result
|
|
1324
|
+
// (headless auto-deny); see shapeFinalText for the note.
|
|
1325
|
+
if (!outcome.answerText) {
|
|
1326
|
+
return { content: [{ type: "text", text: finalText }], details };
|
|
1696
1327
|
}
|
|
1697
1328
|
|
|
1698
1329
|
// Success. Clear the last partial status line (claude-bridge
|
|
1699
1330
|
// idiom) so the running-tail preview doesn't linger under the final
|
|
1700
|
-
// answer
|
|
1701
|
-
// can see (and thread) the id without inspecting details.
|
|
1331
|
+
// answer.
|
|
1702
1332
|
onUpdate?.({
|
|
1703
1333
|
content: [{ type: "text", text: "" }],
|
|
1704
1334
|
details: { ...details },
|
|
1705
1335
|
});
|
|
1706
|
-
const footer = details.conversationId
|
|
1707
|
-
? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
|
|
1708
|
-
: "";
|
|
1709
1336
|
|
|
1710
1337
|
return {
|
|
1711
|
-
content: [{ type: "text", text:
|
|
1338
|
+
content: [{ type: "text", text: finalText }],
|
|
1712
1339
|
details,
|
|
1713
1340
|
};
|
|
1714
1341
|
} catch (err) {
|
|
1715
|
-
if (statusInterval) clearInterval(statusInterval);
|
|
1716
1342
|
details.durationMs = Date.now() - start;
|
|
1717
1343
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1718
1344
|
return {
|
|
1719
1345
|
content: [{ type: "text", text: `failed to run agy: ${msg}` }],
|
|
1720
1346
|
details,
|
|
1721
1347
|
};
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1348
|
+
} finally {
|
|
1349
|
+
if (isContinuation && lockAcquired) busyHandles.delete(rawConvId as string);
|
|
1724
1350
|
if (contextFile) {
|
|
1725
1351
|
try {
|
|
1726
1352
|
fs.unlinkSync(contextFile);
|
|
@@ -1734,6 +1360,156 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1734
1360
|
}
|
|
1735
1361
|
},
|
|
1736
1362
|
});
|
|
1363
|
+
// --- /agy-stop: kill a background run ----------------------------------
|
|
1364
|
+
|
|
1365
|
+
pi.registerCommand("agy-stop", {
|
|
1366
|
+
description: "Stop a background AskAntigravity run (id prefix, or the only running one). Usage: /agy-stop [runId]",
|
|
1367
|
+
handler: async (args, ctx) => {
|
|
1368
|
+
const message = await stopBackground(args ?? "");
|
|
1369
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
1370
|
+
},
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
const startBackgroundRun = (o: {
|
|
1374
|
+
summary: string;
|
|
1375
|
+
binary: string;
|
|
1376
|
+
args: string[];
|
|
1377
|
+
workdir: string;
|
|
1378
|
+
timeoutMin: number;
|
|
1379
|
+
startAt: number;
|
|
1380
|
+
details: AgyDetails;
|
|
1381
|
+
contextFile: string | null;
|
|
1382
|
+
reviewerAgent: { name: string; dir: string } | null;
|
|
1383
|
+
modelLabel: string;
|
|
1384
|
+
isContinuation: boolean;
|
|
1385
|
+
conversationId?: string;
|
|
1386
|
+
}): AskToolResult => {
|
|
1387
|
+
registry.sweep();
|
|
1388
|
+
// Per-handle lock: two background runs resuming the same conversation
|
|
1389
|
+
// would interleave turns inside agy's session state.
|
|
1390
|
+
if (o.conversationId && busyHandles.has(o.conversationId)) {
|
|
1391
|
+
return {
|
|
1392
|
+
content: [
|
|
1393
|
+
{
|
|
1394
|
+
type: "text",
|
|
1395
|
+
text: `background refused: conversation ${o.conversationId} already has a background run. Wait for its wake message, or use blocking mode.`,
|
|
1396
|
+
},
|
|
1397
|
+
],
|
|
1398
|
+
details: { ...o.details },
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
if (o.conversationId) busyHandles.add(o.conversationId);
|
|
1402
|
+
let run;
|
|
1403
|
+
try {
|
|
1404
|
+
run = registry.start(summarizePrompt(o.summary));
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
if (o.conversationId) busyHandles.delete(o.conversationId);
|
|
1407
|
+
if (o.contextFile) {
|
|
1408
|
+
try {
|
|
1409
|
+
fs.unlinkSync(o.contextFile);
|
|
1410
|
+
} catch {}
|
|
1411
|
+
}
|
|
1412
|
+
if (o.reviewerAgent) {
|
|
1413
|
+
try {
|
|
1414
|
+
fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
|
|
1415
|
+
} catch {}
|
|
1416
|
+
}
|
|
1417
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1418
|
+
return {
|
|
1419
|
+
content: [
|
|
1420
|
+
{
|
|
1421
|
+
type: "text",
|
|
1422
|
+
text: `background refused: ${msg}. Use blocking (omit background) or free a slot with /agy-stop.`,
|
|
1423
|
+
},
|
|
1424
|
+
],
|
|
1425
|
+
details: { ...o.details },
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
const runId = run.runId;
|
|
1429
|
+
const cleanupStaging = () => {
|
|
1430
|
+
// Both staged artifacts must outlive the execute() return in
|
|
1431
|
+
// background mode: agy reads them mid-run.
|
|
1432
|
+
if (o.contextFile) {
|
|
1433
|
+
try {
|
|
1434
|
+
fs.unlinkSync(o.contextFile);
|
|
1435
|
+
} catch {}
|
|
1436
|
+
}
|
|
1437
|
+
if (o.reviewerAgent) {
|
|
1438
|
+
try {
|
|
1439
|
+
fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
|
|
1440
|
+
} catch {}
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
cleanups.set(runId, cleanupStaging);
|
|
1444
|
+
void runAgyProcess({
|
|
1445
|
+
binary: o.binary,
|
|
1446
|
+
args: o.args,
|
|
1447
|
+
workdir: o.workdir,
|
|
1448
|
+
timeoutMin: o.timeoutMin,
|
|
1449
|
+
startAt: o.startAt,
|
|
1450
|
+
details: o.details,
|
|
1451
|
+
isContinuation: o.isContinuation,
|
|
1452
|
+
snapshot: o.isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR),
|
|
1453
|
+
onSpawn: (kill) => {
|
|
1454
|
+
kills.set(runId, kill);
|
|
1455
|
+
},
|
|
1456
|
+
})
|
|
1457
|
+
.then((outcome) => {
|
|
1458
|
+
kills.delete(runId);
|
|
1459
|
+
const finalText = shapeFinalText(o.details, outcome, o.timeoutMin);
|
|
1460
|
+
const elapsedS = Math.round((Date.now() - o.startAt) / 1000);
|
|
1461
|
+
const handle = o.details.conversationId ? `conversationId=${o.details.conversationId}` : undefined;
|
|
1462
|
+
const failed = outcome.timedOut || outcome.exitCode !== 0 || !outcome.answerText;
|
|
1463
|
+
// The settle latch makes /agy-stop and shutdown win races
|
|
1464
|
+
// against this handler: a false return means their wake went out.
|
|
1465
|
+
const settled = registry.settle(runId, {
|
|
1466
|
+
status: failed ? "failed" : "done",
|
|
1467
|
+
output: finalText,
|
|
1468
|
+
handle,
|
|
1469
|
+
error: outcome.timedOut
|
|
1470
|
+
? `timeout after ${o.timeoutMin}m`
|
|
1471
|
+
: outcome.exitCode !== 0
|
|
1472
|
+
? `exit status ${outcome.exitCode}`
|
|
1473
|
+
: !outcome.answerText
|
|
1474
|
+
? "agy exited cleanly but produced no output"
|
|
1475
|
+
: undefined,
|
|
1476
|
+
});
|
|
1477
|
+
if (!settled) return;
|
|
1478
|
+
if (outcome.timedOut) {
|
|
1479
|
+
const partial = outcome.answerText ? ` Partial output: ${outcome.answerText.slice(0, 400)}` : "";
|
|
1480
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: `timeout after ${o.timeoutMin}m.${partial}`, handle });
|
|
1481
|
+
} else if (outcome.exitCode !== 0) {
|
|
1482
|
+
const reason = o.details.stderr.trim().slice(0, 400) || `exit status ${outcome.exitCode}`;
|
|
1483
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: reason, handle });
|
|
1484
|
+
} else if (!outcome.answerText) {
|
|
1485
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: "agy exited cleanly but produced no output (headless auto-deny?)", handle });
|
|
1486
|
+
} else {
|
|
1487
|
+
sendWake({ toolLabel: "agy", runId, ok: true, elapsedS, handle }, finalText);
|
|
1488
|
+
}
|
|
1489
|
+
})
|
|
1490
|
+
.catch((err) => {
|
|
1491
|
+
kills.delete(runId);
|
|
1492
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1493
|
+
// Settle first: an /agy-stop that won the race owns the wake.
|
|
1494
|
+
const settled = registry.settle(runId, { status: "failed", error: msg });
|
|
1495
|
+
if (!settled) return;
|
|
1496
|
+
sendWake({ toolLabel: "agy", runId, ok: false, error: `failed to run agy: ${msg}` });
|
|
1497
|
+
})
|
|
1498
|
+
.finally(() => {
|
|
1499
|
+
cleanups.delete(runId);
|
|
1500
|
+
cleanupStaging();
|
|
1501
|
+
if (o.conversationId) busyHandles.delete(o.conversationId);
|
|
1502
|
+
});
|
|
1503
|
+
return {
|
|
1504
|
+
content: [
|
|
1505
|
+
{
|
|
1506
|
+
type: "text",
|
|
1507
|
+
text: `Background run ${runId} started (model=${o.modelLabel}). The result arrives as a message when agy finishes; the resume handle (conversationId) comes with it when agy reports one. Do not poll. Continue with other work or end the turn.`,
|
|
1508
|
+
},
|
|
1509
|
+
],
|
|
1510
|
+
details: { ...o.details },
|
|
1511
|
+
};
|
|
1512
|
+
};
|
|
1737
1513
|
}
|
|
1738
1514
|
|
|
1739
1515
|
function emptyDetails(
|