@estebanforge/pi-ask-antigravity 1.0.1 → 1.2.0

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/README.md CHANGED
@@ -51,11 +51,40 @@ Verbose `agy models` strings (`Gemini 3.5 Flash (Medium)`) are hostile to natura
51
51
  | `pro low` | latest Pro, Low |
52
52
  | `3.5 flash` | pinned version, default tier |
53
53
  | `3.1 pro` | pinned Pro version (→ High) |
54
+ | `sonnet` | Claude Sonnet 4.6 (Thinking) |
55
+ | `opus` | Claude Opus 4.6 (Thinking) |
56
+ | `gpt-oss` | GPT-OSS 120B (Medium) |
54
57
  | `Gemini 3.5 Flash (Medium)` | exact passthrough |
55
58
 
56
59
  - **Latest** = highest version number available for the family.
57
60
  - **Default tier**: Flash → Medium, Pro → High. Overridable per-config (below).
58
61
  - When a tier is unavailable for a family, the nearest is chosen with ties broken toward the higher tier (so "latest and greatest" wins).
62
+ - The non-Gemini aliases (`sonnet`, `opus`, `gpt-oss`) are a static overlay merged with the live `agy models` catalog. Live entries always win on case-insensitive full-string equality, so an updated `agy` listing takes precedence over the hardcoded fallback.
63
+
64
+ ## Execution modes (`mode`)
65
+
66
+ | Mode | Flag | Use when |
67
+ | --- | --- | --- |
68
+ | `plan` | `--mode plan` | agy reviews and plans without writing. Use for cross-review and read-only tasks. |
69
+ | `accept-edits` (default) | `--mode accept-edits` | agy applies edits directly inside the workspace. |
70
+
71
+ 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.
72
+
73
+ Recommended pairings:
74
+
75
+ ```text
76
+ # Gemini produces, Claude reviews
77
+ AskAntigravity prompt="Implement the approved refactor" mode=accept-edits
78
+ AskAntigravity prompt="Review the resulting diff" model=opus mode=plan digest=true
79
+
80
+ # Claude produces, Gemini reviews
81
+ AskAntigravity prompt="Fix the parser root cause" model=opus mode=accept-edits
82
+ AskAntigravity prompt="Review the resulting diff" model=pro mode=plan digest=true
83
+ ```
84
+
85
+ ## Compact output (`digest`)
86
+
87
+ Set `digest: true` to prefix the prompt with `(Use compact digests, not full file contents.)` so `agy` returns summaries instead of full file payloads. Defaults on for `plan`, off for `accept-edits`. Use `true` for any read-only call (review, exploration, planning) where full file contents are noise.
59
88
 
60
89
  ## Configuration
61
90
 
@@ -84,8 +113,9 @@ Interactive picker for the default model and default thinking. If the project co
84
113
  | `prompt` | yes | Self-contained task. agy cannot see the Pi conversation. |
85
114
  | `cwd` | no | Workspace agy runs in. Defaults to the project root. |
86
115
  | `model` | no | Alias or exact id. Omit for the configured default. |
