@estebanforge/pi-ask-antigravity 1.2.7 → 1.2.9

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,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.9] - 2026-09-28
6
+
7
+ ### Fixed
8
+
9
+ - **Plan-mode delegations enforce read-only instead of promising it.** Port of the bridge fix one commit earlier in the stack: the prompt-only guard failed in the field (a plan delegation behind an env-injected skip flag still edited files), so plan-mode runs now stage a temporary agy agent — `tools:` limited to `view_file` and `run_command`, `commandExecutionPolicy: auto`, `inheritMcp: false` — under the shared `~/.gemini/config/agents` discovery root and pass `--agent`. No file-editing tool in the toolset means no edit call is possible, with or without `--dangerously-skip-permissions` (probed on agy 1.2.12); the policy is what lets analysis commands run headless for users with no allow rules. With the agent staged, the skip flag returns under the shared `skipPermissions` knob; staging failure falls back fail-closed to no agent, no flag, and the strict all-commands-forbidden guard, with an env-injected skip flag stripped from `AGY_EXTRA_ARGS` on that path. Includes the standalone sweep for orphaned agent dirs (pid-marker doctrine), duplicated rather than extracted: the standalone stays self-contained per repo convention. Tool descriptions state the residual honestly: file-editing tools are hard-blocked, arbitrary shell execution under the flag is prompt-guarded alone.
10
+ - **Headless plan prompts say what they cannot do.** Same hardening as the bridge: plan prompts append an explicit no-commands constraint and a state-what-is-missing instruction, converting the soft-denied empty-output death of a shell-command attempt into a visible missing-content request. On the enforced path the guard yields to the mutation-forbidding variant; the command-forbidding rule remains on the fallback path.
11
+
12
+ ## [1.2.8] - 2026-09-25
13
+
14
+ ### Fixed
15
+
16
+ - **Exit 0 with no output is a failure, not a silent success.** Same bug as the bridge fix: a headless `agy -p` run auto-denies a permission-gated tool call (the command gate in plan mode bites hardest since plan runs never receive the skip flag), prints the reason only to stderr, exits 0, and the execute path fell through to success, returning just the conversation footer. An empty answer now returns a loud failure note carrying the stderr plus the remedy (allow-list the command under `permissions.allow`, or rerun outside plan mode), and a new `details.empty` marker flips the TUI result row to its error glyph instead of a green checkmark. Three new tests pin both stderr shapes and the glyph flip through the fake-binary stub; HOME is pointed at an empty tmpdir during them so the bridge-deferral scan finds nothing and the factory registers.
17
+
5
18
  ## [1.2.7] - 2026-09-25
6
19
 
7
20
  ### Fixed
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import { spawn } from "node:child_process";
32
+ import { randomBytes } from "node:crypto";
32
33
  import * as fs from "node:fs";
33
34
  import * as os from "node:os";
34
35
  import * as path from "node:path";
@@ -156,7 +157,7 @@ TWO MODES (you choose):
156
157
  - **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.
157
158
 
158
159
  EXECUTION MODES (param: mode):
159
- - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs never receive the skip-permissions flag, so a write or command attempt ends the run quickly with a "confirm plan" message instead of executing.
160
+ - **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.
160
161
  - **accept-edits** (default): agy applies edits directly inside the workspace.
161
162
  - For agy's orthogonal \`--sandbox\` shell-containment flag, set the \`AGY_EXTRA_ARGS=--sandbox\` env var.
162
163
 
@@ -282,6 +283,150 @@ function saveConfig(patch: Partial<Config>): SaveResult {
282
283
  return { path: targetPath, routedToProject: projectShadows };
283
284
  }
284
285
 
