pi-session-mcp 0.6.3 → 0.7.0

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 (60) hide show
  1. package/.pi-session-mcp-cli/config.js +11 -1
  2. package/.pi-session-mcp-cli/doctor-cli.js +5 -1
  3. package/.pi-session-mcp-cli/doctor.js +22 -1
  4. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  5. package/.pi-session-mcp-cli/extension-spec.js +21 -0
  6. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  7. package/CHANGELOG.md +54 -0
  8. package/CONTRIBUTING.md +1 -1
  9. package/README.md +2 -2
  10. package/SECURITY.md +1 -1
  11. package/dist/capabilities.d.ts +1 -0
  12. package/dist/capabilities.js +2 -1
  13. package/dist/client-adapters/mcp-verifier.js +5 -2
  14. package/dist/config.d.ts +1 -0
  15. package/dist/config.js +11 -1
  16. package/dist/doctor-cli.js +5 -1
  17. package/dist/doctor.d.ts +4 -1
  18. package/dist/doctor.js +22 -1
  19. package/dist/execution-profile.d.ts +3 -0
  20. package/dist/execution-profile.js +1 -0
  21. package/dist/extension-spec.d.ts +15 -0
  22. package/dist/extension-spec.js +21 -0
  23. package/dist/main.js +6 -2
  24. package/dist/pi-adapter.d.ts +1 -1
  25. package/dist/pi-adapter.js +4 -0
  26. package/dist/pi-extensions.d.ts +17 -0
  27. package/dist/pi-extensions.js +67 -0
  28. package/dist/sdk-pi-adapter.d.ts +24 -6
  29. package/dist/sdk-pi-adapter.js +253 -86
  30. package/dist/server.js +1 -0
  31. package/dist/session-tools.d.ts +9 -0
  32. package/dist/session-tools.js +25 -0
  33. package/dist/setup-command-guardian.js +29 -73
  34. package/dist/setup-main.js +2 -1
  35. package/dist/setup-ownership.js +18 -28
  36. package/dist/setup-platform.d.ts +57 -0
  37. package/dist/setup-platform.js +195 -0
  38. package/dist/setup-process.d.ts +6 -3
  39. package/dist/setup-process.js +85 -93
  40. package/dist/setup.js +2 -1
  41. package/dist/stdout-guard-install.d.ts +5 -0
  42. package/dist/stdout-guard-install.js +6 -0
  43. package/dist/stdout-guard.d.ts +20 -0
  44. package/dist/stdout-guard.js +72 -0
  45. package/docs/acceptance.md +245 -0
  46. package/docs/adr/0005-macos-setup-platform.md +84 -0
  47. package/docs/adr/0006-pi-extension-allowlist.md +58 -0
  48. package/docs/architecture.md +1 -1
  49. package/docs/client-setup-design.md +21 -9
  50. package/docs/client-setup.md +56 -10
  51. package/docs/doctor-design.md +9 -2
  52. package/docs/installation.md +129 -4
  53. package/docs/macos-setup-design.md +386 -0
  54. package/docs/pi-extensions-design.md +421 -0
  55. package/docs/releasing.md +1 -1
  56. package/docs/superpowers/plans/2026-10-10-macos-setup.md +1725 -0
  57. package/docs/superpowers/plans/2026-10-11-pi-extensions.md +2450 -0
  58. package/docs/threat-model.md +54 -18
  59. package/docs/tool-contracts.md +8 -3
  60. package/package.json +1 -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,11 @@ 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
+ preflightResult: (disposition) => signalPreflight(admitsTurn(disposition)),
227
+ });
163
228
  }
164
229
  catch {
165
230
  cleanup();
@@ -178,8 +243,14 @@ class SdkHandle {
178
243
  const closed = this.mcpTools.close();
179
244
  for (const cleanup of [...this.subscriptions])
180
245
  cleanup();
181
- disposeSdkSession(this.session);
182
- void closed.then(finish, finish);
246
+ let sessionClosed;
247
+ try {
248
+ sessionClosed = Promise.resolve(this.disposeSession()).then(() => undefined, () => undefined);
249
+ }
250
+ catch {
251
+ sessionClosed = Promise.resolve();
252
+ }
253
+ void Promise.all([closed.then(() => undefined, () => undefined), sessionClosed]).then(finish, finish);
183
254
  return this.disposal;
184
255
  }
185
256
  }
