@estebanforge/pi-antigravity-bridge 1.7.4 → 1.7.6

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.7.6] - 2026-09-28
6
+
7
+ ### Fixed
8
+
9
+ - **Plan-mode delegations enforce read-only instead of promising it.** The old design withheld the skip flag and relied on a prompt asking nicely; the field disagreed once — a plan delegation with the flag re-injected through the environment still edited files, because a prompt guard is a request, not a wall. Plan-mode runs now stage a temporary agy agent under `~/.gemini/config/agents/pi-bridge-ask-*` whose `tools:` list carries only `view_file` and `run_command`: with no file-editing tool in the toolset the model cannot emit an edit call at all (probed on agy 1.2.12 with and without `--dangerously-skip-permissions` — the restricted toolset reports "none" for edits both ways). The staged agent's `commandExecutionPolicy: auto` is what keeps reviews useful: analysis commands (git, rg, test runners) run headless for every user, with no `permissions.allow` edits — the per-user-config approach was rejected as unshippable for a product. With the agent staged, the skip flag returns under the same `skipPermissions` knob (default on): its only real danger was auto-approving edit calls, and none exist to approve. The prompt guard switches to a mutation-forbidding text (read-only commands allowed), and the tool description now states the true residual plainly: file-editing tools are hard-blocked, arbitrary shell execution under the flag stays prompt-guarded alone. Staging failure degrades fail-closed — no agent, no flag, the stricter all-commands-forbidden guard, with an env-injected skip flag stripped from `AGY_EXTRA_ARGS` on that path only. Agent dirs are nonce-named, pid-marked, removed in the run's finally, and orphans swept at registration (the web-delegate sweep gained a prefix parameter so both doctrines share one implementation).
10
+ - **Headless plan prompts say what they cannot do.** `agy -p` cannot answer permission prompts, and in plan mode a shell-command attempt is soft-denied: the run ends cleanly with nothing on stdout and the reason only on stderr. Plan prompts now append an explicit constraint — no shell commands, answer from the material in the prompt, state exactly what is missing — converting the silent death into a visible missing-content request (probed: the previously-fatal prompt shape now answers "Missing information: ..." in about ten seconds). With the staged reviewer agent above, this guard yields to the mutation-forbidding variant; it remains the rule on the staging-failure fallback path.
11
+
12
+ ## [1.7.5] - 2026-09-25
13
+
14
+ ### Fixed
15
+
16
+ - **Exit 0 with no output is a failure, not a silent success.** A headless `agy -p` run can end cleanly with nothing on stdout when a delegated tool call hits a permission gate the headless run cannot prompt for. The plan-mode fix (withholding the skip flag in plan) makes the command gate the common case: the call is auto-denied, the reason goes only to stderr, agy exits 0, and the execute path fell through to success, returning just the conversation footer. The delegating model read that as "returned only a conversationId, zero output, no error" (silent-failure handoff, 2026-09-25). An empty answer now returns a loud failure note carrying the redacted stderr plus the remedy (allow-list the command under `permissions.allow` in the CLI settings, or rerun outside plan mode), the ask-end log marks the run warn with an `empty` flag so error-only default logging records what the chat saw, 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.
17
+
5
18
  ## [1.7.4] - 2026-09-25
6
19
 
7
20
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask-tool.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // nothing (see its defer guard). This module is the single source of truth.
13
13
 
14
14
  import { spawn } from "node:child_process";
15
+ import { randomBytes } from "node:crypto";
15
16
  import * as fs from "node:fs";
16
17
  import * as path from "node:path";
17
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -28,6 +29,7 @@ import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
28
29
  import { redactText } from "./redact.js";
29
30
  import { acquireBridgeSuppression } from "./mcp-registration.js";
30
31
  import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
32
+ import { sweepStaleWebAgents, webAgentsRoot } from "./web-tools.js";
31
33
 
32
34
  // --- Constants -------------------------------------------------------------
33
35
 
@@ -78,7 +80,7 @@ TWO MODES (you choose):
78
80
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
79
81
 
80
82
  EXECUTION MODES (param: mode):
81
- - **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.
83
+ - **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.
82
84
  - **accept-edits** (default): agy applies edits directly inside the workspace.
83
85
 
84
86
  COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
@@ -270,6 +272,109 @@ function extraArgs(): string[] {
270
272
  return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
271
273
  }
272
274
 