116
+ | `mode` | no | `plan` (review-only) or `accept-edits` (agy applies edits, default). |
117
+ | `digest` | no | Prefix the prompt with `(Use compact digests, not full file contents.)`. Defaults on for `plan`, off for `accept-edits`. |
87
118
  | `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). |
88
- | `skipPermissions` | no | Pass `--dangerously-skip-permissions`. Needed for mutating tasks. |
89
119
  | `timeoutMinutes` | no | Hard cap on the run. Default 10. |
90
120
 
91
121
  ## Environment
@@ -54,6 +54,9 @@ const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "ask-antigrav
54
54
 
55
55
  const DEFAULT_MODEL = "flash";
56
56
  const DEFAULT_THINKING = "medium";
57
+ // Default on: without --dangerously-skip-permissions, any run_command hangs in
58
+ // non-interactive -p mode (accept-edits auto-approves edits, NOT commands).
59
+ const DEFAULT_SKIP_PERMISSIONS = true;
57
60
 
58
61
  // Per-family fallback tier when none is specified and no config default.
59
62
  // Flash defaults to medium (per spec); Pro only ships Low/High, so "latest
@@ -66,16 +69,70 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
66
69
 
67
70
  const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
68
71
 
72
+ // Mode = which agy tool-loop policy to apply. Distinct from the alias layer.
73
+ // "plan" → --mode plan (no edits; review-only)
74
+ // "accept-edits" → --mode accept-edits (agy applies edits)
75
+ //
76
+ // Note: agy's --sandbox flag is an orthogonal shell-containment setting
77
+ // (not an "edit preview" mode), so it is not exposed here. Users who need
78
+ // it can pass it via the AGY_EXTRA_ARGS env var.
79
+ type Mode = "plan" | "accept-edits";
80
+
81
+ // Static alias overlay for non-Gemini models agy may or may not surface in
82
+ // `agy models` depending on plan. When the live catalog contains an entry
83
+ // whose full string equals the overlay target, the live entry wins. When it
84
+ // does not (older agy, missing model, plan gate), the overlay entry resolves
85
+ // the alias so the user can still type "sonnet" and get a working answer.
86
+ // "sonnet" -> Claude Sonnet 4.6 (Thinking)
87
+ // "opus" -> Claude Opus 4.6 (Thinking)
88
+ // "gpt-oss" -> GPT-OSS 120B (Medium)
89
+ const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
90
+ { full: "Claude Sonnet 4.6 (Thinking)", family: "other", version: null, tier: null },
91
+ { full: "Claude Opus 4.6 (Thinking)", family: "other", version: null, tier: null },
92
+ { full: "GPT-OSS 120B (Medium)", family: "other", version: null, tier: null },
93
+ ];
94
+
95
+ // Short alias → overlay full string. Used by resolveModel to recognize
96
+ // friendly short names ("sonnet") that the family-parser (flash/pro) does
97
+ // not match. The overlay entries are also merged into the live catalog for
98
+ // exact-string passthrough, so this map only needs to cover the short names.
99
+ const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
100
+ ["sonnet", "Claude Sonnet 4.6 (Thinking)"],
101
+ ["opus", "Claude Opus 4.6 (Thinking)"],
102
+ ["gpt-oss", "GPT-OSS 120B (Medium)"],
103
+ ]);
104
+
105
+ /** Merge the live catalog with the static alias overlay. Live entries win on
106
+ * case-insensitive full-string equality so an updated `agy models` listing
107
+ * always takes precedence over the hardcoded fallback. */
108
+ function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
109
+ const seen = new Set(live.map((e) => e.full.toLowerCase()));
110
+ const merged = [...live];
111
+ for (const entry of STATIC_ALIAS_OVERLAY) {
112
+ if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
113
+ }
114
+ return merged;
115
+ }
116
+
69
117
  // agy conversation ids are UUID DB-stems (e.g. "9e6fdc2f-f9f9-4096-95fc-7852528b50cc").
70
118
  // Reject anything that isn't, so a leading-dash value can't misbind on agy's
71
- // arg parser as the token after --conversation.
72
- const CONV_ID_RE = /^[A-Za-z0-9]{1,128}$/;
119
+ // arg parser as the token after --conversation. First char must be
120
+ // alphanumeric (rejects leading-dash flag injection); hyphens allowed in the
121
+ // body because real UUIDs contain them.
122
+ const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
73
123
 
74
124
  const AGY_DESCRIPTION = `Delegate a self-contained sub-task to Google Antigravity. agy is the CLI for Gemini, so this tool is reached under three equivalent names the user may use interchangeably: **gemini**, **antigravity**, and **agy**. When the user says "ask gemini", "ask antigravity", "ask agy", or otherwise refers to any of these, call THIS tool. agy runs its OWN tool loop: it can read, write, edit, and execute inside the workspace, then returns its final answer. Use for a second opinion from a different model family, Gemini-specific reasoning, or isolated sub-tasks you do not need to drive step-by-step. Provide a complete, self-contained task description; agy will not see this conversation.
75
125
 
76
126
  TWO MODES (you choose):
77
127
  - **One-shot (isolated)**: omit conversationId. agy starts fresh with no memory of prior calls. Use for independent questions.
78
- - **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.`;
128
+ - **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.
129
+
130
+ EXECUTION MODES (param: mode):
131
+ - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks.
132
+ - **accept-edits** (default): agy applies edits directly inside the workspace.
133
+ - For agy's orthogonal \`--sandbox\` shell-containment flag, set the \`AGY_EXTRA_ARGS=--sandbox\` env var.
134
+
135
+ 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. Use true whenever you do not need full file payloads (review, exploration, planning).`;
79
136
 
