@ai-sdk/harness-pi 0.0.0 → 1.0.0-beta.10

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,276 @@
1
+ import {
2
+ mkdir,
3
+ readFile,
4
+ readdir,
5
+ rm,
6
+ stat,
7
+ writeFile,
8
+ } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
11
+ import { shellQuote } from './pi-utils';
12
+
13
+ /*
14
+ * Pi runs on the host with its working directory pointed at the local mirror,
15
+ * but the only thing it reads from that directory is its own resource
16
+ * configuration: the `.pi` and `.agents` directories (skills, prompts, themes,
17
+ * extensions) and the root-level agent context files (`AGENTS.md`). The model
18
+ * never reads workspace source through the host — file reads, directory
19
+ * listings, and greps all run as tools against the sandbox. Mirroring the whole
20
+ * sandbox workspace to the host would therefore copy files Pi never looks at,
21
+ * one `readBinaryFile` round-trip per file. For a real project that has been
22
+ * cloned and had its dependencies installed (hundreds of thousands of files
23
+ * under `node_modules`) that makes session startup take hours. The mirror is
24
+ * consequently scoped to exactly the paths Pi's resource loader consults.
25
+ *
26
+ * Within those config directories, symlinks are resolved and their targets
27
+ * copied as real files. `.agents/skills` is frequently a symlink to a `skills`
28
+ * directory living elsewhere in the workspace; a mirrored symlink would dangle
29
+ * because its target falls outside the scoped mirror, so the linked content is
30
+ * walked and copied verbatim instead.
31
+ */
32
+ const PI_CONFIG_DIRS = ['.pi', '.agents'] as const;
33
+ const PI_CONTEXT_FILENAMES = ['AGENTS.md', 'AGENTS.MD'] as const;
34
+
35
+ function normalizeRelativePath(inputPath: string): string {
36
+ const normalized = inputPath.split(path.posix.sep).join(path.sep);
37
+ const relative = path.normalize(normalized);
38
+ if (
39
+ relative === '' ||
40
+ relative === '.' ||
41
+ path.isAbsolute(relative) ||
42
+ relative === '..' ||
43
+ relative.startsWith(`..${path.sep}`)
44
+ ) {
45
+ throw new Error(
46
+ `Sandbox workspace mirror received an invalid relative path: ${inputPath}`,
47
+ );
48
+ }
49
+ return relative;
50
+ }
51
+
52
+ async function readCommandOutput(
53
+ sandbox: Experimental_SandboxSession,
54
+ command: string,
55
+ ): Promise<string> {
56
+ const result = await sandbox.run({ command });
57
+ const output = result.stdout || result.stderr;
58
+ if (result.exitCode != null && result.exitCode !== 0) {
59
+ throw new Error(
60
+ output || `Sandbox command failed with exit code ${result.exitCode}`,
61
+ );
62
+ }
63
+ return output;
64
+ }
65
+
66
+ async function listRemoteWorkspaceEntries(
67
+ sandbox: Experimental_SandboxSession,
68
+ sandboxWorkDir: string,
69
+ ): Promise<{ directories: string[]; files: string[] }> {
70
+ const contextPredicate = PI_CONTEXT_FILENAMES.map(
71
+ name => `-name ${shellQuote(name)}`,
72
+ ).join(' -o ');
73
+
74
+ // Enumerate only the `.pi`/`.agents` config subtrees plus the root-level
75
+ // context files — never the rest of the workspace. `find -L` dereferences
76
+ // symlinks so that linked targets (e.g. `.agents/skills` pointing elsewhere)
77
+ // are walked and reported through the symlinked path; the resolved file/dir
78
+ // types from `[ -d ]`/`[ -f ]` then tag each entry `d`/`f`, NUL-joined
79
+ // exactly like a full-tree walk so the reconcile below is unchanged.
80
+ const configFinds = PI_CONFIG_DIRS.map(
81
+ dir =>
82
+ ` if [ -d ./${dir} ]; then find -L ./${dir} \\( -type d -o -type f \\) -print0; fi;`,
83
+ );
84
+ const listCommand = [
85
+ '{',
86
+ ...configFinds,
87
+ ` find . -maxdepth 1 -type f \\( ${contextPredicate} \\) -print0;`,
88
+ '} |',
89
+ "while IFS= read -r -d '' entry; do",
90
+ ' rel=${entry#./}',
91
+ ' if [ -d "$entry" ]; then',
92
+ ` printf 'd\\t%s\\n' "$rel"`,
93
+ ' elif [ -f "$entry" ]; then',
94
+ ` printf 'f\\t%s\\n' "$rel"`,
95
+ ' fi',
96
+ 'done | LC_ALL=C sort',
97
+ ].join('\n');
98
+
99
+ const output = await readCommandOutput(
100
+ sandbox,
101
+ [`cd ${shellQuote(sandboxWorkDir)}`, listCommand].join(' && '),
102
+ );
103
+
104
+ const directories: string[] = [];
105
+ const files: string[] = [];
106
+
107
+ for (const line of output.split('\n').filter(Boolean)) {
108
+ const [kind, rawPath] = line.split('\t', 2);
109
+ if (!rawPath) continue;
110
+
111
+ const relativePath = normalizeRelativePath(rawPath);
112
+ if (kind === 'd') directories.push(relativePath);
113
+ else if (kind === 'f') files.push(relativePath);
114
+ }
115
+
116
+ return { directories, files };
117
+ }
118
+
119
+ async function pathKind(
120
+ target: string,
121
+ ): Promise<'file' | 'directory' | undefined> {
122
+ try {
123
+ const stats = await stat(target);
124
+ if (stats.isDirectory()) return 'directory';
125
+ if (stats.isFile()) return 'file';
126
+ return undefined;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ async function collectHostSubtree(
133
+ rootDir: string,
134
+ currentDir: string,
135
+ directories: string[],
136
+ files: string[],
137
+ ): Promise<void> {
138
+ const entries = await readdir(currentDir, { withFileTypes: true });
139
+ for (const entry of entries) {
140
+ if (entry.isSymbolicLink()) continue;
141
+ const absolutePath = path.join(currentDir, entry.name);
142
+ const relativePath = path.relative(rootDir, absolutePath);
143
+ if (entry.isDirectory()) {
144
+ directories.push(relativePath);
145
+ await collectHostSubtree(rootDir, absolutePath, directories, files);
146
+ } else if (entry.isFile()) {
147
+ files.push(relativePath);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Enumerate the locally-mirrored entries that fall within Pi's scope: the
154
+ * `.pi`/`.agents` config subtrees and the root-level context files. Anything
155
+ * else on the local side (it should not normally exist) is intentionally
156
+ * ignored so the reconcile below neither copies nor deletes it.
157
+ */
158
+ async function collectHostScopedEntries(
159
+ rootDir: string,
160
+ ): Promise<{ directories: string[]; files: string[] }> {
161
+ const directories: string[] = [];
162
+ const files: string[] = [];
163
+
164
+ for (const dir of PI_CONFIG_DIRS) {
165
+ const configDir = path.join(rootDir, dir);
166
+ if ((await pathKind(configDir)) === 'directory') {
167
+ directories.push(dir);
168
+ await collectHostSubtree(rootDir, configDir, directories, files);
169
+ }
170
+ }
171
+
172
+ for (const name of PI_CONTEXT_FILENAMES) {
173
+ if ((await pathKind(path.join(rootDir, name))) === 'file') {
174
+ files.push(name);
175
+ }
176
+ }
177
+
178
+ return { directories, files };
179
+ }
180
+
181
+ function buildRequiredDirectories(
182
+ remoteDirectories: string[],
183
+ remoteFiles: string[],
184
+ ): Set<string> {
185
+ const directories = new Set<string>();
186
+ for (const directory of remoteDirectories) {
187
+ directories.add(normalizeRelativePath(directory));
188
+ }
189
+ for (const file of remoteFiles) {
190
+ let current = path.dirname(normalizeRelativePath(file));
191
+ while (current !== '.' && current !== path.sep && current.length > 0) {
192
+ directories.add(current);
193
+ current = path.dirname(current);
194
+ }
195
+ }
196
+ return directories;
197
+ }
198
+
199
+ export async function syncHostWorkspaceFromSandbox(args: {
200
+ sandbox: Experimental_SandboxSession;
201
+ sandboxWorkDir: string;
202
+ hostWorkDir: string;
203
+ }): Promise<void> {
204
+ const { sandbox, sandboxWorkDir, hostWorkDir } = args;
205
+ const remoteEntries = await listRemoteWorkspaceEntries(
206
+ sandbox,
207
+ sandboxWorkDir,
208
+ );
209
+ const hostEntries = await collectHostScopedEntries(hostWorkDir);
210
+ const remoteFiles = new Set(remoteEntries.files);
211
+ const requiredDirectories = buildRequiredDirectories(
212
+ remoteEntries.directories,
213
+ remoteEntries.files,
214
+ );
215
+
216
+ for (const relativePath of hostEntries.files) {
217
+ if (!remoteFiles.has(relativePath)) {
218
+ await rm(path.join(hostWorkDir, relativePath), { force: true });
219
+ }
220
+ }
221
+
222
+ const removableDirectories = [...hostEntries.directories]
223
+ .filter(p => !requiredDirectories.has(p))
224
+ .sort((a, b) => b.length - a.length);
225
+ for (const relativePath of removableDirectories) {
226
+ await rm(path.join(hostWorkDir, relativePath), {
227
+ recursive: true,
228
+ force: true,
229
+ });
230
+ }
231
+
232
+ for (const relativePath of [...requiredDirectories].sort(
233
+ (a, b) => a.length - b.length,
234
+ )) {
235
+ await mkdir(path.join(hostWorkDir, relativePath), { recursive: true });
236
+ }
237
+
238
+ for (const relativePath of remoteEntries.files) {
239
+ const remotePath = path.posix.join(
240
+ sandboxWorkDir,
241
+ relativePath.split(path.sep).join('/'),
242
+ );
243
+ const bytes = await sandbox.readBinaryFile({ path: remotePath });
244
+ if (!bytes) {
245
+ throw new Error(
246
+ `Sandbox workspace file disappeared during mirror sync: ${remotePath}`,
247
+ );
248
+ }
249
+ const content = Buffer.from(bytes);
250
+
251
+ const hostPath = path.join(hostWorkDir, relativePath);
252
+ let shouldWrite = true;
253
+ try {
254
+ const existing = await readFile(hostPath);
255
+ shouldWrite = !existing.equals(content);
256
+ } catch {
257
+ shouldWrite = true;
258
+ }
259
+
260
+ if (shouldWrite) {
261
+ await mkdir(path.dirname(hostPath), { recursive: true });
262
+ await writeFile(hostPath, content);
263
+ }
264
+ }
265
+ }
266
+
267
+ export async function writeHostWorkspaceFile(
268
+ hostWorkDir: string,
269
+ relativePath: string,
270
+ content: Buffer,
271
+ ): Promise<void> {
272
+ const normalizedPath = normalizeRelativePath(relativePath);
273
+ const hostPath = path.join(hostWorkDir, normalizedPath);
274
+ await mkdir(path.dirname(hostPath), { recursive: true });
275
+ await writeFile(hostPath, content);
276
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Pi VFS — global Node `fs` monkey-patch that redirects reads/writes against a
3
+ * mount point (the session's sandbox working directory, e.g.
4
+ * `/vercel/sandbox/<harnessId>-<sessionId>/...`) to a real backing directory on
5
+ * the host. The mount point is a sandbox path that does not exist on the host,
6
+ * so the redirect never shadows real host files.
7
+ *
8
+ * The mapping is process-global because Pi's upstream runtime reads and
9
+ * writes through the shared `fs` module. Concurrent mounts are supported as
10
+ * long as each instance uses a distinct mount point (each session's sandbox
11
+ * working directory is unique).
12
+ *
13
+ * Multi-instance invariants:
14
+ * - Multiple `PiWorkspaceVfs` instances may stay mounted concurrently.
15
+ * - Path routing uses longest-prefix matching, so mount points must not
16
+ * overlap.
17
+ * - Extra `fs` patches stay installed while `mountedRoots.size > 0` and are
18
+ * restored only after the final unmount.
19
+ * - Mount and unmount remain synchronous, so Node's single-threaded execution
20
+ * preserves patch/install ordering.
21
+ *
22
+ * Supported logical-path APIs:
23
+ * - Sync: `existsSync`, `readFileSync`, `writeFileSync`, `mkdirSync`,
24
+ * `readdirSync`, `renameSync`, `rmSync`, `openSync`, `statSync`,
25
+ * `realpathSync` (+ `.native`).
26
+ * - Callback: `mkdir`, `realpath`, `stat`, `rmdir`, `utimes`, `writeFile`.
27
+ * - Promises: `fs.promises.mkdir`, `fs.promises.readFile`,
28
+ * `fs.promises.writeFile`.
29
+ *
30
+ * Anything else is intentionally unsupported. If Pi starts using additional
31
+ * `fs` APIs against logical roots, those call sites must be added here
32
+ * deliberately with tests.
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import { syncBuiltinESMExports } from 'node:module';
37
+ import path from 'node:path';
38
+
39
+ type MountedRoot = {
40
+ backingRoot: string;
41
+ mountPoint: string;
42
+ };
43
+
44
+ type WrappedRealpathSync = typeof fs.realpathSync & {
45
+ native?: typeof fs.realpathSync.native;
46
+ };
47
+
48
+ type Mutable<T> = {
49
+ -readonly [K in keyof T]: T[K];
50
+ };
51
+
52
+ const mountedRoots = new Map<string, MountedRoot>();
53
+ const mutableFs = fs as Mutable<typeof fs>;
54
+ const mutableFsPromises = fs.promises as Mutable<typeof fs.promises>;
55
+
56
+ const originalFsSyncMethods = {
57
+ existsSync: fs.existsSync,
58
+ mkdirSync: fs.mkdirSync,
59
+ openSync: fs.openSync,
60
+ readFileSync: fs.readFileSync,
61
+ readdirSync: fs.readdirSync,
62
+ realpathSync: fs.realpathSync,
63
+ renameSync: fs.renameSync,
64
+ rmSync: fs.rmSync,
65
+ statSync: fs.statSync,
66
+ writeFileSync: fs.writeFileSync,
67
+ };
68
+
69
+ const originalFsCallbackMethods = {
70
+ mkdir: fs.mkdir,
71
+ realpath: fs.realpath,
72
+ rmdir: fs.rmdir,
73
+ stat: fs.stat,
74
+ utimes: fs.utimes,
75
+ writeFile: fs.writeFile,
76
+ };
77
+
78
+ const originalFsPromises = {
79
+ mkdir: fs.promises.mkdir.bind(fs.promises),
80
+ readFile: fs.promises.readFile.bind(fs.promises),
81
+ writeFile: fs.promises.writeFile.bind(fs.promises),
82
+ };
83
+
84
+ function isInsidePath(parent: string, candidate: string): boolean {
85
+ const relative = path.relative(parent, candidate);
86
+ return (
87
+ relative === '' ||
88
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
89
+ );
90
+ }
91
+
92
+ function resolveAbsolutePath(inputPath: string): string {
93
+ return path.isAbsolute(inputPath)
94
+ ? path.normalize(inputPath)
95
+ : path.resolve(inputPath);
96
+ }
97
+
98
+ function findMountedRoot(inputPath: string): MountedRoot | undefined {
99
+ const resolvedPath = resolveAbsolutePath(inputPath);
100
+ let bestMatch: MountedRoot | undefined;
101
+
102
+ for (const mountedRoot of mountedRoots.values()) {
103
+ if (!isInsidePath(mountedRoot.mountPoint, resolvedPath)) {
104
+ continue;
105
+ }
106
+ if (
107
+ !bestMatch ||
108
+ mountedRoot.mountPoint.length > bestMatch.mountPoint.length
109
+ ) {
110
+ bestMatch = mountedRoot;
111
+ }
112
+ }
113
+
114
+ return bestMatch;
115
+ }
116
+
117
+ function mapToBackingPath(inputPath: string): string | null {
118
+ const mountedRoot = findMountedRoot(inputPath);
119
+ if (!mountedRoot) {
120
+ return null;
121
+ }
122
+
123
+ const resolvedPath = resolveAbsolutePath(inputPath);
124
+ const relativePath = path.relative(mountedRoot.mountPoint, resolvedPath);
125
+ return relativePath
126
+ ? path.join(mountedRoot.backingRoot, relativePath)
127
+ : mountedRoot.backingRoot;
128
+ }
129
+
130
+ function mapRealpathResult(inputPath: string, result: string): string {
131
+ const mountedRoot = findMountedRoot(inputPath);
132
+ if (!mountedRoot) {
133
+ return result;
134
+ }
135
+
136
+ let normalizedBackingRoot = path.resolve(mountedRoot.backingRoot);
137
+ try {
138
+ const realpath =
139
+ originalFsSyncMethods.realpathSync.native ??
140
+ originalFsSyncMethods.realpathSync;
141
+ normalizedBackingRoot = path.resolve(realpath(mountedRoot.backingRoot));
142
+ } catch {
143
+ // Fall back to the configured backing root when canonicalization fails.
144
+ }
145
+
146
+ const normalizedResult = path.resolve(result);
147
+ if (!isInsidePath(normalizedBackingRoot, normalizedResult)) {
148
+ return result;
149
+ }
150
+
151
+ const relativePath = path.relative(normalizedBackingRoot, normalizedResult);
152
+ return relativePath
153
+ ? path.join(mountedRoot.mountPoint, relativePath)
154
+ : mountedRoot.mountPoint;
155
+ }
156
+
157
+ function mapRenamePaths(
158
+ sourcePath: string,
159
+ destinationPath: string,
160
+ ): [string, string] | null {
161
+ const sourceRoot = findMountedRoot(sourcePath);
162
+ const destinationRoot = findMountedRoot(destinationPath);
163
+
164
+ if (!sourceRoot && !destinationRoot) {
165
+ return null;
166
+ }
167
+
168
+ if (
169
+ !sourceRoot ||
170
+ !destinationRoot ||
171
+ sourceRoot.mountPoint !== destinationRoot.mountPoint
172
+ ) {
173
+ throw new Error(
174
+ 'Pi logical VFS paths cannot rename across mount boundaries',
175
+ );
176
+ }
177
+
178
+ return [
179
+ mapToBackingPath(sourcePath) ?? sourcePath,
180
+ mapToBackingPath(destinationPath) ?? destinationPath,
181
+ ];
182
+ }
183
+
184
+ function wrapSinglePathSync<Fn extends (...args: never[]) => unknown>(
185
+ original: Fn,
186
+ options: {
187
+ mapResult?: (inputPath: string, result: ReturnType<Fn>) => ReturnType<Fn>;
188
+ } = {},
189
+ ): Fn {
190
+ return ((...args: Parameters<Fn>) => {
191
+ const [inputPath] = args;
192
+ if (typeof inputPath === 'string') {
193
+ const mappedPath = mapToBackingPath(inputPath);
194
+ if (mappedPath) {
195
+ const result = original(
196
+ ...([mappedPath, ...args.slice(1)] as Parameters<Fn>),
197
+ ) as ReturnType<Fn>;
198
+ return options.mapResult
199
+ ? options.mapResult(inputPath, result)
200
+ : result;
201
+ }
202
+ }
203
+ return original(...args);
204
+ }) as Fn;
205
+ }
206
+
207
+ function wrapRenameSync<Fn extends (...args: never[]) => unknown>(
208
+ original: Fn,
209
+ ): Fn {
210
+ return ((...args: Parameters<Fn>) => {
211
+ const [sourcePath, destinationPath] = args;
212
+ if (typeof sourcePath === 'string' && typeof destinationPath === 'string') {
213
+ const mappedPaths = mapRenamePaths(sourcePath, destinationPath);
214
+ if (mappedPaths) {
215
+ return original(
216
+ ...([
217
+ mappedPaths[0],
218
+ mappedPaths[1],
219
+ ...args.slice(2),
220
+ ] as Parameters<Fn>),
221
+ );
222
+ }
223
+ }
224
+ return original(...args);
225
+ }) as Fn;
226
+ }
227
+
228
+ function wrapSinglePathCallback<Fn extends (...args: never[]) => unknown>(
229
+ original: Fn,
230
+ options: {
231
+ mapCallbackResult?: (inputPath: string, result: unknown) => unknown;
232
+ } = {},
233
+ ): Fn {
234
+ return ((...args: Parameters<Fn>) => {
235
+ const [inputPath] = args;
236
+ if (typeof inputPath === 'string') {
237
+ const mappedPath = mapToBackingPath(inputPath);
238
+ if (mappedPath) {
239
+ const nextArgs = [mappedPath, ...args.slice(1)] as unknown[];
240
+ const maybeCallback = nextArgs.at(-1);
241
+ if (options.mapCallbackResult && typeof maybeCallback === 'function') {
242
+ nextArgs[nextArgs.length - 1] = (
243
+ error: NodeJS.ErrnoException | null,
244
+ result: unknown,
245
+ ...rest: unknown[]
246
+ ) => {
247
+ const mappedResult = error
248
+ ? result
249
+ : options.mapCallbackResult?.(inputPath, result);
250
+ (maybeCallback as (...callbackArgs: unknown[]) => void)(
251
+ error,
252
+ mappedResult,
253
+ ...rest,
254
+ );
255
+ };
256
+ }
257
+ return original(...(nextArgs as Parameters<Fn>));
258
+ }
259
+ }
260
+ return original(...args);
261
+ }) as Fn;
262
+ }
263
+
264
+ function wrapSinglePathPromise<
265
+ Fn extends (...args: never[]) => Promise<unknown>,
266
+ >(original: Fn): Fn {
267
+ return (async (...args: Parameters<Fn>) => {
268
+ const [inputPath] = args;
269
+ if (typeof inputPath === 'string') {
270
+ const mappedPath = mapToBackingPath(inputPath);
271
+ if (mappedPath) {
272
+ return await original(
273
+ ...([mappedPath, ...args.slice(1)] as Parameters<Fn>),
274
+ );
275
+ }
276
+ }
277
+ return await original(...args);
278
+ }) as Fn;
279
+ }
280
+
281
+ function createWrappedRealpathSync(): WrappedRealpathSync {
282
+ const wrapped = wrapSinglePathSync(originalFsSyncMethods.realpathSync, {
283
+ mapResult: (inputPath, result) =>
284
+ typeof result === 'string'
285
+ ? mapRealpathResult(inputPath, result)
286
+ : result,
287
+ }) as WrappedRealpathSync;
288
+
289
+ if (originalFsSyncMethods.realpathSync.native) {
290
+ wrapped.native = wrapSinglePathSync(
291
+ originalFsSyncMethods.realpathSync.native,
292
+ {
293
+ mapResult: (inputPath, result) =>
294
+ typeof result === 'string'
295
+ ? mapRealpathResult(inputPath, result)
296
+ : result,
297
+ },
298
+ ) as typeof fs.realpathSync.native;
299
+ }
300
+
301
+ return wrapped;
302
+ }
303
+
304
+ const wrappedFsSyncMethods = {
305
+ existsSync: wrapSinglePathSync(originalFsSyncMethods.existsSync),
306
+ mkdirSync: wrapSinglePathSync(originalFsSyncMethods.mkdirSync),
307
+ openSync: wrapSinglePathSync(originalFsSyncMethods.openSync),
308
+ readFileSync: wrapSinglePathSync(originalFsSyncMethods.readFileSync),
309
+ readdirSync: wrapSinglePathSync(originalFsSyncMethods.readdirSync),
310
+ realpathSync: createWrappedRealpathSync(),
311
+ renameSync: wrapRenameSync(originalFsSyncMethods.renameSync),
312
+ rmSync: wrapSinglePathSync(originalFsSyncMethods.rmSync),
313
+ statSync: wrapSinglePathSync(originalFsSyncMethods.statSync),
314
+ writeFileSync: wrapSinglePathSync(originalFsSyncMethods.writeFileSync),
315
+ };
316
+
317
+ const wrappedFsCallbackMethods = {
318
+ mkdir: wrapSinglePathCallback(originalFsCallbackMethods.mkdir),
319
+ realpath: wrapSinglePathCallback(originalFsCallbackMethods.realpath, {
320
+ mapCallbackResult: (inputPath, result) =>
321
+ typeof result === 'string'
322
+ ? mapRealpathResult(inputPath, result)
323
+ : result,
324
+ }),
325
+ rmdir: wrapSinglePathCallback(originalFsCallbackMethods.rmdir),
326
+ stat: wrapSinglePathCallback(originalFsCallbackMethods.stat),
327
+ utimes: wrapSinglePathCallback(originalFsCallbackMethods.utimes),
328
+ writeFile: wrapSinglePathCallback(originalFsCallbackMethods.writeFile),
329
+ };
330
+
331
+ const wrappedFsPromises = {
332
+ mkdir: wrapSinglePathPromise(originalFsPromises.mkdir),
333
+ readFile: wrapSinglePathPromise(originalFsPromises.readFile),
334
+ writeFile: wrapSinglePathPromise(originalFsPromises.writeFile),
335
+ };
336
+
337
+ function areExtraFsPatchesInstalled(): boolean {
338
+ return mutableFs.existsSync === wrappedFsSyncMethods.existsSync;
339
+ }
340
+
341
+ function installExtraFsPatches() {
342
+ if (areExtraFsPatchesInstalled()) return;
343
+
344
+ mutableFs.existsSync = wrappedFsSyncMethods.existsSync;
345
+ mutableFs.mkdirSync = wrappedFsSyncMethods.mkdirSync;
346
+ mutableFs.openSync = wrappedFsSyncMethods.openSync;
347
+ mutableFs.readFileSync = wrappedFsSyncMethods.readFileSync;
348
+ mutableFs.readdirSync = wrappedFsSyncMethods.readdirSync;
349
+ mutableFs.realpathSync = wrappedFsSyncMethods.realpathSync;
350
+ mutableFs.renameSync = wrappedFsSyncMethods.renameSync;
351
+ mutableFs.rmSync = wrappedFsSyncMethods.rmSync;
352
+ mutableFs.statSync = wrappedFsSyncMethods.statSync;
353
+ mutableFs.writeFileSync = wrappedFsSyncMethods.writeFileSync;
354
+
355
+ mutableFs.mkdir = wrappedFsCallbackMethods.mkdir;
356
+ mutableFs.realpath = wrappedFsCallbackMethods.realpath;
357
+ mutableFs.rmdir = wrappedFsCallbackMethods.rmdir;
358
+ mutableFs.stat = wrappedFsCallbackMethods.stat;
359
+ mutableFs.utimes = wrappedFsCallbackMethods.utimes;
360
+ mutableFs.writeFile = wrappedFsCallbackMethods.writeFile;
361
+
362
+ mutableFsPromises.mkdir = wrappedFsPromises.mkdir;
363
+ mutableFsPromises.readFile = wrappedFsPromises.readFile;
364
+ mutableFsPromises.writeFile = wrappedFsPromises.writeFile;
365
+
366
+ syncBuiltinESMExports();
367
+ }
368
+
369
+ function restoreExtraFsPatches() {
370
+ if (!areExtraFsPatchesInstalled()) return;
371
+
372
+ mutableFs.existsSync = originalFsSyncMethods.existsSync;
373
+ mutableFs.mkdirSync = originalFsSyncMethods.mkdirSync;
374
+ mutableFs.openSync = originalFsSyncMethods.openSync;
375
+ mutableFs.readFileSync = originalFsSyncMethods.readFileSync;
376
+ mutableFs.readdirSync = originalFsSyncMethods.readdirSync;
377
+ mutableFs.realpathSync = originalFsSyncMethods.realpathSync;
378
+ mutableFs.renameSync = originalFsSyncMethods.renameSync;
379
+ mutableFs.rmSync = originalFsSyncMethods.rmSync;
380
+ mutableFs.statSync = originalFsSyncMethods.statSync;
381
+ mutableFs.writeFileSync = originalFsSyncMethods.writeFileSync;
382
+
383
+ mutableFs.mkdir = originalFsCallbackMethods.mkdir;
384
+ mutableFs.realpath = originalFsCallbackMethods.realpath;
385
+ mutableFs.rmdir = originalFsCallbackMethods.rmdir;
386
+ mutableFs.stat = originalFsCallbackMethods.stat;
387
+ mutableFs.utimes = originalFsCallbackMethods.utimes;
388
+ mutableFs.writeFile = originalFsCallbackMethods.writeFile;
389
+
390
+ mutableFsPromises.mkdir = originalFsPromises.mkdir;
391
+ mutableFsPromises.readFile = originalFsPromises.readFile;
392
+ mutableFsPromises.writeFile = originalFsPromises.writeFile;
393
+
394
+ syncBuiltinESMExports();
395
+ }
396
+
397
+ export class PiWorkspaceVfs {
398
+ private backingRoot: string | null = null;
399
+ private mountPoint: string | null = null;
400
+
401
+ private disposeMount(): void {
402
+ if (this.mountPoint) {
403
+ mountedRoots.delete(this.mountPoint);
404
+ }
405
+ this.backingRoot = null;
406
+ this.mountPoint = null;
407
+ }
408
+
409
+ mount(backingRoot: string, mountPoint: string): void {
410
+ if (this.backingRoot === backingRoot && this.mountPoint === mountPoint) {
411
+ return;
412
+ }
413
+
414
+ const resolvedBackingRoot = path.resolve(backingRoot);
415
+ const resolvedMountPoint = path.resolve(mountPoint);
416
+ if (this.mountPoint) {
417
+ this.disposeMount();
418
+ }
419
+
420
+ mountedRoots.set(resolvedMountPoint, {
421
+ backingRoot: resolvedBackingRoot,
422
+ mountPoint: resolvedMountPoint,
423
+ });
424
+ installExtraFsPatches();
425
+
426
+ this.backingRoot = resolvedBackingRoot;
427
+ this.mountPoint = resolvedMountPoint;
428
+ }
429
+
430
+ unmount(): void {
431
+ this.disposeMount();
432
+
433
+ if (mountedRoots.size === 0) {
434
+ restoreExtraFsPatches();
435
+ }
436
+ }
437
+ }