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.
Files changed (56) hide show
  1. package/.pi-session-mcp-cli/config.js +11 -1
  2. package/.pi-session-mcp-cli/doctor.js +22 -1
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/extension-spec.js +26 -0
  5. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  6. package/CHANGELOG.md +62 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +2 -2
  9. package/SECURITY.md +1 -1
  10. package/dist/capabilities.d.ts +1 -0
  11. package/dist/capabilities.js +2 -1
  12. package/dist/client-adapters/mcp-verifier.js +5 -2
  13. package/dist/config.d.ts +1 -0
  14. package/dist/config.js +11 -1
  15. package/dist/doctor.d.ts +4 -1
  16. package/dist/doctor.js +22 -1
  17. package/dist/execution-profile.d.ts +3 -0
  18. package/dist/execution-profile.js +1 -0
  19. package/dist/extension-spec.d.ts +15 -0
  20. package/dist/extension-spec.js +26 -0
  21. package/dist/main.js +6 -2
  22. package/dist/pi-adapter.d.ts +1 -1
  23. package/dist/pi-adapter.js +4 -0
  24. package/dist/pi-extensions.d.ts +17 -0
  25. package/dist/pi-extensions.js +67 -0
  26. package/dist/sdk-pi-adapter.d.ts +24 -6
  27. package/dist/sdk-pi-adapter.js +256 -86
  28. package/dist/server.js +1 -0
  29. package/dist/session-tools.d.ts +9 -0
  30. package/dist/session-tools.js +41 -0
  31. package/dist/setup-command-guardian.js +29 -73
  32. package/dist/setup-main.js +2 -1
  33. package/dist/setup-ownership.js +18 -28
  34. package/dist/setup-platform.d.ts +57 -0
  35. package/dist/setup-platform.js +195 -0
  36. package/dist/setup-process.d.ts +6 -3
  37. package/dist/setup-process.js +85 -93
  38. package/dist/setup.js +2 -1
  39. package/dist/stdout-guard-install.d.ts +5 -0
  40. package/dist/stdout-guard-install.js +6 -0
  41. package/dist/stdout-guard.d.ts +20 -0
  42. package/dist/stdout-guard.js +72 -0
  43. package/docs/acceptance.md +343 -0
  44. package/docs/adr/0005-macos-setup-platform.md +84 -0
  45. package/docs/adr/0006-pi-extension-allowlist.md +58 -0
  46. package/docs/architecture.md +1 -1
  47. package/docs/client-setup-design.md +21 -9
  48. package/docs/client-setup.md +25 -10
  49. package/docs/doctor-design.md +7 -2
  50. package/docs/installation.md +125 -4
  51. package/docs/macos-setup-design.md +386 -0
  52. package/docs/pi-extensions-design.md +437 -0
  53. package/docs/releasing.md +16 -7
  54. package/docs/threat-model.md +54 -18
  55. package/docs/tool-contracts.md +8 -3
  56. 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
+ }
@@ -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 type { PermissionProfile, ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
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 createSessionWithDeadline;
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 {};
@@ -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 command or input handler consumed the prompt without a model turn. The
97
- // isolated resource loader (`noExtensions`) makes this unreachable; no public turn exists.
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, { preflightResult: (disposition) => signalPreflight(admitsTurn(disposition)) });
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
- disposeSdkSession(this.session);
182
- void closed.then(finish, finish);
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
- export async function createPhase0ResourceLoader(cwd) {
199
- const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
200
- const resourceLoader = new DefaultResourceLoader({
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
- let resourceLoader;
285
- let settingsManager;
286
- try {
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
- mcpTools = await connectSessionMcpTools(executionProfile.mcpServers ?? {}, executionProfile.permissionProfile, input.cwd, this.mcpConnectionFactory, input.signal === undefined ? {} : { signal: input.signal });
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 (mcpTools.tools.length > 0) {
524
+ if (toolMismatch !== undefined) {
328
525
  const active = new Set(session.getActiveToolNames());
329
- if (active.size !== tools.length || tools.some((name) => !active.has(name))) {
330
- throw this.failure(executionProfile, "postcheck", "external_mcp_activation_mismatch");
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
- disposeSdkSession(session);
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
@@ -41,6 +41,7 @@ const capabilitiesSchema = z.object({
41
41
  provider: z.string(),
42
42
  model: z.string(),
43
43
  thinkingLevel: thinkingLevelSchema,
44
+ extensions: z.array(z.string()),
44
45
  }).strict()),
45
46
  }).strict();
46
47
  function result(value, isError = false) {
@@ -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;