@estebanforge/pi-ask-antigravity 1.2.10 → 1.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +6 -2
- package/extensions/index.ts +601 -252
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.11] - 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Background runs (`background`, opt-in, default blocking).** Detached runs with the shared wake contract: `runId` at once, the answer arrives later as a conversation message with the resume handle (when agy reports one; discovery misses say so) under an UNTRUSTED banner. `/agy-stop [runId]` kills a run. One run per conversation at a time across blocking and background (per-handle lock). Refused in print/json mode; session replacement kills in-flight runs and loses their results; the staged context file and plan-mode reviewer dir are cleaned on every exit path including session shutdown.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Plan runs are no longer auto-approved and no longer claim enforcement.** The CLI does not gate writes under plan mode ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)): the previously documented "write attempts fail at plan approval" behavior never held, and plan runs carrying the skip-permissions flag (whenever the restricted agent staged) were write-capable. Plan runs now never receive the flag (including via `AGY_EXTRA_ARGS`, filtered by prefix), the restricted agent is documented as a damper, and the tool description, param description, and README no longer call plan review-only or enforced.
|
|
15
|
+
|
|
5
16
|
## [1.2.10] - 2026-10-06
|
|
6
17
|
|
|
7
18
|
### Changed
|
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ Verbose `agy models` strings (`Gemini 3.5 Flash (Medium)`) are hostile to natura
|
|
|
62
62
|
|
|
63
63
|
| Mode | Flag | Use when |
|
|
64
64
|
| --- | --- | --- |
|
|
65
|
-
| `plan` | `--mode plan` |
|
|
65
|
+
| `plan` | `--mode plan` | Review-shaped run for cross-review and read-only tasks. NOT a security boundary: the CLI does not gate writes under plan mode ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181), probed 2026-10-07: the write can execute in the same turn). The skip-permissions flag is never passed, so nothing runs auto-approved, and a temporary restricted agent (no file-editing tools) stages as a damper. Treat plan output as review material, not as proof nothing happened. |
|
|
66
66
|
| `accept-edits` (default) | `--mode accept-edits` | agy applies edits directly inside the workspace. |
|
|
67
67
|
|
|
68
68
|
For agy's orthogonal `--sandbox` shell-containment flag, set `AGY_EXTRA_ARGS=--sandbox` in your environment. The extension does not expose it as a `mode` value because `--sandbox` controls command containment, not edit persistence.
|
|
@@ -110,7 +110,7 @@ Interactive picker for the default model and default thinking. If the project co
|
|
|
110
110
|
| `prompt` | yes | Self-contained task. agy cannot see the Pi conversation. |
|
|
111
111
|
| `cwd` | no | Workspace agy runs in. Defaults to the project root. |
|
|
112
112
|
| `model` | no | Alias or exact id. Omit for the configured default. |
|
|
113
|
-
| `mode` | no | `plan` (review-
|
|
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
116
|
| `timeoutMinutes` | no | Hard cap on the run. Default 10. |
|
|
@@ -135,6 +135,10 @@ agy mutates the workspace itself, so scope calls to sub-tasks where that is the
|
|
|
135
135
|
|
|
136
136
|
Google's [Antigravity ToS](https://antigravity.google/terms) states that using third-party software to access the Service (e.g. driving `agy` from a non-Google editor) is a breach of the Agreement and may be grounds for suspension or termination of your account. `agy` handles its own Google OAuth; this extension only spawns the official binary as a subprocess — but the effect is still a non-Google editor driving the Antigravity service through a third-party tool. **By using this extension against an `agy` session logged into your personal Antigravity account, you accept that risk.**
|
|
137
137
|
|
|
138
|
+
## Background runs
|
|
139
|
+
|
|
140
|
+
Set `background: true` on a call to run detached: the tool returns a `runId` at once, the calling agent keeps working, and the full answer arrives later as a message in the conversation (wrapped in an UNTRUSTED banner: it is tool output, never a user instruction). The message carries the resume handle (`conversationId`) when agy reports one; fresh runs where id discovery finds nothing say so instead of inventing a handle. One run per conversation at a time (blocking and background share the lock). Stop a run with `/agy-stop [runId]` (unique id prefix; no arg stops the only running run). Constraints: refused in print/json mode (the process exits before the message could arrive); a `/new`, `/resume`, `/fork`, or `/reload` kills in-flight runs and their results are lost; at most 4 runs at once; failure wakes (timeout, crash) carry up to 400 chars of peer output as the reason, also banner-marked.
|
|
141
|
+
|
|
138
142
|
## License
|
|
139
143
|
|
|
140
144
|
MIT
|
package/extensions/index.ts
CHANGED
|
@@ -45,6 +45,13 @@ import {
|
|
|
45
45
|
import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
|
|
46
46
|
import { contentText } from "@earendil-works/pi-ai";
|
|
47
47
|
import { Type } from "typebox";
|
|
48
|
+
import {
|
|
49
|
+
BackgroundRunRegistry,
|
|
50
|
+
backgroundFlagText,
|
|
51
|
+
createStopHandler,
|
|
52
|
+
createWakeSender,
|
|
53
|
+
summarizePrompt,
|
|
54
|
+
} from "@estebanforge/pi-ask-shared";
|
|
48
55
|
|
|
49
56
|
// --- Constants -------------------------------------------------------------
|
|
50
57
|
|
|
@@ -97,9 +104,11 @@ function levelToTier(level: string): ThinkingTier {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
// Mode = which agy tool-loop policy to apply. Distinct from the alias layer.
|
|
100
|
-
// "plan" → --mode plan (
|
|
101
|
-
//
|
|
102
|
-
//
|
|
107
|
+
// "plan" → --mode plan (review-shaped; NOT a security boundary:
|
|
108
|
+
// the CLI does not gate writes under
|
|
109
|
+
// plan, upstream #1181. The skip-
|
|
110
|
+
// permissions flag is still withheld so
|
|
111
|
+
// nothing runs auto-approved.)
|
|
103
112
|
// "accept-edits" → --mode accept-edits (agy applies edits)
|
|
104
113
|
//
|
|
105
114
|
// Note: agy's --sandbox flag is an orthogonal shell-containment setting
|
|
@@ -169,7 +178,7 @@ TWO MODES (you choose):
|
|
|
169
178
|
- **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact — use for follow-ups, multi-turn refinement, or when the user says "ask agy to follow up / continue / now do X based on what you just did". Thread the id from each result into the next call.
|
|
170
179
|
|
|
171
180
|
EXECUTION MODES (param: mode):
|
|
172
|
-
- **plan**: agy
|
|
181
|
+
- **plan**: agy proposes a plan for review-shaped tasks. NOT a security boundary: the CLI does not gate writes under plan mode (upstream google-antigravity/antigravity-cli#1181, probed 2026-10-07: the write can execute in the same turn). The run additionally stages a temporary restricted agent whose toolset has no file-editing tools, as a damper, and the skip-permissions flag is never passed. Inline the material to review - a plan run cannot fetch it.
|
|
173
182
|
- **accept-edits** (default): agy applies edits directly inside the workspace.
|
|
174
183
|
- For agy's orthogonal \`--sandbox\` shell-containment flag, set the \`AGY_EXTRA_ARGS=--sandbox\` env var.
|
|
175
184
|
|
|
@@ -314,15 +323,17 @@ export const PLAN_HEADLESS_GUARD = [
|
|
|
314
323
|
].join("\n");
|
|
315
324
|
|
|
316
325
|
/** Guard for plan runs backed by the restricted reviewer agent. Difference
|
|
317
|
-
* to PLAN_HEADLESS_GUARD: commands are the analysis
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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. */
|
|
321
332
|
export const AGENT_REVIEW_GUARD = [
|
|
322
333
|
"",
|
|
323
334
|
"--- Review constraints ---",
|
|
324
335
|
"- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
|
|
325
|
-
"-
|
|
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.",
|
|
326
337
|
"- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
|
|
327
338
|
].join("\n");
|
|
328
339
|
|
|
@@ -335,11 +346,12 @@ export function buildFinalPrompt(
|
|
|
335
346
|
mode: Mode,
|
|
336
347
|
digest: boolean,
|
|
337
348
|
/** True when the restricted reviewer agent is staged: its empty edit
|
|
338
|
-
* toolset is
|
|
339
|
-
|
|
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,
|
|
340
352
|
): string {
|
|
341
353
|
let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
|
|
342
|
-
if (mode === "plan") out +=
|
|
354
|
+
if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
|
|
343
355
|
return out;
|
|
344
356
|
}
|
|
345
357
|
|
|
@@ -354,12 +366,12 @@ export function askAgentsRoot(): string {
|
|
|
354
366
|
return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
|
|
355
367
|
}
|
|
356
368
|
|
|
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
|
|
369
|
+
/** agent.md for the plan-mode reviewer. The tools list is a damper, not
|
|
370
|
+
* enforcement: with no file-editing tool present the model cannot emit an
|
|
371
|
+
* edit call through the agent toolset, but the CLI does not enforce
|
|
372
|
+
* review-only (upstream #1181 - writes bypass the permission system and
|
|
373
|
+
* the toolset is not honored reliably on 1.3.x). Prefer view_file for
|
|
374
|
+
* reads here too. Never add a write-capable tool. */
|
|
363
375
|
export function reviewerAgentMd(name: string): string {
|
|
364
376
|
return [
|
|
365
377
|
"---",
|
|
@@ -968,6 +980,276 @@ interface AgyDetails {
|
|
|
968
980
|
stderr: string;
|
|
969
981
|
}
|
|
970
982
|
|
|
983
|
+
|
|
984
|
+
// --- Background-mode plumbing ----------------------------------------------
|
|
985
|
+
// Shared shape for both call styles: blocking awaits it, background lets it
|
|
986
|
+
// run detached and pushes the outcome through the wake sender on close.
|
|
987
|
+
|
|
988
|
+
const STDOUT_BUF_MAX_CHARS = 1_000_000;
|
|
989
|
+
|
|
990
|
+
/** Spawn failure (binary vanished between resolve and spawn). */
|
|
991
|
+
class AgySpawnError extends Error {}
|
|
992
|
+
|
|
993
|
+
interface ProcessRunOptions {
|
|
994
|
+
binary: string;
|
|
995
|
+
args: string[];
|
|
996
|
+
workdir: string;
|
|
997
|
+
timeoutMin: number;
|
|
998
|
+
startAt: number;
|
|
999
|
+
/** Mutated in place: conversationId discovery, stderr, durationMs. */
|
|
1000
|
+
details: AgyDetails;
|
|
1001
|
+
isContinuation: boolean;
|
|
1002
|
+
snapshot: ReturnType<typeof snapshotConversations> | null;
|
|
1003
|
+
/** Blocking passes pi's per-run signal; background passes none on purpose: a later Esc must not kill a detached run. */
|
|
1004
|
+
signal?: AbortSignal;
|
|
1005
|
+
/** Blocking only: throttled progress partials. */
|
|
1006
|
+
onPartial?: (text: string) => void;
|
|
1007
|
+
/** Background only: hands over the tree-kill switch as soon as the child exists. */
|
|
1008
|
+
onSpawn?: (kill: () => void) => void;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
interface ProcessRunOutcome {
|
|
1012
|
+
exitCode: number;
|
|
1013
|
+
aborted: boolean;
|
|
1014
|
+
timedOut: boolean;
|
|
1015
|
+
/** Full stdout, trimmed. Empty means the headless auto-deny failure mode. */
|
|
1016
|
+
answerText: string;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Tool-result shape this extension returns; keeps the background helper's literals narrow. */
|
|
1020
|
+
interface AskToolResult {
|
|
1021
|
+
content: Array<{ type: "text"; text: string }>;
|
|
1022
|
+
details: AgyDetails;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Spawns agy -p, accumulates stdout, runs conversation-id discovery (during
|
|
1027
|
+
* the run via the /proc FD resolver, then the post-exit scan fallback), and
|
|
1028
|
+
* resolves when the process tree closes.
|
|
1029
|
+
*/
|
|
1030
|
+
async function runAgyProcess(opts: ProcessRunOptions): Promise<ProcessRunOutcome> {
|
|
1031
|
+
const { binary, args, workdir, timeoutMin, startAt, details, isContinuation, snapshot, signal, onPartial, onSpawn } = opts;
|
|
1032
|
+
let out = "";
|
|
1033
|
+
|
|
1034
|
+
const statusInterval = onPartial
|
|
1035
|
+
? setInterval(() => {
|
|
1036
|
+
const elapsed = Math.floor((Date.now() - startAt) / 1000);
|
|
1037
|
+
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
1038
|
+
const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
|
|
1039
|
+
onPartial(text);
|
|
1040
|
+
}, STATUS_INTERVAL_MS)
|
|
1041
|
+
: null;
|
|
1042
|
+
|
|
1043
|
+
try {
|
|
1044
|
+
// Bind the conversation id DURING the run (agy is alive then) so the
|
|
1045
|
+
// pid-based /proc FD resolver can disambiguate when a concurrent agy
|
|
1046
|
+
// also drops a new .db. Awaited after the run; the post-exit loop
|
|
1047
|
+
// below is the fallback for runs that exit before the poll binds.
|
|
1048
|
+
let bindDuringRun: Promise<void> = Promise.resolve();
|
|
1049
|
+
const outcome = await new Promise<{
|
|
1050
|
+
exitCode: number;
|
|
1051
|
+
aborted: boolean;
|
|
1052
|
+
timedOut: boolean;
|
|
1053
|
+
}>((resolveP, rejectP) => {
|
|
1054
|
+
// detached: true so we can signal the whole process group.
|
|
1055
|
+
// agy spawns its own exec subprocesses in -p mode; a direct
|
|
1056
|
+
// kill would orphan those grandchildren.
|
|
1057
|
+
const proc = spawn(binary, args, {
|
|
1058
|
+
cwd: workdir,
|
|
1059
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1060
|
+
shell: false,
|
|
1061
|
+
detached: true,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// Decode at the stream level so multibyte UTF-8 split across
|
|
1065
|
+
// pipe chunks doesn't corrupt (Gemini output is non-ASCII).
|
|
1066
|
+
proc.stdout?.setEncoding("utf8");
|
|
1067
|
+
proc.stderr?.setEncoding("utf8");
|
|
1068
|
+
|
|
1069
|
+
proc.stdout?.on("data", (d: string) => {
|
|
1070
|
+
out += d;
|
|
1071
|
+
// Safety valve for pathological output; keeps the tail.
|
|
1072
|
+
if (out.length > STDOUT_BUF_MAX_CHARS) out = out.slice(-100_000);
|
|
1073
|
+
});
|
|
1074
|
+
proc.stderr?.on("data", (d: string) => {
|
|
1075
|
+
// Cap: stderr feeds notes and wake messages, not forensics.
|
|
1076
|
+
if (details.stderr.length < 64_000) details.stderr += d;
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// Concurrent bind: poll for the new id while agy is alive. The FD
|
|
1080
|
+
// resolver needs a live process tree, so this stops (and the
|
|
1081
|
+
// post-exit fallback below takes over) once agy has exited.
|
|
1082
|
+
if (!isContinuation && snapshot && proc.pid) {
|
|
1083
|
+
bindDuringRun = (async () => {
|
|
1084
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
1085
|
+
if (details.conversationId) return;
|
|
1086
|
+
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
1087
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
1088
|
+
pid: proc.pid,
|
|
1089
|
+
});
|
|
1090
|
+
if (found) {
|
|
1091
|
+
details.conversationId = found;
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
1095
|
+
}
|
|
1096
|
+
})().catch(() => {
|
|
1097
|
+
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1102
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
1103
|
+
let settled = false;
|
|
1104
|
+
let timedOut = false;
|
|
1105
|
+
|
|
1106
|
+
// Kill the whole process group; SIGTERM first, then SIGKILL
|
|
1107
|
+
// after a grace period if it hasn't exited.
|
|
1108
|
+
const killTree = () => {
|
|
1109
|
+
try {
|
|
1110
|
+
if (proc.pid) process.kill(-proc.pid, "SIGTERM");
|
|
1111
|
+
} catch {}
|
|
1112
|
+
// Only arm the SIGKILL timer once.
|
|
1113
|
+
if (!sigkillTimer) {
|
|
1114
|
+
sigkillTimer = setTimeout(() => {
|
|
1115
|
+
try {
|
|
1116
|
+
if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
1117
|
+
} catch {}
|
|
1118
|
+
}, GRACE_AFTER_TIMEOUT_MS);
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// Hand the kill switch to the background registry before any terminal event can fire.
|
|
1123
|
+
onSpawn?.(killTree);
|
|
1124
|
+
|
|
1125
|
+
const cleanup = () => {
|
|
1126
|
+
if (watchdog) clearTimeout(watchdog);
|
|
1127
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
1128
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
const onAbort = () => killTree();
|
|
1132
|
+
|
|
1133
|
+
// Enforce the timeout cap ourselves (not just via
|
|
1134
|
+
// --print-timeout, which agy could ignore or not support).
|
|
1135
|
+
watchdog = setTimeout(() => {
|
|
1136
|
+
timedOut = true;
|
|
1137
|
+
killTree();
|
|
1138
|
+
}, timeoutMin * 60_000);
|
|
1139
|
+
|
|
1140
|
+
if (signal) {
|
|
1141
|
+
if (signal.aborted) killTree();
|
|
1142
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const finish = (code: number | null) => {
|
|
1146
|
+
if (settled) return;
|
|
1147
|
+
settled = true;
|
|
1148
|
+
cleanup();
|
|
1149
|
+
// Distinguish an aborted run from a normal close: if the
|
|
1150
|
+
// abort signal fired, treat as cancelled regardless of
|
|
1151
|
+
// exit code (killTree produces a non-zero code, but be
|
|
1152
|
+
// explicit and order-independent). Likewise surface a
|
|
1153
|
+
// timeout distinctly from a genuine non-zero exit.
|
|
1154
|
+
resolveP({
|
|
1155
|
+
exitCode: code ?? 0,
|
|
1156
|
+
aborted: !!signal?.aborted,
|
|
1157
|
+
timedOut,
|
|
1158
|
+
});
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
proc.on("error", () => {
|
|
1162
|
+
cleanup();
|
|
1163
|
+
rejectP(new AgySpawnError("failed to spawn agy"));
|
|
1164
|
+
});
|
|
1165
|
+
proc.on("close", finish);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
1169
|
+
|
|
1170
|
+
// Let the during-run bind poll finish (it bails immediately once agy
|
|
1171
|
+
// has exited, so this rarely blocks).
|
|
1172
|
+
await bindDuringRun;
|
|
1173
|
+
|
|
1174
|
+
details.exitCode = outcome.exitCode;
|
|
1175
|
+
details.aborted = outcome.aborted;
|
|
1176
|
+
details.timedOut = outcome.timedOut;
|
|
1177
|
+
details.durationMs = Date.now() - startAt;
|
|
1178
|
+
|
|
1179
|
+
// For a fresh run, discover the conversation id agy just created
|
|
1180
|
+
// (agy -p never prints it). Retry briefly since agy may flush its
|
|
1181
|
+
// SQLite DB a moment after the process closes. A continuation run
|
|
1182
|
+
// reuses the provided id (already set on details).
|
|
1183
|
+
if (!isContinuation && !details.conversationId && snapshot) {
|
|
1184
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
1185
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
1186
|
+
if (found) {
|
|
1187
|
+
details.conversationId = found;
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return { ...outcome, answerText: out.trim() };
|
|
1195
|
+
} finally {
|
|
1196
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Builds the user/model-facing answer text for every terminal outcome.
|
|
1202
|
+
* Pure except the empty-output marker it stamps on details.
|
|
1203
|
+
*/
|
|
1204
|
+
function shapeFinalText(details: AgyDetails, outcome: ProcessRunOutcome, timeoutMin: number): string {
|
|
1205
|
+
const text = outcome.answerText;
|
|
1206
|
+
|
|
1207
|
+
// Aborted: a distinct result so the caller knows it was cancelled, not a silent success.
|
|
1208
|
+
if (outcome.aborted) {
|
|
1209
|
+
return text ? `agy was aborted. Partial output:\n\n${text}` : "agy was aborted before producing output.";
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Timeout: distinct from a genuine non-zero exit (the watchdog
|
|
1213
|
+
// killed the tree because the configured cap elapsed).
|
|
1214
|
+
if (outcome.timedOut) {
|
|
1215
|
+
const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
|
|
1216
|
+
return text ? `${text}\n\n[${note}]` : note;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Non-zero exit: surface the failure even when partial text exists.
|
|
1220
|
+
if (outcome.exitCode !== 0) {
|
|
1221
|
+
const note = details.stderr.trim()
|
|
1222
|
+
? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
|
|
1223
|
+
: `agy exited with status ${outcome.exitCode}`;
|
|
1224
|
+
return text ? `${text}\n\n[${note}]` : note;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Exit 0 with nothing on stdout is still a failure for the
|
|
1228
|
+
// caller: headless agy auto-denies a permission-gated tool call
|
|
1229
|
+
// (e.g. the command gate in plan mode), prints the reason only to
|
|
1230
|
+
// stderr, and ends cleanly. Falling through to the success path
|
|
1231
|
+
// here returned just the conversation footer, which read as an
|
|
1232
|
+
// empty success (silent-failure bug found 2026-09-25).
|
|
1233
|
+
if (!text) {
|
|
1234
|
+
details.empty = true;
|
|
1235
|
+
const note = [
|
|
1236
|
+
"agy exited cleanly but produced no output.",
|
|
1237
|
+
details.stderr.trim() ? `stderr: ${details.stderr.trim()}` : null,
|
|
1238
|
+
"Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
|
|
1239
|
+
]
|
|
1240
|
+
.filter(Boolean)
|
|
1241
|
+
.join(" ");
|
|
1242
|
+
return note;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Success. Conversation footer lets the orchestrating model thread the id.
|
|
1246
|
+
const footer = details.conversationId
|
|
1247
|
+
? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
|
|
1248
|
+
: "";
|
|
1249
|
+
|
|
1250
|
+
return text + footer;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
971
1253
|
export default async function (pi: ExtensionAPI) {
|
|
972
1254
|
// If pi-antigravity-bridge is installed, it owns the AskAntigravity tool
|
|
973
1255
|
// (and the provider). Stay silent and register nothing to avoid a
|
|
@@ -991,6 +1273,43 @@ export default async function (pi: ExtensionAPI) {
|
|
|
991
1273
|
// aliases resolve even when agy doesn't surface them in the live catalog.
|
|
992
1274
|
const discovered = mergeCatalog(await discoverModels(binary).catch(() => []));
|
|
993
1275
|
|
|
1276
|
+
// --- Background-run state (one set per extension load) -------------------
|
|
1277
|
+
// Pi wipes module state on /new, /resume, /fork and /reload: an in-flight
|
|
1278
|
+
// background run is killed and its result is never delivered. Accepted
|
|
1279
|
+
// trade-off, documented in the README.
|
|
1280
|
+
const registry = new BackgroundRunRegistry({ toolName: "ask-antigravity" });
|
|
1281
|
+
const kills = new Map<string, () => void>();
|
|
1282
|
+
// Staged-artifact cleanup (contextFile + reviewer agent dir), keyed by run:
|
|
1283
|
+
// the session_shutdown backstop runs these when a hard exit would skip the
|
|
1284
|
+
// close-path finally.
|
|
1285
|
+
const cleanups = new Map<string, () => void>();
|
|
1286
|
+
// One background run per conversationId at a time: two runs resuming the
|
|
1287
|
+
// same conversation interleave their turns inside agy's SQLite state.
|
|
1288
|
+
const busyHandles = new Set<string>();
|
|
1289
|
+
const sendWake = createWakeSender(pi, {
|
|
1290
|
+
customType: "ask-antigravity-result",
|
|
1291
|
+
isDisposed: () => registry.isDisposed(),
|
|
1292
|
+
});
|
|
1293
|
+
const stopBackground = createStopHandler(registry, {
|
|
1294
|
+
kill: (run) => kills.get(run.runId)?.(),
|
|
1295
|
+
onStopped: (run) =>
|
|
1296
|
+
sendWake({
|
|
1297
|
+
toolLabel: "agy",
|
|
1298
|
+
runId: run.runId,
|
|
1299
|
+
ok: false,
|
|
1300
|
+
elapsedS: Math.round((Date.now() - run.startedAt) / 1000),
|
|
1301
|
+
error: "stopped via /agy-stop",
|
|
1302
|
+
}),
|
|
1303
|
+
});
|
|
1304
|
+
pi.on("session_shutdown", () => {
|
|
1305
|
+
// Latch first so close-handler wakes stay silent, then kill children,
|
|
1306
|
+
// then sweep staged artifacts (hard exit would skip the close-path cleanup).
|
|
1307
|
+
for (const run of registry.dispose()) kills.get(run.runId)?.();
|
|
1308
|
+
for (const fn of cleanups.values()) fn();
|
|
1309
|
+
cleanups.clear();
|
|
1310
|
+
kills.clear();
|
|
1311
|
+
});
|
|
1312
|
+
|
|
994
1313
|
// --- /agy: view / change default model + thinking ---------------------
|
|
995
1314
|
|
|
996
1315
|
// Friendly model options offered in the picker. Exact strings also work
|
|
@@ -1161,7 +1480,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1161
1480
|
],
|
|
1162
1481
|
{
|
|
1163
1482
|
description:
|
|
1164
|
-
"agy execution mode. 'plan' = review-only (
|
|
1483
|
+
"agy execution mode. 'plan' = plan-shaped run: the CLI does not enforce review-only (see the tool description); the staged reviewer agent's toolset omits file-editing tools as a damper, and the skip-permissions flag is never passed. 'accept-edits' = agy applies edits directly (--mode accept-edits, default). For agy's orthogonal --sandbox shell-containment flag, set the AGY_EXTRA_ARGS env var.",
|
|
1165
1484
|
default: "accept-edits",
|
|
1166
1485
|
},
|
|
1167
1486
|
),
|
|
@@ -1189,6 +1508,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1189
1508
|
"When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
|
|
1190
1509
|
}),
|
|
1191
1510
|
),
|
|
1511
|
+
background: Type.Optional(
|
|
1512
|
+
Type.Boolean({
|
|
1513
|
+
description: backgroundFlagText("conversationId"),
|
|
1514
|
+
default: false,
|
|
1515
|
+
}),
|
|
1516
|
+
),
|
|
1192
1517
|
}),
|
|
1193
1518
|
renderCall(args, theme, _context) {
|
|
1194
1519
|
// Show RESOLVED model/thinking/mode (config defaults applied) so the
|
|
@@ -1216,6 +1541,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1216
1541
|
if (useDigest) tags.push("digest");
|
|
1217
1542
|
if (isContinue) tags.push("continue");
|
|
1218
1543
|
if (args.includeContext) tags.push("context=full");
|
|
1544
|
+
if (args.background) tags.push("bg");
|
|
1219
1545
|
|
|
1220
1546
|
let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
|
|
1221
1547
|
text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
|
|
@@ -1370,10 +1696,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1370
1696
|
|
|
1371
1697
|
const mode: Mode = (params.mode as Mode | undefined) ?? "accept-edits";
|
|
1372
1698
|
// 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
|
-
//
|
|
1699
|
+
// file-editing tool, a toolset-level damper on edits (the prompt
|
|
1700
|
+
// guard alone was observed failing once - a sub-agent still edited
|
|
1701
|
+
// files; the CLI itself does not enforce review-only, upstream
|
|
1702
|
+
// #1181). Staging failure degrades to the legacy fallback: no agent,
|
|
1703
|
+
// no skip flag, the stricter command-forbidding guard.
|
|
1377
1704
|
let reviewerAgent: { name: string; dir: string } | null = null;
|
|
1378
1705
|
if (mode === "plan") {
|
|
1379
1706
|
try {
|
|
@@ -1382,7 +1709,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1382
1709
|
reviewerAgent = null;
|
|
1383
1710
|
}
|
|
1384
1711
|
}
|
|
1385
|
-
// digest default: on for plan (review-
|
|
1712
|
+
// digest default: on for plan (review-shaped contexts where full file
|
|
1386
1713
|
// contents are noise), off for accept-edits (agy applies edits and
|
|
1387
1714
|
// may need richer context for diffs).
|
|
1388
1715
|
const useDigest: boolean =
|
|
@@ -1421,12 +1748,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1421
1748
|
|
|
1422
1749
|
const args: string[] = ["--add-dir", cwd];
|
|
1423
1750
|
const extraRaw = extraArgs();
|
|
1424
|
-
// Fail-closed on
|
|
1425
|
-
//
|
|
1426
|
-
// plan
|
|
1751
|
+
// Fail-closed on every plan run: AGY_EXTRA_ARGS lands before the mode
|
|
1752
|
+
// flags, so an env-injected skip flag would re-arm exactly what the
|
|
1753
|
+
// never-flag-on-plan rule withholds (upstream #1181: an auto-approved
|
|
1754
|
+
// plan run is write-capable). The reviewer agent is a damper, not a
|
|
1755
|
+
// license for the flag.
|
|
1427
1756
|
const extra =
|
|
1428
|
-
mode === "plan"
|
|
1429
|
-
? extraRaw.filter((a) => a
|
|
1757
|
+
mode === "plan"
|
|
1758
|
+
? extraRaw.filter((a) => !a.startsWith("--dangerously-skip-permissions"))
|
|
1430
1759
|
: extraRaw;
|
|
1431
1760
|
if (extra.length) args.push(...extra);
|
|
1432
1761
|
if (resolved.model) args.push("--model", resolved.model);
|
|
@@ -1436,14 +1765,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1436
1765
|
// accept-edits auto-approves file edits but NOT shell commands, so a
|
|
1437
1766
|
// run_command would hang on an unanswerable y/n prompt in non-interactive
|
|
1438
1767
|
// -p mode. Honor the shared permissions setting (same knob as the bridge).
|
|
1439
|
-
// Plan runs
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
|
|
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") {
|
|
1447
1777
|
args.push("--dangerously-skip-permissions");
|
|
1448
1778
|
}
|
|
1449
1779
|
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
@@ -1466,261 +1796,130 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1466
1796
|
stderr: "",
|
|
1467
1797
|
};
|
|
1468
1798
|
|
|
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 = () => {
|
|
1799
|
+
// Background: hand the run to the registry and return at once. The
|
|
1800
|
+
// result travels back through pi.sendMessage (wake), not this call.
|
|
1801
|
+
if (params.background) {
|
|
1802
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
1803
|
+
if (contextFile) {
|
|
1551
1804
|
try {
|
|
1552
|
-
|
|
1805
|
+
fs.unlinkSync(contextFile);
|
|
1553
1806
|
} 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
1807
|
}
|
|
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);
|
|
1808
|
+
if (reviewerAgent) {
|
|
1809
|
+
try {
|
|
1810
|
+
fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
|
|
1811
|
+
} catch {}
|
|
1630
1812
|
}
|
|
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
1813
|
return {
|
|
1639
1814
|
content: [
|
|
1640
1815
|
{
|
|
1641
1816
|
type: "text",
|
|
1642
|
-
text:
|
|
1643
|
-
? `agy was aborted. Partial output:\n\n${text}`
|
|
1644
|
-
: "agy was aborted before producing output.",
|
|
1817
|
+
text: "background is not available in print/json mode: the process exits before the result message can arrive. Call again without background.",
|
|
1645
1818
|
},
|
|
1646
1819
|
],
|
|
1647
|
-
details,
|
|
1820
|
+
details: { ...details },
|
|
1648
1821
|
};
|
|
1649
1822
|
}
|
|
1823
|
+
return startBackgroundRun({
|
|
1824
|
+
summary: params.prompt,
|
|
1825
|
+
binary,
|
|
1826
|
+
args,
|
|
1827
|
+
workdir: cwd,
|
|
1828
|
+
timeoutMin,
|
|
1829
|
+
startAt: start,
|
|
1830
|
+
details,
|
|
1831
|
+
contextFile,
|
|
1832
|
+
reviewerAgent,
|
|
1833
|
+
modelLabel: resolved.model ?? requestedModel,
|
|
1834
|
+
isContinuation,
|
|
1835
|
+
conversationId: isContinuation ? (rawConvId as string) : undefined,
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1650
1838
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1839
|
+
// One run per conversationId at a time, blocking included: a blocking
|
|
1840
|
+
// resume and a background resume of the same conversation would
|
|
1841
|
+
// interleave turns inside agy's session state. Declared out here so
|
|
1842
|
+
// the finally can see it; set only when THIS call takes the lock.
|
|
1843
|
+
let lockAcquired = false;
|
|
1844
|
+
try {
|
|
1845
|
+
if (isContinuation && busyHandles.has(rawConvId as string)) {
|
|
1846
|
+
if (contextFile) {
|
|
1847
|
+
try {
|
|
1848
|
+
fs.unlinkSync(contextFile);
|
|
1849
|
+
} catch {}
|
|
1850
|
+
}
|
|
1851
|
+
if (reviewerAgent) {
|
|
1852
|
+
try {
|
|
1853
|
+
fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
|
|
1854
|
+
} catch {}
|
|
1855
|
+
}
|
|
1655
1856
|
return {
|
|
1656
1857
|
content: [
|
|
1657
|
-
{
|
|
1858
|
+
{
|
|
1859
|
+
type: "text",
|
|
1860
|
+
text: `conversation ${rawConvId} already has a run in flight. Wait for its result, or stop it with /agy-stop.`,
|
|
1861
|
+
},
|
|
1658
1862
|
],
|
|
1659
|
-
details,
|
|
1863
|
+
details: emptyDetails(requestedModel, resolved.model),
|
|
1660
1864
|
};
|
|
1661
1865
|
}
|
|
1866
|
+
// Track acquisition: the finally must only release a lock THIS call
|
|
1867
|
+
// took, never the one held by the run that caused a refusal.
|
|
1868
|
+
lockAcquired = isContinuation;
|
|
1869
|
+
if (isContinuation) busyHandles.add(rawConvId as string);
|
|
1870
|
+
const outcome = await runAgyProcess({
|
|
1871
|
+
binary,
|
|
1872
|
+
args,
|
|
1873
|
+
workdir: cwd,
|
|
1874
|
+
timeoutMin,
|
|
1875
|
+
startAt: start,
|
|
1876
|
+
details,
|
|
1877
|
+
isContinuation,
|
|
1878
|
+
snapshot,
|
|
1879
|
+
signal,
|
|
1880
|
+
onPartial: onUpdate
|
|
1881
|
+
? (text) => {
|
|
1882
|
+
onUpdate({
|
|
1883
|
+
content: [{ type: "text", text }],
|
|
1884
|
+
details: { ...details, durationMs: Date.now() - start },
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
: undefined,
|
|
1888
|
+
});
|
|
1662
1889
|
|
|
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
|
-
};
|
|
1890
|
+
const finalText = shapeFinalText(details, outcome, timeoutMin);
|
|
1891
|
+
|
|
1892
|
+
if (outcome.aborted || outcome.timedOut || outcome.exitCode !== 0) {
|
|
1893
|
+
return { content: [{ type: "text", text: finalText }], details };
|
|
1675
1894
|
}
|
|
1676
1895
|
|
|
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
|
-
};
|
|
1896
|
+
// Exit 0 with no output keeps its dedicated failure result
|
|
1897
|
+
// (headless auto-deny); see shapeFinalText for the note.
|
|
1898
|
+
if (!outcome.answerText) {
|
|
1899
|
+
return { content: [{ type: "text", text: finalText }], details };
|
|
1696
1900
|
}
|
|
1697
1901
|
|
|
1698
1902
|
// Success. Clear the last partial status line (claude-bridge
|
|
1699
1903
|
// idiom) so the running-tail preview doesn't linger under the final
|
|
1700
|
-
// answer
|
|
1701
|
-
// can see (and thread) the id without inspecting details.
|
|
1904
|
+
// answer.
|
|
1702
1905
|
onUpdate?.({
|
|
1703
1906
|
content: [{ type: "text", text: "" }],
|
|
1704
1907
|
details: { ...details },
|
|
1705
1908
|
});
|
|
1706
|
-
const footer = details.conversationId
|
|
1707
|
-
? `\n\n[agy conversationId: ${details.conversationId} — pass as conversationId to continue this conversation]`
|
|
1708
|
-
: "";
|
|
1709
1909
|
|
|
1710
1910
|
return {
|
|
1711
|
-
content: [{ type: "text", text:
|
|
1911
|
+
content: [{ type: "text", text: finalText }],
|
|
1712
1912
|
details,
|
|
1713
1913
|
};
|
|
1714
1914
|
} catch (err) {
|
|
1715
|
-
if (statusInterval) clearInterval(statusInterval);
|
|
1716
1915
|
details.durationMs = Date.now() - start;
|
|
1717
1916
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1718
1917
|
return {
|
|
1719
1918
|
content: [{ type: "text", text: `failed to run agy: ${msg}` }],
|
|
1720
1919
|
details,
|
|
1721
1920
|
};
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1921
|
+
} finally {
|
|
1922
|
+
if (isContinuation && lockAcquired) busyHandles.delete(rawConvId as string);
|
|
1724
1923
|
if (contextFile) {
|
|
1725
1924
|
try {
|
|
1726
1925
|
fs.unlinkSync(contextFile);
|
|
@@ -1734,6 +1933,156 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1734
1933
|
}
|
|
1735
1934
|
},
|
|
1736
1935
|
});
|
|
1936
|
+
// --- /agy-stop: kill a background run ----------------------------------
|
|
1937
|
+
|
|
1938
|
+
pi.registerCommand("agy-stop", {
|
|
1939
|
+
description: "Stop a background AskAntigravity run (id prefix, or the only running one). Usage: /agy-stop [runId]",
|
|
1940
|
+
handler: async (args, ctx) => {
|
|
1941
|
+
const message = await stopBackground(args ?? "");
|
|
1942
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
1943
|
+
},
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
const startBackgroundRun = (o: {
|
|
1947
|
+
summary: string;
|
|
1948
|
+
binary: string;
|
|
1949
|
+
args: string[];
|
|
1950
|
+
workdir: string;
|
|
1951
|
+
timeoutMin: number;
|
|
1952
|
+
startAt: number;
|
|
1953
|
+
details: AgyDetails;
|
|
1954
|
+
contextFile: string | null;
|
|
1955
|
+
reviewerAgent: { name: string; dir: string } | null;
|
|
1956
|
+
modelLabel: string;
|
|
1957
|
+
isContinuation: boolean;
|
|
1958
|
+
conversationId?: string;
|
|
1959
|
+
}): AskToolResult => {
|
|
1960
|
+
registry.sweep();
|
|
1961
|
+
// Per-handle lock: two background runs resuming the same conversation
|
|
1962
|
+
// would interleave turns inside agy's session state.
|
|
1963
|
+
if (o.conversationId && busyHandles.has(o.conversationId)) {
|
|
1964
|
+
return {
|
|
1965
|
+
content: [
|
|
1966
|
+
{
|
|
1967
|
+
type: "text",
|
|
1968
|
+
text: `background refused: conversation ${o.conversationId} already has a background run. Wait for its wake message, or use blocking mode.`,
|
|
1969
|
+
},
|
|
1970
|
+
],
|
|
1971
|
+
details: { ...o.details },
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
if (o.conversationId) busyHandles.add(o.conversationId);
|
|
1975
|
+
let run;
|
|
1976
|
+
try {
|
|
1977
|
+
run = registry.start(summarizePrompt(o.summary));
|
|
1978
|
+
} catch (err) {
|
|
1979
|
+
if (o.conversationId) busyHandles.delete(o.conversationId);
|
|
1980
|
+
if (o.contextFile) {
|
|
1981
|
+
try {
|
|
1982
|
+
fs.unlinkSync(o.contextFile);
|
|
1983
|
+
} catch {}
|
|
1984
|
+
}
|
|
1985
|
+
if (o.reviewerAgent) {
|
|
1986
|
+
try {
|
|
1987
|
+
fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
|
|
1988
|
+
} catch {}
|
|
1989
|
+
}
|
|
1990
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1991
|
+
return {
|
|
1992
|
+
content: [
|
|
1993
|
+
{
|
|
1994
|
+
type: "text",
|
|
1995
|
+
text: `background refused: ${msg}. Use blocking (omit background) or free a slot with /agy-stop.`,
|
|
1996
|
+
},
|
|
1997
|
+
],
|
|
1998
|
+
details: { ...o.details },
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
const runId = run.runId;
|
|
2002
|
+
const cleanupStaging = () => {
|
|
2003
|
+
// Both staged artifacts must outlive the execute() return in
|
|
2004
|
+
// background mode: agy reads them mid-run.
|
|
2005
|
+
if (o.contextFile) {
|
|
2006
|
+
try {
|
|
2007
|
+
fs.unlinkSync(o.contextFile);
|
|
2008
|
+
} catch {}
|
|
2009
|
+
}
|
|
2010
|
+
if (o.reviewerAgent) {
|
|
2011
|
+
try {
|
|
2012
|
+
fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
|
|
2013
|
+
} catch {}
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
cleanups.set(runId, cleanupStaging);
|
|
2017
|
+
void runAgyProcess({
|
|
2018
|
+
binary: o.binary,
|
|
2019
|
+
args: o.args,
|
|
2020
|
+
workdir: o.workdir,
|
|
2021
|
+
timeoutMin: o.timeoutMin,
|
|
2022
|
+
startAt: o.startAt,
|
|
2023
|
+
details: o.details,
|
|
2024
|
+
isContinuation: o.isContinuation,
|
|
2025
|
+
snapshot: o.isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR),
|
|
2026
|
+
onSpawn: (kill) => {
|
|
2027
|
+
kills.set(runId, kill);
|
|
2028
|
+
},
|
|
2029
|
+
})
|
|
2030
|
+
.then((outcome) => {
|
|
2031
|
+
kills.delete(runId);
|
|
2032
|
+
const finalText = shapeFinalText(o.details, outcome, o.timeoutMin);
|
|
2033
|
+
const elapsedS = Math.round((Date.now() - o.startAt) / 1000);
|
|
2034
|
+
const handle = o.details.conversationId ? `conversationId=${o.details.conversationId}` : undefined;
|
|
2035
|
+
const failed = outcome.timedOut || outcome.exitCode !== 0 || !outcome.answerText;
|
|
2036
|
+
// The settle latch makes /agy-stop and shutdown win races
|
|
2037
|
+
// against this handler: a false return means their wake went out.
|
|
2038
|
+
const settled = registry.settle(runId, {
|
|
2039
|
+
status: failed ? "failed" : "done",
|
|
2040
|
+
output: finalText,
|
|
2041
|
+
handle,
|
|
2042
|
+
error: outcome.timedOut
|
|
2043
|
+
? `timeout after ${o.timeoutMin}m`
|
|
2044
|
+
: outcome.exitCode !== 0
|
|
2045
|
+
? `exit status ${outcome.exitCode}`
|
|
2046
|
+
: !outcome.answerText
|
|
2047
|
+
? "agy exited cleanly but produced no output"
|
|
2048
|
+
: undefined,
|
|
2049
|
+
});
|
|
2050
|
+
if (!settled) return;
|
|
2051
|
+
if (outcome.timedOut) {
|
|
2052
|
+
const partial = outcome.answerText ? ` Partial output: ${outcome.answerText.slice(0, 400)}` : "";
|
|
2053
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: `timeout after ${o.timeoutMin}m.${partial}`, handle });
|
|
2054
|
+
} else if (outcome.exitCode !== 0) {
|
|
2055
|
+
const reason = o.details.stderr.trim().slice(0, 400) || `exit status ${outcome.exitCode}`;
|
|
2056
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: reason, handle });
|
|
2057
|
+
} else if (!outcome.answerText) {
|
|
2058
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: "agy exited cleanly but produced no output (headless auto-deny?)", handle });
|
|
2059
|
+
} else {
|
|
2060
|
+
sendWake({ toolLabel: "agy", runId, ok: true, elapsedS, handle }, finalText);
|
|
2061
|
+
}
|
|
2062
|
+
})
|
|
2063
|
+
.catch((err) => {
|
|
2064
|
+
kills.delete(runId);
|
|
2065
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2066
|
+
// Settle first: an /agy-stop that won the race owns the wake.
|
|
2067
|
+
const settled = registry.settle(runId, { status: "failed", error: msg });
|
|
2068
|
+
if (!settled) return;
|
|
2069
|
+
sendWake({ toolLabel: "agy", runId, ok: false, error: `failed to run agy: ${msg}` });
|
|
2070
|
+
})
|
|
2071
|
+
.finally(() => {
|
|
2072
|
+
cleanups.delete(runId);
|
|
2073
|
+
cleanupStaging();
|
|
2074
|
+
if (o.conversationId) busyHandles.delete(o.conversationId);
|
|
2075
|
+
});
|
|
2076
|
+
return {
|
|
2077
|
+
content: [
|
|
2078
|
+
{
|
|
2079
|
+
type: "text",
|
|
2080
|
+
text: `Background run ${runId} started (model=${o.modelLabel}). The result arrives as a message when agy finishes; the resume handle (conversationId) comes with it when agy reports one. Do not poll. Continue with other work or end the turn.`,
|
|
2081
|
+
},
|
|
2082
|
+
],
|
|
2083
|
+
details: { ...o.details },
|
|
2084
|
+
};
|
|
2085
|
+
};
|
|
1737
2086
|
}
|
|
1738
2087
|
|
|
1739
2088
|
function emptyDetails(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-ask-antigravity",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.11",
|
|
4
4
|
"description": "Pi tool that delegates a self-contained sub-task to Google Antigravity's agy CLI (the CLI for Gemini). Friendly model aliases (flash/pro/gemini), configurable default model + thinking, and the AskAntigravity delegation tool.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"type": "module",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@estebanforge/pi-ask-shared": "^0.1.0"
|
|
32
|
+
},
|
|
30
33
|
"files": [
|
|
31
34
|
"extensions",
|
|
32
35
|
"README.md",
|