@@ -195,12 +266,9 @@ function isTextBlock(block) {
195
266
  const candidate = block;
196
267
  return candidate.type === "text" && typeof candidate.text === "string";
197
268
  }
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,
269
+ /** Resource-loader options for every embedded session: no discovery, skills, templates or themes. */
270
+ function phase0ResourceLoaderOptions() {
271
+ return {
204
272
  noExtensions: true,
205
273
  noSkills: true,
206
274
  noPromptTemplates: true,
@@ -210,7 +278,11 @@ export async function createPhase0ResourceLoader(cwd) {
210
278
  appendSystemPrompt: [],
211
279
  systemPromptOverride: () => undefined,
212
280
  appendSystemPromptOverride: () => [],
213
- });
281
+ };
282
+ }
283
+ export async function createPhase0ResourceLoader(cwd) {
284
+ const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
285
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
214
286
  await resourceLoader.reload();
215
287
  return { resourceLoader, settingsManager };
216
288
  }
@@ -220,13 +292,20 @@ export class SdkPiSessionAdapter {
220
292
  createSession;
221
293
  diagnosticSink;
222
294
  mcpConnectionFactory;
295
+ resolveExtension;
296
+ extensionSdk;
223
297
  constructor(options = {}) {
224
298
  this.modelRuntimeFactory = options.modelRuntimeFactory ?? (() => ModelRuntime.create({ refreshOnCreate: false }));
225
299
  this.createSession = options.createSession ?? createAgentSession;
226
300
  this.diagnosticSink = options.diagnosticSink ?? (() => undefined);
227
301
  this.mcpConnectionFactory = options.mcpConnectionFactory ?? createStdioMcpConnection;
302
+ this.resolveExtension = options.resolveExtension ?? ((extension) => resolvePiExtension(extension));
303
+ this.extensionSdk = options.extensionSdk ?? DEFAULT_EXTENSION_SDK;
228
304
  }
229
305
  async create(input) {
306
+ const extensions = input.executionProfile.extensions ?? [];
307
+ if (extensions.length > 0)
308
+ return this.createWithExtensions(input, extensions);
230
309
  const { executionProfile } = input;
231
310
  let runtime;
232
311
  try {
@@ -235,6 +314,142 @@ export class SdkPiSessionAdapter {
235
314
  catch {
236
315
  throw this.failure(executionProfile, "runtime", "pi_session_creation_failed");
237
316
  }
317
+ const model = await this.resolveVerifiedModel(runtime, executionProfile);
318
+ let resourceLoader;
319
+ let settingsManager;
320
+ try {
321
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
322
+ }
323
+ catch {
324
+ throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
325
+ }
326
+ const mcpTools = await this.connectMcpTools(input);
327
+ const tools = [...PROFILE_TOOLS[executionProfile.permissionProfile], ...mcpTools.tools.map((tool) => tool.name)];
328
+ let session;
329
+ try {
330
+ ({ session } = await withCreationDeadline(() => this.createSession({
331
+ cwd: input.cwd,
332
+ modelRuntime: runtime,
333
+ model,
334
+ thinkingLevel: executionProfile.thinkingLevel,
335
+ sessionManager: SessionManager.inMemory(input.cwd),
336
+ settingsManager,
337
+ resourceLoader,
338
+ // With an explicit tools list, Pi also needs custom tool names here to activate them.
339
+ tools,
340
+ ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
341
+ }), (late) => disposeSdkSession(late.session)));
342
+ }
343
+ catch {
344
+ await mcpTools.close();
345
+ throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
346
+ }
347
+ return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined);
348
+ }
349
+ async createWithExtensions(input, extensions) {
350
+ const { cwd, executionProfile } = input;
351
+ const directories = [];
352
+ for (const extension of extensions) {
353
+ try {
354
+ directories.push(await this.resolveExtension(extension));
355
+ }
356
+ catch (error) {
357
+ throw this.failure(executionProfile, "extension_resolve", error instanceof PiExtensionError ? error.code : "extension_load_failed", { extension: `${extension.name}@${extension.version}` });
358
+ }
359
+ }
360
+ // Extension providers register into the runtime they load with, and the last
361
+ // registration wins, so every extension session gets a runtime of its own.
362
+ let runtime;
363
+ try {
364
+ runtime = await this.modelRuntimeFactory();
365
+ }
366
+ catch {
367
+ throw this.failure(executionProfile, "runtime", "pi_session_creation_failed");
368
+ }
369
+ let services;
370
+ let extensionTools;
371
+ try {
372
+ services = await withCreationDeadline(() => this.extensionSdk.createServices({
373
+ cwd,
374
+ agentDir: cwd,
375
+ modelRuntime: runtime,
376
+ settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
377
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
378
+ }));
379
+ const loaded = services.resourceLoader.getExtensions();
380
+ if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
381
+ throw new Error("extension load reported errors");
382
+ }
383
+ extensionTools = registeredExtensionTools(services);
384
+ }
385
+ catch {
386
+ throw this.failure(executionProfile, "extension_load", "extension_load_failed");
387
+ }
388
+ const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
389
+ const mcpTools = await this.connectMcpTools(input);
390
+ const tools = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools);
391
+ if (!tools) {
392
+ await mcpTools.close();
393
+ throw this.failure(executionProfile, "extension_load", "extension_tool_conflict");
394
+ }
395
+ let sessionRuntime;
396
+ try {
397
+ sessionRuntime = await withCreationDeadline(() => this.extensionSdk.createRuntime(async ({ sessionManager }) => ({
398
+ ...(await this.extensionSdk.createSessionFromServices({
399
+ services,
400
+ sessionManager,
401
+ model,
402
+ thinkingLevel: executionProfile.thinkingLevel,
403
+ tools,
404
+ ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
405
+ })),
406
+ services,
407
+ diagnostics: services.diagnostics,
408
+ }), { cwd, agentDir: cwd, sessionManager: SessionManager.inMemory(cwd) }), (late) => { void late.dispose().catch(() => undefined); });
409
+ }
410
+ catch {
411
+ await mcpTools.close();
412
+ throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
413
+ }
414
+ // `AgentSessionRuntime.dispose()` emits `session_shutdown` before disposing the session.
415
+ const disposeRuntime = () => sessionRuntime.dispose().catch(() => undefined);
416
+ // Bounded and concurrent: a hanging `session_shutdown` handler must not keep MCP processes open.
417
+ const cleanUp = async () => {
418
+ await Promise.all([boundedDisposal(disposeRuntime), mcpTools.close()]);
419
+ };
420
+ let bindErrors = 0;
421
+ let bound = false;
422
+ let conflict = false;
423
+ try {
424
+ // Without a UI context the SDK binds its no-op UI; binding emits `session_start`.
425
+ await withCreationDeadline(() => sessionRuntime.session.bindExtensions({
426
+ mode: "print",
427
+ // `ExtensionRunner.emit` routes handler errors here instead of rejecting the bind.
428
+ onError: () => { if (!bound)
429
+ bindErrors += 1; },
430
+ }));
431
+ bound = true;
432
+ if (bindErrors > 0)
433
+ throw new Error("extension hook reported errors while binding");
434
+ // A `session_start` handler may register a tool that replaces a built-in or granted tool.
435
+ // Tools an extension registers later, during a turn, are not re-checked: allowlisted
436
+ // extensions are trusted code.
437
+ const recheck = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), registeredExtensionTools(services));
438
+ if (!recheck || recheck.length !== tools.length || recheck.some((name, index) => name !== tools[index]))
439
+ conflict = true;
440
+ }
441
+ catch {
442
+ await cleanUp();
443
+ throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
444
+ }
445
+ if (conflict) {
446
+ await cleanUp();
447
+ throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
448
+ }
449
+ return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset");
450
+ }
451
+ /** Provider, model, authentication and availability, exactly as configured. */
452
+ async resolveVerifiedModel(runtime, executionProfile) {
238
453
  let provider;
239
454
  try {
240
455
  provider = runtime.getProvider(executionProfile.provider);
@@ -281,42 +496,21 @@ export class SdkPiSessionAdapter {
281
496
  if (!available.some((candidate) => candidate.provider === executionProfile.provider && candidate.id === executionProfile.model)) {
282
497
  throw this.failure(executionProfile, "availability", "model_unavailable");
283
498
  }
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;
499
+ return model;
500
+ }
501
+ async connectMcpTools(input) {
293
502
  try {
294
- mcpTools = await connectSessionMcpTools(executionProfile.mcpServers ?? {}, executionProfile.permissionProfile, input.cwd, this.mcpConnectionFactory, input.signal === undefined ? {} : { signal: input.signal });
503
+ return await connectSessionMcpTools(input.executionProfile.mcpServers ?? {}, input.executionProfile.permissionProfile, input.cwd, this.mcpConnectionFactory, input.signal === undefined ? {} : { signal: input.signal });
295
504
  }
296
505
  catch (error) {
297
506
  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");
507
+ throw this.failure(input.executionProfile, "mcp_tools", failure?.code ?? "external_mcp_unavailable", failure);
319
508
  }
509
+ }
510
+ /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
511
+ async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
512
+ // `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
513
+ toolCheck = "exact") {
320
514
  try {
321
515
  if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
322
516
  throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
@@ -324,57 +518,29 @@ export class SdkPiSessionAdapter {
324
518
  if (session.thinkingLevel !== executionProfile.thinkingLevel) {
325
519
  throw this.failure(executionProfile, "postcheck", "thinking_level_unavailable");
326
520
  }
327
- if (mcpTools.tools.length > 0) {
521
+ if (toolMismatch !== undefined) {
328
522
  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");
523
+ const allowed = new Set(tools);
524
+ const mismatch = toolCheck === "subset"
525
+ ? [...active].some((name) => !allowed.has(name))
526
+ : active.size !== tools.length || tools.some((name) => !active.has(name));
527
+ if (mismatch) {
528
+ throw this.failure(executionProfile, "postcheck", toolMismatch);
331
529
  }
332
530
  }
333
531
  return new SdkHandle(session, {
334
532
  provider: session.model.provider,
335
533
  model: session.model.id,
336
534
  thinkingLevel: session.thinkingLevel,
337
- }, mcpTools);
535
+ }, mcpTools, disposeSession);
338
536
  }
339
537
  catch (error) {
340
- disposeSdkSession(session);
341
- await mcpTools.close();
538
+ await Promise.all([boundedDisposal(disposeSession), mcpTools.close()]);
342
539
  if (error instanceof PiSessionCreationError)
343
540
  throw error;
344
541
  throw this.failure(executionProfile, "postcheck", "pi_session_creation_failed");
345
542
  }
346
543
  }
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
544
  getRuntime() {
379
545
  if (!this.runtimePromise) {
380
546
  const pending = this.modelRuntimeFactory();
@@ -400,6 +566,7 @@ export class SdkPiSessionAdapter {
400
566
  ...(metadata?.serverAlias === undefined ? {} : { serverAlias: metadata.serverAlias }),
401
567
  ...(metadata?.toolAlias === undefined ? {} : { toolAlias: metadata.toolAlias }),
402
568
  ...(metadata?.discoveredToolCount === undefined ? {} : { discoveredToolCount: metadata.discoveredToolCount }),
569
+ ...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
403
570
  });
404
571
  }
405
572
  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 also fails closed.
8
+ */
9
+ export declare function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[]): string[] | undefined;
@@ -0,0 +1,25 @@
1
+ export const PROFILE_TOOLS = {
2
+ "read-only": ["read", "grep", "find", "ls"],
3
+ coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
4
+ };
5
+ /** The SDK reads `*` in an allowlist as a glob and a leading `+` or `-` as a modifier, so those names are rejected: the first character may not be `-`. */
6
+ const EXTENSION_TOOL_NAME = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]*$/;
7
+ /**
8
+ * The tool allowlist of a session, or undefined when an extension tool name conflicts.
9
+ * Extension tools are active only in coding profiles, but a conflict fails closed in both,
10
+ * so an extension can never shadow a built-in or granted tool. A name outside the safe
11
+ * character set also fails closed.
12
+ */
13
+ export function composeSessionTools(permissionProfile, mcpToolNames, extensionToolNames) {
14
+ const reserved = new Set([...PROFILE_TOOLS.coding, ...PROFILE_TOOLS["read-only"], ...mcpToolNames]);
15
+ const extensionTools = [];
16
+ for (const names of extensionToolNames) {
17
+ for (const name of names) {
18
+ if (!EXTENSION_TOOL_NAME.test(name) || reserved.has(name))
19
+ return undefined;
20
+ reserved.add(name);
21
+ extensionTools.push(name);
22
+ }
23
+ }
24
+ return [...PROFILE_TOOLS[permissionProfile], ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : [])];
25
+ }