@estebanforge/pi-antigravity-bridge 1.2.6 → 1.3.1
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 +35 -0
- package/README.md +40 -32
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +2 -0
- package/docs/ARCHITECTURE.md +58 -15
- package/docs/DEVELOPMENT.md +17 -8
- package/docs/PI-BRIDGE-GAPS.md +65 -82
- package/extensions/index.ts +199 -139
- package/package.json +2 -2
- 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
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
|
-
//
|
|
19
|
-
//
|
|
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
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
253
|
+
const callId = crypto.randomUUID();
|
|
254
|
+
log("call-tool", { name, callId });
|
|
280
255
|
try {
|
|
281
|
-
const r = await
|
|
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
|
-
//
|
|
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
|
|
369
|
-
|
|
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
|
}
|