@estebanforge/pi-antigravity-bridge 1.0.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.
@@ -0,0 +1,227 @@
1
+ # The `pi.invokeTool` local patch
2
+
3
+ > Status: **local, not upstream.** This is a patch to the installed `pi` package
4
+ > that adds one method, `pi.invokeTool(name, args)`, to the extension API. It is
5
+ > what lets the pi-antigravity-bridge expose pi's tools to agy over MCP. It is
6
+ > small, self-contained, and gated by a runtime capability check, so pi runs
7
+ > that lack it are unaffected. An upstream PR is the durable fix (planned).
8
+
9
+ > **Self-applied by the extension.** As of v1.0.0, on first load with the patch
10
+ > missing the bridge **asks you once** whether to apply it (`src/patcher.ts`); if
11
+ > you decline it stays silent and won't ask again until `/agy patch apply`, so
12
+ > you normally never touch these files by hand. The edits below are documented
13
+ > for reference, auditing, and manual recovery. Auto-applier facts:
14
+ > - Targets the **running** pi's `dist/` (located via `realpath(process.argv[1])`
15
+ > and verified by anchor presence), never a sibling extension's decoy copy.
16
+ > - Idempotent and two-phase: validates all anchors before writing, so a pi
17
+ > version that moved the code aborts cleanly with nothing written. The facade
18
+ > file is written **last**, so a crashed/partial patch leaves
19
+ > `hasInvokeTool()` false (safe degraded), never a half-wired chain.
20
+ > - Originals backed up under
21
+ > `~/.pi/agent/antigravity-bridge/pi-patch-backup/<version>-<ts>-<pid>/`.
22
+ > - `/agy patch status|apply|restore` inspects, forces, or rolls it back; restore
23
+ > is version-guarded (refuses across pi versions, no silent downgrade).
24
+ > - Takes effect only after a **full pi restart** (not `/reload`): pi caches its
25
+ > compiled native-ESM core per process.
26
+
27
+ This document is written for an LLM (or human) that needs to understand exactly
28
+ what was changed, where, and why, without re-deriving it from the codebase.
29
+
30
+ ## What it adds
31
+
32
+ One new method on pi's `ExtensionAPI`:
33
+
34
+ ```ts
35
+ pi.invokeTool(name: string, args?: Record<string, unknown>, options?: {
36
+ toolCallId?: string;
37
+ signal?: AbortSignal;
38
+ onUpdate?: (update: unknown) => void;
39
+ }): Promise<{ content: unknown[]; details: unknown; isError?: boolean }>
40
+ ```
41
+
42
+ It looks up a registered tool by name and runs its `execute()` **out-of-band**
43
+ (not inside an agent turn), returning the same `{ content, details, isError? }`
44
+ shape the agent itself produces. Upstream pi already exposes tool *metadata*
45
+ (`pi.getAllTools()`) but not tool *execution*; this is the missing sibling.
46
+
47
+ ## Why the bridge needs it
48
+
49
+ The bridge hosts an MCP server inside pi's process. agy connects to it and asks
50
+ "what tools do you have?" (`tools/list`) and "run this tool" (`tools/call`).
51
+ `tools/list` is served by the already-public `pi.getAllTools()`. `tools/call`
52
+ must actually execute a pi tool, which requires the primitive this patch adds.
53
+ Without it, the bridge can advertise pi's tools but cannot run them, so it
54
+ detects the missing capability at load time and skips the MCP server entirely
55
+ (see `hasInvokeTool()` in `src/mcp-server.ts`).
56
+
57
+ ## The delegation chain (why there are 6 sites)
58
+
59
+ pi's extension API is a facade. A call travels:
60
+
61
+ ```
62
+ pi.invokeTool() (facade, built in loader.js)
63
+ -> runner.invokeTool() (ExtensionRunner, runner.js)
64
+ -> runner.runtime.invokeTool() (shared runtime object)
65
+ -> AgentSession.invokeTool() (the actions bundle wires this in bindCore)
66
+ -> _toolRegistry.get(name).execute(...)
67
+ ```
68
+
69
+ `bindCore` copies session methods onto a shared `runtime` object that all
70
+ extension APIs reference. That indirection is why a single method must be wired
71
+ at **six** places: the implementation, the actions-bundle binding, the runner's
72
+ copy, the runner's delegating method, the facade method, and the type.
73
+
74
+ ## Where it goes (paths are relative to the pi package root)
75
+
76
+ pi ships **compiled** (`dist/`); there is no `src/` to edit. Find the package
77
+ root:
78
+
79
+ ```bash
80
+ node -e "console.log(require('path').dirname(require.resolve('@earendil-works/pi-coding-agent/package.json')))"
81
+ # typical: ~/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent
82
+ ```
83
+
84
+ All paths below are under `<package>/dist/`. Developed against pi `^0.82.1`.
85
+
86
+ ### Site 1 — `core/agent-session.js`: the implementation on `AgentSession`
87
+
88
+ Added immediately after `getToolDefinition(name) { ... }`:
89
+
90
+ ```js
91
+ /**
92
+ * LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
93
+ * out-of-band and return its result. The tool wrapper synthesizes ctx via
94
+ * its ctxFactory when none is passed. Not upstream pi (yet).
95
+ */
96
+ async invokeTool(name, args = {}, options = {}) {
97
+ const tool = this._toolRegistry.get(name);
98
+ if (!tool) {
99
+ throw new Error(`invokeTool: tool "${name}" not found in registry`);
100
+ }
101
+ const toolCallId = options.toolCallId ?? `invokeTool:${name}:${Date.now()}`;
102
+ return tool.execute(toolCallId, args, options.signal ?? undefined, options.onUpdate);
103
+ }
104
+ ```
105
+
106
+ `_toolRegistry` is `Map<string, AgentTool>` and already exists on `AgentSession`.
107
+ Stored tools are **wrapped** (`wrapRegisteredTools(..., runner)` in
108
+ `tool-definition-wrapper.js`); their `execute` is
109
+ `(toolCallId, params, signal, onUpdate, ctx) => definition.execute(..., ctx ?? ctxFactory?.())`.
110
+ By passing only four args (no `ctx`), the wrapper synthesizes a valid
111
+ `ExtensionContext` via the runner's `createContext()`. No per-turn agent state
112
+ is required; this is what makes out-of-band execution safe.
113
+
114
+ ### Site 2 — `core/agent-session.js`: the `bindCore` actions bundle (CRITICAL)
115
+
116
+ Inside the object literal passed to `runner.bindCore({ ... })`, immediately
117
+ after `refreshTools: () => this._refreshToolRegistry(),`:
118
+
119
+ ```js
120
+ invokeTool: (name, args, options) => this.invokeTool(name, args, options),
121
+ ```
122
+
123
+ **This is the easy site to miss.** `bindCore` is the only place session methods
124
+ are wired onto the shared `runtime` object. Without this line,
125
+ `actions.invokeTool` is `undefined`, so site 3 assigns `undefined`, and the
126
+ facade throws `runtime.invokeTool is not a function`.
127
+
128
+ ### Site 3 — `core/extensions/runner.js`: copy onto the shared runtime
129
+
130
+ In `bindCore`, immediately after `this.runtime.refreshTools = actions.refreshTools;`:
131
+
132
+ ```js
133
+ this.runtime.invokeTool = actions.invokeTool;
134
+ ```
135
+
136
+ ### Site 4 — `core/extensions/runner.js`: the delegating method
137
+
138
+ On `ExtensionRunner`, immediately after the `getActiveTools() { ... }` method:
139
+
140
+ ```js
141
+ invokeTool(name, args, options) {
142
+ this.assertActive();
143
+ return this.runtime.invokeTool(name, args, options);
144
+ }
145
+ ```
146
+
147
+ ### Site 5 — `core/extensions/loader.js`: the facade method
148
+
149
+ In the `api` object literal returned to extensions, immediately after the
150
+ `getAllTools() { ... }` entry:
151
+
152
+ ```js
153
+ invokeTool(name, args, options) {
154
+ runtime.assertActive();
155
+ return runtime.invokeTool(name, args, options);
156
+ },
157
+ ```
158
+
159
+ ### Site 6 — `core/extensions/types.d.ts`: the type declaration
160
+
161
+ On the `ExtensionAPI` interface, immediately after `getAllTools(): ToolInfo[];`:
162
+
163
+ ```ts
164
+ /**
165
+ * LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
166
+ * out-of-band and return { content, details, isError? }. ctx is synthesized.
167
+ */
168
+ invokeTool(name: string, args?: Record<string, unknown>, options?: { toolCallId?: string; signal?: AbortSignal; onUpdate?: (update: unknown) => void }): Promise<{ content: unknown[]; details: unknown; isError?: boolean }>;
169
+ ```
170
+
171
+ ## How to apply
172
+
173
+ **You usually don't.** On first load, the extension asks you once whether to
174
+ apply the patch (`src/patcher.ts`); see the note at the top of this document.
175
+ The manual steps below are for reference, auditing, or recovering without the
176
+ extension loaded.
177
+
178
+ The patch is plain edits to the six compiled files above. Because pi ships no
179
+ `src/`, there is nothing to recompile. (The old "clear jiti's cache" step is a
180
+ no-op on pi 0.82.1, which sets `moduleCache: false` in its extension loader, so
181
+ jiti never writes an fs cache.)
182
+
183
+ A `pi` reinstall or update overwrites `dist/` and **removes the patch**, but the
184
+ extension re-applies it on the next start (then prompts you to restart pi).
185
+ Durable fix: merge it upstream.
186
+
187
+ ## How to verify
188
+
189
+ 1. The facade has the method:
190
+
191
+ ```bash
192
+ pi -e /path/to/some-ext.ts --list-models # ext logs typeof pi.invokeTool
193
+ # expect: "function"
194
+ ```
195
+
196
+ 2. It executes a real tool. Quickest: a tiny extension that on `session_start`
197
+ calls `pi.invokeTool("read", { path: <some file> })` and prints the result,
198
+ run via `pi -e that-ext.ts --mode rpc` (RPC mode fires `session_start`
199
+ without a model turn). The returned `content` should hold the file text.
200
+ (Note: print/`-p` mode hangs on a remote default model before `session_start`
201
+ fires; RPC mode avoids that.)
202
+
203
+ ## Capability gate (how the bridge stays safe without it)
204
+
205
+ `src/mcp-server.ts` checks at load time:
206
+
207
+ ```ts
208
+ export function hasInvokeTool(pi: ExtensionAPI): boolean {
209
+ return typeof (pi as unknown as { invokeTool?: unknown }).invokeTool === "function";
210
+ }
211
+ ```
212
+
213
+ If false, `startMcpServer()` returns `{ ok: false }` immediately and the bridge
214
+ runs exactly as it did before this feature existed: provider + `AskAntigravity`
215
+ tool, no MCP server. So an unpatched pi, or a pi updated past the patch, degrades
216
+ gracefully rather than crashing.
217
+
218
+ ## Scope and risk
219
+
220
+ - Adds one read-only-by-convention method. Does not modify existing behavior,
221
+ tool registration, the agent loop, or tool results.
222
+ - Calls the existing wrapped `execute` with a synthesized ctx; tools that depend
223
+ on live per-turn agent state (an active abort signal, the current message)
224
+ get a quiescent ctx. This is fine for the stateless tools the bridge exposes
225
+ (memory, codegraph, search, delegations) and is the same synthesis pi itself
226
+ uses for out-of-band tool execution.
227
+ - No new dependencies; no changes to persisted state or session format.