@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.
- package/CHANGELOG.md +125 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/docs/ARCHITECTURE.md +48 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/docs/PI-BRIDGE-GAPS.md +186 -0
- package/docs/PI-INVOKETOOL-PATCH.md +227 -0
- package/extensions/index.ts +474 -0
- package/package.json +69 -0
- package/src/ask-tool.ts +579 -0
- package/src/config.ts +119 -0
- package/src/diff-render.ts +190 -0
- package/src/discovery.ts +199 -0
- package/src/mcp-server.ts +443 -0
- package/src/models.ts +261 -0
- package/src/patcher.ts +571 -0
- package/src/poller.ts +202 -0
- package/src/protobuf.ts +184 -0
- package/src/provider.ts +502 -0
- package/src/runner.ts +386 -0
- package/src/sessions.ts +159 -0
package/src/patcher.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// Self-applier for the pi.invokeTool local patch (docs/PI-INVOKETOOL-PATCH.md).
|
|
2
|
+
//
|
|
3
|
+
// Adds pi.invokeTool() at 6 sites in 4 compiled files under pi's dist/. pi ships
|
|
4
|
+
// compiled (no src/); these are plain, sentinel-detectable text insertions, so a
|
|
5
|
+
// runtime patcher can apply them durably and idempotently. This lets the bridge
|
|
6
|
+
// self-heal after a pi reinstall/update without manual re-patching.
|
|
7
|
+
//
|
|
8
|
+
// Activation: pi's core is native ESM, cached per process; /reload does NOT pick
|
|
9
|
+
// up these edits. A patched dist only takes effect on a FULL pi restart. The
|
|
10
|
+
// extension notifies the user accordingly (see extensions/index.ts).
|
|
11
|
+
//
|
|
12
|
+
// Hardening (peer-reviewed):
|
|
13
|
+
// - Two-phase apply: validate every anchor/sentinel BEFORE writing anything,
|
|
14
|
+
// so a missing anchor (version drift) aborts with zero files touched.
|
|
15
|
+
// - Facade (loader.js) is written LAST: a crashed/partial patch leaves
|
|
16
|
+
// hasInvokeTool() === false (safe degraded), never a half-wired chain.
|
|
17
|
+
// - Per-pid temp file + rename (atomic per file): no torn writes, no
|
|
18
|
+
// concurrent-launch race (mirrors mcp-server.ts writeBridgeMcpConfig).
|
|
19
|
+
// - Backups carry a VERSION stamp; restore refuses a version mismatch so it
|
|
20
|
+
// can never silently downgrade a newer pi's shipped core.
|
|
21
|
+
// - Actionable EACCES message for sudo-installed pi.
|
|
22
|
+
// - No jiti cache clear: pi 0.82.1 sets moduleCache:false (loader.js), so jiti
|
|
23
|
+
// never fs-caches; rm -rf /tmp/jiti is a no-op here.
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import { execSync } from "node:child_process";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
|
32
|
+
const BRIDGE_BASE = path.join(os.homedir(), ".pi", "agent", "antigravity-bridge");
|
|
33
|
+
export const PATCH_BACKUP_DIR = path.join(BRIDGE_BASE, "pi-patch-backup");
|
|
34
|
+
|
|
35
|
+
/** One insertion site in one compiled file. */
|
|
36
|
+
interface PatchSite {
|
|
37
|
+
/** Path relative to pi's dist/. */
|
|
38
|
+
file: string;
|
|
39
|
+
/** Unique existing text; insertion is placed immediately after it. */
|
|
40
|
+
anchor: string;
|
|
41
|
+
/** Text appended after the anchor (no leading newline; one is added). */
|
|
42
|
+
insertion: string;
|
|
43
|
+
/** Substring proving this site is already patched (idempotency guard). */
|
|
44
|
+
sentinel: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Write order is significant: facade (loader.js) MUST be last so a partial
|
|
48
|
+
// patch fails closed (hasInvokeTool reads the facade). Sites within a file are
|
|
49
|
+
// applied together in one atomic write.
|
|
50
|
+
const PATCH_FILES: Array<{ file: string; sites: PatchSite[] }> = [
|
|
51
|
+
{
|
|
52
|
+
file: "core/agent-session.js",
|
|
53
|
+
sites: [
|
|
54
|
+
{
|
|
55
|
+
file: "core/agent-session.js",
|
|
56
|
+
anchor:
|
|
57
|
+
" getToolDefinition(name) {\n return this._toolDefinitions.get(name)?.definition;\n }\n",
|
|
58
|
+
insertion: ` /**
|
|
59
|
+
* LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
|
|
60
|
+
* out-of-band and return its result. The tool wrapper synthesizes ctx via
|
|
61
|
+
* its ctxFactory when none is passed. Not upstream pi (yet).
|
|
62
|
+
*/
|
|
63
|
+
async invokeTool(name, args = {}, options = {}) {
|
|
64
|
+
const tool = this._toolRegistry.get(name);
|
|
65
|
+
if (!tool) {
|
|
66
|
+
throw new Error(\`invokeTool: tool "\${name}" not found in registry\`);
|
|
67
|
+
}
|
|
68
|
+
const toolCallId = options.toolCallId ?? \`invokeTool:\${name}:\${Date.now()}\`;
|
|
69
|
+
return tool.execute(toolCallId, args, options.signal ?? undefined, options.onUpdate);
|
|
70
|
+
}
|
|
71
|
+
`,
|
|
72
|
+
sentinel: "async invokeTool(name, args = {}, options = {}) {",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
file: "core/agent-session.js",
|
|
76
|
+
anchor: " refreshTools: () => this._refreshToolRegistry(),\n",
|
|
77
|
+
insertion:
|
|
78
|
+
" invokeTool: (name, args, options) => this.invokeTool(name, args, options),\n",
|
|
79
|
+
sentinel:
|
|
80
|
+
"invokeTool: (name, args, options) => this.invokeTool(name, args, options),",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
file: "core/extensions/runner.js",
|
|
86
|
+
sites: [
|
|
87
|
+
{
|
|
88
|
+
file: "core/extensions/runner.js",
|
|
89
|
+
anchor: " this.runtime.refreshTools = actions.refreshTools;\n",
|
|
90
|
+
insertion: " this.runtime.invokeTool = actions.invokeTool;\n",
|
|
91
|
+
sentinel: "this.runtime.invokeTool = actions.invokeTool;",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
file: "core/extensions/runner.js",
|
|
95
|
+
anchor:
|
|
96
|
+
" getActiveTools() {\n this.assertActive();\n return this.runtime.getActiveTools();\n }\n",
|
|
97
|
+
insertion: ` invokeTool(name, args, options) {
|
|
98
|
+
this.assertActive();
|
|
99
|
+
return this.runtime.invokeTool(name, args, options);
|
|
100
|
+
}
|
|
101
|
+
`,
|
|
102
|
+
sentinel: "return this.runtime.invokeTool(name, args, options);",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
file: "core/extensions/types.d.ts",
|
|
108
|
+
sites: [
|
|
109
|
+
{
|
|
110
|
+
file: "core/extensions/types.d.ts",
|
|
111
|
+
anchor: " getAllTools(): ToolInfo[];\n",
|
|
112
|
+
insertion: ` /**
|
|
113
|
+
* LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
|
|
114
|
+
* out-of-band and return { content, details, isError? }. ctx is synthesized.
|
|
115
|
+
*/
|
|
116
|
+
invokeTool(name: string, args?: Record<string, unknown>, options?: { toolCallId?: string; signal?: AbortSignal; onUpdate?: (update: unknown) => void }): Promise<{ content: unknown[]; details: unknown; isError?: boolean }>;
|
|
117
|
+
`,
|
|
118
|
+
sentinel: "invokeTool(name: string, args?: Record<string, unknown>",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
// Facade. Written LAST on purpose (see file header).
|
|
124
|
+
file: "core/extensions/loader.js",
|
|
125
|
+
sites: [
|
|
126
|
+
{
|
|
127
|
+
file: "core/extensions/loader.js",
|
|
128
|
+
anchor:
|
|
129
|
+
" getAllTools() {\n runtime.assertActive();\n return runtime.getAllTools();\n },\n",
|
|
130
|
+
insertion: ` invokeTool(name, args, options) {
|
|
131
|
+
runtime.assertActive();
|
|
132
|
+
return runtime.invokeTool(name, args, options);
|
|
133
|
+
},
|
|
134
|
+
`,
|
|
135
|
+
sentinel: "return runtime.invokeTool(name, args, options);",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const ALL_SITES: PatchSite[] = PATCH_FILES.flatMap((f) => f.sites);
|
|
142
|
+
|
|
143
|
+
export interface PatchResult {
|
|
144
|
+
/** True when every required site is present (after this run). */
|
|
145
|
+
present: boolean;
|
|
146
|
+
/** True when >=1 file was actually written this run. */
|
|
147
|
+
patched: boolean;
|
|
148
|
+
/** True when all sites were already present and nothing was written. */
|
|
149
|
+
alreadyPresent: boolean;
|
|
150
|
+
root?: string;
|
|
151
|
+
version?: string;
|
|
152
|
+
changedFiles: string[];
|
|
153
|
+
backupDir?: string;
|
|
154
|
+
errors: string[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface PatchStatus {
|
|
158
|
+
/** True when every sentinel is present across all files. */
|
|
159
|
+
present: boolean;
|
|
160
|
+
root?: string;
|
|
161
|
+
version?: string;
|
|
162
|
+
/** Site labels missing (by file). */
|
|
163
|
+
missing: string[];
|
|
164
|
+
/** Newest backup dir found, if any. */
|
|
165
|
+
backupDir?: string;
|
|
166
|
+
backupVersion?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface RestoreResult {
|
|
170
|
+
ok: boolean;
|
|
171
|
+
restoredFiles: string[];
|
|
172
|
+
backupDir?: string;
|
|
173
|
+
reason?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type Logger = (s: string, d?: unknown) => void;
|
|
177
|
+
|
|
178
|
+
/** Read the installed pi version from a package root's package.json. */
|
|
179
|
+
function readVersion(root: string): string | undefined {
|
|
180
|
+
try {
|
|
181
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
182
|
+
if (pkg?.name === PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
|
|
183
|
+
} catch {
|
|
184
|
+
/* not a pi root */
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Verify a candidate is a real pi package root whose dist holds the target
|
|
190
|
+
* files. This is the safety net that makes multi-strategy root-finding safe:
|
|
191
|
+
* a wrong candidate (a sibling extension's decoy node_modules copy) fails here. */
|
|
192
|
+
function verifyRoot(root: string): { root: string; version: string } | null {
|
|
193
|
+
if (!root) return null;
|
|
194
|
+
const version = readVersion(root);
|
|
195
|
+
if (!version) return null;
|
|
196
|
+
// The anchor of site 1 is the most specific fingerprint of a real, unedited
|
|
197
|
+
// pi agent-session.js. Require its file to exist and contain it.
|
|
198
|
+
const probe = path.join(root, "dist", "core", "agent-session.js");
|
|
199
|
+
try {
|
|
200
|
+
const txt = fs.readFileSync(probe, "utf8");
|
|
201
|
+
// Accept either patched or unpatched: the getToolDefinition body is stable.
|
|
202
|
+
if (!txt.includes("getToolDefinition(name)")) return null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
// All target files must exist.
|
|
207
|
+
for (const f of PATCH_FILES) {
|
|
208
|
+
if (!fs.existsSync(path.join(root, "dist", f.file))) return null;
|
|
209
|
+
}
|
|
210
|
+
return { root, version };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Candidate from realpath(process.argv[1]) — the pi cli entry the OS launched.
|
|
214
|
+
* Most reliable signal for the RUNNING pi regardless of node_modules layout. */
|
|
215
|
+
function candidateFromArgv(): string | null {
|
|
216
|
+
try {
|
|
217
|
+
const launched = process.argv[1];
|
|
218
|
+
if (!launched) return null;
|
|
219
|
+
const real = fs.realpathSync(launched);
|
|
220
|
+
const dir = path.dirname(real);
|
|
221
|
+
if (path.basename(dir) === "dist") return path.dirname(dir);
|
|
222
|
+
// Some launchers place the entry directly under <root>/dist; if not, bail.
|
|
223
|
+
return null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Candidate from import.meta.resolve — works in single-install (normal npm -g)
|
|
230
|
+
* layouts. NOTE: in a dev dual-install this resolves the LOCAL node_modules copy,
|
|
231
|
+
* not the running global pi; verifyRoot + the argv strategy keep it safe. */
|
|
232
|
+
function candidateFromResolve(): string | null {
|
|
233
|
+
try {
|
|
234
|
+
const url = import.meta.resolve(PACKAGE_NAME);
|
|
235
|
+
const mainFile = fileURLToPath(url); // .../dist/index.js
|
|
236
|
+
const dist = path.dirname(mainFile);
|
|
237
|
+
if (path.basename(dist) === "dist") return path.dirname(dist);
|
|
238
|
+
return null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Candidate from `npm root -g`. */
|
|
245
|
+
function candidateFromNpmGlobal(): string | null {
|
|
246
|
+
try {
|
|
247
|
+
const root = execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
248
|
+
return path.join(root, PACKAGE_NAME);
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Candidates from well-known global install locations + env overrides. */
|
|
255
|
+
function candidateFromKnownPaths(): string[] {
|
|
256
|
+
const out: string[] = [];
|
|
257
|
+
if (process.env.PI_PACKAGE_ROOT) out.push(process.env.PI_PACKAGE_ROOT);
|
|
258
|
+
const home = os.homedir();
|
|
259
|
+
out.push(path.join(home, ".npm-global", "lib", "node_modules", PACKAGE_NAME));
|
|
260
|
+
out.push(path.join("/usr/local/lib/node_modules", PACKAGE_NAME));
|
|
261
|
+
out.push(path.join("/usr/lib/node_modules", PACKAGE_NAME));
|
|
262
|
+
if (process.env.NVM_DIR) {
|
|
263
|
+
out.push(path.join(process.env.NVM_DIR, "lib", "node_modules", PACKAGE_NAME));
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Locate the running pi's package root. Verify-as-you-go and short-circuit on
|
|
269
|
+
* the first verified candidate, so the common case (argv already resolves)
|
|
270
|
+
* never spawns `npm root -g`. */
|
|
271
|
+
export function findPiRoot(): { root: string; version: string } | null {
|
|
272
|
+
const tried = new Set<string>();
|
|
273
|
+
const attempt = (cand: string | null): { root: string; version: string } | null => {
|
|
274
|
+
if (!cand || tried.has(cand)) return null;
|
|
275
|
+
tried.add(cand);
|
|
276
|
+
return verifyRoot(cand);
|
|
277
|
+
};
|
|
278
|
+
// Cheapest + most reliable first: the OS-launched pi entry.
|
|
279
|
+
let v = attempt(candidateFromArgv());
|
|
280
|
+
if (v) return v;
|
|
281
|
+
// ESM resolution (single-install layouts).
|
|
282
|
+
v = attempt(candidateFromResolve());
|
|
283
|
+
if (v) return v;
|
|
284
|
+
// Only now (argv + resolve both missed) pay for spawning npm.
|
|
285
|
+
v = attempt(candidateFromNpmGlobal());
|
|
286
|
+
if (v) return v;
|
|
287
|
+
// Known global locations + PI_PACKAGE_ROOT env override.
|
|
288
|
+
for (const k of candidateFromKnownPaths()) {
|
|
289
|
+
v = attempt(k);
|
|
290
|
+
if (v) return v;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Whether every site's sentinel is already present. */
|
|
296
|
+
function siteMissing(content: string, site: PatchSite): boolean {
|
|
297
|
+
return !content.includes(site.sentinel);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Atomic write: per-pid tmp in the SAME dir (same filesystem) + rename. */
|
|
301
|
+
function atomicWrite(filePath: string, content: string): void {
|
|
302
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
303
|
+
fs.writeFileSync(tmp, content);
|
|
304
|
+
fs.renameSync(tmp, filePath);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Persist the validated pre-write content into the backup dir, mirroring path.
|
|
308
|
+
* Backs up the in-memory `original` (NOT a fresh disk read) so a concurrent
|
|
309
|
+
* apply between phase-1 and phase-2 can never capture already-patched bytes. */
|
|
310
|
+
function backupOriginal(file: string, content: string, backupDir: string): void {
|
|
311
|
+
const dst = path.join(backupDir, file);
|
|
312
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
313
|
+
fs.writeFileSync(dst, content);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Human-readable, actionable message for a write failure (sudo-global pi). */
|
|
317
|
+
function describeWriteError(err: unknown, root: string): string {
|
|
318
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
319
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
320
|
+
return (
|
|
321
|
+
`permission denied writing under ${root}. pi was likely installed with sudo. ` +
|
|
322
|
+
`Fix: 'sudo chown -R "$(id -un)" "${root}"', or reinstall pi under a user-writable prefix ` +
|
|
323
|
+
`('npm config set prefix "${path.join(os.homedir(), ".npm-global")}"' then 'npm i -g ${PACKAGE_NAME}').`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return err instanceof Error ? err.message : String(err);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Find the newest backup dir (by VERSION file mtime), if any. */
|
|
330
|
+
function findNewestBackup(base: string): { dir: string; version: string } | null {
|
|
331
|
+
let entries: string[];
|
|
332
|
+
try {
|
|
333
|
+
entries = fs.readdirSync(base);
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
let best: { dir: string; version: string; mtime: number } | null = null;
|
|
338
|
+
for (const name of entries) {
|
|
339
|
+
const dir = path.join(base, name);
|
|
340
|
+
const verFile = path.join(dir, "VERSION");
|
|
341
|
+
try {
|
|
342
|
+
const stat = fs.statSync(verFile);
|
|
343
|
+
const manifest = JSON.parse(fs.readFileSync(verFile, "utf8"));
|
|
344
|
+
if (typeof manifest.version !== "string") continue;
|
|
345
|
+
if (!best || stat.mtimeMs > best.mtime) {
|
|
346
|
+
best = { dir, version: manifest.version, mtime: stat.mtimeMs };
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
/* incomplete backup entry */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return best ? { dir: best.dir, version: best.version } : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Shared options. root/backupBase are test seams (and let /agy patch target a
|
|
356
|
+
* specific install); production calls omit them and use auto-discovery. */
|
|
357
|
+
export interface PatchOpts {
|
|
358
|
+
root?: string;
|
|
359
|
+
backupBase?: string;
|
|
360
|
+
log?: Logger;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function resolveRoot(opts: { root?: string }): { root: string; version: string } | null {
|
|
364
|
+
return opts.root ? verifyRoot(opts.root) : findPiRoot();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function backupBaseOf(opts: { backupBase?: string }): string {
|
|
368
|
+
return opts.backupBase ?? PATCH_BACKUP_DIR;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Report the current patch state without changing anything. */
|
|
372
|
+
export function patchStatus(opts: { root?: string; backupBase?: string } = {}): PatchStatus {
|
|
373
|
+
const root = resolveRoot(opts);
|
|
374
|
+
if (!root) {
|
|
375
|
+
return { present: false, missing: ["pi-root-not-found"] };
|
|
376
|
+
}
|
|
377
|
+
const missing: string[] = [];
|
|
378
|
+
for (const f of PATCH_FILES) {
|
|
379
|
+
let txt = "";
|
|
380
|
+
try {
|
|
381
|
+
txt = fs.readFileSync(path.join(root.root, "dist", f.file), "utf8");
|
|
382
|
+
} catch {
|
|
383
|
+
missing.push(f.file);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
for (const site of f.sites) {
|
|
387
|
+
if (siteMissing(txt, site)) missing.push(`${f.file}:${site.sentinel.slice(0, 40)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const backup = findNewestBackup(backupBaseOf(opts));
|
|
391
|
+
return {
|
|
392
|
+
present: missing.length === 0,
|
|
393
|
+
root: root.root,
|
|
394
|
+
version: root.version,
|
|
395
|
+
missing,
|
|
396
|
+
backupDir: backup?.dir,
|
|
397
|
+
backupVersion: backup?.version,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** What session_start should do about the patch + MCP bridge, given the four
|
|
402
|
+
* observable signals. Pure so the matrix is unit-testable without a pi harness. */
|
|
403
|
+
export type PatchAction =
|
|
404
|
+
| { kind: "proceed" } // patch is live: start the MCP bridge
|
|
405
|
+
| { kind: "notify-restart" } // on disk but not live this process: tell user to restart
|
|
406
|
+
| { kind: "silent" } // user declined: do nothing, stay quiet
|
|
407
|
+
| { kind: "ask" } // missing + has UI: show the consent gate
|
|
408
|
+
| { kind: "headless-skip" }; // missing + no UI: log and skip (can't ask)
|
|
409
|
+
|
|
410
|
+
/** Decide the session_start patch action. Precedence:
|
|
411
|
+
* live > on-disk-needs-restart > declined > interactive-ask > headless-skip. */
|
|
412
|
+
export function decidePatchAction(
|
|
413
|
+
live: boolean,
|
|
414
|
+
diskPresent: boolean,
|
|
415
|
+
declined: boolean,
|
|
416
|
+
hasUI: boolean,
|
|
417
|
+
): PatchAction {
|
|
418
|
+
if (live) return { kind: "proceed" };
|
|
419
|
+
if (diskPresent) return { kind: "notify-restart" };
|
|
420
|
+
if (declined) return { kind: "silent" };
|
|
421
|
+
return hasUI ? { kind: "ask" } : { kind: "headless-skip" };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Apply the patch idempotently. Two-phase: validate all anchors first, write
|
|
425
|
+
* only if something is missing. Facade file is written last. */
|
|
426
|
+
export function applyInvokeToolPatch(opts: PatchOpts = {}): PatchResult {
|
|
427
|
+
const log = opts.log ?? (() => {});
|
|
428
|
+
const errors: string[] = [];
|
|
429
|
+
const changedFiles: string[] = [];
|
|
430
|
+
|
|
431
|
+
const found = resolveRoot(opts);
|
|
432
|
+
if (!found) {
|
|
433
|
+
const msg =
|
|
434
|
+
"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.";
|
|
435
|
+
log("root-not-found", msg);
|
|
436
|
+
return { present: false, patched: false, alreadyPresent: false, changedFiles, errors: [msg] };
|
|
437
|
+
}
|
|
438
|
+
const { root, version } = found;
|
|
439
|
+
log("root", { root, version });
|
|
440
|
+
|
|
441
|
+
// Phase 1: read every file, validate anchors/sentinels, build the change plan.
|
|
442
|
+
type Plan = { file: string; original: string; next: string; changed: boolean };
|
|
443
|
+
const plan: Plan[] = [];
|
|
444
|
+
for (const f of PATCH_FILES) {
|
|
445
|
+
const filePath = path.join(root, "dist", f.file);
|
|
446
|
+
let original: string;
|
|
447
|
+
try {
|
|
448
|
+
original = fs.readFileSync(filePath, "utf8");
|
|
449
|
+
} catch (e) {
|
|
450
|
+
errors.push(`cannot read ${f.file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
451
|
+
return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
|
|
452
|
+
}
|
|
453
|
+
let next = original;
|
|
454
|
+
let changed = false;
|
|
455
|
+
for (const site of f.sites) {
|
|
456
|
+
if (!siteMissing(original, site)) continue; // already patched at this site
|
|
457
|
+
if (!original.includes(site.anchor)) {
|
|
458
|
+
// Version drift: the code pi ships moved/renamed. Abort cleanly.
|
|
459
|
+
const msg =
|
|
460
|
+
`anchor not found in ${f.file} (pi ${version}). pi's structure changed; ` +
|
|
461
|
+
`the patch needs updating for this version. No files were written. ` +
|
|
462
|
+
`See docs/PI-INVOKETOOL-PATCH.md.`;
|
|
463
|
+
errors.push(msg);
|
|
464
|
+
log("anchor-missing", { file: f.file, version });
|
|
465
|
+
return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
|
|
466
|
+
}
|
|
467
|
+
next = next.replace(site.anchor, site.anchor + site.insertion);
|
|
468
|
+
changed = true;
|
|
469
|
+
}
|
|
470
|
+
plan.push({ file: f.file, original, next, changed });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const anyChange = plan.some((p) => p.changed);
|
|
474
|
+
if (!anyChange) {
|
|
475
|
+
log("already-present", { root, version });
|
|
476
|
+
return { present: true, patched: false, alreadyPresent: true, changedFiles, errors, root, version };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Phase 2: back up originals (one VERSION-stamped dir), then write facade-last.
|
|
480
|
+
const stamp = `${version}-${Date.now()}-${process.pid}`;
|
|
481
|
+
const backupDir = path.join(backupBaseOf(opts), stamp);
|
|
482
|
+
try {
|
|
483
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
484
|
+
fs.writeFileSync(
|
|
485
|
+
path.join(backupDir, "VERSION"),
|
|
486
|
+
`${JSON.stringify({ version, root, createdAt: new Date().toISOString() }, null, 2)}\n`,
|
|
487
|
+
);
|
|
488
|
+
// Back up the validated originals (in memory) for restore.
|
|
489
|
+
for (const p of plan) backupOriginal(p.file, p.original, backupDir);
|
|
490
|
+
log("backup-written", { backupDir });
|
|
491
|
+
} catch (e) {
|
|
492
|
+
const msg = `backup failed (${e instanceof Error ? e.message : String(e)}); aborting before any write.`;
|
|
493
|
+
errors.push(msg);
|
|
494
|
+
return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Write in declared order (facade/loader.js is last in PATCH_FILES).
|
|
498
|
+
for (const p of plan) {
|
|
499
|
+
if (!p.changed) continue;
|
|
500
|
+
const filePath = path.join(root, "dist", p.file);
|
|
501
|
+
try {
|
|
502
|
+
atomicWrite(filePath, p.next);
|
|
503
|
+
changedFiles.push(p.file);
|
|
504
|
+
log("file-patched", { file: p.file });
|
|
505
|
+
} catch (e) {
|
|
506
|
+
const msg = describeWriteError(e, root);
|
|
507
|
+
errors.push(`failed writing ${p.file}: ${msg}`);
|
|
508
|
+
log("write-failed", { file: p.file, code: (e as NodeJS.ErrnoException)?.code });
|
|
509
|
+
// Stop here; earlier files in this run are already patched and backed
|
|
510
|
+
// up, so a retry (or restore) recovers cleanly. hasInvokeTool() stays
|
|
511
|
+
// false unless the facade (last file) already succeeded.
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const status = patchStatus({ root, backupBase: opts.backupBase });
|
|
517
|
+
return {
|
|
518
|
+
present: status.present,
|
|
519
|
+
patched: changedFiles.length > 0,
|
|
520
|
+
alreadyPresent: false,
|
|
521
|
+
changedFiles,
|
|
522
|
+
backupDir,
|
|
523
|
+
errors,
|
|
524
|
+
root,
|
|
525
|
+
version,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Restore the most recent backup. Refuses if the installed pi version differs
|
|
530
|
+
* from the backup's version (prevents silently downgrading shipped core). */
|
|
531
|
+
export function restorePatch(opts: PatchOpts = {}): RestoreResult {
|
|
532
|
+
const log = opts.log ?? (() => {});
|
|
533
|
+
const found = resolveRoot(opts);
|
|
534
|
+
if (!found) {
|
|
535
|
+
return { ok: false, restoredFiles: [], reason: "could not locate the running pi package root to restore into." };
|
|
536
|
+
}
|
|
537
|
+
const { root, version } = found;
|
|
538
|
+
const backup = findNewestBackup(backupBaseOf(opts));
|
|
539
|
+
if (!backup) {
|
|
540
|
+
return { ok: false, restoredFiles: [], reason: "no backup found; nothing to restore." };
|
|
541
|
+
}
|
|
542
|
+
if (backup.version !== version) {
|
|
543
|
+
const msg =
|
|
544
|
+
`refusing restore: backup is from pi ${backup.version} but installed pi is ${version}. ` +
|
|
545
|
+
`Restoring across versions would downgrade pi's shipped core files. Delete the backup manually if intended.`;
|
|
546
|
+
log("restore-version-mismatch", { backup: backup.version, installed: version });
|
|
547
|
+
return { ok: false, restoredFiles: [], backupDir: backup.dir, reason: msg };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const restoredFiles: string[] = [];
|
|
551
|
+
for (const f of PATCH_FILES) {
|
|
552
|
+
const src = path.join(backup.dir, f.file);
|
|
553
|
+
const dst = path.join(root, "dist", f.file);
|
|
554
|
+
if (!fs.existsSync(src)) continue;
|
|
555
|
+
try {
|
|
556
|
+
const content = fs.readFileSync(src, "utf8");
|
|
557
|
+
atomicWrite(dst, content);
|
|
558
|
+
restoredFiles.push(f.file);
|
|
559
|
+
} catch (e) {
|
|
560
|
+
const msg = describeWriteError(e, root);
|
|
561
|
+
return { ok: false, restoredFiles, backupDir: backup.dir, reason: `failed restoring ${f.file}: ${msg}` };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
log("restore-done", { backupDir: backup.dir, count: restoredFiles.length });
|
|
565
|
+
return { ok: true, restoredFiles, backupDir: backup.dir };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Exposed for tests: the full site list (read-only view). */
|
|
569
|
+
export function listSites(): ReadonlyArray<Readonly<PatchSite>> {
|
|
570
|
+
return ALL_SITES;
|
|
571
|
+
}
|