@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 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` | agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: the skip-permissions flag is never passed in plan mode, so write and command attempts fail visibly at plan approval instead of executing. |
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-only; enforced via plan approval) or `accept-edits` (agy applies edits, default). |
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
@@ -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 (no edits; review-only — the skip-
101
- // permissions flag is withheld in plan
102
- // mode so the plan-approval gate holds)
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 reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs execute in a temporary restricted agent whose toolset has NO file-editing tools (hard block); the reviewer may still execute arbitrary shell commands, kept read-only by the prompt guard alone. Inline the material to review - a plan run cannot fetch it.
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 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. */
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
- "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
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 the hard block, so the prompt may ALLOW read-only commands. */
339
- agentEnforced = false,
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 += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
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 the ENFORCEMENT:
358
- * with no file-editing tool present the model cannot emit an edit call at
359
- * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
360
- * edits, with and without the skip flag). commandExecutionPolicy auto is
361
- * what lets read commands run headless with no user allow rules. Never add
362
- * a write-capable tool here. */
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 (--mode plan in a temporary restricted agent: file-editing tools are hard-blocked; arbitrary shell commands remain prompt-guarded only; with skipPermissions on, commands still run for analysis). '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.",
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, which is the hard edit block (the prompt guard
1374
- // alone was observed failing once - a sub-agent still edited files).
1375
- // Staging failure degrades to the legacy fallback: no agent, no skip
1376
- // flag, the stricter command-forbidding guard.
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-only contexts where full file
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 the fallback path: AGY_EXTRA_ARGS lands before the
1425
- // mode flags, so an env-injected skip flag would re-arm what the
1426
- // plan-mode rule strips. Only the enforced agent makes the flag safe.
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" && !reviewerAgent
1429
- ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
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 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)) {
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
- let out = "";
1470
-
1471
- // Throttled status updates (claude-bridge pattern): emit a short
1472
- // status line on an interval instead of the full buffer on every
1473
- // stdout chunk, avoiding O(n²) re-renders on long runs.
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
- if (proc.pid) process.kill(-proc.pid, "SIGTERM");
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
- const finish = (code: number | null) => {
1585
- if (settled) return;
1586
- settled = true;
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: 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
- // Timeout: distinct from a genuine non-zero exit (the watchdog
1652
- // killed the tree because the configured cap elapsed).
1653
- if (outcome.timedOut) {
1654
- const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
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
- { type: "text", text: text ? `${text}\n\n[${note}]` : note },
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
- // Non-zero exit: surface the failure even when partial text
1664
- // exists, instead of returning silent success.
1665
- if (outcome.exitCode !== 0) {
1666
- const note = details.stderr.trim()
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 nothing on stdout is still a failure for the
1678
- // caller: headless agy auto-denies a permission-gated tool call
1679
- // (e.g. the command gate in plan mode), prints the reason only to
1680
- // stderr, and ends cleanly. Falling through to the success path
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, then append a conversation footer so the orchestrating model
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: text + footer }],
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
- finally {
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.10",
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",