275
+ // --- Prompt assembly -------------------------------------------------------
276
+
277
+ /** Guard for plan runs backed by the restricted reviewer agent. Difference
278
+ * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
279
+ * agent's commandExecutionPolicy auto-approves them headless, probed
280
+ * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
281
+ * and keeps the answer-from-material discipline. */
282
+ export const AGENT_REVIEW_GUARD = [
283
+ "",
284
+ "--- Review constraints ---",
285
+ "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
286
+ "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
287
+ "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
288
+ ].join("\n");
289
+
290
+ /** Appended to every headless plan-mode prompt. `agy -p` cannot answer
291
+ * permission prompts: in plan mode a run_command attempt is soft-denied and
292
+ * the turn ends AT the denial (exit 0, empty stdout, notice only on stderr -
293
+ * the empty-output branch in execute). Probed 2026-09-28 on agy 1.2.12:
294
+ * allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
295
+ * --sandbox does NOT relax the command gate, so the only input every user
296
+ * is guaranteed is the prompt itself. The guard steers the model to answer
297
+ * from that material. accept-edits runs keep their tools and never get it. */
298
+ export const PLAN_HEADLESS_GUARD = [
299
+ "",
300
+ "--- Headless session constraints ---",
301
+ "- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
302
+ "- Work only from the material provided in this prompt.",
303
+ "- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
304
+ ].join("\n");
305
+
306
+ /** Assemble the prompt sent to agy: digest marker first (existing behavior),
307
+ * then the caller's prompt, then the plan-mode guard last - the position
308
+ * the model reads with the most recency. No user-config mutation: this is
309
+ * the only plan-mode lever the tool itself owns. */
310
+ export function buildFinalPrompt(
311
+ prompt: string,
312
+ mode: AgyMode,
313
+ digest: boolean,
314
+ /** True when the restricted reviewer agent is staged: its empty edit
315
+ * toolset is the hard block, so the prompt may ALLOW read-only commands. */
316
+ agentEnforced = false,
317
+ ): string {
318
+ let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
319
+ if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
320
+ return out;
321
+ }
322
+
323
+ // --- Plan-mode reviewer agent ----------------------------------------------
324
+
325
+ /** Temp agent dirs share the ~/.gemini/config/agents discovery root with the
326
+ * web delegates; AGY_AGENTS_ROOT overrides for tests and sandboxes. */
327
+ export function askAgentsRoot(): string {
328
+ return process.env.AGY_AGENTS_ROOT ?? webAgentsRoot();
329
+ }
330
+
331
+ const ASK_AGENT_PREFIX = "pi-bridge-ask-";
332
+ const ASK_AGENT_TOOLS = ["view_file", "run_command"];
333
+
334
+ /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
335
+ * with no file-editing tool present the model cannot emit an edit call at
336
+ * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
337
+ * edits, with and without the skip flag). commandExecutionPolicy auto is
338
+ * what lets read commands run headless with no user allow rules. Never add
339
+ * a write-capable tool here. */
340
+ export function reviewerAgentMd(name: string): string {
341
+ return [
342
+ "---",
343
+ `name: ${name}`,
344
+ "description: Temporary Pi plan-mode reviewer",
345
+ "mainAgent: true",
346
+ "subagent: false",
347
+ "model: inherit",
348
+ "excludeDefaultComponents: true",
349
+ "inheritCustomizations: false",
350
+ "inheritMcp: false",
351
+ "commandExecutionPolicy: auto",
352
+ "tools:",
353
+ ...ASK_AGENT_TOOLS.map((t) => ` - ${t}`),
354
+ "skills: []",
355
+ "rules: []",
356
+ "agents: []",
357
+ "mcpServers: []",
358
+ "---",
359
+ "",
360
+ "You are a strict read-only code reviewer. Review, analyze, and plan; never modify anything.",
361
+ "",
362
+ ].join("\n");
363
+ }
364
+
365
+ /** Stage one unique reviewer agent (agent.md + pid marker) under root. Same
366
+ * hygiene doctrine as the web delegates: nonce-named, cleaned in finally,
367
+ * orphans swept by pid marker at registration. */
368
+ export function stageReviewerAgent(root: string): { name: string; dir: string } {
369
+ fs.mkdirSync(root, { recursive: true });
370
+ const name = `${ASK_AGENT_PREFIX}${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
371
+ const dir = path.join(root, name);
372
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
373
+ fs.writeFileSync(path.join(dir, "agent.md"), reviewerAgentMd(name), { mode: 0o600 });
374
+ fs.writeFileSync(path.join(dir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
375
+ return { name, dir };
376
+ }
377
+
273
378
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
274
379
 
275
380
  // --- Registration ----------------------------------------------------------
@@ -284,6 +389,13 @@ export async function registerAskAntigravityTool(
284
389
  * verbose tier; info/warn/error always land. */
285
390
  log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void,
286
391
  ): Promise<void> {
392
+ // Orphan sweep for the plan-mode reviewer agents (SIGKILL can skip the
393
+ // run's finally): same pid-marker doctrine as the web delegates.
394
+ try {
395
+ sweepStaleWebAgents(askAgentsRoot(), Date.now(), ASK_AGENT_PREFIX);
396
+ } catch {
397
+ // a sweep failure must never block registration
398
+ }
287
399
  pi.registerTool({
288
400
  name: "AskAntigravity",
289
401
  label: "Ask Antigravity",
@@ -390,7 +502,8 @@ export async function registerAskAntigravityTool(
390
502
  }
391
503
 
392
504
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
393
- const errored = d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut;
505
+ const errored =
506
+ d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
394
507
 
395
508
  let text = errored
396
509
  ? theme.fg("error", "✗ AskAntigravity error")
@@ -493,11 +606,27 @@ export async function registerAskAntigravityTool(
493
606
  const snapshot = isContinuation ? null : snapshotConversations();
494
607
 
495
608
  const mode: AgyMode = (params.mode as AgyMode | undefined) ?? "accept-edits";
609
+ // Plan runs stage a restricted reviewer agent: its tools list has NO
610
+ // file-editing tool, which is the hard edit block (the prompt guard
611
+ // alone was observed failing once - a sub-agent still edited files).
612
+ // Staging failure degrades to the legacy fallback: no agent, no skip
613
+ // flag, the stricter command-forbidding guard.
614
+ let reviewerAgent: { name: string; dir: string } | null = null;
615
+ if (mode === "plan") {
616
+ try {
617
+ reviewerAgent = stageReviewerAgent(askAgentsRoot());
618
+ } catch {
619
+ reviewerAgent = null;
620
+ }
621
+ }
496
622
  const useDigest: boolean =
497
623
  typeof params.digest === "boolean" ? params.digest : mode === "plan";
498
- const finalPrompt: string = useDigest
499
- ? `(Use compact digests, not full file contents.)\n${params.prompt}`
500
- : params.prompt;
624
+ const finalPrompt: string = buildFinalPrompt(
625
+ params.prompt,
626
+ mode,
627
+ useDigest,
628
+ reviewerAgent !== null,
629
+ );
501
630
 
502
631
  // Opt-in full-context export (isolated stays the default).
503
632
  let contextFile: string | null = null;
@@ -528,22 +657,31 @@ export async function registerAskAntigravityTool(
528
657
  { model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
529
658
  "info",
530
659
  );
531
- const extra = extraArgs();
660
+ const extraRaw = extraArgs();
661
+ // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
662
+ // mode flags, so an env-injected skip flag would re-arm what the
663
+ // plan-mode rule strips. Only the enforced agent makes the flag safe.
664
+ const extra =
665
+ mode === "plan" && !reviewerAgent
666
+ ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
667
+ : extraRaw;
532
668
  if (extra.length) args.push(...extra);
533
669
  if (resolved.model) args.push("--model", resolved.model);
534
670
  if (resolved.effort) args.push("--effort", resolved.effort);
535
671
  args.push("--mode", mode);
536
- // Honor the shared permissions setting (same knob as the provider). Non-
537
- // interactive -p can't answer a permission prompt, so when this is off
538
- // any run_command will hang - but the setting must mean what it says.
539
- // Plan mode never gets the flag even when the knob is on: the flag
540
- // auto-approves ALL permission requests including plan mode's own
541
- // approval gate, which would silently turn "review-only" into full
542
- // write access inside --add-dir (probed 2026-09-25: with the flag a
543
- // plan run wrote files; without it, file and command attempts end
544
- // exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
545
- // never a hang). Omitting it is what makes "plan = review-only" true.
546
- if (mode !== "plan" && config.skipPermissions !== false) {
672
+ if (reviewerAgent) args.push("--agent", reviewerAgent.name);
673
+ // Honor the shared permissions setting (same knob as the provider).
674
+ // accept-edits: the flag keeps commands from hanging on an
675
+ // unanswerable prompt in -p mode. Plan runs WITH the reviewer agent:
676
+ // the flag is safe again because the agent's toolset has no
677
+ // file-editing tool - the 2026-09-25 write incident rode edit tools
678
+ // that existed then; probed 2026-09-28 the restricted toolset reports
679
+ // "none" for edits even with the flag, and plan discipline turned a
680
+ // create-a-file request into a proposed plan instead of a write. The
681
+ // flag is what lets analysis commands (builds, test runners) run for
682
+ // users without allow rules. Plan fallback (staging failed): strict
683
+ // legacy behavior, no flag - the prompt guard is all that is left.
684
+ if (config.skipPermissions !== false && (mode !== "plan" || reviewerAgent !== null)) {
547
685
  args.push("--dangerously-skip-permissions");
548
686
  }
549
687
  if (isContinuation) args.push("--conversation", rawConvId as string);
@@ -710,10 +848,11 @@ export async function registerAskAntigravityTool(
710
848
  details.aborted = outcome.aborted;
711
849
  details.timedOut = outcome.timedOut;
712
850
  details.durationMs = Date.now() - start;
851
+ const text = out.trim();
713
852
  log?.(
714
853
  "ask-end",
715
- { exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, durationMs: details.durationMs, conversationId: details.conversationId },
716
- outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut ? "warn" : "info",
854
+ { exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !text, durationMs: details.durationMs, conversationId: details.conversationId },
855
+ outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !text ? "warn" : "info",
717
856
  );
718
857
 
719
858
  if (!isContinuation && !details.conversationId && snapshot) {
@@ -727,8 +866,6 @@ export async function registerAskAntigravityTool(
727
866
  }
728
867
  }
729
868
 
730
- const text = out.trim();
731
-
732
869
  if (outcome.aborted) {
733
870
  return {
734
871
  content: [
@@ -763,6 +900,25 @@ export async function registerAskAntigravityTool(
763
900
  };
764
901
  }
765
902
 
903
+ // Exit 0 with nothing on stdout is still a failure for the
904
+ // delegator: headless agy auto-denies a permission-gated tool call
905
+ // (e.g. the command gate in plan mode), prints the reason only to
906
+ // stderr, and ends cleanly. Falling through to the success path here
907
+ // returned just the conversation footer, which read as an empty
908
+ // success (silent-failure bug found 2026-09-25).
909
+ if (!text) {
910
+ details.empty = true;
911
+ const stderr = redactText(details.stderr.trim());
912
+ const note = [
913
+ "agy exited cleanly but produced no output.",
914
+ stderr ? `stderr: ${stderr}` : null,
915
+ "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.",
916
+ ]
917
+ .filter(Boolean)
918
+ .join(" ");
919
+ return { content: [{ type: "text", text: note }], details };
920
+ }
921
+
766
922
  onUpdate?.({ content: [{ type: "text", text: "" }], details: { ...details } });
767
923
  const footer = details.conversationId
768
924
  ? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
@@ -784,6 +940,11 @@ export async function registerAskAntigravityTool(
784
940
  fs.unlinkSync(contextFile);
785
941
  } catch {}
786
942
  }
943
+ if (reviewerAgent) {
944
+ try {
945
+ fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
946
+ } catch {}
947
+ }
787
948
  }
788
949
  },
789
950
  });
@@ -866,6 +1027,9 @@ interface AgyDetails {
866
1027
  exitCode: number;
867
1028
  aborted: boolean;
868
1029
  timedOut: boolean;
1030
+ // Exit 0 with no answer on stdout (the empty-output failure branch).
1031
+ // renderResult flips to the error glyph on it.
1032
+ empty?: boolean;
869
1033
  durationMs: number;
870
1034
  stderr: string;
871
1035
  }
package/src/web-tools.ts CHANGED
@@ -37,10 +37,15 @@ export function webAgentsRoot(): string {
37
37
  return path.join(os.homedir(), ".gemini", "config", "agents");
38
38
  }
39
39
 
40
- /** Remove leftover pi-bridge-web-* agent dirs: dead-pid markers always, and
41
- * marker-less dirs only after a grace period (a live sibling process may be
42
- * between mkdir and its pid write). Never touches foreign agent dirs. */
43
- export function sweepStaleWebAgents(root: string = webAgentsRoot(), now = Date.now()): void {
40
+ /** Remove leftover pi-bridge agent dirs matching `prefix`: dead-pid markers
41
+ * always, and marker-less dirs only after a grace period (a live sibling
42
+ * process may be between mkdir and its pid write). Never touches foreign
43
+ * agent dirs. The ask-tool reuses this for its reviewer agents. */
44
+ export function sweepStaleWebAgents(
45
+ root: string = webAgentsRoot(),
46
+ now = Date.now(),
47
+ prefix: string = WEB_AGENT_PREFIX,
48
+ ): void {
44
49
  let entries: string[];
45
50
  try {
46
51
  entries = readdirSync(root);
@@ -48,7 +53,7 @@ export function sweepStaleWebAgents(root: string = webAgentsRoot(), now = Date.n
48
53
  return; // no agents dir yet: nothing to sweep
49
54
  }
50
55
  for (const entry of entries) {
51
- if (!entry.startsWith(WEB_AGENT_PREFIX)) continue;
56
+ if (!entry.startsWith(prefix)) continue;
52
57
  try {
53
58
  if (isStaleWebAgentDir(path.join(root, entry), now)) {
54
59
  rmSync(path.join(root, entry), { recursive: true, force: true });