@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/CHANGELOG.md +28 -0
- package/README.md +30 -2
- package/docs/ARCHITECTURE.md +0 -1
- package/extensions/index.ts +199 -139
- package/package.json +1 -1
- package/src/config.ts +55 -5
- package/src/driver.ts +644 -0
- package/src/mcp-server.ts +28 -48
- package/src/native-tools.ts +104 -0
- package/src/{patcher.ts → patch-cleanup.ts} +25 -197
- package/src/provider.ts +447 -24
- package/src/skills.ts +116 -0
- package/src/stream-events.ts +123 -0
- package/docs/PI-INVOKETOOL-PATCH.md +0 -254
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// agy stream-json NDJSON event types + parser.
|
|
2
|
+
//
|
|
3
|
+
// `agy --input-format stream-json --output-format stream-json` emits one JSON
|
|
4
|
+
// object per line on stdout: `init` (conversation binding), `step_update`
|
|
5
|
+
// (user_input / agent_response / checkpoint / tool steps), `result` (terminal).
|
|
6
|
+
// Shapes captured from live output and cross-checked against
|
|
7
|
+
// tianzuo/pi-antigravity lib/events.ts (MIT). Unknown event kinds parse as
|
|
8
|
+
// {kind:"unknown"} so a future agy release degrades instead of crashing the
|
|
9
|
+
// reader loop.
|
|
10
|
+
|
|
11
|
+
export interface AgyUsage {
|
|
12
|
+
input_tokens?: number;
|
|
13
|
+
output_tokens?: number;
|
|
14
|
+
thinking_tokens?: number;
|
|
15
|
+
cache_read_tokens?: number;
|
|
16
|
+
total_tokens?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type AgyStepState = "ACTIVE" | "DONE" | "ERROR" | string;
|
|
20
|
+
export type AgyStepType = "user_input" | "checkpoint" | "agent_response" | "tool" | (string & {});
|
|
21
|
+
|
|
22
|
+
export interface AgyToolInfo {
|
|
23
|
+
name?: string;
|
|
24
|
+
parameters?: Record<string, unknown>;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AgyStepUpdate {
|
|
29
|
+
step_index?: number;
|
|
30
|
+
state?: AgyStepState;
|
|
31
|
+
step_type?: AgyStepType;
|
|
32
|
+
conversation_id?: string;
|
|
33
|
+
tool_name?: string;
|
|
34
|
+
tool_info?: AgyToolInfo;
|
|
35
|
+
response_text?: string;
|
|
36
|
+
/** Live agy (1.1.13+): agent_response deltas arrive here, not in response_text. */
|
|
37
|
+
text_delta?: string;
|
|
38
|
+
thought_text?: string;
|
|
39
|
+
thinking_tokens?: number;
|
|
40
|
+
thinking_signature?: string;
|
|
41
|
+
duration_seconds?: number;
|
|
42
|
+
error_message?: string;
|
|
43
|
+
usage?: AgyUsage;
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgyResult {
|
|
48
|
+
status?: string;
|
|
49
|
+
response?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
usage?: AgyUsage;
|
|
52
|
+
conversation_id?: string;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ParsedAgyEvent =
|
|
57
|
+
| { kind: "init"; conversationId?: string; usage?: AgyUsage }
|
|
58
|
+
| { kind: "step"; step: AgyStepUpdate }
|
|
59
|
+
| { kind: "result"; result: AgyResult }
|
|
60
|
+
| { kind: "unknown"; raw: unknown };
|
|
61
|
+
|
|
62
|
+
/** Parse one stdout line. Never throws; bad JSON / non-objects become "unknown". */
|
|
63
|
+
export function parseAgyLine(line: string): ParsedAgyEvent {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed) return { kind: "unknown", raw: null };
|
|
66
|
+
let obj: unknown;
|
|
67
|
+
try {
|
|
68
|
+
obj = JSON.parse(trimmed);
|
|
69
|
+
} catch {
|
|
70
|
+
return { kind: "unknown", raw: trimmed.slice(0, 200) };
|
|
71
|
+
}
|
|
72
|
+
if (typeof obj !== "object" || obj === null) return { kind: "unknown", raw: obj };
|
|
73
|
+
const rec = obj as Record<string, unknown>;
|
|
74
|
+
if (rec.event === "init" && typeof rec.init === "object" && rec.init !== null) {
|
|
75
|
+
const init = rec.init as Record<string, unknown>;
|
|
76
|
+
return {
|
|
77
|
+
kind: "init",
|
|
78
|
+
conversationId:
|
|
79
|
+
typeof init.conversation_id === "string" ? init.conversation_id : undefined,
|
|
80
|
+
usage: isUsage(init.usage) ? init.usage : undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (rec.event === "step_update" && typeof rec.step_update === "object" && rec.step_update !== null) {
|
|
84
|
+
return { kind: "step", step: rec.step_update as AgyStepUpdate };
|
|
85
|
+
}
|
|
86
|
+
if (rec.event === "result" && typeof rec.result === "object" && rec.result !== null) {
|
|
87
|
+
return { kind: "result", result: rec.result as AgyResult };
|
|
88
|
+
}
|
|
89
|
+
// Tolerate top-level shorthand: some agy builds put the payload at the root.
|
|
90
|
+
if (rec.event === "init") {
|
|
91
|
+
return {
|
|
92
|
+
kind: "init",
|
|
93
|
+
conversationId: typeof rec.conversation_id === "string" ? rec.conversation_id : undefined,
|
|
94
|
+
usage: isUsage(rec.usage) ? (rec.usage as AgyUsage) : undefined,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (typeof rec.step_type === "string") return { kind: "step", step: rec as AgyStepUpdate };
|
|
98
|
+
if (typeof rec.status === "string") return { kind: "result", result: rec as AgyResult };
|
|
99
|
+
return { kind: "unknown", raw: obj };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isUsage(u: unknown): u is AgyUsage {
|
|
103
|
+
return typeof u === "object" && u !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Map agy usage onto pi-ai's Usage (cost stays zero: subscription quota). */
|
|
107
|
+
export function toPiUsage(
|
|
108
|
+
u: AgyUsage | undefined,
|
|
109
|
+
piUsage: {
|
|
110
|
+
input: number;
|
|
111
|
+
output: number;
|
|
112
|
+
cacheRead: number;
|
|
113
|
+
cacheWrite: number;
|
|
114
|
+
totalTokens: number;
|
|
115
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
|
116
|
+
},
|
|
117
|
+
): void {
|
|
118
|
+
if (!u) return;
|
|
119
|
+
piUsage.input = u.input_tokens ?? piUsage.input;
|
|
120
|
+
piUsage.output = u.output_tokens ?? piUsage.output;
|
|
121
|
+
piUsage.cacheRead = u.cache_read_tokens ?? piUsage.cacheRead;
|
|
122
|
+
piUsage.totalTokens = u.total_tokens ?? piUsage.input + piUsage.output;
|
|
123
|
+
}
|
|
@@ -1,254 +0,0 @@
|
|
|
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
|
-
Since pi 0.84.3 there is a **seventh edit**, not part of the chain but the
|
|
75
|
-
switch that makes the chain reachable: the entry redirect in
|
|
76
|
-
`bundle/cli.js` (see Site 7).
|
|
77
|
-
|
|
78
|
-
## Where it goes (paths are relative to the pi package root)
|
|
79
|
-
|
|
80
|
-
pi ships **compiled** (`dist/`); there is no `src/` to edit. Find the package
|
|
81
|
-
root:
|
|
82
|
-
|
|
83
|
-
```bash
|
|
84
|
-
node -e "console.log(require('path').dirname(require.resolve('@earendil-works/pi-coding-agent/package.json')))"
|
|
85
|
-
# typical: ~/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
All paths below are under `<package>/dist/`. Developed against pi `^0.82.1`;
|
|
89
|
-
anchors verified against `0.84.3` (the `loader.js` facade switched to a local
|
|
90
|
-
`assertActive()` guard in 0.84.3).
|
|
91
|
-
|
|
92
|
-
### Site 1 — `core/agent-session.js`: the implementation on `AgentSession`
|
|
93
|
-
|
|
94
|
-
Added immediately after `getToolDefinition(name) { ... }`:
|
|
95
|
-
|
|
96
|
-
```js
|
|
97
|
-
/**
|
|
98
|
-
* LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
|
|
99
|
-
* out-of-band and return its result. The tool wrapper synthesizes ctx via
|
|
100
|
-
* its ctxFactory when none is passed. Not upstream pi (yet).
|
|
101
|
-
*/
|
|
102
|
-
async invokeTool(name, args = {}, options = {}) {
|
|
103
|
-
const tool = this._toolRegistry.get(name);
|
|
104
|
-
if (!tool) {
|
|
105
|
-
throw new Error(`invokeTool: tool "${name}" not found in registry`);
|
|
106
|
-
}
|
|
107
|
-
const toolCallId = options.toolCallId ?? `invokeTool:${name}:${Date.now()}`;
|
|
108
|
-
return tool.execute(toolCallId, args, options.signal ?? undefined, options.onUpdate);
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
`_toolRegistry` is `Map<string, AgentTool>` and already exists on `AgentSession`.
|
|
113
|
-
Stored tools are **wrapped** (`wrapRegisteredTools(..., runner)` in
|
|
114
|
-
`tool-definition-wrapper.js`); their `execute` is
|
|
115
|
-
`(toolCallId, params, signal, onUpdate, ctx) => definition.execute(..., ctx ?? ctxFactory?.())`.
|
|
116
|
-
By passing only four args (no `ctx`), the wrapper synthesizes a valid
|
|
117
|
-
`ExtensionContext` via the runner's `createContext()`. No per-turn agent state
|
|
118
|
-
is required; this is what makes out-of-band execution safe.
|
|
119
|
-
|
|
120
|
-
### Site 2 — `core/agent-session.js`: the `bindCore` actions bundle (CRITICAL)
|
|
121
|
-
|
|
122
|
-
Inside the object literal passed to `runner.bindCore({ ... })`, immediately
|
|
123
|
-
after `refreshTools: () => this._refreshToolRegistry(),`:
|
|
124
|
-
|
|
125
|
-
```js
|
|
126
|
-
invokeTool: (name, args, options) => this.invokeTool(name, args, options),
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
**This is the easy site to miss.** `bindCore` is the only place session methods
|
|
130
|
-
are wired onto the shared `runtime` object. Without this line,
|
|
131
|
-
`actions.invokeTool` is `undefined`, so site 3 assigns `undefined`, and the
|
|
132
|
-
facade throws `runtime.invokeTool is not a function`.
|
|
133
|
-
|
|
134
|
-
### Site 3 — `core/extensions/runner.js`: copy onto the shared runtime
|
|
135
|
-
|
|
136
|
-
In `bindCore`, immediately after `this.runtime.refreshTools = actions.refreshTools;`:
|
|
137
|
-
|
|
138
|
-
```js
|
|
139
|
-
this.runtime.invokeTool = actions.invokeTool;
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### Site 4 — `core/extensions/runner.js`: the delegating method
|
|
143
|
-
|
|
144
|
-
On `ExtensionRunner`, immediately after the `getActiveTools() { ... }` method:
|
|
145
|
-
|
|
146
|
-
```js
|
|
147
|
-
invokeTool(name, args, options) {
|
|
148
|
-
this.assertActive();
|
|
149
|
-
return this.runtime.invokeTool(name, args, options);
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
### Site 5 — `core/extensions/loader.js`: the facade method
|
|
154
|
-
|
|
155
|
-
In the `api` object literal returned to extensions, immediately after the
|
|
156
|
-
`getAllTools() { ... }` entry:
|
|
157
|
-
|
|
158
|
-
```js
|
|
159
|
-
invokeTool(name, args, options) {
|
|
160
|
-
assertActive();
|
|
161
|
-
return runtime.invokeTool(name, args, options);
|
|
162
|
-
},
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### Site 6 — `core/extensions/types.d.ts`: the type declaration
|
|
166
|
-
|
|
167
|
-
On the `ExtensionAPI` interface, immediately after `getAllTools(): ToolInfo[];`:
|
|
168
|
-
```ts
|
|
169
|
-
/**
|
|
170
|
-
* LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
|
|
171
|
-
* out-of-band and return { content, details, isError? }. ctx is synthesized.
|
|
172
|
-
*/
|
|
173
|
-
invokeTool(name: string, args?: Record<string, unknown>, options?: { toolCallId?: string; signal?: AbortSignal; onUpdate?: (update: unknown) => void }): Promise<{ content: unknown[]; details: unknown; isError?: boolean }>;
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### Site 7 — `bundle/cli.js`: the entry redirect (pi 0.84.3+)
|
|
177
|
-
|
|
178
|
-
pi 0.84.3 points its `bin` at `dist/bundle/cli.js`, a bundled runtime with its
|
|
179
|
-
own embedded copy of the core. A process launched from the bundle never loads
|
|
180
|
-
`dist/core/*`, so the six sites above stay inert. The fix is a full-file
|
|
181
|
-
replacement of the tiny bundle entry with a shim that loads the still-shipped
|
|
182
|
-
modular `dist/cli.js`:
|
|
183
|
-
|
|
184
|
-
```js
|
|
185
|
-
#!/usr/bin/env node
|
|
186
|
-
// LOCAL PATCH (pi-antigravity-bridge): redirect pi's bundled entry to the
|
|
187
|
-
// modular runtime under dist/, where the invokeTool patch sites take effect.
|
|
188
|
-
// Restore via /agy patch restore. See docs/PI-INVOKETOOL-PATCH.md.
|
|
189
|
-
import "../cli.js";
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Tradeoff: pi starts through the modular runtime, giving up the bundle's faster
|
|
193
|
-
startup. The patcher validates before writing that the file really is the
|
|
194
|
-
bundled entry (it imports `chunks/`) and that `dist/cli.js` exists; on pre-bundle
|
|
195
|
-
pi the file is absent and this site is skipped entirely.
|
|
196
|
-
|
|
197
|
-
## How to apply
|
|
198
|
-
|
|
199
|
-
**You usually don't.** On first load, the extension asks you once whether to
|
|
200
|
-
apply the patch (`src/patcher.ts`); see the note at the top of this document.
|
|
201
|
-
The manual steps below are for reference, auditing, or recovering without the
|
|
202
|
-
extension loaded.
|
|
203
|
-
|
|
204
|
-
The patch is plain edits to the six compiled files above (plus the entry
|
|
205
|
-
redirect on pi 0.84.3+). Because pi ships no `src/`, there is nothing to
|
|
206
|
-
recompile. (The old "clear jiti's cache" step is a
|
|
207
|
-
no-op on pi 0.82.1+ (including 0.84.3), which sets `moduleCache: false` in its
|
|
208
|
-
extension loader, so jiti never writes an fs cache.)
|
|
209
|
-
|
|
210
|
-
A `pi` reinstall or update overwrites `dist/` and **removes the patch**, but the
|
|
211
|
-
extension re-applies it on the next start (then prompts you to restart pi).
|
|
212
|
-
Durable fix: merge it upstream.
|
|
213
|
-
|
|
214
|
-
## How to verify
|
|
215
|
-
|
|
216
|
-
1. The facade has the method:
|
|
217
|
-
|
|
218
|
-
```bash
|
|
219
|
-
pi -e /path/to/some-ext.ts --list-models # ext logs typeof pi.invokeTool
|
|
220
|
-
# expect: "function"
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
2. It executes a real tool. Quickest: a tiny extension that on `session_start`
|
|
224
|
-
calls `pi.invokeTool("read", { path: <some file> })` and prints the result,
|
|
225
|
-
run via `pi -e that-ext.ts --mode rpc` (RPC mode fires `session_start`
|
|
226
|
-
without a model turn). The returned `content` should hold the file text.
|
|
227
|
-
(Note: print/`-p` mode hangs on a remote default model before `session_start`
|
|
228
|
-
fires; RPC mode avoids that.)
|
|
229
|
-
|
|
230
|
-
## Capability gate (how the bridge stays safe without it)
|
|
231
|
-
|
|
232
|
-
`src/mcp-server.ts` checks at load time:
|
|
233
|
-
|
|
234
|
-
```ts
|
|
235
|
-
export function hasInvokeTool(pi: ExtensionAPI): boolean {
|
|
236
|
-
return typeof (pi as unknown as { invokeTool?: unknown }).invokeTool === "function";
|
|
237
|
-
}
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
If false, `startMcpServer()` returns `{ ok: false }` immediately and the bridge
|
|
241
|
-
runs exactly as it did before this feature existed: provider + `AskAntigravity`
|
|
242
|
-
tool, no MCP server. So an unpatched pi, or a pi updated past the patch, degrades
|
|
243
|
-
gracefully rather than crashing.
|
|
244
|
-
|
|
245
|
-
## Scope and risk
|
|
246
|
-
|
|
247
|
-
- Adds one read-only-by-convention method. Does not modify existing behavior,
|
|
248
|
-
tool registration, the agent loop, or tool results.
|
|
249
|
-
- Calls the existing wrapped `execute` with a synthesized ctx; tools that depend
|
|
250
|
-
on live per-turn agent state (an active abort signal, the current message)
|
|
251
|
-
get a quiescent ctx. This is fine for the stateless tools the bridge exposes
|
|
252
|
-
(memory, codegraph, search, delegations) and is the same synthesis pi itself
|
|
253
|
-
uses for out-of-band tool execution.
|
|
254
|
-
- No new dependencies; no changes to persisted state or session format.
|