286
+ // --- Prompt assembly -------------------------------------------------------
287
+
288
+ /** Appended to every headless plan-mode prompt. `agy -p` cannot answer
289
+ * permission prompts: in plan mode a run_command attempt is soft-denied and
290
+ * the turn ends AT the denial (exit 0, empty stdout, notice only on stderr -
291
+ * the empty-output branch in execute). Probed 2026-09-28 on agy 1.2.12:
292
+ * allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
293
+ * --sandbox does NOT relax the command gate, so the only input every user
294
+ * is guaranteed is the prompt itself. The guard steers the model to answer
295
+ * from that material. accept-edits runs keep their tools and never get it. */
296
+ export const PLAN_HEADLESS_GUARD = [
297
+ "",
298
+ "--- Headless session constraints ---",
299
+ "- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
300
+ "- Work only from the material provided in this prompt.",
301
+ "- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
302
+ ].join("\n");
303
+
304
+ /** Guard for plan runs backed by the restricted reviewer agent. Difference
305
+ * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
306
+ * agent's commandExecutionPolicy auto-approves them headless, probed
307
+ * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
308
+ * and keeps the answer-from-material discipline. */
309
+ export const AGENT_REVIEW_GUARD = [
310
+ "",
311
+ "--- Review constraints ---",
312
+ "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
313
+ "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
314
+ "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
315
+ ].join("\n");
316
+
317
+ /** Assemble the prompt sent to agy: digest marker first (existing behavior),
318
+ * then the caller's prompt, then the plan-mode guard last - the position
319
+ * the model reads with the most recency. No user-config mutation: this is
320
+ * the only plan-mode lever the tool itself owns. */
321
+ export function buildFinalPrompt(
322
+ prompt: string,
323
+ mode: Mode,
324
+ digest: boolean,
325
+ /** True when the restricted reviewer agent is staged: its empty edit
326
+ * toolset is the hard block, so the prompt may ALLOW read-only commands. */
327
+ agentEnforced = false,
328
+ ): string {
329
+ let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
330
+ if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
331
+ return out;
332
+ }
333
+
334
+ // --- Plan-mode reviewer agent ----------------------------------------------
335
+
336
+ const ASK_AGENT_PREFIX = "pi-bridge-ask-";
337
+ const ASK_AGENT_TOOLS = ["view_file", "run_command"];
338
+
339
+ /** Temp agent dirs share the ~/.gemini/config/agents discovery root; the
340
+ * AGY_AGENTS_ROOT override exists for tests and sandboxes. */
341
+ export function askAgentsRoot(): string {
342
+ return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
343
+ }
344
+
345
+ /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
346
+ * with no file-editing tool present the model cannot emit an edit call at
347
+ * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
348
+ * edits, with and without the skip flag). commandExecutionPolicy auto is
349
+ * what lets read commands run headless with no user allow rules. Never add
350
+ * a write-capable tool here. */
351
+ export function reviewerAgentMd(name: string): string {
352
+ return [
353
+ "---",
354
+ `name: ${name}`,
355
+ "description: Temporary Pi plan-mode reviewer",
356
+ "mainAgent: true",
357
+ "subagent: false",
358
+ "model: inherit",
359
+ "excludeDefaultComponents: true",
360
+ "inheritCustomizations: false",
361
+ "inheritMcp: false",
362
+ "commandExecutionPolicy: auto",
363
+ "tools:",
364
+ ...ASK_AGENT_TOOLS.map((t) => ` - ${t}`),
365
+ "skills: []",
366
+ "rules: []",
367
+ "agents: []",
368
+ "mcpServers: []",
369
+ "---",
370
+ "",
371
+ "You are a strict read-only code reviewer. Review, analyze, and plan; never modify anything.",
372
+ "",
373
+ ].join("\n");
374
+ }
375
+
376
+ /** Stage one unique reviewer agent (agent.md + pid marker) under root. Same
377
+ * hygiene doctrine as the web delegates: nonce-named, cleaned in finally,
378
+ * orphans swept by pid marker at registration. */
379
+ export function stageReviewerAgent(root: string): { name: string; dir: string } {
380
+ fs.mkdirSync(root, { recursive: true });
381
+ const name = `${ASK_AGENT_PREFIX}${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
382
+ const dir = path.join(root, name);
383
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
384
+ fs.writeFileSync(path.join(dir, "agent.md"), reviewerAgentMd(name), { mode: 0o600 });
385
+ fs.writeFileSync(path.join(dir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
386
+ return { name, dir };
387
+ }
388
+
389
+ /** Remove leftover reviewer agent dirs: dead-pid markers always, marker-less
390
+ * dirs only after the grace period (a live sibling may sit between mkdir and
391
+ * its pid write). Never touches foreign agent dirs. */
392
+ export function sweepStaleAskAgents(
393
+ root: string = askAgentsRoot(),
394
+ now = Date.now(),
395
+ prefix: string = ASK_AGENT_PREFIX,
396
+ ): void {
397
+ let entries: string[];
398
+ try {
399
+ entries = fs.readdirSync(root);
400
+ } catch {
401
+ return; // no agents dir yet: nothing to sweep
402
+ }
403
+ for (const entry of entries) {
404
+ if (!entry.startsWith(prefix)) continue;
405
+ const dir = path.join(root, entry);
406
+ try {
407
+ let stale: boolean;
408
+ try {
409
+ const pid = Number.parseInt(fs.readFileSync(path.join(dir, ".pid"), "utf8").trim(), 10);
410
+ stale = Number.isInteger(pid) && pid > 0 ? !pidAlive(pid) : now - fs.statSync(dir).mtimeMs > 24 * 60 * 60 * 1000;
411
+ } catch {
412
+ stale = now - fs.statSync(dir).mtimeMs > 24 * 60 * 60 * 1000;
413
+ }
414
+ if (stale) fs.rmSync(dir, { recursive: true, force: true });
415
+ } catch {
416
+ // vanished mid-sweep: nothing to remove
417
+ }
418
+ }
419
+ }
420
+
421
+ function pidAlive(pid: number): boolean {
422
+ try {
423
+ process.kill(pid, 0);
424
+ return true;
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
285
430
  // --- Model parsing + alias resolution --------------------------------------
286
431
 
287
432
  /** Parse one `agy models` line into a structured entry. */
@@ -762,6 +907,9 @@ interface AgyDetails {
762
907
  exitCode: number;
763
908
  aborted: boolean;
764
909
  timedOut: boolean;
910
+ // Exit 0 with no answer on stdout (the empty-output failure branch).
911
+ // renderResult flips to the error glyph on it.
912
+ empty?: boolean;
765
913
  durationMs: number;
766
914
  stderr: string;
767
915
  }
@@ -773,6 +921,14 @@ export default async function (pi: ExtensionAPI) {
773
921
  // before (standalone tool).
774
922
  if (isBridgeInstalled()) return;
775
923
 
924
+ // Orphan sweep for the plan-mode reviewer agents (SIGKILL can skip the
925
+ // run's finally): same pid-marker doctrine as the bridge's web delegates.
926
+ try {
927
+ sweepStaleAskAgents();
928
+ } catch {
929
+ // a sweep failure must never block registration
930
+ }
931
+
776
932
  const binary = resolveAgy();
777
933
  // Discovered once at load; frozen for the session. Run /reload after an
778
934
  // `agy update` to refresh. Failure is non-fatal: resolveModel falls back
@@ -951,7 +1107,7 @@ export default async function (pi: ExtensionAPI) {
951
1107
  ],
952
1108
  {
953
1109
  description:
954
- "agy execution mode. 'plan' = review-only, no edits (--mode plan; the skip-permissions flag is never passed, so write and command attempts fail visibly at plan approval). '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.",
1110
+ "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.",
955
1111
  default: "accept-edits",
956
1112
  },
957
1113
  ),
@@ -1027,7 +1183,8 @@ export default async function (pi: ExtensionAPI) {
1027
1183
  }
1028
1184
 
1029
1185
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
1030
- const errored = d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut;
1186
+ const errored =
1187
+ d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
1031
1188
 
1032
1189
  let text = errored
1033
1190
  ? theme.fg("error", "✗ AskAntigravity error")
@@ -1158,6 +1315,19 @@ export default async function (pi: ExtensionAPI) {
1158
1315
  const snapshot = isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR);
1159
1316
 
1160
1317
  const mode: Mode = (params.mode as Mode | undefined) ?? "accept-edits";
1318
+ // Plan runs stage a restricted reviewer agent: its tools list has NO
1319
+ // file-editing tool, which is the hard edit block (the prompt guard
1320
+ // alone was observed failing once - a sub-agent still edited files).
1321
+ // Staging failure degrades to the legacy fallback: no agent, no skip
1322
+ // flag, the stricter command-forbidding guard.
1323
+ let reviewerAgent: { name: string; dir: string } | null = null;
1324
+ if (mode === "plan") {
1325
+ try {
1326
+ reviewerAgent = stageReviewerAgent(askAgentsRoot());
1327
+ } catch {
1328
+ reviewerAgent = null;
1329
+ }
1330
+ }
1161
1331
  // digest default: on for plan (review-only contexts where full file
1162
1332
  // contents are noise), off for accept-edits (agy applies edits and
1163
1333
  // may need richer context for diffs).
@@ -1165,9 +1335,12 @@ export default async function (pi: ExtensionAPI) {
1165
1335
  typeof params.digest === "boolean"
1166
1336
  ? params.digest
1167
1337
  : mode === "plan";
1168
- const finalPrompt: string = useDigest
1169
- ? `(Use compact digests, not full file contents.)\n${params.prompt}`
1170
- : params.prompt;
1338
+ const finalPrompt: string = buildFinalPrompt(
1339
+ params.prompt,
1340
+ mode,
1341
+ useDigest,
1342
+ reviewerAgent !== null,
1343
+ );
1171
1344
 
1172
1345
  // Opt-in full-context export (isolated stays the default).
1173
1346
  let contextFile: string | null = null;
@@ -1193,22 +1366,30 @@ export default async function (pi: ExtensionAPI) {
1193
1366
  : finalPrompt;
1194
1367
 
1195
1368
  const args: string[] = ["--add-dir", cwd];
1196
- const extra = extraArgs();
1369
+ const extraRaw = extraArgs();
1370
+ // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
1371
+ // mode flags, so an env-injected skip flag would re-arm what the
1372
+ // plan-mode rule strips. Only the enforced agent makes the flag safe.
1373
+ const extra =
1374
+ mode === "plan" && !reviewerAgent
1375
+ ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
1376
+ : extraRaw;
1197
1377
  if (extra.length) args.push(...extra);
1198
1378
  if (resolved.model) args.push("--model", resolved.model);
1199
1379
  if (resolved.effort) args.push("--effort", resolved.effort);
1200
1380
  args.push("--mode", mode);
1381
+ if (reviewerAgent) args.push("--agent", reviewerAgent.name);
1201
1382
  // accept-edits auto-approves file edits but NOT shell commands, so a
1202
1383
  // run_command would hang on an unanswerable y/n prompt in non-interactive
1203
1384
  // -p mode. Honor the shared permissions setting (same knob as the bridge).
1204
- // Plan mode never gets the flag even when the knob is on: the flag
1205
- // auto-approves ALL permission requests including plan mode's own
1206
- // approval gate, which would silently turn "review-only" into full
1207
- // write access inside --add-dir (probed 2026-09-25: with the flag a
1208
- // plan run wrote files; without it, file and command attempts end
1209
- // exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
1210
- // never a hang). Same fix as the bridge's ask-tool and driver.
1211
- if (mode !== "plan" && config.skipPermissions) {
1385
+ // Plan runs WITH the reviewer agent: the flag is safe again because the
1386
+ // agent's toolset has no file-editing tool - the 2026-09-25 write
1387
+ // incident rode edit tools that existed then; probed 2026-09-28 the
1388
+ // restricted toolset reports "none" for edits even with the flag. The
1389
+ // flag is what lets analysis commands (builds, test runners) run for
1390
+ // users without allow rules. Plan fallback (staging failed): strict
1391
+ // legacy behavior, no flag - the prompt guard is all that is left.
1392
+ if (config.skipPermissions && (mode !== "plan" || reviewerAgent !== null)) {
1212
1393
  args.push("--dangerously-skip-permissions");
1213
1394
  }
1214
1395
  if (isContinuation) args.push("--conversation", rawConvId as string);
@@ -1439,6 +1620,27 @@ export default async function (pi: ExtensionAPI) {
1439
1620
  };
1440
1621
  }
1441
1622
 
1623
+ // Exit 0 with nothing on stdout is still a failure for the
1624
+ // caller: headless agy auto-denies a permission-gated tool call
1625
+ // (e.g. the command gate in plan mode), prints the reason only to
1626
+ // stderr, and ends cleanly. Falling through to the success path
1627
+ // here returned just the conversation footer, which read as an
1628
+ // empty success (silent-failure bug found 2026-09-25).
1629
+ if (!text) {
1630
+ details.empty = true;
1631
+ const note = [
1632
+ "agy exited cleanly but produced no output.",
1633
+ details.stderr.trim() ? `stderr: ${details.stderr.trim()}` : null,
1634
+ "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.",
1635
+ ]
1636
+ .filter(Boolean)
1637
+ .join(" ");
1638
+ return {
1639
+ content: [{ type: "text", text: note }],
1640
+ details,
1641
+ };
1642
+ }
1643
+
1442
1644
  // Success. Clear the last partial status line (claude-bridge
1443
1645
  // idiom) so the running-tail preview doesn't linger under the final
1444
1646
  // answer, then append a conversation footer so the orchestrating model
@@ -1470,6 +1672,11 @@ export default async function (pi: ExtensionAPI) {
1470
1672
  fs.unlinkSync(contextFile);
1471
1673
  } catch {}
1472
1674
  }
1675
+ if (reviewerAgent) {
1676
+ try {
1677
+ fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
1678
+ } catch {}
1679
+ }
1473
1680
  }
1474
1681
  },
1475
1682
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-ask-antigravity",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
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",