@estebanforge/pi-ask-antigravity 1.2.0 → 1.2.3

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 +210 -40
  2. package/package.json +14 -5
@@ -83,13 +83,13 @@ type Mode = "plan" | "accept-edits";
83
83
  // whose full string equals the overlay target, the live entry wins. When it
84
84
  // does not (older agy, missing model, plan gate), the overlay entry resolves
85
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)
86
+ // "sonnet" -> claude-sonnet-4-6
87
+ // "opus" -> claude-opus-4-6-thinking
88
+ // "gpt-oss" -> gpt-oss-120b-medium
89
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 },
90
+ { full: "claude-sonnet-4-6", 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
93
  ];
94
94
 
95
95
  // Short alias → overlay full string. Used by resolveModel to recognize
@@ -97,15 +97,15 @@ const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
97
97
  // not match. The overlay entries are also merged into the live catalog for
98
98
  // exact-string passthrough, so this map only needs to cover the short names.
99
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)"],
100
+ ["sonnet", "claude-sonnet-4-6"],
101
+ ["opus", "claude-opus-4-6-thinking"],
102
+ ["gpt-oss", "gpt-oss-120b-medium"],
103
103
  ]);
104
104
 
105
105
  /** Merge the live catalog with the static alias overlay. Live entries win on
106
106
  * case-insensitive full-string equality so an updated `agy models` listing
107
107
  * always takes precedence over the hardcoded fallback. */
108
- function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
108
+ export function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
109
109
  const seen = new Set(live.map((e) => e.full.toLowerCase()));
110
110
  const merged = [...live];
