@ai-sdk/harness-pi 0.0.0 → 1.0.0-beta.11
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 +95 -0
- package/LICENSE +13 -0
- package/README.md +52 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2178 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -1
- package/src/index.ts +11 -0
- package/src/pi-auth.ts +168 -0
- package/src/pi-events.ts +137 -0
- package/src/pi-harness.ts +145 -0
- package/src/pi-model-resolver.ts +52 -0
- package/src/pi-paths.ts +136 -0
- package/src/pi-remote-ops.ts +293 -0
- package/src/pi-resume-state.ts +81 -0
- package/src/pi-session.ts +1179 -0
- package/src/pi-skills.ts +69 -0
- package/src/pi-translate.ts +331 -0
- package/src/pi-typebox-adapter.ts +11 -0
- package/src/pi-utils.ts +91 -0
- package/src/pi-workspace-mirror.ts +276 -0
- package/src/pi-workspace-vfs.ts +437 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
3
|
+
import type { PiPathMapper } from './pi-paths';
|
|
4
|
+
import { shellQuote } from './pi-utils';
|
|
5
|
+
|
|
6
|
+
export type PiRemoteFileChangeKind = 'create' | 'modify';
|
|
7
|
+
|
|
8
|
+
export interface PiRemoteOpsOptions {
|
|
9
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
10
|
+
readonly paths: PiPathMapper;
|
|
11
|
+
readonly env?: Record<string, string>;
|
|
12
|
+
readonly onFileChange?: (
|
|
13
|
+
event: PiRemoteFileChangeKind,
|
|
14
|
+
relativePath: string,
|
|
15
|
+
content: Buffer,
|
|
16
|
+
) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PiRemoteOps {
|
|
20
|
+
readonly paths: PiPathMapper;
|
|
21
|
+
readBuffer(inputPath: string): Promise<Buffer>;
|
|
22
|
+
writeFile(inputPath: string, content: string): Promise<void>;
|
|
23
|
+
editFile(
|
|
24
|
+
inputPath: string,
|
|
25
|
+
oldText: string,
|
|
26
|
+
newText: string,
|
|
27
|
+
): Promise<string>;
|
|
28
|
+
listDirectory(inputPath?: string, limit?: number): Promise<string[]>;
|
|
29
|
+
findFiles(
|
|
30
|
+
pattern: string,
|
|
31
|
+
inputPath?: string,
|
|
32
|
+
limit?: number,
|
|
33
|
+
): Promise<string[]>;
|
|
34
|
+
grepFiles(
|
|
35
|
+
pattern: string,
|
|
36
|
+
input: {
|
|
37
|
+
path?: string;
|
|
38
|
+
glob?: string;
|
|
39
|
+
ignoreCase?: boolean;
|
|
40
|
+
literal?: boolean;
|
|
41
|
+
context?: number;
|
|
42
|
+
limit?: number;
|
|
43
|
+
},
|
|
44
|
+
): Promise<string>;
|
|
45
|
+
access(inputPath: string): Promise<void>;
|
|
46
|
+
exec(
|
|
47
|
+
command: string,
|
|
48
|
+
cwd: string,
|
|
49
|
+
input: {
|
|
50
|
+
onData: (data: Buffer) => void;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
timeout?: number;
|
|
53
|
+
},
|
|
54
|
+
): Promise<{ exitCode: number | null }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RunShellInput {
|
|
58
|
+
cwd?: string;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
onData?: (data: Buffer) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface RunShellResult {
|
|
64
|
+
exitCode: number | null;
|
|
65
|
+
output: Buffer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createPiRemoteOps(options: PiRemoteOpsOptions): PiRemoteOps {
|
|
69
|
+
const runShell = async (
|
|
70
|
+
command: string,
|
|
71
|
+
input: RunShellInput = {},
|
|
72
|
+
): Promise<RunShellResult> => {
|
|
73
|
+
// `sandbox.run({ command })` already wraps in `bash -c`; we pass the
|
|
74
|
+
// shell snippet directly. shellQuote is still used inside `command`
|
|
75
|
+
// for path/value interpolation by the callers.
|
|
76
|
+
const result = await options.sandbox.run({
|
|
77
|
+
command,
|
|
78
|
+
...(input.cwd
|
|
79
|
+
? { workingDirectory: options.paths.toSandboxPath(input.cwd) }
|
|
80
|
+
: {}),
|
|
81
|
+
...(options.env ? { env: options.env } : {}),
|
|
82
|
+
...(input.signal ? { abortSignal: input.signal } : {}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const combined = `${result.stdout}${result.stderr}`;
|
|
86
|
+
const output = Buffer.from(combined, 'utf8');
|
|
87
|
+
if (output.length > 0) {
|
|
88
|
+
input.onData?.(output);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
exitCode: result.exitCode,
|
|
93
|
+
output,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const readBuffer = async (inputPath: string): Promise<Buffer> => {
|
|
98
|
+
const bytes = await options.sandbox.readBinaryFile({
|
|
99
|
+
path: options.paths.toReadableSandboxPath(inputPath),
|
|
100
|
+
});
|
|
101
|
+
if (!bytes) {
|
|
102
|
+
throw new Error(`Path not found: ${inputPath}`);
|
|
103
|
+
}
|
|
104
|
+
return Buffer.from(bytes);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const writeFile = async (
|
|
108
|
+
inputPath: string,
|
|
109
|
+
content: string,
|
|
110
|
+
): Promise<void> => {
|
|
111
|
+
const remotePath = options.paths.toSandboxPath(inputPath);
|
|
112
|
+
const previous = await options.sandbox.readBinaryFile({ path: remotePath });
|
|
113
|
+
await runShell(`mkdir -p ${shellQuote(path.posix.dirname(remotePath))}`);
|
|
114
|
+
await options.sandbox.writeTextFile({ path: remotePath, content });
|
|
115
|
+
options.onFileChange?.(
|
|
116
|
+
previous ? 'modify' : 'create',
|
|
117
|
+
options.paths.toRelativePath(remotePath),
|
|
118
|
+
Buffer.from(content, 'utf8'),
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const editFile = async (
|
|
123
|
+
inputPath: string,
|
|
124
|
+
oldText: string,
|
|
125
|
+
newText: string,
|
|
126
|
+
): Promise<string> => {
|
|
127
|
+
const current = (await readBuffer(inputPath)).toString('utf8');
|
|
128
|
+
const index = current.indexOf(oldText);
|
|
129
|
+
if (index === -1) {
|
|
130
|
+
throw new Error(`Text to replace was not found in ${inputPath}`);
|
|
131
|
+
}
|
|
132
|
+
const updated = `${current.slice(0, index)}${newText}${current.slice(
|
|
133
|
+
index + oldText.length,
|
|
134
|
+
)}`;
|
|
135
|
+
await writeFile(inputPath, updated);
|
|
136
|
+
return updated;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const listDirectory = async (
|
|
140
|
+
inputPath: string = '.',
|
|
141
|
+
limit: number = 500,
|
|
142
|
+
): Promise<string[]> => {
|
|
143
|
+
const remotePath = options.paths.toReadableSandboxPath(inputPath);
|
|
144
|
+
const result = await runShell(
|
|
145
|
+
[
|
|
146
|
+
`if [ ! -e ${shellQuote(remotePath)} ]; then echo "__PI_LS_NOT_FOUND__"; exit 2; fi`,
|
|
147
|
+
`if [ ! -d ${shellQuote(remotePath)} ]; then echo "__PI_LS_NOT_DIR__"; exit 3; fi`,
|
|
148
|
+
`cd ${shellQuote(remotePath)}`,
|
|
149
|
+
'ls -1Ap',
|
|
150
|
+
].join('; '),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const output = result.output.toString('utf8').trim();
|
|
154
|
+
if (output.includes('__PI_LS_NOT_FOUND__')) {
|
|
155
|
+
throw new Error(`Path not found: ${inputPath}`);
|
|
156
|
+
}
|
|
157
|
+
if (output.includes('__PI_LS_NOT_DIR__')) {
|
|
158
|
+
throw new Error(`Not a directory: ${inputPath}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return output
|
|
162
|
+
.split('\n')
|
|
163
|
+
.filter(Boolean)
|
|
164
|
+
.map(line => line.replace(/[*=@|]$/, ''))
|
|
165
|
+
.sort((left, right) =>
|
|
166
|
+
left.toLowerCase().localeCompare(right.toLowerCase()),
|
|
167
|
+
)
|
|
168
|
+
.slice(0, limit);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const findFiles = async (
|
|
172
|
+
pattern: string,
|
|
173
|
+
inputPath: string = '.',
|
|
174
|
+
limit: number = 1_000,
|
|
175
|
+
): Promise<string[]> => {
|
|
176
|
+
const remotePath = options.paths.toReadableSandboxPath(inputPath);
|
|
177
|
+
const result = await runShell(
|
|
178
|
+
[
|
|
179
|
+
`if [ ! -e ${shellQuote(remotePath)} ]; then echo "__PI_FIND_NOT_FOUND__"; exit 2; fi`,
|
|
180
|
+
`if [ -d ${shellQuote(remotePath)} ]; then find ${shellQuote(remotePath)} -type f -print; else printf '%s\\n' ${shellQuote(remotePath)}; fi`,
|
|
181
|
+
].join('; '),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const output = result.output.toString('utf8').trim();
|
|
185
|
+
if (output.includes('__PI_FIND_NOT_FOUND__')) {
|
|
186
|
+
throw new Error(`Path not found: ${inputPath}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const searchRoot = remotePath;
|
|
190
|
+
return output
|
|
191
|
+
.split('\n')
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.map(absolutePath => {
|
|
194
|
+
if (absolutePath === searchRoot) {
|
|
195
|
+
return path.posix.basename(absolutePath);
|
|
196
|
+
}
|
|
197
|
+
return path.posix.relative(searchRoot, absolutePath);
|
|
198
|
+
})
|
|
199
|
+
.filter(
|
|
200
|
+
candidate =>
|
|
201
|
+
candidate.length > 0 && path.matchesGlob(candidate, pattern),
|
|
202
|
+
)
|
|
203
|
+
.sort((left, right) =>
|
|
204
|
+
left.toLowerCase().localeCompare(right.toLowerCase()),
|
|
205
|
+
)
|
|
206
|
+
.slice(0, limit);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const grepFiles = async (
|
|
210
|
+
pattern: string,
|
|
211
|
+
input: {
|
|
212
|
+
path?: string;
|
|
213
|
+
glob?: string;
|
|
214
|
+
ignoreCase?: boolean;
|
|
215
|
+
literal?: boolean;
|
|
216
|
+
context?: number;
|
|
217
|
+
limit?: number;
|
|
218
|
+
},
|
|
219
|
+
): Promise<string> => {
|
|
220
|
+
const remotePath = options.paths.toReadableSandboxPath(input.path ?? '.');
|
|
221
|
+
const relativeTarget = options.paths.toRelativePath(remotePath);
|
|
222
|
+
const targetPath =
|
|
223
|
+
relativeTarget.startsWith('../') || path.posix.isAbsolute(relativeTarget)
|
|
224
|
+
? remotePath
|
|
225
|
+
: relativeTarget;
|
|
226
|
+
const flags = [
|
|
227
|
+
'-R',
|
|
228
|
+
'-n',
|
|
229
|
+
'--binary-files=without-match',
|
|
230
|
+
...(input.ignoreCase ? ['-i'] : []),
|
|
231
|
+
...(input.literal ? ['-F'] : []),
|
|
232
|
+
...(typeof input.context === 'number' && input.context > 0
|
|
233
|
+
? ['-C', String(input.context)]
|
|
234
|
+
: []),
|
|
235
|
+
...(input.glob ? ['--include', input.glob] : []),
|
|
236
|
+
];
|
|
237
|
+
const limit = Math.max(1, input.limit ?? 100);
|
|
238
|
+
const result = await runShell(
|
|
239
|
+
[
|
|
240
|
+
`if [ ! -e ${shellQuote(remotePath)} ]; then echo "__PI_GREP_NOT_FOUND__"; exit 2; fi`,
|
|
241
|
+
`cd ${shellQuote(options.paths.sandboxWorkDir)}`,
|
|
242
|
+
`grep ${flags.map(shellQuote).join(' ')} -- ${shellQuote(pattern)} ${shellQuote(targetPath)} 2>/dev/null | head -n ${limit}`,
|
|
243
|
+
].join('; '),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const output = result.output.toString('utf8').trim();
|
|
247
|
+
if (output.includes('__PI_GREP_NOT_FOUND__')) {
|
|
248
|
+
throw new Error(`Path not found: ${input.path ?? '.'}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return output || 'No matches found';
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
paths: options.paths,
|
|
256
|
+
readBuffer,
|
|
257
|
+
writeFile,
|
|
258
|
+
editFile,
|
|
259
|
+
listDirectory,
|
|
260
|
+
findFiles,
|
|
261
|
+
grepFiles,
|
|
262
|
+
async access(inputPath: string) {
|
|
263
|
+
await readBuffer(inputPath);
|
|
264
|
+
},
|
|
265
|
+
async exec(command, cwd, input): Promise<{ exitCode: number | null }> {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
// `input.timeout` is expressed in seconds (Pi's `bash` tool contract),
|
|
268
|
+
// so convert to milliseconds for `setTimeout`.
|
|
269
|
+
const timeoutId =
|
|
270
|
+
typeof input.timeout === 'number' && input.timeout > 0
|
|
271
|
+
? setTimeout(() => controller.abort(), input.timeout * 1000)
|
|
272
|
+
: undefined;
|
|
273
|
+
|
|
274
|
+
const forwardedSignal = input.signal;
|
|
275
|
+
const onAbort = () => controller.abort();
|
|
276
|
+
forwardedSignal?.addEventListener('abort', onAbort, { once: true });
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const result = await runShell(command, {
|
|
280
|
+
cwd,
|
|
281
|
+
signal: controller.signal,
|
|
282
|
+
onData: input.onData,
|
|
283
|
+
});
|
|
284
|
+
return { exitCode: result.exitCode };
|
|
285
|
+
} finally {
|
|
286
|
+
forwardedSignal?.removeEventListener('abort', onAbort);
|
|
287
|
+
if (timeoutId) {
|
|
288
|
+
clearTimeout(timeoutId);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Schema for the adapter-specific portion of lifecycle state `data` produced
|
|
8
|
+
* by Pi's resumable lifecycle methods. Carries the basename
|
|
9
|
+
* (including extension) of the Pi session file. The actual session bytes live
|
|
10
|
+
* in the sandbox under `${sessionWorkDir}/.pi-sessions/<sessionFileName>` so
|
|
11
|
+
* they survive cross-process resume via the sandbox snapshot.
|
|
12
|
+
*/
|
|
13
|
+
export const piResumeStateSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
sessionFileName: z.string().optional(),
|
|
16
|
+
})
|
|
17
|
+
.passthrough();
|
|
18
|
+
|
|
19
|
+
export type PiResumeStateData = z.infer<typeof piResumeStateSchema>;
|
|
20
|
+
|
|
21
|
+
const PI_SESSIONS_DIR = '.pi-sessions';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Copy the Pi session file from the host's local mirror to a stable location
|
|
25
|
+
* inside the sandbox workspace. Called during resumable lifecycle methods so
|
|
26
|
+
* the session survives a sandbox snapshot or a process handoff.
|
|
27
|
+
*/
|
|
28
|
+
export async function persistSessionFileToSandbox(args: {
|
|
29
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
30
|
+
readonly sessionWorkDir: string;
|
|
31
|
+
readonly hostSessionDir: string;
|
|
32
|
+
readonly sessionFileName: string;
|
|
33
|
+
readonly abortSignal?: AbortSignal;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
const hostPath = path.join(args.hostSessionDir, args.sessionFileName);
|
|
36
|
+
const content = await readFile(hostPath);
|
|
37
|
+
const remotePath = path.posix.join(
|
|
38
|
+
args.sessionWorkDir,
|
|
39
|
+
PI_SESSIONS_DIR,
|
|
40
|
+
args.sessionFileName,
|
|
41
|
+
);
|
|
42
|
+
// Ensure the parent dir exists in the sandbox before writing.
|
|
43
|
+
await args.sandbox.run({
|
|
44
|
+
command: `mkdir -p ${path.posix.dirname(remotePath)}`,
|
|
45
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
46
|
+
});
|
|
47
|
+
await args.sandbox.writeBinaryFile({
|
|
48
|
+
path: remotePath,
|
|
49
|
+
content,
|
|
50
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pull a previously persisted Pi session file from the sandbox into a fresh
|
|
56
|
+
* local mirror dir. Called during `doStart` on the resume path before Pi is
|
|
57
|
+
* initialised. Returns the absolute path of the local session file or
|
|
58
|
+
* `undefined` if the sandbox copy is missing.
|
|
59
|
+
*/
|
|
60
|
+
export async function pullSessionFileFromSandbox(args: {
|
|
61
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
62
|
+
readonly sessionWorkDir: string;
|
|
63
|
+
readonly hostSessionDir: string;
|
|
64
|
+
readonly sessionFileName: string;
|
|
65
|
+
readonly abortSignal?: AbortSignal;
|
|
66
|
+
}): Promise<string | undefined> {
|
|
67
|
+
const remotePath = path.posix.join(
|
|
68
|
+
args.sessionWorkDir,
|
|
69
|
+
PI_SESSIONS_DIR,
|
|
70
|
+
args.sessionFileName,
|
|
71
|
+
);
|
|
72
|
+
const bytes = await args.sandbox.readBinaryFile({
|
|
73
|
+
path: remotePath,
|
|
74
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
75
|
+
});
|
|
76
|
+
if (!bytes) return undefined;
|
|
77
|
+
await mkdir(args.hostSessionDir, { recursive: true });
|
|
78
|
+
const hostPath = path.join(args.hostSessionDir, args.sessionFileName);
|
|
79
|
+
await writeFile(hostPath, bytes);
|
|
80
|
+
return hostPath;
|
|
81
|
+
}
|