@estebanforge/pi-ask-antigravity 1.1.0 → 1.2.1

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.
Files changed (2) hide show
  1. package/extensions/index.ts +227 -5
  2. package/package.json +14 -5
@@ -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
@@ -146,6 +149,9 @@ interface ModelEntry {
146
149
  interface Config {
147
150
  defaultModel: string;
148
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;
149
155
  }
150
156
 
151
157
  // --- Version helpers -------------------------------------------------------
@@ -188,9 +194,16 @@ function loadConfig(): Config {
188
194
  const thinking: ThinkingTier =
189
195
  thinkingRaw === "low" || thinkingRaw === "high" ? thinkingRaw : "medium";
190
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
+
191
203
  return {
192
204
  defaultModel: String(merged.defaultModel ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL,
193
205
  defaultThinking: thinking,
206
+ skipPermissions,
194
207
  };
195
208
  }
196
209
 
@@ -367,6 +380,52 @@ function resolveAgy(): string {
367
380
  return process.env.AGY_BIN || "agy";
368
381
  }
369
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
+
370
429
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
371
430
 
372
431
  /** Query `agy models`. Returns [] on any failure (non-fatal). */
@@ -441,13 +500,124 @@ function snapshotConversations(dir: string): Set<string> {
441
500
  return out;
442
501
  }
443
502
 
444
- /** Find the single new conversation id created since `before`. Returns null if
445
- * none, or if several appeared (can't safely pick which one is ours). */
446
- function newConversationId(dir: string, before: Set<string>): string | null {
503
+ /** Resolve which of the `candidates` DB ids is held open by the process tree
504
+ * rooted at `rootPid`. Used to disambiguate concurrent agy runs. Returns the
505
+ * single matching id, or null when none/several are open or /proc is
506
+ * unavailable. Ported from pi-antigravity-bridge src/discovery.ts; keep in
507
+ * sync. */
508
+ type OpenDbResolver = (
509
+ rootPid: number,
510
+ dir: string,
511
+ candidates: Set<string>,
512
+ ) => string | null;
513
+
514
+ function readProcStat(pid: number): { pid: number; ppid: number } | null {
515
+ let raw: string;
516
+ try {
517
+ raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
518
+ } catch {
519
+ return null;
520
+ }
521
+ const closeParen = raw.lastIndexOf(")");
522
+ if (closeParen < 0) return null;
523
+ const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
524
+ const ppid = Number(fields[1]);
525
+ if (!Number.isFinite(ppid)) return null;
526
+ return { pid, ppid };
527
+ }
528
+
529
+ function collectDescendants(rootPid: number): Set<number> {
530
+ const out = new Set<number>([rootPid]);
531
+ let entries: string[];
532
+ try {
533
+ entries = fs.readdirSync("/proc");
534
+ } catch {
535
+ return out;
536
+ }
537
+ const ppidOf = new Map<number, number>();
538
+ for (const e of entries) {
539
+ if (!/^\d+$/.test(e)) continue;
540
+ const s = readProcStat(Number(e));
541
+ if (s) ppidOf.set(s.pid, s.ppid);
542
+ }
543
+ let changed = true;
544
+ while (changed) {
545
+ changed = false;
546
+ for (const [pid, ppid] of ppidOf) {
547
+ if (out.has(pid)) continue;
548
+ if (out.has(ppid)) {
549
+ out.add(pid);
550
+ changed = true;
551
+ }
552
+ }
553
+ }
554
+ return out;
555
+ }
556
+
557
+ function safeRealpath(p: string): string | null {
558
+ try {
559
+ return fs.realpathSync(p);
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
566
+ if (candidates.size <= 1) return null;
567
+ if (process.platform !== "linux") return null;
568
+ const dirResolved = safeRealpath(dir);
569
+ const tree = collectDescendants(rootPid);
570
+ const found = new Set<string>();
571
+ for (const pid of tree) {
572
+ let fds: string[];
573
+ try {
574
+ fds = fs.readdirSync(`/proc/${pid}/fd`);
575
+ } catch {
576
+ continue;
577
+ }
578
+ for (const fd of fds) {
579
+ let target: string;
580
+ try {
581
+ target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
582
+ } catch {
583
+ continue;
584
+ }
585
+ const base = path.basename(target);
586
+ if (!base.endsWith(".db")) continue;
587
+ if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
588
+ const id = base.slice(0, -3);
589
+ if (candidates.has(id)) found.add(id);
590
+ }
591
+ }
592
+ if (found.size === 1) return [...found][0] ?? null;
593
+ return null;
594
+ };
595
+
596
+ interface BindOptions {
597
+ pid?: number;
598
+ resolveOpenDb?: OpenDbResolver;
599
+ }
600
+
601
+ /** Find the conversation id created since `before`. Returns null when none
602
+ * appeared, or when several appeared and we cannot tie one to our process.
603
+ * Pass `opts.pid` (the spawned agy) to enable concurrent-run disambiguation
604
+ * via the process-tree FD scan. */
605
+ function newConversationId(
606
+ dir: string,
607
+ before: Set<string>,
608
+ opts: BindOptions = {},
609
+ ): string | null {
447
610
  const created = [...snapshotConversations(dir)].filter((id) => !before.has(id));
448
611
  if (created.length === 0) return null;
449
- if (created.length > 1) return null; // ambiguous; refuse to bind
450
- return created[0] ?? null;
612
+ if (created.length === 1) return created[0] ?? null;
613
+ // Ambiguous: try to authoritatively identify ours via the spawned
614
+ // process's open files. Fail safe to null when we can't pick exactly one.
615
+ if (opts.pid !== undefined) {
616
+ const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
617
+ const hit = resolve(opts.pid, dir, new Set(created));
618
+ if (hit && created.includes(hit)) return hit;
619
+ }
620
+ return null;
451
621
  }
452
622
 
453
623
  // --- Extension -------------------------------------------------------------
@@ -466,6 +636,12 @@ interface AgyDetails {
466
636
  }
467
637
 
468
638
  export default async function (pi: ExtensionAPI) {
639
+ // If pi-antigravity-bridge is installed, it owns the AskAntigravity tool
640
+ // (and the provider). Stay silent and register nothing to avoid a
641
+ // duplicate-tool clash. Without the bridge, this extension behaves as
642
+ // before (standalone tool).
643
+ if (isBridgeInstalled()) return;
644
+
469
645
  const binary = resolveAgy();
470
646
  // Discovered once at load; frozen for the session. Run /reload after an
471
647
  // `agy update` to refresh. Failure is non-fatal: resolveModel falls back
@@ -494,6 +670,7 @@ export default async function (pi: ExtensionAPI) {
494
670
  `AskAntigravity config`,
495
671
  ` defaultModel: ${config.defaultModel}`,
496
672
  ` defaultThinking: ${config.defaultThinking}`,
673
+ ` permissions: ${config.skipPermissions ? "auto-approved" : "prompt"}`,
497
674
  ` resolved: ${resolveModel(config.defaultModel, discovered, config.defaultThinking) ?? "(agy default)"}`,
498
675
  ``,
499
676
  `Edit: ~/.pi/agent/ask-antigravity.json`,
@@ -527,6 +704,14 @@ export default async function (pi: ExtensionAPI) {
527
704
  currentValue: config.defaultThinking,
528
705
  values: THINKING_OPTIONS,
529
706
  },
707
+ {
708
+ id: "permissions",
709
+ label: "Permissions",
710
+ description:
711
+ "auto-approved: --dangerously-skip-permissions (required so run_command doesn't hang in -p mode). prompt: agy asks y/n (hangs non-interactively).",
712
+ currentValue: config.skipPermissions ? "auto-approved" : "prompt",
713
+ values: ["auto-approved", "prompt"],
714
+ },
530
715
  ];
531
716
 
532
717
  const pending: Partial<Config> = {};
@@ -548,6 +733,8 @@ export default async function (pi: ExtensionAPI) {
548
733
  pending.defaultModel = alias;
549
734
  } else if (id === "defaultThinking") {
550
735
  pending.defaultThinking = newValue as ThinkingTier;
736
+ } else if (id === "permissions") {
737
+ pending.skipPermissions = newValue === "auto-approved";
551
738
  }
552
739
  },
553
740
  () => done(undefined),
@@ -742,6 +929,10 @@ export default async function (pi: ExtensionAPI) {
742
929
  if (extra.length) args.push(...extra);
743
930
  if (resolved) args.push("--model", resolved);
744
931
  args.push("--mode", mode);
932
+ // accept-edits auto-approves file edits but NOT shell commands, so a
933
+ // run_command would hang on an unanswerable y/n prompt in non-interactive
934
+ // -p mode. Honor the shared permissions setting (same knob as the bridge).
935
+ if (config.skipPermissions) args.push("--dangerously-skip-permissions");
745
936
  if (isContinuation) args.push("--conversation", rawConvId as string);
746
937
  args.push("--print-timeout", `${timeoutMin}m`);
747
938
  args.push("-p", finalPrompt);
@@ -779,6 +970,11 @@ export default async function (pi: ExtensionAPI) {
779
970
  : null;
780
971
 
781
972
  try {
973
+ // Bind the conversation id DURING the run (agy is alive then) so the
974
+ // pid-based /proc FD resolver can disambiguate when a concurrent agy
975
+ // also drops a new .db. Awaited after the run; the post-exit loop
976
+ // below is the fallback for runs that exit before the poll binds.
977
+ let bindDuringRun: Promise<void> = Promise.resolve();
782
978
  const outcome = await new Promise<{
783
979
  exitCode: number;
784
980
  aborted: boolean;
@@ -806,6 +1002,28 @@ export default async function (pi: ExtensionAPI) {
806
1002
  details.stderr += d;
807
1003
  });
808
1004
 
1005
+ // Concurrent bind: poll for the new id while agy is alive. The FD
1006
+ // resolver needs a live process tree, so this stops (and the
1007
+ // post-exit fallback below takes over) once agy has exited.
1008
+ if (!isContinuation && snapshot && proc.pid) {
1009
+ bindDuringRun = (async () => {
1010
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
1011
+ if (details.conversationId) return;
1012
+ if (proc.exitCode !== null) return; // agy gone: scan useless now
1013
+ const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
1014
+ pid: proc.pid,
1015
+ });
1016
+ if (found) {
1017
+ details.conversationId = found;
1018
+ return;
1019
+ }
1020
+ await sleep(DISCOVERY_POLL_MS);
1021
+ }
1022
+ })().catch(() => {
1023
+ /* best-effort: a bind error must never fail an otherwise-OK turn */
1024
+ });
1025
+ }
1026
+
809
1027
  let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
810
1028
  let watchdog: ReturnType<typeof setTimeout> | undefined;
811
1029
  let settled = false;
@@ -872,6 +1090,10 @@ export default async function (pi: ExtensionAPI) {
872
1090
 
873
1091
  if (statusInterval) clearInterval(statusInterval);
874
1092
 
1093
+ // Let the during-run bind poll finish (it bails immediately once agy
1094
+ // has exited, so this rarely blocks).
1095
+ await bindDuringRun;
1096
+
875
1097
  details.exitCode = outcome.exitCode;
876
1098
  details.aborted = outcome.aborted;
877
1099
  details.timedOut = outcome.timedOut;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-ask-antigravity",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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",
@@ -22,7 +22,15 @@
22
22
  "type": "git",
23
23
  "url": "git+https://github.com/EstebanForge/pi-ask-antigravity.git"
24
24
  },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
25
28
  "type": "module",
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "prepublishOnly": "npm run typecheck && npm test"
33
+ },
26
34
  "files": [
27
35
  "extensions",
28
36
  "README.md"
@@ -41,11 +49,12 @@
41
49
  }
42
50
  },
43
51
  "devDependencies": {
44
- "@earendil-works/pi-coding-agent": "^0.80.2",
45
- "@earendil-works/pi-ai": "^0.80.2",
46
- "@earendil-works/pi-tui": "^0.80.2",
52
+ "@earendil-works/pi-coding-agent": "^0.84.0",
53
+ "@earendil-works/pi-ai": "^0.84.0",
54
+ "@earendil-works/pi-tui": "^0.84.0",
47
55
  "@types/node": "^22.0.0",
48
56
  "typebox": "^1.1.38",
49
- "typescript": "^5.8.0"
57
+ "typescript": "^5.8.0",
58
+ "vitest": "^4.1.7"
50
59
  }
51
60
  }