111
111
  for (const entry of STATIC_ALIAS_OVERLAY) {
@@ -139,13 +139,22 @@ COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request com
139
139
  type ThinkingTier = "low" | "medium" | "high";
140
140
  type Family = "flash" | "pro" | "other";
141
141
 
142
- interface ModelEntry {
143
- full: string; // exact agy string, e.g. "Gemini 3.5 Flash (Medium)"
142
+ export interface ModelEntry {
143
+ full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
144
144
  family: Family;
145
- version: string | null; // "3.5"
145
+ version: string | null; // "3.6"
146
146
  tier: ThinkingTier | null;
147
147
  }
148
148
 
149
+ /** Argv-facing model resolution: the exact --model slug plus an optional
150
+ * --effort tier. Gemini bases split the tier out (the base slug alone is
151
+ * invalid without --effort); fixed-thinking families keep agy's exact slug
152
+ * and carry no effort. */
153
+ export interface ResolvedModel {
154
+ model: string;
155
+ effort?: ThinkingTier;
156
+ }
157
+
149
158
  interface Config {
150
159
  defaultModel: string;
151
160
  defaultThinking: ThinkingTier;
@@ -243,8 +252,11 @@ function saveConfig(patch: Partial<Config>): SaveResult {
243
252
  // --- Model parsing + alias resolution --------------------------------------
244
253
 
245
254
  /** Parse one `agy models` line into a structured entry. */
246
- function parseModelLine(line: string): ModelEntry | null {
247
- const full = line.trim();
255
+ export function parseModelLine(line: string): ModelEntry | null {
256
+ // agy prints TWO columns: "<slug> <display label>". --model takes only the
257
+ // slug (col 1), so split it off; the label is display-only. A bare-slug line
258
+ // (no whitespace) splits to itself.
259
+ const full = line.trim().split(/\s+/)[0] ?? "";
248
260
  if (!full) return null;
249
261
 
250
262
  const lower = full.toLowerCase();
@@ -257,7 +269,7 @@ function parseModelLine(line: string): ModelEntry | null {
257
269
  const versionMatch = lower.match(/(\d+\.\d+)/);
258
270
  const version = versionMatch ? versionMatch[1] : null;
259
271
 
260
- const tierMatch = lower.match(/\((low|medium|high)\)/);
272
+ const tierMatch = lower.match(/-(low|medium|high)$/);
261
273
  const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
262
274
 
263
275
  return { full, family, version, tier };
@@ -276,21 +288,34 @@ function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): Thinki
276
288
  return sorted[0] ?? preferred;
277
289
  }
278
290
 
291
+ /** Build the argv-facing resolution from a picked catalog entry. Gemini bases
292
+ * (slugs starting "gemini-") accept a separate --effort, so split the tier
293
+ * suffix out of the slug: the base alone (gemini-3.6-flash) is what --model
294
+ * wants, and the tier goes to --effort. Fixed-thinking families keep agy's
295
+ * exact slug even when it carries a -medium suffix (gpt-oss-120b-medium):
296
+ * agy rejects --effort for them, so the suffix stays part of the slug. */
297
+ function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
298
+ if (tier && full.toLowerCase().startsWith("gemini-")) {
299
+ return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
300
+ }
301
+ return { model: full };
302
+ }
303
+
279
304
  /**
280
- * Resolve a friendly alias / partial name to an exact agy model string.
281
- * Returns null if resolution is not possible (caller passes input through
282
- * to agy, which may resolve or fail on its own).
305
+ * Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
306
+ * Returns null only when the family is unrecognized; the caller then passes
307
+ * the raw input straight to agy.
283
308
  */
284
- function resolveModel(
309
+ export function resolveModel(
285
310
  input: string,
286
311
  entries: ModelEntry[],
287
312
  defaultThinking: ThinkingTier,
288
- ): string | null {
313
+ ): ResolvedModel | null {
289
314
  const lower = input.toLowerCase().trim();
290
315
 
291
316
  // 1. Exact full-string match (case-insensitive).
292
317
  const exact = entries.find((e) => e.full.toLowerCase() === lower);
293
- if (exact) return exact.full;
318
+ if (exact) return toResolved(exact.full, exact.tier);
294
319
 
295
320
  // 1b. Static short alias ("sonnet" / "opus" / "gpt-oss"). Checked
296
321
  // before the family parser because none of these names contain
@@ -303,9 +328,8 @@ function resolveModel(
303
328
  // the model under different casing than the overlay.
304
329
  if (STATIC_SHORT_ALIAS.has(lower)) {
305
330
  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;
331
+ const fromCatalog = entries.find((e) => e.full.toLowerCase() === target.toLowerCase());
332
+ return toResolved(fromCatalog?.full ?? target, fromCatalog?.tier ?? null);
309
333
  }
310
334
 
311
335
  // 2. Parse the alias.
@@ -365,13 +389,14 @@ function resolveModel(
365
389
  const familyTiers = new Set(
366
390
  candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
367
391
  );
368
- if (familyTiers.size === 0) return candidates[0].full; // no tiers on any entry
392
+ if (familyTiers.size === 0) return toResolved(candidates[0].full, null); // no tiers on any entry
369
393
 
370
394
  const preferred =
371
395
  tier ??
372
396
  (familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
373
397
  const chosenTier = nearestTier([...familyTiers], preferred);
374
- return (candidates.find((e) => e.tier === chosenTier) ?? candidates[0]).full;
398
+ const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
399
+ return toResolved(picked.full, picked.tier);
375
400
  }
376
401
 
377
402
  // --- agy process helpers ---------------------------------------------------
@@ -500,13 +525,124 @@ function snapshotConversations(dir: string): Set<string> {
500
525
  return out;
501
526
  }
502
527
 
503
- /** Find the single new conversation id created since `before`. Returns null if
504
- * none, or if several appeared (can't safely pick which one is ours). */
505
- function newConversationId(dir: string, before: Set<string>): string | null {
528
+ /** Resolve which of the `candidates` DB ids is held open by the process tree
529
+ * rooted at `rootPid`. Used to disambiguate concurrent agy runs. Returns the
530
+ * single matching id, or null when none/several are open or /proc is
531
+ * unavailable. Ported from pi-antigravity-bridge src/discovery.ts; keep in
532
+ * sync. */
533
+ type OpenDbResolver = (
534
+ rootPid: number,
535
+ dir: string,
536
+ candidates: Set<string>,
537
+ ) => string | null;
538
+
539
+ function readProcStat(pid: number): { pid: number; ppid: number } | null {
540
+ let raw: string;
541
+ try {
542
+ raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
543
+ } catch {
544
+ return null;
545
+ }
546
+ const closeParen = raw.lastIndexOf(")");
547
+ if (closeParen < 0) return null;
548
+ const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
549
+ const ppid = Number(fields[1]);
550
+ if (!Number.isFinite(ppid)) return null;
551
+ return { pid, ppid };
552
+ }
553
+
554
+ function collectDescendants(rootPid: number): Set<number> {
555
+ const out = new Set<number>([rootPid]);
556
+ let entries: string[];
557
+ try {
558
+ entries = fs.readdirSync("/proc");
559
+ } catch {
560
+ return out;
561
+ }
562
+ const ppidOf = new Map<number, number>();
563
+ for (const e of entries) {
564
+ if (!/^\d+$/.test(e)) continue;
565
+ const s = readProcStat(Number(e));
566
+ if (s) ppidOf.set(s.pid, s.ppid);
567
+ }
568
+ let changed = true;
569
+ while (changed) {
570
+ changed = false;
571
+ for (const [pid, ppid] of ppidOf) {
572
+ if (out.has(pid)) continue;
573
+ if (out.has(ppid)) {
574
+ out.add(pid);
575
+ changed = true;
576
+ }
577
+ }
578
+ }
579
+ return out;
580
+ }
581
+
582
+ function safeRealpath(p: string): string | null {
583
+ try {
584
+ return fs.realpathSync(p);
585
+ } catch {
586
+ return null;
587
+ }
588
+ }
589
+
590
+ const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
591
+ if (candidates.size <= 1) return null;
592
+ if (process.platform !== "linux") return null;
593
+ const dirResolved = safeRealpath(dir);
594
+ const tree = collectDescendants(rootPid);
595
+ const found = new Set<string>();
596
+ for (const pid of tree) {
597
+ let fds: string[];
598
+ try {
599
+ fds = fs.readdirSync(`/proc/${pid}/fd`);
600
+ } catch {
601
+ continue;
602
+ }
603
+ for (const fd of fds) {
604
+ let target: string;
605
+ try {
606
+ target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
607
+ } catch {
608
+ continue;
609
+ }
610
+ const base = path.basename(target);
611
+ if (!base.endsWith(".db")) continue;
612
+ if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
613
+ const id = base.slice(0, -3);
614
+ if (candidates.has(id)) found.add(id);
615
+ }
616
+ }
617
+ if (found.size === 1) return [...found][0] ?? null;
618
+ return null;
619
+ };
620
+
621
+ interface BindOptions {
622
+ pid?: number;
623
+ resolveOpenDb?: OpenDbResolver;
624
+ }
625
+
626
+ /** Find the conversation id created since `before`. Returns null when none
627
+ * appeared, or when several appeared and we cannot tie one to our process.
628
+ * Pass `opts.pid` (the spawned agy) to enable concurrent-run disambiguation
629
+ * via the process-tree FD scan. */
630
+ function newConversationId(
631
+ dir: string,
632
+ before: Set<string>,
633
+ opts: BindOptions = {},
634
+ ): string | null {
506
635
  const created = [...snapshotConversations(dir)].filter((id) => !before.has(id));
507
636
  if (created.length === 0) return null;
508
- if (created.length > 1) return null; // ambiguous; refuse to bind
509
- return created[0] ?? null;
637
+ if (created.length === 1) return created[0] ?? null;
638
+ // Ambiguous: try to authoritatively identify ours via the spawned
639
+ // process's open files. Fail safe to null when we can't pick exactly one.
640
+ if (opts.pid !== undefined) {
641
+ const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
642
+ const hit = resolve(opts.pid, dir, new Set(created));
643
+ if (hit && created.includes(hit)) return hit;
644
+ }
645
+ return null;
510
646
  }
511
647
 
512
648
  // --- Extension -------------------------------------------------------------
@@ -560,7 +696,7 @@ export default async function (pi: ExtensionAPI) {
560
696
  ` defaultModel: ${config.defaultModel}`,
561
697
  ` defaultThinking: ${config.defaultThinking}`,
562
698
  ` permissions: ${config.skipPermissions ? "auto-approved" : "prompt"}`,
563
- ` resolved: ${resolveModel(config.defaultModel, discovered, config.defaultThinking) ?? "(agy default)"}`,
699
+ ` resolved: ${resolveModel(config.defaultModel, discovered, config.defaultThinking)?.model ?? "(agy default)"}`,
564
700
  ``,
565
701
  `Edit: ~/.pi/agent/ask-antigravity.json`,
566
702
  ].join("\n"),
@@ -571,7 +707,7 @@ export default async function (pi: ExtensionAPI) {
571
707
 
572
708
  // Resolve the display string for the current default model.
573
709
  const currentResolved =
574
- resolveModel(config.defaultModel, discovered, config.defaultThinking) ?? config.defaultModel;
710
+ resolveModel(config.defaultModel, discovered, config.defaultThinking)?.model ?? config.defaultModel;
575
711
 
576
712
  const items: SettingItem[] = [
577
713
  {
@@ -581,7 +717,7 @@ export default async function (pi: ExtensionAPI) {
581
717
  "Friendly alias resolved to the latest matching agy model. 'flash' = latest Flash, 'pro' = latest Pro, 'gemini' = latest Flash.",
582
718
  currentValue: `${config.defaultModel} → ${currentResolved}`,
583
719
  values: MODEL_OPTIONS.map((m) => {
584
- const r = resolveModel(m, discovered, config.defaultThinking) ?? m;
720
+ const r = resolveModel(m, discovered, config.defaultThinking)?.model ?? m;
585
721
  return `${m} → ${r}`;
586
722
  }),
587
723
  },
@@ -766,7 +902,9 @@ export default async function (pi: ExtensionAPI) {
766
902
  };
767
903
  }
768
904
  const resolved =
769
- resolveModel(requestedModel, discovered, config.defaultThinking) ?? requestedModel;
905
+ resolveModel(requestedModel, discovered, config.defaultThinking) ?? {
906
+ model: requestedModel,
907
+ };
770
908
 
771
909
  const start = Date.now();
772
910
  const cwd = params.cwd || ctx.cwd || process.cwd();
@@ -777,13 +915,13 @@ export default async function (pi: ExtensionAPI) {
777
915
  if (!stat.isDirectory()) {
778
916
  return {
779
917
  content: [{ type: "text", text: `cwd is not a directory: ${cwd}` }],
780
- details: emptyDetails(requestedModel, resolved),
918
+ details: emptyDetails(requestedModel, resolved.model),
781
919
  };
782
920
  }
783
921
  } catch {
784
922
  return {
785
923
  content: [{ type: "text", text: `cwd does not exist: ${cwd}` }],
786
- details: emptyDetails(requestedModel, resolved),
924
+ details: emptyDetails(requestedModel, resolved.model),
787
925
  };
788
926
  }
789
927
 
@@ -816,7 +954,8 @@ export default async function (pi: ExtensionAPI) {
816
954
  const args: string[] = ["--add-dir", cwd];
817
955
  const extra = extraArgs();
818
956
  if (extra.length) args.push(...extra);
819
- if (resolved) args.push("--model", resolved);
957
+ if (resolved.model) args.push("--model", resolved.model);
958
+ if (resolved.effort) args.push("--effort", resolved.effort);
820
959
  args.push("--mode", mode);
821
960
  // accept-edits auto-approves file edits but NOT shell commands, so a
822
961
  // run_command would hang on an unanswerable y/n prompt in non-interactive
@@ -828,7 +967,7 @@ export default async function (pi: ExtensionAPI) {
828
967
 
829
968
  const details: AgyDetails = {
830
969
  model: requestedModel,
831
- resolvedModel: resolved,
970
+ resolvedModel: resolved.model,
832
971
  mode,
833
972
  digest: useDigest,
834
973
  conversationId: isContinuation ? (rawConvId as string) : null,
@@ -859,6 +998,11 @@ export default async function (pi: ExtensionAPI) {
859
998
  : null;
860
999
 
861
1000
  try {
1001
+ // Bind the conversation id DURING the run (agy is alive then) so the
1002
+ // pid-based /proc FD resolver can disambiguate when a concurrent agy
1003
+ // also drops a new .db. Awaited after the run; the post-exit loop
1004
+ // below is the fallback for runs that exit before the poll binds.
1005
+ let bindDuringRun: Promise<void> = Promise.resolve();
862
1006
  const outcome = await new Promise<{
863
1007
  exitCode: number;
864
1008
  aborted: boolean;
@@ -886,6 +1030,28 @@ export default async function (pi: ExtensionAPI) {
886
1030
  details.stderr += d;
887
1031
  });
888
1032
 
1033
+ // Concurrent bind: poll for the new id while agy is alive. The FD
1034
+ // resolver needs a live process tree, so this stops (and the
1035
+ // post-exit fallback below takes over) once agy has exited.
1036
+ if (!isContinuation && snapshot && proc.pid) {
1037
+ bindDuringRun = (async () => {
1038
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
1039
+ if (details.conversationId) return;
1040
+ if (proc.exitCode !== null) return; // agy gone: scan useless now
1041
+ const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
1042
+ pid: proc.pid,
1043
+ });
1044
+ if (found) {
1045
+ details.conversationId = found;
1046
+ return;
1047
+ }
1048
+ await sleep(DISCOVERY_POLL_MS);
1049
+ }
1050
+ })().catch(() => {
1051
+ /* best-effort: a bind error must never fail an otherwise-OK turn */
1052
+ });
1053
+ }
1054
+
889
1055
  let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
890
1056
  let watchdog: ReturnType<typeof setTimeout> | undefined;
891
1057
  let settled = false;
@@ -952,6 +1118,10 @@ export default async function (pi: ExtensionAPI) {
952
1118
 
953
1119
  if (statusInterval) clearInterval(statusInterval);
954
1120
 
1121
+ // Let the during-run bind poll finish (it bails immediately once agy
1122
+ // has exited, so this rarely blocks).
1123
+ await bindDuringRun;
1124
+
955
1125
  details.exitCode = outcome.exitCode;
956
1126
  details.aborted = outcome.aborted;
957
1127
  details.timedOut = outcome.timedOut;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-ask-antigravity",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
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
  }