@estebanforge/pi-antigravity-bridge 1.2.6 → 1.3.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/src/mcp-server.ts CHANGED
@@ -15,16 +15,14 @@
15
15
  // the loopback server. Combined with 127.0.0.1 binding.
16
16
  // - Request body size cap.
17
17
  //
18
- // CAPABILITY GATE: pi.invokeTool is NOT upstream pi (local patch, see
19
- // docs/PI-INVOKETOOL-PATCH.md). If absent, startMcpServer returns { ok:false }
20
- // and the bridge runs unchanged.
18
+ // The bridge routes calls through the provider's toolUse round-trip; it
19
+ // needs no privileged pi API.
21
20
 
22
21
  import http from "node:http";
23
22
  import fs from "node:fs";
24
23
  import os from "node:os";
25
24
  import path from "node:path";
26
25
  import crypto from "node:crypto";
27
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
26
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
27
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
30
28
  import {
@@ -54,9 +52,20 @@ export interface McpStartResult {
54
52
  reason?: string;
55
53
  }
56
54
 
57
- /** True only if the running pi exposes the local invokeTool patch. */
58
- export function hasInvokeTool(pi: ExtensionAPI): boolean {
59
- return typeof (pi as unknown as { invokeTool?: unknown }).invokeTool === "function";
55
+ /** Provider-owned bridge surface. The provider builds the tool catalog
56
+ * (config-filtered: none|mcp|all + skills) and owns the toolUse round-trip:
57
+ * onToolCall parks the call, ends the pi assistant message with stopReason
58
+ * "toolUse" for the REAL pi tool, and resolves when pi hands back the
59
+ * toolResult on the next stream call. Fail-closed: the provider enforces a
60
+ * 480s timeout and rejects when no agy turn is active. */
61
+ export interface McpBridgeDeps {
62
+ listTools(): Array<{ name: string; description: string; inputSchema: object }>;
63
+ onToolCall(
64
+ callId: string,
65
+ name: string,
66
+ args: Record<string, unknown>,
67
+ signal: AbortSignal,
68
+ ): Promise<{ content: Array<{ type: string; text?: string }>; isError: boolean }>;
60
69
  }
61
70
 
62
71
  /** Clamp an unsupported MCP-Protocol-Version header down to the SDK's LATEST.
@@ -83,18 +92,6 @@ function clampProtocolVersionHeader(req: http.IncomingMessage): void {
83
92
  }
84
93
  }
85
94
 
86
- interface PiToolMeta {
87
- name: string;
88
- description?: string;
89
- parameters?: object;
90
- sourceInfo?: { source?: string };
91
- }
92
-
93
- interface InvokeResult {
94
- content?: Array<{ type: string; text?: string }>;
95
- isError?: boolean;
96
- }
97
-
98
95
  const BRIDGE_BASE = path.join(os.homedir(), ".pi", "agent", "antigravity-bridge");
99
96
 
100
97
  /** Per-process config dir. Each pi session owns its own file, so concurrent
@@ -240,52 +237,35 @@ export function registerExitCleanup(
240
237
  }
241
238
 
242
239
  export async function startMcpServer(
243
- pi: ExtensionAPI,
240
+ deps: McpBridgeDeps,
244
241
  opts: { preferredPort?: number; log?: (s: string, d?: unknown) => void } = {},
245
242
  ): Promise<McpStartResult> {
246
243
  const log = opts.log ?? (() => {});
247
244
 
248
- if (!hasInvokeTool(pi)) {
249
- const reason =
250
- "pi.invokeTool unavailable (needs the local pi patch). MCP tool bridge disabled; provider and AskAntigravity tool run unchanged.";
251
- log("capability-missing", reason);
252
- return { ok: false, reason };
253
- }
254
-
255
- const getAll = (pi as unknown as { getAllTools: () => PiToolMeta[] }).getAllTools.bind(pi);
256
- const invoke =
257
- (pi as unknown as { invokeTool: (n: string, a?: unknown, o?: { signal?: AbortSignal }) => Promise<InvokeResult> }).invokeTool.bind(pi);
258
-
259
245
  const listHandler = async () => {
260
- const all = getAll();
261
- const tools = all
262
- .filter((t) => t.sourceInfo?.source !== "builtin")
263
- .filter((t) => !SKIP_CIRCULAR.has(t.name))
264
- .map((t) => {
265
- let inputSchema: object | undefined;
266
- try {
267
- inputSchema = t.parameters ? JSON.parse(JSON.stringify(t.parameters)) : undefined;
268
- } catch {
269
- inputSchema = { type: "object", properties: {}, additionalProperties: true };
270
- }
271
- return { name: t.name, description: t.description ?? t.name, inputSchema };
272
- });
246
+ const tools = deps.listTools();
273
247
  log("list-tools", { count: tools.length });
274
248
  return { tools };
275
249
  };
276
250
 
277
251
  const callHandler = async (request: { params: { name: string; arguments?: unknown } }, signal?: AbortSignal) => {
278
252
  const { name, arguments: args } = request.params;
279
- log("call-tool", { name });
253
+ const callId = crypto.randomUUID();
254
+ log("call-tool", { name, callId });
280
255
  try {
281
- const r = await invoke(name, args ?? {}, { signal });
256
+ const r = await deps.onToolCall(
257
+ callId,
258
+ name,
259
+ (args && typeof args === "object" ? args : {}) as Record<string, unknown>,
260
+ signal ?? new AbortController().signal,
261
+ );
282
262
  const content =
283
263
  r.content && r.content.length > 0 ? r.content : [{ type: "text", text: JSON.stringify(r) }];
284
- log("call-tool-ok", { name });
264
+ log("call-tool-ok", { name, callId });
285
265
  return { content, isError: r.isError ?? false };
286
266
  } catch (e) {
287
267
  const msg = e instanceof Error ? e.message : String(e);
288
- log("call-tool-fail", { name, msg });
268
+ log("call-tool-fail", { name, callId, msg });
289
269
  return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
290
270
  }
291
271
  };
@@ -0,0 +1,104 @@
1
+ // Native re-execution mapping - agy read-only tools whose work pi can redo
2
+ // cheaply and safely with its own builtins. The provider emits these as native
3
+ // pi toolCalls (real builtin name, pi-schema args); pi executes the real
4
+ // builtin, so cards render with pi's own renderers.
5
+ //
6
+ // Mutating tools (commands, edits, writes) and agy-specialty tools are never
7
+ // re-executed - they replay through the display-only `antigravity` wrapper
8
+ // tool. Ported from tianzuo/pi-antigravity lib/native-tools.ts (MIT).
9
+
10
+ export interface NativeToolCall {
11
+ /** pi builtin tool name (`read`, `ls`, `grep`, `find`). */
12
+ tool: string;
13
+ /** Arguments conforming to the builtin's schema. */
14
+ args: Record<string, unknown>;
15
+ }
16
+
17
+ function str(input: Record<string, unknown>, keys: string[]): string | undefined {
18
+ for (const key of keys) {
19
+ const value = input[key];
20
+ if (typeof value === "string" && value.trim()) return value;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function num(input: Record<string, unknown>, keys: string[]): number | undefined {
26
+ for (const key of keys) {
27
+ const value = input[key];
28
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ function onlyKeys(input: Record<string, unknown>, allowed: readonly string[]): boolean {
34
+ const accepted = new Set(allowed);
35
+ return Object.entries(input).every(
36
+ ([key, value]) => value === undefined || value === null || accepted.has(key),
37
+ );
38
+ }
39
+
40
+ const VIEW_PATH_KEYS = [
41
+ "path",
42
+ "Path",
43
+ "AbsolutePath",
44
+ "absolute_path",
45
+ "TargetFile",
46
+ "target_file",
47
+ "FilePath",
48
+ "file_path",
49
+ ] as const;
50
+ const START_LINE_KEYS = ["StartLine", "start_line", "startLine"] as const;
51
+ const END_LINE_KEYS = ["EndLine", "end_line", "endLine"] as const;
52
+
53
+ /** Map an agy tool step to a native pi toolCall when re-execution is safe.
54
+ * Returns undefined for everything that must stay on the replay wrapper. */
55
+ export function mapAgyToolToNative(
56
+ tool: string,
57
+ args: Record<string, unknown>,
58
+ ): NativeToolCall | undefined {
59
+ switch (tool) {
60
+ case "view_file": {
61
+ if (!onlyKeys(args, [...VIEW_PATH_KEYS, ...START_LINE_KEYS, ...END_LINE_KEYS])) {
62
+ return undefined;
63
+ }
64
+ const path = str(args, [...VIEW_PATH_KEYS]);
65
+ const start = num(args, [...START_LINE_KEYS]);
66
+ const end = num(args, [...END_LINE_KEYS]);
67
+ if (!path || (end !== undefined && start === undefined) || (start && end && end < start)) {
68
+ return undefined;
69
+ }
70
+ return {
71
+ tool: "read",
72
+ args: {
73
+ path,
74
+ ...(start === undefined ? {} : { offset: start }),
75
+ ...(start === undefined || end === undefined ? {} : { limit: end - start + 1 }),
76
+ },
77
+ };
78
+ }
79
+ case "list_dir": {
80
+ const keys = ["path", "Path", "DirectoryPath", "directory", "Directory"];
81
+ if (!onlyKeys(args, keys)) return undefined;
82
+ const path = str(args, keys);
83
+ return path ? { tool: "ls", args: { path } } : undefined;
84
+ }
85
+ case "grep_search": {
86
+ const patternKeys = ["query", "Query", "pattern", "Pattern"];
87
+ const pathKeys = ["search_path", "SearchPath", "path", "Path"];
88
+ if (!onlyKeys(args, [...patternKeys, ...pathKeys])) return undefined;
89
+ const pattern = str(args, patternKeys);
90
+ const path = str(args, pathKeys);
91
+ return pattern ? { tool: "grep", args: path ? { pattern, path } : { pattern } } : undefined;
92
+ }
93
+ case "find_by_name": {
94
+ const patternKeys = ["pattern", "Pattern", "glob", "name"];
95
+ const pathKeys = ["search_directory", "SearchDirectory", "path", "Path", "directory"];
96
+ if (!onlyKeys(args, [...patternKeys, ...pathKeys])) return undefined;
97
+ const pattern = str(args, patternKeys);
98
+ const path = str(args, pathKeys);
99
+ return pattern ? { tool: "find", args: path ? { pattern, path } : { pattern } } : undefined;
100
+ }
101
+ default:
102
+ return undefined;
103
+ }
104
+ }
@@ -1,4 +1,15 @@
1
- // Self-applier for the pi.invokeTool local patch (docs/PI-INVOKETOOL-PATCH.md).
1
+ // Cleanup for the LEGACY pi.invokeTool local patch (removed in 1.3.0).
2
+ //
3
+ // Users who ran the old consent-gated patcher still carry pi.invokeTool in
4
+ // their installed pi dist/. It is inert (nothing calls it) and a pi
5
+ // update/reinstall wipes it, but it is a silent vendor modification. This
6
+ // module detects it and restores the original files from the versioned
7
+ // backup the patcher left behind - only on an explicit /agy patch-cleanup
8
+ // command. It never writes to the pi install on its own.
9
+ //
10
+ // Everything below (site table, sentinels, findPiRoot, version-guarded
11
+ // restore) is carried over verbatim from the deleted patcher so detection
12
+ // and restore stay byte-compatible with what users actually have on disk.
2
13
  //
3
14
  // Adds pi.invokeTool() at 6 sites in 4 compiled files under pi's dist/, plus
4
15
  // (pi 0.84.3+) an entry redirect that swaps the bundled dist/bundle/cli.js
@@ -365,8 +376,14 @@ function describeWriteError(err: unknown, root: string): string {
365
376
  return err instanceof Error ? err.message : String(err);
366
377
  }
367
378
 
368
- /** Find the newest backup dir (by VERSION file mtime), if any. */
369
- function findNewestBackup(base: string): { dir: string; version: string } | null {
379
+ /** Find the best backup dir: an exact version match when `preferVersion` is
380
+ * given, else the newest by VERSION mtime. Multi-version backup dirs are
381
+ * normal (the old patcher re-applied across pi upgrades), so newest-by-mtime
382
+ * alone could pick a backup that cannot legally restore. */
383
+ function findNewestBackup(
384
+ base: string,
385
+ preferVersion?: string,
386
+ ): { dir: string; version: string } | null {
370
387
  let entries: string[];
371
388
  try {
372
389
  entries = fs.readdirSync(base);
@@ -381,6 +398,9 @@ function findNewestBackup(base: string): { dir: string; version: string } | null
381
398
  const stat = fs.statSync(verFile);
382
399
  const manifest = JSON.parse(fs.readFileSync(verFile, "utf8"));
383
400
  if (typeof manifest.version !== "string") continue;
401
+ if (preferVersion && manifest.version === preferVersion) {
402
+ return { dir, version: manifest.version };
403
+ }
384
404
  if (!best || stat.mtimeMs > best.mtime) {
385
405
  best = { dir, version: manifest.version, mtime: stat.mtimeMs };
386
406
  }
@@ -433,7 +453,7 @@ export function patchStatus(opts: { root?: string; backupBase?: string } = {}):
433
453
  if (!txt.includes(ENTRY_REDIRECT.sentinel)) missing.push(ENTRY_REDIRECT.file);
434
454
  }
435
455
  // Absent file = pre-bundle pi: the bin already points at the modular entry.
436
- const backup = findNewestBackup(backupBaseOf(opts));
456
+ const backup = findNewestBackup(backupBaseOf(opts), root.version);
437
457
  return {
438
458
  present: missing.length === 0,
439
459
  root: root.root,
@@ -455,198 +475,6 @@ export type PatchAction =
455
475
 
456
476
  /** Decide the session_start patch action. Precedence:
457
477
  * live > on-disk-needs-restart > declined > interactive-ask > headless-skip. */
458
- export function decidePatchAction(
459
- live: boolean,
460
- diskPresent: boolean,
461
- declined: boolean,
462
- hasUI: boolean,
463
- ): PatchAction {
464
- if (live) return { kind: "proceed" };
465
- if (diskPresent) return { kind: "notify-restart" };
466
- if (declined) return { kind: "silent" };
467
- return hasUI ? { kind: "ask" } : { kind: "headless-skip" };
468
- }
469
-
470
- /** Apply the patch idempotently. Two-phase: validate all anchors first, write
471
- * only if something is missing. Facade file is written last. */
472
- export function applyInvokeToolPatch(opts: PatchOpts = {}): PatchResult {
473
- const log = opts.log ?? (() => {});
474
- const errors: string[] = [];
475
- const changedFiles: string[] = [];
476
-
477
- const found = resolveRoot(opts);
478
- if (!found) {
479
- const msg =
480
- "could not locate the running pi package root (tried argv, import.meta.resolve, npm root -g, and known paths, none verified). Is pi installed? For a bundled-binary pi there are no JS files to patch.";
481
- log("root-not-found", msg);
482
- return { present: false, patched: false, alreadyPresent: false, changedFiles, errors: [msg] };
483
- }
484
- const { root, version } = found;
485
- log("root", { root, version });
486
-
487
- // Phase 1: read every file, validate anchors/sentinels, build the change plan.
488
- type Plan = { file: string; original: string; next: string; changed: boolean };
489
- const plan: Plan[] = [];
490
- for (const f of PATCH_FILES) {
491
- const filePath = path.join(root, "dist", f.file);
492
- let original: string;
493
- try {
494
- original = fs.readFileSync(filePath, "utf8");
495
- } catch (e) {
496
- errors.push(`cannot read ${f.file}: ${e instanceof Error ? e.message : String(e)}`);
497
- return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
498
- }
499
- let next = original;
500
- let changed = false;
501
- for (const site of f.sites) {
502
- if (!siteMissing(original, site)) continue; // already patched at this site
503
- if (!original.includes(site.anchor)) {
504
- // Version drift: the code pi ships moved/renamed. Abort cleanly.
505
- const msg =
506
- `anchor not found in ${f.file} (pi ${version}). pi's structure changed; ` +
507
- `the patch needs updating for this version. No files were written. ` +
508
- `See docs/PI-INVOKETOOL-PATCH.md.`;
509
- errors.push(msg);
510
- log("anchor-missing", { file: f.file, version });
511
- return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
512
- }
513
- next = next.replace(site.anchor, site.anchor + site.insertion);
514
- changed = true;
515
- }
516
- plan.push({ file: f.file, original, next, changed });
517
- }
518
-
519
- // Phase 1b: entry redirect. An absent file means pre-bundle pi; skip.
520
- // Compute the previous backup now: the copy-forward in phase 2 needs it,
521
- // and the already-redirected warning below needs to know whether any
522
- // backup still holds the original entry bytes.
523
- const prevBackup = findNewestBackup(backupBaseOf(opts));
524
- let entry: { original: string } | null = null;
525
- const entryPath = path.join(root, "dist", ENTRY_REDIRECT.file);
526
- if (fs.existsSync(entryPath)) {
527
- const original = fs.readFileSync(entryPath, "utf8");
528
- if (!original.includes(ENTRY_REDIRECT.sentinel)) {
529
- if (!original.includes(ENTRY_REDIRECT.probe)) {
530
- const msg =
531
- `unexpected content in ${ENTRY_REDIRECT.file} (pi ${version}); it does not look like ` +
532
- `pi's bundled entry. No files were written. See docs/PI-INVOKETOOL-PATCH.md.`;
533
- errors.push(msg);
534
- log("entry-unexpected", { file: ENTRY_REDIRECT.file, version });
535
- return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
536
- }
537
- if (!fs.existsSync(path.join(root, "dist", "cli.js"))) {
538
- const msg =
539
- `modular dist/cli.js not found (pi ${version}); the bundled entry has nowhere to ` +
540
- `redirect to. No files were written.`;
541
- errors.push(msg);
542
- log("entry-target-missing", { version });
543
- return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
544
- }
545
- entry = { original };
546
- } else if (!prevBackup || !fs.existsSync(path.join(prevBackup.dir, ENTRY_REDIRECT.file))) {
547
- // Already redirected, but no backup holds the original entry. Repair
548
- // still proceeds (core sites matter); only the entry becomes unrestorable.
549
- const msg =
550
- `entry redirect already applied but no backup holds the original ${ENTRY_REDIRECT.file}; ` +
551
- `/agy patch restore cannot revert it. Reinstall pi to fully revert.`;
552
- errors.push(msg);
553
- log("entry-original-missing", { file: ENTRY_REDIRECT.file });
554
- }
555
- }
556
- // Absent entry file: pre-bundle pi, the bin already points at the modular entry.
557
-
558
- const anyChange = plan.some((p) => p.changed) || entry !== null;
559
- if (!anyChange) {
560
- log("already-present", { root, version });
561
- return { present: true, patched: false, alreadyPresent: true, changedFiles, errors, root, version };
562
- }
563
-
564
- // Phase 2: back up originals (one VERSION-stamped dir), then write facade-last.
565
- const stamp = `${version}-${Date.now()}-${process.pid}`;
566
- const backupDir = path.join(backupBaseOf(opts), stamp);
567
- try {
568
- fs.mkdirSync(backupDir, { recursive: true });
569
- fs.writeFileSync(
570
- path.join(backupDir, "VERSION"),
571
- `${JSON.stringify({ version, root, createdAt: new Date().toISOString() }, null, 2)}\n`,
572
- );
573
- // Copy-forward: seed the new backup with the previous SAME-version backup
574
- // so it stays complete even when this run changes only some files (a
575
- // repair run after a partial patch). Without this, a repair that does not
576
- // touch the entry would create a backup lacking it, and a later restore
577
- // would silently skip the entry redirect.
578
- if (prevBackup && prevBackup.version === version) {
579
- for (const rel of [...PATCH_FILES.map((f) => f.file), ENTRY_REDIRECT.file]) {
580
- const src = path.join(prevBackup.dir, rel);
581
- if (!fs.existsSync(src)) continue;
582
- const dst = path.join(backupDir, rel);
583
- fs.mkdirSync(path.dirname(dst), { recursive: true });
584
- fs.copyFileSync(src, dst);
585
- }
586
- }
587
- // Overlay the pre-change bytes ONLY for files this run is about to write,
588
- // so already-patched files keep their pristine bytes from the older backup
589
- // instead of capturing current (patched) bytes.
590
- for (const p of plan) if (p.changed) backupOriginal(p.file, p.original, backupDir);
591
- if (entry) backupOriginal(ENTRY_REDIRECT.file, entry.original, backupDir);
592
- log("backup-written", { backupDir });
593
- } catch (e) {
594
- const msg = `backup failed (${e instanceof Error ? e.message : String(e)}); aborting before any write.`;
595
- errors.push(msg);
596
- return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
597
- }
598
-
599
- // Write in declared order (facade/loader.js is last in PATCH_FILES).
600
- for (const p of plan) {
601
- if (!p.changed) continue;
602
- const filePath = path.join(root, "dist", p.file);
603
- try {
604
- atomicWrite(filePath, p.next);
605
- changedFiles.push(p.file);
606
- log("file-patched", { file: p.file });
607
- } catch (e) {
608
- const msg = describeWriteError(e, root);
609
- errors.push(`failed writing ${p.file}: ${msg}`);
610
- log("write-failed", { file: p.file, code: (e as NodeJS.ErrnoException)?.code });
611
- // Stop here; earlier files in this run are already patched and backed
612
- // up, so a retry (or restore) recovers cleanly. hasInvokeTool() stays
613
- // false unless the facade (last file) already succeeded.
614
- break;
615
- }
616
- }
617
-
618
- // The entry redirect goes LAST: it is the activator. Until it lands, the
619
- // patched core stays inert (the bundle still runs), so a partial run fails
620
- // closed exactly like a facade-only failure. Never written after any write
621
- // error: activating the modular runtime is the accepted tradeoff of a
622
- // SUCCESSFUL patch, not of a failed one.
623
- if (entry && errors.length === 0) {
624
- try {
625
- atomicWrite(entryPath, ENTRY_REDIRECT.content);
626
- changedFiles.push(ENTRY_REDIRECT.file);
627
- log("entry-redirected", { file: ENTRY_REDIRECT.file });
628
- } catch (e) {
629
- const msg = describeWriteError(e, root);
630
- errors.push(`failed writing ${ENTRY_REDIRECT.file}: ${msg}`);
631
- log("write-failed", { file: ENTRY_REDIRECT.file, code: (e as NodeJS.ErrnoException)?.code });
632
- }
633
- }
634
-
635
- const status = patchStatus({ root, backupBase: opts.backupBase });
636
- return {
637
- present: status.present,
638
- patched: changedFiles.length > 0,
639
- alreadyPresent: false,
640
- changedFiles,
641
- backupDir,
642
- errors,
643
- root,
644
- version,
645
- };
646
- }
647
-
648
- /** Restore the most recent backup. Refuses if the installed pi version differs
649
- * from the backup's version (prevents silently downgrading shipped core). */
650
478
  export function restorePatch(opts: PatchOpts = {}): RestoreResult {
651
479
  const log = opts.log ?? (() => {});
652
480
  const found = resolveRoot(opts);
@@ -654,7 +482,7 @@ export function restorePatch(opts: PatchOpts = {}): RestoreResult {
654
482
  return { ok: false, restoredFiles: [], reason: "could not locate the running pi package root to restore into." };
655
483
  }
656
484
  const { root, version } = found;
657
- const backup = findNewestBackup(backupBaseOf(opts));
485
+ const backup = findNewestBackup(backupBaseOf(opts), found.version);
658
486
  if (!backup) {
659
487
  return { ok: false, restoredFiles: [], reason: "no backup found; nothing to restore." };
660
488
  }