pi-session-mcp 0.6.4 → 0.7.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/.pi-session-mcp-cli/config.js +11 -1
- package/.pi-session-mcp-cli/doctor.js +22 -1
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/extension-spec.js +26 -0
- package/.pi-session-mcp-cli/pi-extensions.js +67 -0
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +5 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -1
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +22 -1
- package/dist/execution-profile.d.ts +3 -0
- package/dist/execution-profile.js +1 -0
- package/dist/extension-spec.d.ts +15 -0
- package/dist/extension-spec.js +26 -0
- package/dist/main.js +6 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +4 -0
- package/dist/pi-extensions.d.ts +17 -0
- package/dist/pi-extensions.js +67 -0
- package/dist/sdk-pi-adapter.d.ts +24 -6
- package/dist/sdk-pi-adapter.js +256 -86
- package/dist/server.js +1 -0
- package/dist/session-tools.d.ts +9 -0
- package/dist/session-tools.js +41 -0
- package/dist/setup-command-guardian.js +29 -73
- package/dist/setup-main.js +2 -1
- package/dist/setup-ownership.js +18 -28
- package/dist/setup-platform.d.ts +57 -0
- package/dist/setup-platform.js +195 -0
- package/dist/setup-process.d.ts +6 -3
- package/dist/setup-process.js +85 -93
- package/dist/setup.js +2 -1
- package/dist/stdout-guard-install.d.ts +5 -0
- package/dist/stdout-guard-install.js +6 -0
- package/dist/stdout-guard.d.ts +20 -0
- package/dist/stdout-guard.js +72 -0
- package/docs/acceptance.md +343 -0
- package/docs/adr/0005-macos-setup-platform.md +84 -0
- package/docs/adr/0006-pi-extension-allowlist.md +58 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +21 -9
- package/docs/client-setup.md +25 -10
- package/docs/doctor-design.md +7 -2
- package/docs/installation.md +125 -4
- package/docs/macos-setup-design.md +386 -0
- package/docs/pi-extensions-design.md +437 -0
- package/docs/releasing.md +16 -7
- package/docs/threat-model.md +54 -18
- package/docs/tool-contracts.md +8 -3
- package/package.json +2 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { PiExtensionError } from "./extension-spec.js";
|
|
5
|
+
// The SDK has no side-effect-free lookup: `resolve()` runs `npm install`, and `getInstalledPath`
|
|
6
|
+
// falls back to a synchronous `npm root -g` on a miss and accepts global installs made outside Pi.
|
|
7
|
+
// So this only computes where Pi puts user-scope npm packages; a missing directory means not installed.
|
|
8
|
+
function installedPathFromPi(source, agentDir) {
|
|
9
|
+
return join(agentDir, "npm", "node_modules", source.slice("npm:".length));
|
|
10
|
+
}
|
|
11
|
+
function inside(root, candidate) {
|
|
12
|
+
const path = relative(root, candidate);
|
|
13
|
+
return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
14
|
+
}
|
|
15
|
+
function isMissing(error) {
|
|
16
|
+
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolves a pinned extension to the real directory of Pi's installed package.
|
|
20
|
+
* It never installs, never imports extension code and never reports a path.
|
|
21
|
+
*/
|
|
22
|
+
export async function resolvePiExtension(extension, dependencies = {}) {
|
|
23
|
+
const agentDir = dependencies.agentDir ?? getAgentDir();
|
|
24
|
+
const read = dependencies.readFile ?? ((path) => readFile(path, "utf8"));
|
|
25
|
+
const real = dependencies.realpath ?? realpath;
|
|
26
|
+
const statFile = dependencies.stat ?? stat;
|
|
27
|
+
let installed;
|
|
28
|
+
try {
|
|
29
|
+
installed = (dependencies.installedPath ?? installedPathFromPi)(`npm:${extension.name}`, agentDir);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new PiExtensionError("extension_load_failed");
|
|
33
|
+
}
|
|
34
|
+
if (installed === undefined)
|
|
35
|
+
throw new PiExtensionError("extension_not_installed");
|
|
36
|
+
let root;
|
|
37
|
+
let manifest;
|
|
38
|
+
try {
|
|
39
|
+
root = await real(installed);
|
|
40
|
+
manifest = JSON.parse((await read(join(root, "package.json"))).replace(/^\uFEFF/, ""));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new PiExtensionError(isMissing(error) ? "extension_not_installed" : "extension_load_failed");
|
|
44
|
+
}
|
|
45
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest))
|
|
46
|
+
throw new PiExtensionError("extension_load_failed");
|
|
47
|
+
const { version, pi } = manifest;
|
|
48
|
+
if (version !== extension.version)
|
|
49
|
+
throw new PiExtensionError("extension_version_mismatch");
|
|
50
|
+
const entries = pi?.extensions;
|
|
51
|
+
if (!Array.isArray(entries) || entries.length === 0
|
|
52
|
+
|| !entries.every((entry) => typeof entry === "string" && entry.length > 0 && !isAbsolute(entry))) {
|
|
53
|
+
throw new PiExtensionError("extension_load_failed");
|
|
54
|
+
}
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
let target;
|
|
57
|
+
try {
|
|
58
|
+
target = await real(join(root, entry));
|
|
59
|
+
if (!inside(root, target) || !(await statFile(target)).isFile())
|
|
60
|
+
throw new PiExtensionError("extension_load_failed");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new PiExtensionError("extension_load_failed");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return root;
|
|
67
|
+
}
|
package/dist/sdk-pi-adapter.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { type AgentSessionEvent, type CreateAgentSessionOptions, type CreateAgentSessionResult, DefaultResourceLoader, ModelRuntime, type PromptOptions, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import
|
|
1
|
+
import { type AgentSessionEvent, type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, type PromptOptions, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type ExtensionSpec } from "./extension-spec.js";
|
|
3
|
+
import type { ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
|
|
3
4
|
import { type McpConnectionFactory } from "./mcp-session-tools.js";
|
|
4
5
|
import { type PiSessionAdapter, type PiSessionCreationErrorCode, type PiSessionHandle, type PiTurnOutcome } from "./pi-adapter.js";
|
|
6
|
+
export { PROFILE_TOOLS } from "./session-tools.js";
|
|
5
7
|
type CreateSession = (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
6
8
|
export declare const AUTH_OPERATION_TIMEOUT_MS = 5000;
|
|
7
9
|
/** Server-side bound for SDK session construction. The SDK does not document cancellation. */
|
|
@@ -9,7 +11,7 @@ export declare const SESSION_CREATION_TIMEOUT_MS = 10000;
|
|
|
9
11
|
export interface SdkPiAdapterDiagnostic {
|
|
10
12
|
readonly level: "error";
|
|
11
13
|
readonly event: "pi_session_creation_failed";
|
|
12
|
-
readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck";
|
|
14
|
+
readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind";
|
|
13
15
|
readonly code: PiSessionCreationErrorCode;
|
|
14
16
|
readonly executionProfile: string;
|
|
15
17
|
readonly provider: string;
|
|
@@ -18,12 +20,23 @@ export interface SdkPiAdapterDiagnostic {
|
|
|
18
20
|
readonly serverAlias?: string;
|
|
19
21
|
readonly toolAlias?: string;
|
|
20
22
|
readonly discoveredToolCount?: number;
|
|
23
|
+
/** `<name>@<version>` of the extension that failed to resolve; never a path. */
|
|
24
|
+
readonly extension?: string;
|
|
25
|
+
}
|
|
26
|
+
/** The Pi SDK session-lifecycle API used for profiles with extensions; injectable for tests. */
|
|
27
|
+
export interface ExtensionSdk {
|
|
28
|
+
readonly createServices: typeof createAgentSessionServices;
|
|
29
|
+
readonly createSessionFromServices: typeof createAgentSessionFromServices;
|
|
30
|
+
readonly createRuntime: typeof createAgentSessionRuntime;
|
|
21
31
|
}
|
|
22
32
|
export interface SdkPiSessionAdapterOptions {
|
|
23
33
|
readonly modelRuntimeFactory?: () => Promise<ModelRuntime>;
|
|
24
34
|
readonly createSession?: CreateSession;
|
|
25
35
|
readonly diagnosticSink?: (diagnostic: SdkPiAdapterDiagnostic) => void;
|
|
26
36
|
readonly mcpConnectionFactory?: McpConnectionFactory;
|
|
37
|
+
/** Resolves a pinned extension to its installed package directory. */
|
|
38
|
+
readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
|
|
39
|
+
readonly extensionSdk?: ExtensionSdk;
|
|
27
40
|
}
|
|
28
41
|
type SdkAssistantMessage = Extract<Extract<AgentSessionEvent, {
|
|
29
42
|
type: "message_end";
|
|
@@ -40,7 +53,6 @@ export type SdkStopReason = SdkAssistantMessage["stopReason"];
|
|
|
40
53
|
* values outside the SDK union, a missing stop reason included, fail closed.
|
|
41
54
|
*/
|
|
42
55
|
export declare function normalizeAssistantOutcome(message: unknown): PiTurnOutcome;
|
|
43
|
-
export declare const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly string[]>>;
|
|
44
56
|
/** Derived from the root-exported `PromptOptions`; the SDK does not export the union itself. */
|
|
45
57
|
export type PromptDisposition = Parameters<NonNullable<PromptOptions["preflightResult"]>>[0];
|
|
46
58
|
/**
|
|
@@ -62,14 +74,20 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
|
|
|
62
74
|
private readonly createSession;
|
|
63
75
|
private readonly diagnosticSink;
|
|
64
76
|
private readonly mcpConnectionFactory;
|
|
77
|
+
private readonly resolveExtension;
|
|
78
|
+
private readonly extensionSdk;
|
|
65
79
|
constructor(options?: SdkPiSessionAdapterOptions);
|
|
66
80
|
create(input: {
|
|
67
81
|
cwd: string;
|
|
68
82
|
executionProfile: ResolvedExecutionProfile;
|
|
69
83
|
signal?: AbortSignal;
|
|
70
84
|
}): Promise<PiSessionHandle>;
|
|
71
|
-
private
|
|
85
|
+
private createWithExtensions;
|
|
86
|
+
/** Provider, model, authentication and availability, exactly as configured. */
|
|
87
|
+
private resolveVerifiedModel;
|
|
88
|
+
private connectMcpTools;
|
|
89
|
+
/** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
|
|
90
|
+
private admit;
|
|
72
91
|
private getRuntime;
|
|
73
92
|
private failure;
|
|
74
93
|
}
|
|
75
|
-
export {};
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PiExtensionError } from "./extension-spec.js";
|
|
2
3
|
import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
|
|
3
4
|
import { createStdioMcpConnection } from "./mcp-stdio-client.js";
|
|
4
5
|
import { PiSessionCreationError, } from "./pi-adapter.js";
|
|
6
|
+
import { resolvePiExtension } from "./pi-extensions.js";
|
|
7
|
+
import { composeSessionTools, PROFILE_TOOLS } from "./session-tools.js";
|
|
8
|
+
export { PROFILE_TOOLS } from "./session-tools.js";
|
|
5
9
|
export const AUTH_OPERATION_TIMEOUT_MS = 5_000;
|
|
6
10
|
/** Server-side bound for SDK session construction. The SDK does not document cancellation. */
|
|
7
11
|
export const SESSION_CREATION_TIMEOUT_MS = 10_000;
|
|
12
|
+
const DEFAULT_EXTENSION_SDK = {
|
|
13
|
+
createServices: createAgentSessionServices,
|
|
14
|
+
createSessionFromServices: createAgentSessionFromServices,
|
|
15
|
+
createRuntime: createAgentSessionRuntime,
|
|
16
|
+
};
|
|
8
17
|
class AuthDeadlineError extends Error {
|
|
9
18
|
constructor() {
|
|
10
19
|
super("authentication operation timed out");
|
|
@@ -42,6 +51,60 @@ async function withAuthDeadline(operation) {
|
|
|
42
51
|
controller.abort();
|
|
43
52
|
}
|
|
44
53
|
}
|
|
54
|
+
/** Races an SDK operation against the session-creation bound; a late success goes to `disposeLate`. */
|
|
55
|
+
async function withCreationDeadline(start, disposeLate = () => undefined) {
|
|
56
|
+
let operation;
|
|
57
|
+
try {
|
|
58
|
+
operation = Promise.resolve(start());
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new SessionCreationDeadlineError();
|
|
62
|
+
}
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
// This continuation is installed immediately and owns late cleanup. Its rejection
|
|
65
|
+
// branch is required even when the deadline wins the race.
|
|
66
|
+
const observed = operation.then((value) => {
|
|
67
|
+
if (timedOut)
|
|
68
|
+
disposeLate(value);
|
|
69
|
+
return value;
|
|
70
|
+
}, (error) => { throw error; });
|
|
71
|
+
let timer;
|
|
72
|
+
const timeout = new Promise((_, reject) => {
|
|
73
|
+
timer = setTimeout(() => {
|
|
74
|
+
timedOut = true;
|
|
75
|
+
reject(new SessionCreationDeadlineError());
|
|
76
|
+
}, SESSION_CREATION_TIMEOUT_MS);
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([observed, timeout]);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (timer)
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Runs a session disposal bounded by the creation deadline; failures and a timeout are swallowed. */
|
|
87
|
+
async function boundedDisposal(dispose) {
|
|
88
|
+
let timer;
|
|
89
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(resolve, SESSION_CREATION_TIMEOUT_MS); });
|
|
90
|
+
let disposal;
|
|
91
|
+
try {
|
|
92
|
+
disposal = Promise.resolve(dispose()).then(() => undefined, () => undefined);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
disposal = Promise.resolve();
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await Promise.race([disposal, timeout]);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (timer)
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function registeredExtensionTools(services) {
|
|
106
|
+
return services.resourceLoader.getExtensions().extensions.map((extension) => [...extension.tools.keys()]);
|
|
107
|
+
}
|
|
45
108
|
/**
|
|
46
109
|
* Projects the final assistant message of a prompt onto a turn outcome.
|
|
47
110
|
*
|
|
@@ -75,10 +138,6 @@ export function normalizeAssistantOutcome(message) {
|
|
|
75
138
|
}
|
|
76
139
|
}
|
|
77
140
|
}
|
|
78
|
-
export const PROFILE_TOOLS = {
|
|
79
|
-
"read-only": ["read", "grep", "find", "ls"],
|
|
80
|
-
coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
|
81
|
-
};
|
|
82
141
|
/**
|
|
83
142
|
* Whether an SDK prompt disposition admits a public turn. The SDK reports how an accepted
|
|
84
143
|
* prompt was dispatched and does not call the hook on rejection: `prompt()` rejects instead,
|
|
@@ -93,8 +152,8 @@ export function admitsTurn(disposition) {
|
|
|
93
152
|
return true;
|
|
94
153
|
// Only while streaming with `streamingBehavior`; Pi Session MCP never passes it and serializes turns.
|
|
95
154
|
case "queued":
|
|
96
|
-
// An extension
|
|
97
|
-
//
|
|
155
|
+
// An extension input handler consumed the prompt without a model turn. Commands never
|
|
156
|
+
// dispatch (`expandPromptTemplates: false`), but allowlisted extensions may handle input.
|
|
98
157
|
case "handled":
|
|
99
158
|
return false;
|
|
100
159
|
default: {
|
|
@@ -108,13 +167,15 @@ class SdkHandle {
|
|
|
108
167
|
session;
|
|
109
168
|
appliedSelection;
|
|
110
169
|
mcpTools;
|
|
170
|
+
disposeSession;
|
|
111
171
|
disposed = false;
|
|
112
172
|
disposal;
|
|
113
173
|
subscriptions = new Set();
|
|
114
|
-
constructor(session, appliedSelection, mcpTools) {
|
|
174
|
+
constructor(session, appliedSelection, mcpTools, disposeSession = () => disposeSdkSession(session)) {
|
|
115
175
|
this.session = session;
|
|
116
176
|
this.appliedSelection = appliedSelection;
|
|
117
177
|
this.mcpTools = mcpTools;
|
|
178
|
+
this.disposeSession = disposeSession;
|
|
118
179
|
}
|
|
119
180
|
get sdkSessionId() { return this.session.sessionId; }
|
|
120
181
|
prompt(text, preflight, onActivity) {
|
|
@@ -159,7 +220,14 @@ class SdkHandle {
|
|
|
159
220
|
this.subscriptions.add(cleanup);
|
|
160
221
|
let promptResult;
|
|
161
222
|
try {
|
|
162
|
-
promptResult = this.session.prompt(text, {
|
|
223
|
+
promptResult = this.session.prompt(text, {
|
|
224
|
+
// Client text is content, never a command: no extension command, skill or template dispatch.
|
|
225
|
+
expandPromptTemplates: false,
|
|
226
|
+
// Pi's own RPC mode uses `rpc` for input from a program over stdio, which matches an MCP
|
|
227
|
+
// client. `interactive`, the SDK default, means a human at the terminal or the CLI's print mode.
|
|
228
|
+
source: "rpc",
|
|
229
|
+
preflightResult: (disposition) => signalPreflight(admitsTurn(disposition)),
|
|
230
|
+
});
|
|
163
231
|
}
|
|
164
232
|
catch {
|
|
165
233
|
cleanup();
|
|
@@ -178,8 +246,14 @@ class SdkHandle {
|
|
|
178
246
|
const closed = this.mcpTools.close();
|
|
179
247
|
for (const cleanup of [...this.subscriptions])
|
|
180
248
|
cleanup();
|
|
181
|
-
|
|
182
|
-
|
|
249
|
+
let sessionClosed;
|
|
250
|
+
try {
|
|
251
|
+
sessionClosed = Promise.resolve(this.disposeSession()).then(() => undefined, () => undefined);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
sessionClosed = Promise.resolve();
|
|
255
|
+
}
|
|
256
|
+
void Promise.all([closed.then(() => undefined, () => undefined), sessionClosed]).then(finish, finish);
|
|
183
257
|
return this.disposal;
|
|
184
258
|
}
|
|
185
259
|
}
|
|
@@ -195,12 +269,9 @@ function isTextBlock(block) {
|
|
|
195
269
|
const candidate = block;
|
|
196
270
|
return candidate.type === "text" && typeof candidate.text === "string";
|
|
197
271
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
cwd,
|
|
202
|
-
agentDir: cwd,
|
|
203
|
-
settingsManager,
|
|
272
|
+
/** Resource-loader options for every embedded session: no discovery, skills, templates or themes. */
|
|
273
|
+
function phase0ResourceLoaderOptions() {
|
|
274
|
+
return {
|
|
204
275
|
noExtensions: true,
|
|
205
276
|
noSkills: true,
|
|
206
277
|
noPromptTemplates: true,
|
|
@@ -210,7 +281,11 @@ export async function createPhase0ResourceLoader(cwd) {
|
|
|
210
281
|
appendSystemPrompt: [],
|
|
211
282
|
systemPromptOverride: () => undefined,
|
|
212
283
|
appendSystemPromptOverride: () => [],
|
|
213
|
-
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
export async function createPhase0ResourceLoader(cwd) {
|
|
287
|
+
const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
|
|
288
|
+
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
|
|
214
289
|
await resourceLoader.reload();
|
|
215
290
|
return { resourceLoader, settingsManager };
|
|
216
291
|
}
|
|
@@ -220,13 +295,20 @@ export class SdkPiSessionAdapter {
|
|
|
220
295
|
createSession;
|
|
221
296
|
diagnosticSink;
|
|
222
297
|
mcpConnectionFactory;
|
|
298
|
+
resolveExtension;
|
|
299
|
+
extensionSdk;
|
|
223
300
|
constructor(options = {}) {
|
|
224
301
|
this.modelRuntimeFactory = options.modelRuntimeFactory ?? (() => ModelRuntime.create({ refreshOnCreate: false }));
|
|
225
302
|
this.createSession = options.createSession ?? createAgentSession;
|
|
226
303
|
this.diagnosticSink = options.diagnosticSink ?? (() => undefined);
|
|
227
304
|
this.mcpConnectionFactory = options.mcpConnectionFactory ?? createStdioMcpConnection;
|
|
305
|
+
this.resolveExtension = options.resolveExtension ?? ((extension) => resolvePiExtension(extension));
|
|
306
|
+
this.extensionSdk = options.extensionSdk ?? DEFAULT_EXTENSION_SDK;
|
|
228
307
|
}
|
|
229
308
|
async create(input) {
|
|
309
|
+
const extensions = input.executionProfile.extensions ?? [];
|
|
310
|
+
if (extensions.length > 0)
|
|
311
|
+
return this.createWithExtensions(input, extensions);
|
|
230
312
|
const { executionProfile } = input;
|
|
231
313
|
let runtime;
|
|
232
314
|
try {
|
|
@@ -235,6 +317,142 @@ export class SdkPiSessionAdapter {
|
|
|
235
317
|
catch {
|
|
236
318
|
throw this.failure(executionProfile, "runtime", "pi_session_creation_failed");
|
|
237
319
|
}
|
|
320
|
+
const model = await this.resolveVerifiedModel(runtime, executionProfile);
|
|
321
|
+
let resourceLoader;
|
|
322
|
+
let settingsManager;
|
|
323
|
+
try {
|
|
324
|
+
({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
|
|
328
|
+
}
|
|
329
|
+
const mcpTools = await this.connectMcpTools(input);
|
|
330
|
+
const tools = [...PROFILE_TOOLS[executionProfile.permissionProfile], ...mcpTools.tools.map((tool) => tool.name)];
|
|
331
|
+
let session;
|
|
332
|
+
try {
|
|
333
|
+
({ session } = await withCreationDeadline(() => this.createSession({
|
|
334
|
+
cwd: input.cwd,
|
|
335
|
+
modelRuntime: runtime,
|
|
336
|
+
model,
|
|
337
|
+
thinkingLevel: executionProfile.thinkingLevel,
|
|
338
|
+
sessionManager: SessionManager.inMemory(input.cwd),
|
|
339
|
+
settingsManager,
|
|
340
|
+
resourceLoader,
|
|
341
|
+
// With an explicit tools list, Pi also needs custom tool names here to activate them.
|
|
342
|
+
tools,
|
|
343
|
+
...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
|
|
344
|
+
}), (late) => disposeSdkSession(late.session)));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
await mcpTools.close();
|
|
348
|
+
throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
|
|
349
|
+
}
|
|
350
|
+
return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined);
|
|
351
|
+
}
|
|
352
|
+
async createWithExtensions(input, extensions) {
|
|
353
|
+
const { cwd, executionProfile } = input;
|
|
354
|
+
const directories = [];
|
|
355
|
+
for (const extension of extensions) {
|
|
356
|
+
try {
|
|
357
|
+
directories.push(await this.resolveExtension(extension));
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
throw this.failure(executionProfile, "extension_resolve", error instanceof PiExtensionError ? error.code : "extension_load_failed", { extension: `${extension.name}@${extension.version}` });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Extension providers register into the runtime they load with, and the last
|
|
364
|
+
// registration wins, so every extension session gets a runtime of its own.
|
|
365
|
+
let runtime;
|
|
366
|
+
try {
|
|
367
|
+
runtime = await this.modelRuntimeFactory();
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
throw this.failure(executionProfile, "runtime", "pi_session_creation_failed");
|
|
371
|
+
}
|
|
372
|
+
let services;
|
|
373
|
+
let extensionTools;
|
|
374
|
+
try {
|
|
375
|
+
services = await withCreationDeadline(() => this.extensionSdk.createServices({
|
|
376
|
+
cwd,
|
|
377
|
+
agentDir: cwd,
|
|
378
|
+
modelRuntime: runtime,
|
|
379
|
+
settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
|
|
380
|
+
resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
|
|
381
|
+
}));
|
|
382
|
+
const loaded = services.resourceLoader.getExtensions();
|
|
383
|
+
if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
|
|
384
|
+
throw new Error("extension load reported errors");
|
|
385
|
+
}
|
|
386
|
+
extensionTools = registeredExtensionTools(services);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
throw this.failure(executionProfile, "extension_load", "extension_load_failed");
|
|
390
|
+
}
|
|
391
|
+
const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
|
|
392
|
+
const mcpTools = await this.connectMcpTools(input);
|
|
393
|
+
const tools = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools);
|
|
394
|
+
if (!tools) {
|
|
395
|
+
await mcpTools.close();
|
|
396
|
+
throw this.failure(executionProfile, "extension_load", "extension_tool_conflict");
|
|
397
|
+
}
|
|
398
|
+
let sessionRuntime;
|
|
399
|
+
try {
|
|
400
|
+
sessionRuntime = await withCreationDeadline(() => this.extensionSdk.createRuntime(async ({ sessionManager }) => ({
|
|
401
|
+
...(await this.extensionSdk.createSessionFromServices({
|
|
402
|
+
services,
|
|
403
|
+
sessionManager,
|
|
404
|
+
model,
|
|
405
|
+
thinkingLevel: executionProfile.thinkingLevel,
|
|
406
|
+
tools,
|
|
407
|
+
...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
|
|
408
|
+
})),
|
|
409
|
+
services,
|
|
410
|
+
diagnostics: services.diagnostics,
|
|
411
|
+
}), { cwd, agentDir: cwd, sessionManager: SessionManager.inMemory(cwd) }), (late) => { void late.dispose().catch(() => undefined); });
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
await mcpTools.close();
|
|
415
|
+
throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
|
|
416
|
+
}
|
|
417
|
+
// `AgentSessionRuntime.dispose()` emits `session_shutdown` before disposing the session.
|
|
418
|
+
const disposeRuntime = () => sessionRuntime.dispose().catch(() => undefined);
|
|
419
|
+
// Bounded and concurrent: a hanging `session_shutdown` handler must not keep MCP processes open.
|
|
420
|
+
const cleanUp = async () => {
|
|
421
|
+
await Promise.all([boundedDisposal(disposeRuntime), mcpTools.close()]);
|
|
422
|
+
};
|
|
423
|
+
let bindErrors = 0;
|
|
424
|
+
let bound = false;
|
|
425
|
+
let conflict = false;
|
|
426
|
+
try {
|
|
427
|
+
// Without a UI context the SDK binds its no-op UI; binding emits `session_start`.
|
|
428
|
+
await withCreationDeadline(() => sessionRuntime.session.bindExtensions({
|
|
429
|
+
mode: "print",
|
|
430
|
+
// `ExtensionRunner.emit` routes handler errors here instead of rejecting the bind.
|
|
431
|
+
onError: () => { if (!bound)
|
|
432
|
+
bindErrors += 1; },
|
|
433
|
+
}));
|
|
434
|
+
bound = true;
|
|
435
|
+
if (bindErrors > 0)
|
|
436
|
+
throw new Error("extension hook reported errors while binding");
|
|
437
|
+
// A `session_start` handler may register a tool that replaces a built-in or granted tool.
|
|
438
|
+
// Tools an extension registers later, during a turn, are not re-checked: allowlisted
|
|
439
|
+
// extensions are trusted code.
|
|
440
|
+
const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services));
|
|
441
|
+
if (!recheck || recheck.length !== tools.length || recheck.some((name, index) => name !== tools[index]))
|
|
442
|
+
conflict = true;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
await cleanUp();
|
|
446
|
+
throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
|
|
447
|
+
}
|
|
448
|
+
if (conflict) {
|
|
449
|
+
await cleanUp();
|
|
450
|
+
throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
|
|
451
|
+
}
|
|
452
|
+
return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset");
|
|
453
|
+
}
|
|
454
|
+
/** Provider, model, authentication and availability, exactly as configured. */
|
|
455
|
+
async resolveVerifiedModel(runtime, executionProfile) {
|
|
238
456
|
let provider;
|
|
239
457
|
try {
|
|
240
458
|
provider = runtime.getProvider(executionProfile.provider);
|
|
@@ -281,42 +499,21 @@ export class SdkPiSessionAdapter {
|
|
|
281
499
|
if (!available.some((candidate) => candidate.provider === executionProfile.provider && candidate.id === executionProfile.model)) {
|
|
282
500
|
throw this.failure(executionProfile, "availability", "model_unavailable");
|
|
283
501
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
|
|
291
|
-
}
|
|
292
|
-
let mcpTools;
|
|
502
|
+
return model;
|
|
503
|
+
}
|
|
504
|
+
async connectMcpTools(input) {
|
|
293
505
|
try {
|
|
294
|
-
|
|
506
|
+
return await connectSessionMcpTools(input.executionProfile.mcpServers ?? {}, input.executionProfile.permissionProfile, input.cwd, this.mcpConnectionFactory, input.signal === undefined ? {} : { signal: input.signal });
|
|
295
507
|
}
|
|
296
508
|
catch (error) {
|
|
297
509
|
const failure = classifyMcpStartupError(error);
|
|
298
|
-
throw this.failure(executionProfile, "mcp_tools", failure?.code ?? "external_mcp_unavailable", failure);
|
|
299
|
-
}
|
|
300
|
-
const tools = [...PROFILE_TOOLS[executionProfile.permissionProfile], ...mcpTools.tools.map((tool) => tool.name)];
|
|
301
|
-
let session;
|
|
302
|
-
try {
|
|
303
|
-
({ session } = await this.createSessionWithDeadline({
|
|
304
|
-
cwd: input.cwd,
|
|
305
|
-
modelRuntime: runtime,
|
|
306
|
-
model,
|
|
307
|
-
thinkingLevel: executionProfile.thinkingLevel,
|
|
308
|
-
sessionManager: SessionManager.inMemory(input.cwd),
|
|
309
|
-
settingsManager,
|
|
310
|
-
resourceLoader,
|
|
311
|
-
// With an explicit tools list, Pi also needs custom tool names here to activate them.
|
|
312
|
-
tools,
|
|
313
|
-
...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
catch {
|
|
317
|
-
await mcpTools.close();
|
|
318
|
-
throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
|
|
510
|
+
throw this.failure(input.executionProfile, "mcp_tools", failure?.code ?? "external_mcp_unavailable", failure);
|
|
319
511
|
}
|
|
512
|
+
}
|
|
513
|
+
/** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
|
|
514
|
+
async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
|
|
515
|
+
// `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
|
|
516
|
+
toolCheck = "exact") {
|
|
320
517
|
try {
|
|
321
518
|
if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
|
|
322
519
|
throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
|
|
@@ -324,57 +521,29 @@ export class SdkPiSessionAdapter {
|
|
|
324
521
|
if (session.thinkingLevel !== executionProfile.thinkingLevel) {
|
|
325
522
|
throw this.failure(executionProfile, "postcheck", "thinking_level_unavailable");
|
|
326
523
|
}
|
|
327
|
-
if (
|
|
524
|
+
if (toolMismatch !== undefined) {
|
|
328
525
|
const active = new Set(session.getActiveToolNames());
|
|
329
|
-
|
|
330
|
-
|
|
526
|
+
const allowed = new Set(tools);
|
|
527
|
+
const mismatch = toolCheck === "subset"
|
|
528
|
+
? [...active].some((name) => !allowed.has(name))
|
|
529
|
+
: active.size !== tools.length || tools.some((name) => !active.has(name));
|
|
530
|
+
if (mismatch) {
|
|
531
|
+
throw this.failure(executionProfile, "postcheck", toolMismatch);
|
|
331
532
|
}
|
|
332
533
|
}
|
|
333
534
|
return new SdkHandle(session, {
|
|
334
535
|
provider: session.model.provider,
|
|
335
536
|
model: session.model.id,
|
|
336
537
|
thinkingLevel: session.thinkingLevel,
|
|
337
|
-
}, mcpTools);
|
|
538
|
+
}, mcpTools, disposeSession);
|
|
338
539
|
}
|
|
339
540
|
catch (error) {
|
|
340
|
-
|
|
341
|
-
await mcpTools.close();
|
|
541
|
+
await Promise.all([boundedDisposal(disposeSession), mcpTools.close()]);
|
|
342
542
|
if (error instanceof PiSessionCreationError)
|
|
343
543
|
throw error;
|
|
344
544
|
throw this.failure(executionProfile, "postcheck", "pi_session_creation_failed");
|
|
345
545
|
}
|
|
346
546
|
}
|
|
347
|
-
async createSessionWithDeadline(options) {
|
|
348
|
-
let creation;
|
|
349
|
-
try {
|
|
350
|
-
creation = Promise.resolve(this.createSession(options));
|
|
351
|
-
}
|
|
352
|
-
catch {
|
|
353
|
-
throw new SessionCreationDeadlineError();
|
|
354
|
-
}
|
|
355
|
-
let timedOut = false;
|
|
356
|
-
// This continuation is installed immediately and owns late cleanup. Its rejection
|
|
357
|
-
// branch is required even when the deadline wins the race.
|
|
358
|
-
const observed = creation.then((result) => {
|
|
359
|
-
if (timedOut)
|
|
360
|
-
disposeSdkSession(result.session);
|
|
361
|
-
return result;
|
|
362
|
-
}, (error) => { throw error; });
|
|
363
|
-
let timer;
|
|
364
|
-
const timeout = new Promise((_, reject) => {
|
|
365
|
-
timer = setTimeout(() => {
|
|
366
|
-
timedOut = true;
|
|
367
|
-
reject(new SessionCreationDeadlineError());
|
|
368
|
-
}, SESSION_CREATION_TIMEOUT_MS);
|
|
369
|
-
});
|
|
370
|
-
try {
|
|
371
|
-
return await Promise.race([observed, timeout]);
|
|
372
|
-
}
|
|
373
|
-
finally {
|
|
374
|
-
if (timer)
|
|
375
|
-
clearTimeout(timer);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
547
|
getRuntime() {
|
|
379
548
|
if (!this.runtimePromise) {
|
|
380
549
|
const pending = this.modelRuntimeFactory();
|
|
@@ -400,6 +569,7 @@ export class SdkPiSessionAdapter {
|
|
|
400
569
|
...(metadata?.serverAlias === undefined ? {} : { serverAlias: metadata.serverAlias }),
|
|
401
570
|
...(metadata?.toolAlias === undefined ? {} : { toolAlias: metadata.toolAlias }),
|
|
402
571
|
...(metadata?.discoveredToolCount === undefined ? {} : { discoveredToolCount: metadata.discoveredToolCount }),
|
|
572
|
+
...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
|
|
403
573
|
});
|
|
404
574
|
}
|
|
405
575
|
catch {
|
package/dist/server.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PermissionProfile } from "./execution-profile.js";
|
|
2
|
+
export declare const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly string[]>>;
|
|
3
|
+
/**
|
|
4
|
+
* The tool allowlist of a session, or undefined when an extension tool name conflicts.
|
|
5
|
+
* Extension tools are active only in coding profiles, but a conflict fails closed in both,
|
|
6
|
+
* so an extension can never shadow a built-in or granted tool. A name outside the safe
|
|
7
|
+
* character set, or one the SDK treats as an MCP tool, also fails closed.
|
|
8
|
+
*/
|
|
9
|
+
export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[]): string[] | undefined;
|