@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
package/src/pi-auth.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthStorage,
|
|
3
|
+
ModelRegistry,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
|
|
6
|
+
type ProviderConfigInput = Parameters<ModelRegistry['registerProvider']>[1];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pi auth options. Exactly one of `gateway` or `customEnv` is honoured
|
|
10
|
+
* (precedence: explicit `customEnv`, then explicit `gateway`, then ambient
|
|
11
|
+
* gateway from `process.env`). To use multiple providers, use `customEnv`
|
|
12
|
+
* with the standard `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL` pattern.
|
|
13
|
+
*/
|
|
14
|
+
export type PiAuthOptions = {
|
|
15
|
+
readonly gateway?: {
|
|
16
|
+
readonly apiKey?: string;
|
|
17
|
+
readonly baseUrl?: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Resolved environment-variable pairs of the form `<PREFIX>_API_KEY` and
|
|
21
|
+
* (optionally) `<PREFIX>_BASE_URL`. Special-cased prefixes:
|
|
22
|
+
* - `AI_GATEWAY` → registers `vercel-ai-gateway`
|
|
23
|
+
* - `OPENAI` → registers `openai`
|
|
24
|
+
* - `ANTHROPIC` → registers `anthropic` (`ANTHROPIC_AUTH_TOKEN` adds a
|
|
25
|
+
* bearer auth header)
|
|
26
|
+
* Any other `<PREFIX>_API_KEY` with a matching `<PREFIX>_BASE_URL` is
|
|
27
|
+
* registered as the lowercased, dash-separated prefix.
|
|
28
|
+
*/
|
|
29
|
+
readonly customEnv?: Record<string, string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Env subset returned by `resolvePiAuth` for use by the Pi model resolver
|
|
34
|
+
* (it reads `AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN` to decide whether
|
|
35
|
+
* to fall back to the default gateway model).
|
|
36
|
+
*/
|
|
37
|
+
export type PiResolverEnv = Record<string, string>;
|
|
38
|
+
|
|
39
|
+
const DEFAULT_GATEWAY_BASE_URL = 'https://ai-gateway.vercel.sh';
|
|
40
|
+
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
41
|
+
const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
|
|
42
|
+
|
|
43
|
+
function register(
|
|
44
|
+
registries: { authStorage: AuthStorage; modelRegistry: ModelRegistry },
|
|
45
|
+
provider: string,
|
|
46
|
+
apiKey: string,
|
|
47
|
+
config: ProviderConfigInput,
|
|
48
|
+
): void {
|
|
49
|
+
registries.authStorage.setRuntimeApiKey(provider, apiKey);
|
|
50
|
+
registries.modelRegistry.registerProvider(provider, config);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasConfiguredValue(value: unknown): boolean {
|
|
54
|
+
if (value == null) return false;
|
|
55
|
+
if (typeof value === 'string') return value.length > 0;
|
|
56
|
+
if (typeof value !== 'object') return true;
|
|
57
|
+
return Object.values(value).some(hasConfiguredValue);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolvePiAuth(
|
|
61
|
+
options: PiAuthOptions | undefined,
|
|
62
|
+
env: NodeJS.ProcessEnv,
|
|
63
|
+
registries: { authStorage: AuthStorage; modelRegistry: ModelRegistry },
|
|
64
|
+
): PiResolverEnv {
|
|
65
|
+
const customEnvConfigured = hasConfiguredValue(options?.customEnv);
|
|
66
|
+
const gatewayConfigured = hasConfiguredValue(options?.gateway);
|
|
67
|
+
|
|
68
|
+
if (customEnvConfigured) {
|
|
69
|
+
return applyCustomEnv(options!.customEnv ?? {}, registries);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (gatewayConfigured) {
|
|
73
|
+
const apiKey = options!.gateway?.apiKey;
|
|
74
|
+
const baseUrl = options!.gateway?.baseUrl ?? DEFAULT_GATEWAY_BASE_URL;
|
|
75
|
+
if (apiKey) {
|
|
76
|
+
register(registries, 'vercel-ai-gateway', apiKey, {
|
|
77
|
+
apiKey,
|
|
78
|
+
baseUrl,
|
|
79
|
+
authHeader: true,
|
|
80
|
+
});
|
|
81
|
+
return { AI_GATEWAY_API_KEY: apiKey, AI_GATEWAY_BASE_URL: baseUrl };
|
|
82
|
+
}
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Ambient gateway fallback.
|
|
87
|
+
const ambientKey = env.AI_GATEWAY_API_KEY || env.VERCEL_OIDC_TOKEN;
|
|
88
|
+
if (ambientKey) {
|
|
89
|
+
const baseUrl = env.AI_GATEWAY_BASE_URL ?? DEFAULT_GATEWAY_BASE_URL;
|
|
90
|
+
register(registries, 'vercel-ai-gateway', ambientKey, {
|
|
91
|
+
apiKey: ambientKey,
|
|
92
|
+
baseUrl,
|
|
93
|
+
authHeader: true,
|
|
94
|
+
});
|
|
95
|
+
return { AI_GATEWAY_API_KEY: ambientKey, AI_GATEWAY_BASE_URL: baseUrl };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function applyCustomEnv(
|
|
102
|
+
customEnv: Record<string, string>,
|
|
103
|
+
registries: { authStorage: AuthStorage; modelRegistry: ModelRegistry },
|
|
104
|
+
): PiResolverEnv {
|
|
105
|
+
const out: PiResolverEnv = {};
|
|
106
|
+
|
|
107
|
+
const gatewayKey = customEnv.AI_GATEWAY_API_KEY;
|
|
108
|
+
if (gatewayKey) {
|
|
109
|
+
const baseUrl = customEnv.AI_GATEWAY_BASE_URL ?? DEFAULT_GATEWAY_BASE_URL;
|
|
110
|
+
register(registries, 'vercel-ai-gateway', gatewayKey, {
|
|
111
|
+
apiKey: gatewayKey,
|
|
112
|
+
baseUrl,
|
|
113
|
+
authHeader: true,
|
|
114
|
+
});
|
|
115
|
+
out.AI_GATEWAY_API_KEY = gatewayKey;
|
|
116
|
+
out.AI_GATEWAY_BASE_URL = baseUrl;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (customEnv.OPENAI_API_KEY) {
|
|
120
|
+
const baseUrl = customEnv.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL;
|
|
121
|
+
register(registries, 'openai', customEnv.OPENAI_API_KEY, {
|
|
122
|
+
apiKey: customEnv.OPENAI_API_KEY,
|
|
123
|
+
baseUrl,
|
|
124
|
+
authHeader: true,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (customEnv.ANTHROPIC_API_KEY) {
|
|
129
|
+
const baseUrl = customEnv.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL;
|
|
130
|
+
register(registries, 'anthropic', customEnv.ANTHROPIC_API_KEY, {
|
|
131
|
+
apiKey: customEnv.ANTHROPIC_API_KEY,
|
|
132
|
+
baseUrl,
|
|
133
|
+
...(customEnv.ANTHROPIC_AUTH_TOKEN
|
|
134
|
+
? {
|
|
135
|
+
headers: {
|
|
136
|
+
authorization: `Bearer ${customEnv.ANTHROPIC_AUTH_TOKEN}`,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const [name, apiKey] of Object.entries(customEnv)) {
|
|
144
|
+
if (!name.endsWith('_API_KEY') || !apiKey) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const prefix = name.slice(0, -'_API_KEY'.length);
|
|
148
|
+
if (
|
|
149
|
+
prefix === 'AI_GATEWAY' ||
|
|
150
|
+
prefix === 'OPENAI' ||
|
|
151
|
+
prefix === 'ANTHROPIC'
|
|
152
|
+
) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const provider = prefix.toLowerCase().replace(/_/g, '-');
|
|
156
|
+
const baseUrl = customEnv[`${prefix}_BASE_URL`];
|
|
157
|
+
if (!baseUrl) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
register(registries, provider, apiKey, {
|
|
161
|
+
apiKey,
|
|
162
|
+
baseUrl,
|
|
163
|
+
authHeader: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return out;
|
|
168
|
+
}
|
package/src/pi-events.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pi `session.subscribe` emits a discriminated union of events. The exact
|
|
5
|
+
* shape evolves with Pi versions; we accept the events with `passthrough()`
|
|
6
|
+
* and extract only the fields we recognise. The `type` field is required
|
|
7
|
+
* and stringly-typed because Pi may add new types we want to ignore.
|
|
8
|
+
*/
|
|
9
|
+
export const piSessionEventSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
type: z.string(),
|
|
12
|
+
assistantMessageEvent: z
|
|
13
|
+
.object({
|
|
14
|
+
type: z.string().optional(),
|
|
15
|
+
delta: z.string().optional(),
|
|
16
|
+
})
|
|
17
|
+
.passthrough()
|
|
18
|
+
.optional(),
|
|
19
|
+
toolCallId: z.string().optional(),
|
|
20
|
+
toolName: z.string().optional(),
|
|
21
|
+
args: z.unknown().optional(),
|
|
22
|
+
input: z.unknown().optional(),
|
|
23
|
+
result: z.unknown().optional(),
|
|
24
|
+
content: z.unknown().optional(),
|
|
25
|
+
isError: z.boolean().optional(),
|
|
26
|
+
// Compaction events (`compaction_start` / `compaction_end`). `result` (a
|
|
27
|
+
// `CompactionResult`) rides the shared `result` field above; `reason`
|
|
28
|
+
// distinguishes manual vs automatic (threshold/overflow) compaction.
|
|
29
|
+
reason: z.string().optional(),
|
|
30
|
+
aborted: z.boolean().optional(),
|
|
31
|
+
error: z
|
|
32
|
+
.union([
|
|
33
|
+
z.string(),
|
|
34
|
+
z
|
|
35
|
+
.object({
|
|
36
|
+
errorMessage: z.string().optional(),
|
|
37
|
+
stopReason: z.string().optional(),
|
|
38
|
+
})
|
|
39
|
+
.passthrough(),
|
|
40
|
+
])
|
|
41
|
+
.optional(),
|
|
42
|
+
message: z
|
|
43
|
+
.object({
|
|
44
|
+
role: z.string().optional(),
|
|
45
|
+
content: z.unknown().optional(),
|
|
46
|
+
stopReason: z.string().optional(),
|
|
47
|
+
errorMessage: z.string().optional(),
|
|
48
|
+
})
|
|
49
|
+
.passthrough()
|
|
50
|
+
.optional(),
|
|
51
|
+
})
|
|
52
|
+
.passthrough();
|
|
53
|
+
|
|
54
|
+
export type PiSessionEvent = z.infer<typeof piSessionEventSchema>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decode an unknown raw event into a `PiSessionEvent` if it looks like one.
|
|
58
|
+
* Returns `undefined` if it doesn't parse so the caller can skip it.
|
|
59
|
+
*/
|
|
60
|
+
export function parseNativeEvent(raw: unknown): PiSessionEvent | undefined {
|
|
61
|
+
const parsed = piSessionEventSchema.safeParse(raw);
|
|
62
|
+
return parsed.success ? parsed.data : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect whether a Pi event signals a terminal error for the current turn.
|
|
67
|
+
* Returns the error message if so. Mirrors the original adapter's
|
|
68
|
+
* `getPiTerminalError`.
|
|
69
|
+
*/
|
|
70
|
+
export function getPiTerminalError(event: PiSessionEvent): string | undefined {
|
|
71
|
+
const isTerminalStopReason = (value: string | undefined) =>
|
|
72
|
+
value === 'error' || value === 'aborted';
|
|
73
|
+
|
|
74
|
+
if (typeof event.error === 'string' && event.error.trim()) {
|
|
75
|
+
return event.error.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (event.error && typeof event.error === 'object') {
|
|
79
|
+
const errorMessage = event.error.errorMessage?.trim();
|
|
80
|
+
if (errorMessage) {
|
|
81
|
+
return errorMessage;
|
|
82
|
+
}
|
|
83
|
+
const stopReason = event.error.stopReason?.trim();
|
|
84
|
+
if (isTerminalStopReason(stopReason)) {
|
|
85
|
+
return stopReason;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const messageError = event.message?.errorMessage?.trim();
|
|
90
|
+
if (messageError) {
|
|
91
|
+
return messageError;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const messageStopReason = event.message?.stopReason?.trim();
|
|
95
|
+
if (isTerminalStopReason(messageStopReason)) {
|
|
96
|
+
return messageStopReason;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
event.isError &&
|
|
101
|
+
typeof event.content === 'string' &&
|
|
102
|
+
event.content.trim()
|
|
103
|
+
) {
|
|
104
|
+
return event.content.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Pull the assistant text from a `turn_end` / `message_end` event payload. */
|
|
111
|
+
export function extractAssistantText(
|
|
112
|
+
message: PiSessionEvent['message'],
|
|
113
|
+
): string {
|
|
114
|
+
if (!message || message.role !== 'assistant') {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof message.content === 'string') {
|
|
119
|
+
return message.content;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(message.content)) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return message.content
|
|
127
|
+
.flatMap(part => {
|
|
128
|
+
if (!part || typeof part !== 'object') {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const contentPart = part as Record<string, unknown>;
|
|
132
|
+
return contentPart.type === 'text' && typeof contentPart.text === 'string'
|
|
133
|
+
? [contentPart.text]
|
|
134
|
+
: [];
|
|
135
|
+
})
|
|
136
|
+
.join('');
|
|
137
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commonTool,
|
|
3
|
+
type HarnessV1,
|
|
4
|
+
type HarnessV1BuiltinTool,
|
|
5
|
+
} from '@ai-sdk/harness';
|
|
6
|
+
import { tool } from '@ai-sdk/provider-utils';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import type { PiAuthOptions } from './pi-auth';
|
|
9
|
+
import { piResumeStateSchema } from './pi-resume-state';
|
|
10
|
+
import { createPiSession, type PiThinkingLevel } from './pi-session';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration knobs for `createPi`. Pi runs as an in-process Node library
|
|
14
|
+
* (no bridge), so there's no `port` or `startupTimeoutMs` to set.
|
|
15
|
+
*/
|
|
16
|
+
export type PiHarnessSettings = {
|
|
17
|
+
/** Where Pi sources API keys / gateway credentials from. */
|
|
18
|
+
readonly auth?: PiAuthOptions;
|
|
19
|
+
/**
|
|
20
|
+
* Pi model id (or name). Leaving this unset falls back to the AI Gateway
|
|
21
|
+
* default when `AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN` is set, and to
|
|
22
|
+
* Pi's own resolution otherwise.
|
|
23
|
+
*/
|
|
24
|
+
readonly model?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Pi's extended-thinking budget level. Maps directly to the SDK's
|
|
27
|
+
* `thinkingLevel` option on `createAgentSession`.
|
|
28
|
+
*/
|
|
29
|
+
readonly thinkingLevel?: PiThinkingLevel;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const PI_BUILTIN_TOOLS = {
|
|
33
|
+
read: commonTool('read', {
|
|
34
|
+
nativeName: 'read',
|
|
35
|
+
toolUseKind: 'readonly',
|
|
36
|
+
description: 'Read file contents.',
|
|
37
|
+
inputSchema: z.object({
|
|
38
|
+
file_path: z.string(),
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
write: commonTool('write', {
|
|
42
|
+
nativeName: 'write',
|
|
43
|
+
toolUseKind: 'edit',
|
|
44
|
+
description: 'Overwrite or create a file.',
|
|
45
|
+
inputSchema: z.object({
|
|
46
|
+
file_path: z.string(),
|
|
47
|
+
content: z.string(),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
edit: commonTool('edit', {
|
|
51
|
+
nativeName: 'edit',
|
|
52
|
+
toolUseKind: 'edit',
|
|
53
|
+
description: 'Edit a file by exact string replacement.',
|
|
54
|
+
inputSchema: z.object({
|
|
55
|
+
file_path: z.string(),
|
|
56
|
+
old_string: z.string(),
|
|
57
|
+
new_string: z.string(),
|
|
58
|
+
}),
|
|
59
|
+
}),
|
|
60
|
+
bash: commonTool('bash', {
|
|
61
|
+
nativeName: 'bash',
|
|
62
|
+
toolUseKind: 'bash',
|
|
63
|
+
description: 'Execute a shell command in the sandbox.',
|
|
64
|
+
inputSchema: z.object({
|
|
65
|
+
command: z.string(),
|
|
66
|
+
timeout: z.number().optional(),
|
|
67
|
+
}),
|
|
68
|
+
}),
|
|
69
|
+
grep: commonTool('grep', {
|
|
70
|
+
nativeName: 'grep',
|
|
71
|
+
toolUseKind: 'readonly',
|
|
72
|
+
description: 'Search file contents with regex.',
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
pattern: z.string(),
|
|
75
|
+
path: z.string().optional(),
|
|
76
|
+
glob: z.string().optional(),
|
|
77
|
+
ignoreCase: z.boolean().optional(),
|
|
78
|
+
literal: z.boolean().optional(),
|
|
79
|
+
context: z.number().optional(),
|
|
80
|
+
limit: z.number().optional(),
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
glob: commonTool('glob', {
|
|
84
|
+
nativeName: 'find',
|
|
85
|
+
toolUseKind: 'readonly',
|
|
86
|
+
description: 'Find files matching a glob pattern.',
|
|
87
|
+
inputSchema: z.object({
|
|
88
|
+
pattern: z.string(),
|
|
89
|
+
path: z.string().optional(),
|
|
90
|
+
limit: z.number().optional(),
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
ls: {
|
|
94
|
+
...tool({
|
|
95
|
+
description: 'List directory entries.',
|
|
96
|
+
inputSchema: z.object({
|
|
97
|
+
path: z.string().optional(),
|
|
98
|
+
limit: z.number().optional(),
|
|
99
|
+
}),
|
|
100
|
+
outputSchema: z.unknown(),
|
|
101
|
+
}),
|
|
102
|
+
nativeName: 'ls',
|
|
103
|
+
toolUseKind: 'readonly',
|
|
104
|
+
} as HarnessV1BuiltinTool,
|
|
105
|
+
} as const satisfies Record<string, HarnessV1BuiltinTool<any, any>>;
|
|
106
|
+
|
|
107
|
+
export function createPi(
|
|
108
|
+
settings: PiHarnessSettings = {},
|
|
109
|
+
): HarnessV1<typeof PI_BUILTIN_TOOLS> {
|
|
110
|
+
return {
|
|
111
|
+
specificationVersion: 'harness-v1',
|
|
112
|
+
harnessId: 'pi',
|
|
113
|
+
builtinTools: PI_BUILTIN_TOOLS,
|
|
114
|
+
supportsBuiltinToolApprovals: true,
|
|
115
|
+
lifecycleStateSchema: piResumeStateSchema,
|
|
116
|
+
doStart: async startOpts => {
|
|
117
|
+
const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
|
|
118
|
+
const resumeData = lifecycleState?.data as
|
|
119
|
+
| { sessionFileName?: string }
|
|
120
|
+
| undefined;
|
|
121
|
+
|
|
122
|
+
return createPiSession({
|
|
123
|
+
sessionId: startOpts.sessionId,
|
|
124
|
+
sandboxSession: startOpts.sandboxSession,
|
|
125
|
+
sessionWorkDir: startOpts.sessionWorkDir,
|
|
126
|
+
skills: startOpts.skills ?? [],
|
|
127
|
+
settings: {
|
|
128
|
+
...(settings.auth ? { auth: settings.auth } : {}),
|
|
129
|
+
...(settings.model ? { model: settings.model } : {}),
|
|
130
|
+
...(settings.thinkingLevel
|
|
131
|
+
? { thinkingLevel: settings.thinkingLevel }
|
|
132
|
+
: {}),
|
|
133
|
+
},
|
|
134
|
+
isResume: lifecycleState != null,
|
|
135
|
+
permissionMode: startOpts.permissionMode,
|
|
136
|
+
...(resumeData?.sessionFileName
|
|
137
|
+
? { resumeSessionFileName: resumeData.sessionFileName }
|
|
138
|
+
: {}),
|
|
139
|
+
...(startOpts.abortSignal
|
|
140
|
+
? { abortSignal: startOpts.abortSignal }
|
|
141
|
+
: {}),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
type PiModel = ReturnType<ModelRegistry['getAll']>[number];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default model id used when no `model` is configured AND gateway credentials
|
|
7
|
+
* are available in the environment. Looked up from Pi's own model registry —
|
|
8
|
+
* the entry must exist under the `vercel-ai-gateway` provider in
|
|
9
|
+
* `@earendil-works/pi-ai`'s catalog.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_PI_GATEWAY_MODEL_ID = 'anthropic/claude-sonnet-4.6';
|
|
12
|
+
|
|
13
|
+
export function createPiModelResolver(
|
|
14
|
+
modelRegistry: ModelRegistry,
|
|
15
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
16
|
+
) {
|
|
17
|
+
let cachedModels: PiModel[] | undefined;
|
|
18
|
+
|
|
19
|
+
const loadModels = (): PiModel[] => {
|
|
20
|
+
if (cachedModels) {
|
|
21
|
+
return cachedModels;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
cachedModels = modelRegistry.getAll();
|
|
25
|
+
} catch {
|
|
26
|
+
cachedModels = [];
|
|
27
|
+
}
|
|
28
|
+
return cachedModels;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (modelId: string | undefined): PiModel | undefined => {
|
|
32
|
+
const useGateway = Boolean(env.AI_GATEWAY_API_KEY || env.VERCEL_OIDC_TOKEN);
|
|
33
|
+
const effectiveId =
|
|
34
|
+
modelId ?? (useGateway ? DEFAULT_PI_GATEWAY_MODEL_ID : undefined);
|
|
35
|
+
if (!effectiveId) return undefined;
|
|
36
|
+
|
|
37
|
+
const models = loadModels();
|
|
38
|
+
const matches = (m: PiModel) =>
|
|
39
|
+
m.id === effectiveId || m.name === effectiveId;
|
|
40
|
+
|
|
41
|
+
// When gateway creds are present, prefer the gateway-routed entry for the
|
|
42
|
+
// given id. Pi's catalog lists the same model id under multiple providers
|
|
43
|
+
// (e.g. `anthropic/claude-sonnet-4.6` exists under both `openrouter` and
|
|
44
|
+
// `vercel-ai-gateway`); without this preference Pi would dispatch through
|
|
45
|
+
// a provider we didn't register, which fails with "No API key found".
|
|
46
|
+
return (
|
|
47
|
+
(useGateway &&
|
|
48
|
+
models.find(m => m.provider === 'vercel-ai-gateway' && matches(m))) ||
|
|
49
|
+
models.find(matches)
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/pi-paths.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface PiPathMapper {
|
|
5
|
+
/** The host-side mirror directory Pi reads/writes through the workspace VFS. */
|
|
6
|
+
readonly hostWorkDir: string;
|
|
7
|
+
/** The sandbox-side working directory where tools actually operate. */
|
|
8
|
+
readonly sandboxWorkDir: string;
|
|
9
|
+
/**
|
|
10
|
+
* Translate a path the host sees (relative to `hostWorkDir`, or absolute
|
|
11
|
+
* inside it, or already a sandbox path) to the canonical sandbox path. Throws
|
|
12
|
+
* if the path would escape the workspace.
|
|
13
|
+
*/
|
|
14
|
+
toSandboxPath(inputPath: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Translate a path for read-only tools. In addition to the workspace, this
|
|
17
|
+
* allows explicitly configured sandbox roots such as `$HOME/.agents/skills`.
|
|
18
|
+
*/
|
|
19
|
+
toReadableSandboxPath(inputPath: string): string;
|
|
20
|
+
/** Translate any path to its POSIX-relative form under `sandboxWorkDir`. */
|
|
21
|
+
toRelativePath(inputPath: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PiReadablePathRoot {
|
|
25
|
+
readonly sandboxDir: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CreatePiPathMapperOptions {
|
|
29
|
+
readonly hostWorkDir: string;
|
|
30
|
+
readonly sandboxWorkDir: string;
|
|
31
|
+
readonly readableRoots?: ReadonlyArray<PiReadablePathRoot>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isInsidePath(parent: string, candidate: string): boolean {
|
|
35
|
+
const relative = path.relative(parent, candidate);
|
|
36
|
+
return (
|
|
37
|
+
relative === '' ||
|
|
38
|
+
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isInsidePosixPath(parent: string, candidate: string): boolean {
|
|
43
|
+
const relative = path.posix.relative(parent, candidate);
|
|
44
|
+
return (
|
|
45
|
+
relative === '' ||
|
|
46
|
+
(!relative.startsWith('..') && !path.posix.isAbsolute(relative))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function canonicalizeForContainment(inputPath: string): string {
|
|
51
|
+
try {
|
|
52
|
+
return realpathSync.native(inputPath);
|
|
53
|
+
} catch {
|
|
54
|
+
const parent = path.dirname(inputPath);
|
|
55
|
+
if (parent === inputPath) {
|
|
56
|
+
return inputPath;
|
|
57
|
+
}
|
|
58
|
+
return path.join(
|
|
59
|
+
canonicalizeForContainment(parent),
|
|
60
|
+
path.basename(inputPath),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createPiPathMapper(
|
|
66
|
+
options: CreatePiPathMapperOptions,
|
|
67
|
+
): PiPathMapper {
|
|
68
|
+
const normalizedHost = path.resolve(options.hostWorkDir);
|
|
69
|
+
const normalizedSandbox = path.posix.normalize(options.sandboxWorkDir);
|
|
70
|
+
const canonicalHost = canonicalizeForContainment(normalizedHost);
|
|
71
|
+
const readableRoots =
|
|
72
|
+
options.readableRoots?.map(root => ({
|
|
73
|
+
sandboxDir: path.posix.normalize(root.sandboxDir),
|
|
74
|
+
})) ?? [];
|
|
75
|
+
|
|
76
|
+
const toWorkspaceSandboxPath = (inputPath: string): string => {
|
|
77
|
+
if (path.posix.isAbsolute(inputPath)) {
|
|
78
|
+
const normalizedInput = path.posix.normalize(inputPath);
|
|
79
|
+
if (isInsidePosixPath(normalizedSandbox, normalizedInput)) {
|
|
80
|
+
return normalizedInput;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const resolvedHost = path.isAbsolute(inputPath)
|
|
85
|
+
? path.resolve(inputPath)
|
|
86
|
+
: path.resolve(normalizedHost, inputPath);
|
|
87
|
+
const canonicalResolvedHost = canonicalizeForContainment(resolvedHost);
|
|
88
|
+
if (
|
|
89
|
+
!isInsidePath(normalizedHost, resolvedHost) ||
|
|
90
|
+
!isInsidePath(canonicalHost, canonicalResolvedHost)
|
|
91
|
+
) {
|
|
92
|
+
throw new Error(`Pi path escapes the workspace: ${inputPath}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const relative = path
|
|
96
|
+
.relative(normalizedHost, resolvedHost)
|
|
97
|
+
.split(path.sep)
|
|
98
|
+
.join('/');
|
|
99
|
+
return relative
|
|
100
|
+
? path.posix.join(normalizedSandbox, relative)
|
|
101
|
+
: normalizedSandbox;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
hostWorkDir: normalizedHost,
|
|
106
|
+
sandboxWorkDir: normalizedSandbox,
|
|
107
|
+
toSandboxPath(inputPath: string) {
|
|
108
|
+
return toWorkspaceSandboxPath(inputPath);
|
|
109
|
+
},
|
|
110
|
+
toReadableSandboxPath(inputPath: string) {
|
|
111
|
+
if (path.posix.isAbsolute(inputPath)) {
|
|
112
|
+
const normalizedInput = path.posix.normalize(inputPath);
|
|
113
|
+
if (
|
|
114
|
+
isInsidePosixPath(normalizedSandbox, normalizedInput) ||
|
|
115
|
+
readableRoots.some(root =>
|
|
116
|
+
isInsidePosixPath(root.sandboxDir, normalizedInput),
|
|
117
|
+
)
|
|
118
|
+
) {
|
|
119
|
+
return normalizedInput;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return toWorkspaceSandboxPath(inputPath);
|
|
124
|
+
},
|
|
125
|
+
toRelativePath(inputPath: string) {
|
|
126
|
+
const sandboxPath = path.posix.isAbsolute(inputPath)
|
|
127
|
+
? path.posix.normalize(inputPath)
|
|
128
|
+
: path.posix.join(
|
|
129
|
+
normalizedSandbox,
|
|
130
|
+
inputPath.split(path.sep).join('/'),
|
|
131
|
+
);
|
|
132
|
+
const relative = path.posix.relative(normalizedSandbox, sandboxPath);
|
|
133
|
+
return relative || '.';
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|