80
137
  // --- Types -----------------------------------------------------------------
81
138
 
@@ -92,6 +149,9 @@ interface ModelEntry {
92
149
  interface Config {
93
150
  defaultModel: string;
94
151
  defaultThinking: ThinkingTier;
152
+ /** Pass --dangerously-skip-permissions so commands don't hang on an
153
+ * unanswerable y/n prompt in non-interactive -p mode. Default true. */
154
+ skipPermissions: boolean;
95
155
  }
96
156
 
97
157
  // --- Version helpers -------------------------------------------------------
@@ -134,9 +194,16 @@ function loadConfig(): Config {
134
194
  const thinking: ThinkingTier =
135
195
  thinkingRaw === "low" || thinkingRaw === "high" ? thinkingRaw : "medium";
136
196
 
197
+ const envPerm = process.env.AGY_SKIP_PERMISSIONS;
198
+ const skipPermissions =
199
+ envPerm !== undefined
200
+ ? envPerm === "1" || envPerm.toLowerCase() === "true"
201
+ : merged.skipPermissions === false ? false : DEFAULT_SKIP_PERMISSIONS;
202
+
137
203
  return {
138
204
  defaultModel: String(merged.defaultModel ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL,
139
205
  defaultThinking: thinking,
206
+ skipPermissions,
140
207
  };
141
208
  }
142
209
 
@@ -225,6 +292,22 @@ function resolveModel(
225
292
  const exact = entries.find((e) => e.full.toLowerCase() === lower);
226
293
  if (exact) return exact.full;
227
294
 
295
+ // 1b. Static short alias ("sonnet" / "opus" / "gpt-oss"). Checked
296
+ // before the family parser because none of these names contain
297
+ // "flash" or "pro" and would otherwise return null below. The
298
+ // resolved full string is then re-validated against the catalog
299
+ // in step 1's second pass on the next call, so renaming the
300
+ // overlay entry in code still wins on exact-string match.
301
+ // The case-insensitive lookup matches mergeCatalog's dedup logic
302
+ // so the "live entries win" guarantee holds even when agy lists
303
+ // the model under different casing than the overlay.
304
+ if (STATIC_SHORT_ALIAS.has(lower)) {
305
+ const target = STATIC_SHORT_ALIAS.get(lower) as string;
306
+ const targetLower = target.toLowerCase();
307
+ const fromCatalog = entries.find((e) => e.full.toLowerCase() === targetLower);
308
+ return fromCatalog ? fromCatalog.full : target;
309
+ }
310
+
228
311
  // 2. Parse the alias.
229
312
  let family: Family | null = lower.includes("flash")
230
313
  ? "flash"
@@ -297,6 +380,52 @@ function resolveAgy(): string {
297
380
  return process.env.AGY_BIN || "agy";
298
381
  }
299
382
 
383
+ // Defer to pi-antigravity-bridge when it is installed: the bridge provides BOTH
384
+ // the streaming antigravity provider AND the AskAntigravity tool (same shape as
385
+ // pi-claude-bridge). Registering the tool here too would create a duplicate.
386
+ //
387
+ // Detection does NOT rely on module resolution: pi loads each package with a
388
+ // separate module root (docs/packages.md), so require.resolve from here never
389
+ // reaches a sibling package. Instead we check:
390
+ // 1. An in-process Symbol.for flag the bridge sets at its load (fast path
391
+ // when the bridge loaded earlier this session).
392
+ // 2. The bridge's package.json at pi's documented install locations
393
+ // (~/.pi/agent/npm|git/... and project .pi/npm|git/...).
394
+ //
395
+ // Coverage: (2) is order-independent for npm/git installs - installation is a
396
+ // fact on disk. For LOCAL/source installs (`pi install <path>`) the package
397
+ // lives at its original checkout, not under .pi/npm|git/, so (2) sees nothing;
398
+ // there only (1) helps, and only if the bridge loads first. Recommendation for
399
+ // dev with both repos checked out side-by-side: install the bridge first so it
400
+ // is earlier in settings.json (pi loads extensions in that order). If the
401
+ // clash still occurs, both tools are functionally identical, so the only
402
+ // symptom is a duplicate-name TUI warning - not broken behavior.
403
+ const BRIDGE_FLAG = Symbol.for("pi-antigravity-bridge:active");
404
+
405
+ function bridgeInstallPaths(): string[] {
406
+ const npmPkg = path.join("@estebanforge", "pi-antigravity-bridge", "package.json");
407
+ const gitPkg = path.join("EstebanForge", "pi-antigravity-bridge", "package.json");
408
+ const home = os.homedir();
409
+ const cwd = process.cwd();
410
+ return [
411
+ path.join(home, ".pi", "agent", "npm", "node_modules", npmPkg),
412
+ path.join(cwd, ".pi", "npm", "node_modules", npmPkg),
413
+ path.join(home, ".pi", "agent", "git", "github.com", gitPkg),
414
+ path.join(cwd, ".pi", "git", "github.com", gitPkg),
415
+ ];
416
+ }
417
+
418
+ const isBridgeInstalled = (): boolean => {
419
+ if ((globalThis as Record<symbol, unknown>)[BRIDGE_FLAG]) return true;
420
+ return bridgeInstallPaths().some((p) => {
421
+ try {
422
+ return fs.existsSync(p);
423
+ } catch {
424
+ return false;
425
+ }
426
+ });
427
+ };
428
+
300
429
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
301
430
 
302
431
  /** Query `agy models`. Returns [] on any failure (non-fatal). */
@@ -385,6 +514,8 @@ function newConversationId(dir: string, before: Set<string>): string | null {
385
514
  interface AgyDetails {
386
515
  model: string | null;
387
516
  resolvedModel: string | null;
517
+ mode: Mode;
518
+ digest: boolean;
388
519
  conversationId: string | null;
389
520
  exitCode: number;
390
521
  aborted: boolean;
@@ -394,11 +525,19 @@ interface AgyDetails {
394
525
  }
395
526
 
396
527
  export default async function (pi: ExtensionAPI) {
528
+ // If pi-antigravity-bridge is installed, it owns the AskAntigravity tool
529
+ // (and the provider). Stay silent and register nothing to avoid a
530
+ // duplicate-tool clash. Without the bridge, this extension behaves as
531
+ // before (standalone tool).
532
+ if (isBridgeInstalled()) return;
533
+
397
534
  const binary = resolveAgy();
398
535
  // Discovered once at load; frozen for the session. Run /reload after an
399
536
  // `agy update` to refresh. Failure is non-fatal: resolveModel falls back
400
- // to passthrough so exact slugs typed by the user still work.
401
- const discovered = await discoverModels(binary).catch(() => []);
537
+ // to passthrough so exact slugs typed by the user still work. The static
538
+ // alias overlay (sonnet / opus / gpt-oss) is merged on top so those
539
+ // aliases resolve even when agy doesn't surface them in the live catalog.
540
+ const discovered = mergeCatalog(await discoverModels(binary).catch(() => []));
402
541
 
403
542
  // --- /agy: view / change default model + thinking ---------------------
404
543
 
@@ -420,6 +559,7 @@ export default async function (pi: ExtensionAPI) {
420
559
  `AskAntigravity config`,
421
560
  ` defaultModel: ${config.defaultModel}`,
422
561
  ` defaultThinking: ${config.defaultThinking}`,
562
+ ` permissions: ${config.skipPermissions ? "auto-approved" : "prompt"}`,
423
563
  ` resolved: ${resolveModel(config.defaultModel, discovered, config.defaultThinking) ?? "(agy default)"}`,
424
564
  ``,
425
565
  `Edit: ~/.pi/agent/ask-antigravity.json`,
@@ -453,6 +593,14 @@ export default async function (pi: ExtensionAPI) {
453
593
  currentValue: config.defaultThinking,
454
594
  values: THINKING_OPTIONS,
455
595
  },
596
+ {
597
+ id: "permissions",
598
+ label: "Permissions",
599
+ description:
600
+ "auto-approved: --dangerously-skip-permissions (required so run_command doesn't hang in -p mode). prompt: agy asks y/n (hangs non-interactively).",
601
+ currentValue: config.skipPermissions ? "auto-approved" : "prompt",
602
+ values: ["auto-approved", "prompt"],
603
+ },
456
604
  ];
457
605
 
458
606
  const pending: Partial<Config> = {};
@@ -474,6 +622,8 @@ export default async function (pi: ExtensionAPI) {
474
622
  pending.defaultModel = alias;
475
623
  } else if (id === "defaultThinking") {
476
624
  pending.defaultThinking = newValue as ThinkingTier;
625
+ } else if (id === "permissions") {
626
+ pending.skipPermissions = newValue === "auto-approved";
477
627
  }
478
628
  },
479
629
  () => done(undefined),
@@ -539,10 +689,23 @@ export default async function (pi: ExtensionAPI) {
539
689
  }),
540
690
  ),
541
691
  model: modelParam,
542
- skipPermissions: Type.Optional(
692
+ mode: Type.Optional(
693
+ Type.Union(
694
+ [
695
+ Type.Literal("plan"),
696
+ Type.Literal("accept-edits"),
697
+ ],
698
+ {
699
+ description:
700
+ "agy execution mode. 'plan' = review-only, no edits (--mode plan). '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.",
701
+ default: "accept-edits",
702
+ },
703
+ ),
704
+ ),
705
+ digest: Type.Optional(
543
706
  Type.Boolean({
544
707
  description:
545
- "Pass --dangerously-skip-permissions so agy auto-approves its own write/edit/exec tool calls. Required for tasks that mutate files. Use with care.",
708
+ "Request compact digests instead of full file contents. When true, the prompt is prefixed with '(Use compact digests, not full file contents.)'. Defaults on for plan, off for accept-edits.",
546
709
  }),
547
710
  ),
548
711
  conversationId: Type.Optional(
@@ -573,6 +736,8 @@ export default async function (pi: ExtensionAPI) {
573
736
  details: {
574
737
  model: null,
575
738
  resolvedModel: null,
739
+ mode: "accept-edits",
740
+ digest: false,
576
741
  conversationId: null,
577
742
  exitCode: 0,
578
743
  aborted: false,
@@ -636,18 +801,36 @@ export default async function (pi: ExtensionAPI) {
636
801
  typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
637
802
  const snapshot = isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR);
638
803
 
804
+ const mode: Mode = (params.mode as Mode | undefined) ?? "accept-edits";
805
+ // digest default: on for plan (review-only contexts where full file
806
+ // contents are noise), off for accept-edits (agy applies edits and
807
+ // may need richer context for diffs).
808
+ const useDigest: boolean =
809
+ typeof params.digest === "boolean"
810
+ ? params.digest
811
+ : mode === "plan";
812
+ const finalPrompt: string = useDigest
813
+ ? `(Use compact digests, not full file contents.)\n${params.prompt}`
814
+ : params.prompt;
815
+
639
816
  const args: string[] = ["--add-dir", cwd];
640
817
  const extra = extraArgs();
641
818
  if (extra.length) args.push(...extra);
642
819
  if (resolved) args.push("--model", resolved);
643
- if (params.skipPermissions) args.push("--dangerously-skip-permissions");
820
+ args.push("--mode", mode);
821
+ // accept-edits auto-approves file edits but NOT shell commands, so a
822
+ // run_command would hang on an unanswerable y/n prompt in non-interactive
823
+ // -p mode. Honor the shared permissions setting (same knob as the bridge).
824
+ if (config.skipPermissions) args.push("--dangerously-skip-permissions");
644
825
  if (isContinuation) args.push("--conversation", rawConvId as string);
645
826
  args.push("--print-timeout", `${timeoutMin}m`);
646
- args.push("-p", params.prompt);
827
+ args.push("-p", finalPrompt);
647
828
 
648
829
  const details: AgyDetails = {
649
830
  model: requestedModel,
650
831
  resolvedModel: resolved,
832
+ mode,
833
+ digest: useDigest,
651
834
  conversationId: isContinuation ? (rawConvId as string) : null,
652
835
  exitCode: 0,
653
836
  aborted: false,
@@ -866,6 +1049,8 @@ function emptyDetails(model: string | null, resolvedModel: string | null): AgyDe
866
1049
  return {
867
1050
  model,
868
1051
  resolvedModel,
1052
+ mode: "accept-edits",
1053
+ digest: false,
869
1054
  conversationId: null,
870
1055
  exitCode: 0,
871
1056
  aborted: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-ask-antigravity",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
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",