pi-session-mcp 0.6.4 → 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 (58) 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 +21 -0
  5. package/.pi-session-mcp-cli/pi-extensions.js +67 -0
  6. package/CHANGELOG.md +37 -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 +21 -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 +253 -86
  28. package/dist/server.js +1 -0
  29. package/dist/session-tools.d.ts +9 -0
  30. package/dist/session-tools.js +25 -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 +216 -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 +122 -4
  51. package/docs/macos-setup-design.md +386 -0
  52. package/docs/pi-extensions-design.md +421 -0
  53. package/docs/releasing.md +1 -1
  54. package/docs/superpowers/plans/2026-10-10-macos-setup.md +1725 -0
  55. package/docs/superpowers/plans/2026-10-11-pi-extensions.md +2450 -0
  56. package/docs/threat-model.md +54 -18
  57. package/docs/tool-contracts.md +8 -3
  58. package/package.json +1 -1
@@ -0,0 +1,2450 @@
1
+ # Pi Extensions Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Operators can allowlist exactly pinned Pi extensions per execution profile. Those extensions then load with full capabilities in embedded sessions, and stdout stays reserved for MCP frames.
6
+
7
+ **Architecture:** A pure spec module parses `npm:<name>@<version>` entries in the configuration. A resolver finds Pi's installed package without installing anything. For profiles with extensions, the SDK adapter uses the Pi SDK lifecycle API (`createAgentSessionServices`, `createAgentSessionFromServices`, `createAgentSessionRuntime`) with one model runtime per session; profiles without extensions keep today's path. An always-on stdout guard gives the MCP transport the only real stdout writer.
8
+
9
+ **Tech Stack:** Node.js ≥ 22.19.0, TypeScript strict ESM, zod 4.6.5, vitest 5.0.3, `@earendil-works/pi-coding-agent` 1.1.0, `@modelcontextprotocol/server` 2.3.1.
10
+
11
+ **Spec:** `docs/pi-extensions-design.md` (issue #34). Read it before every task.
12
+
13
+ ## Global Constraints
14
+
15
+ - Node.js 22.19.0 or newer, TypeScript strict mode, ESM, `.js` suffixes in relative imports.
16
+ - No new or changed dependencies. Every SDK symbol comes from the package root of `@earendil-works/pi-coding-agent` or from `@modelcontextprotocol/server/stdio`. Never import internals.
17
+ - stdout carries only MCP frames. Diagnostics go to stderr as structured JSON and never contain secrets, paths, extension error text or prompt content.
18
+ - Automated tests use fakes, or the real SDK with the local fixture extension. They need no provider credentials and no network.
19
+ - Doctor stays offline and never imports extension code.
20
+ - Workspaces are reachable only through configured aliases. No tool argument may accept a path or an extension name.
21
+ - Extension spec format, verbatim: `npm:<name>@<exact semver>`, at most 8 per profile, each package at most once per profile.
22
+ - New public error codes, verbatim:
23
+ - `extension_not_installed`
24
+ - `extension_version_mismatch`
25
+ - `extension_load_failed`
26
+ - `extension_tool_conflict`
27
+ - New Doctor check ids, verbatim:
28
+ - `extension_missing`
29
+ - `extension_version_mismatch`
30
+ - stderr event, verbatim: `{"level":"warning","event":"foreign_stdout_suppressed","bytes":<n>,"writes":<m>}`, at most one line per second.
31
+ - Tracked documentation is written in English.
32
+ - Delivery: one branch, `feat/pi-extensions`, and one PR for #34. Each task commits on its own. Every commit message ends with:
33
+
34
+ ```
35
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
36
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ
37
+ ```
38
+ - Gate after every task: `npm run typecheck` and `npx vitest run` both pass. The full suite currently has 1 skipped test; that is expected.
39
+
40
+ ## Review Focus
41
+
42
+ 1. **Scoped package specs** such as `npm:@scope/ext@1.2.3`. The name contains an `@`, so a naive split on `@` breaks. Pinned in Task 1 (`parses npm:@scope/provider-ext@1.2.3`).
43
+ 2. **A symlinked `pi.extensions` entry that points outside the package** must fail closed, even though the path in the manifest looks relative. Pinned in Task 2 (`rejects an entry symlink that leaves the package`).
44
+ 3. **The same extension in two concurrently started sessions** must get two separate model runtimes. Otherwise `pi-cursor-sdk` sessions break each other. Pinned in Task 6 (`gives every extension session its own model runtime`).
45
+ 4. **An extension writing a Buffer to stdout with a completion callback** must have the callback invoked and its bytes counted. Otherwise the extension hangs or the report is wrong. Pinned in Task 4 (`completes foreign writes and counts Buffer bytes`).
46
+ 5. **A profile without extensions, or with an empty list**, must take the unchanged path: shared runtime, `createAgentSession`, no services. Pinned in Task 6 (`keeps profiles without extensions on the unchanged path`).
47
+
48
+ ---
49
+
50
+ ### Task 1: Extension specs in configuration and capabilities
51
+
52
+ **Files:**
53
+ - Create: `src/extension-spec.ts`
54
+ - Modify:
55
+ - `src/execution-profile.ts`
56
+ - `src/config.ts`
57
+ - `src/capabilities.ts`
58
+ - `src/server.ts:30-46` (`capabilitiesSchema`)
59
+ - `docs/installation.md` (configuration rules, around line 153)
60
+ - `docs/tool-contracts.md:18-41` (capability discovery)
61
+ - Test:
62
+ - Create: `test/extension-spec.test.ts`
63
+ - Modify: `test/config.test.ts`, `test/capabilities.test.ts`, `test/mcp-contract.test.ts:107-121`
64
+
65
+ **Interfaces:**
66
+ - Consumes: nothing new.
67
+ - Produces:
68
+ - `interface ExtensionSpec { readonly spec: string; readonly name: string; readonly version: string }`
69
+ - `const MAX_PROFILE_EXTENSIONS = 8`
70
+ - `function parseExtensionSpec(spec: string): ExtensionSpec | undefined`
71
+ - `type PiExtensionErrorCode = "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed"`
72
+ - `class PiExtensionError extends Error { readonly code: PiExtensionErrorCode }`
73
+ - `ConfiguredExecutionProfile.extensions?: readonly ExtensionSpec[]` and `ResolvedExecutionProfile.extensions?: readonly ExtensionSpec[]`, both frozen and absent when not configured.
74
+ - The capabilities profile gains `extensions: string[]`, the spec strings, `[]` when none.
75
+
76
+ - [ ] **Step 1: Write the failing spec tests**
77
+
78
+ Create `test/extension-spec.test.ts`:
79
+
80
+ ```ts
81
+ import { describe, expect, it } from "vitest";
82
+ import { parseExtensionSpec, PiExtensionError } from "../src/extension-spec.js";
83
+
84
+ describe("extension specs", () => {
85
+ it.each([
86
+ ["npm:pi-cursor-sdk@0.5.3", "pi-cursor-sdk", "0.5.3"],
87
+ ["npm:@scope/provider-ext@1.2.3", "@scope/provider-ext", "1.2.3"],
88
+ ["npm:ext@1.0.0-beta.1+build.5", "ext", "1.0.0-beta.1+build.5"],
89
+ ])("parses %s", (spec, name, version) => {
90
+ expect(parseExtensionSpec(spec)).toEqual({ spec, name, version });
91
+ expect(Object.isFrozen(parseExtensionSpec(spec))).toBe(true);
92
+ });
93
+
94
+ it.each([
95
+ "pi-cursor-sdk@0.5.3",
96
+ "npm:pi-cursor-sdk",
97
+ "npm:pi-cursor-sdk@^0.5.3",
98
+ "npm:pi-cursor-sdk@~0.5.3",
99
+ "npm:pi-cursor-sdk@latest",
100
+ "npm:pi-cursor-sdk@0.5",
101
+ "git:github.com/owner/repo@1.0.0",
102
+ "npm:../escape@1.0.0",
103
+ "npm:/absolute/path@1.0.0",
104
+ "npm:Upper@1.0.0",
105
+ "npm:@scope@1.0.0",
106
+ " npm:ext@1.0.0",
107
+ `npm:${"a".repeat(260)}@1.0.0`,
108
+ ])("rejects %s", (spec) => {
109
+ expect(parseExtensionSpec(spec)).toBeUndefined();
110
+ });
111
+
112
+ it("classifies resolution failures without a path or extension text", () => {
113
+ const error = new PiExtensionError("extension_version_mismatch");
114
+ expect(error).toMatchObject({ name: "PiExtensionError", code: "extension_version_mismatch", message: "Pi extension resolution failed" });
115
+ });
116
+ });
117
+ ```
118
+
119
+ - [ ] **Step 2: Run it to verify it fails**
120
+
121
+ Run: `npx vitest run test/extension-spec.test.ts`
122
+ Expected: FAIL. Vitest reports that `../src/extension-spec.js` cannot be resolved.
123
+
124
+ - [ ] **Step 3: Implement `src/extension-spec.ts`**
125
+
126
+ ```ts
127
+ /** An exactly pinned Pi extension package, as configured on an execution profile. */
128
+ export interface ExtensionSpec {
129
+ /** The configured text, for example `npm:pi-cursor-sdk@0.5.3`. Path-free and secret-free. */
130
+ readonly spec: string;
131
+ readonly name: string;
132
+ readonly version: string;
133
+ }
134
+
135
+ export const MAX_PROFILE_EXTENSIONS = 8;
136
+ const MAX_SPEC_LENGTH = 256;
137
+ /** `npm:<name>@<exact semver>`; the name may be scoped. Ranges, tags, git sources and paths never match. */
138
+ const EXTENSION_SPEC_PATTERN = /^npm:((?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
139
+
140
+ export function parseExtensionSpec(spec: string): ExtensionSpec | undefined {
141
+ if (spec.length > MAX_SPEC_LENGTH) return undefined;
142
+ const match = EXTENSION_SPEC_PATTERN.exec(spec);
143
+ if (!match) return undefined;
144
+ return Object.freeze({ spec, name: match[1]!, version: match[2]! });
145
+ }
146
+
147
+ export type PiExtensionErrorCode = "extension_not_installed" | "extension_version_mismatch" | "extension_load_failed";
148
+
149
+ /** A classified extension resolution failure. It never carries a path or extension text. */
150
+ export class PiExtensionError extends Error {
151
+ readonly code: PiExtensionErrorCode;
152
+ constructor(code: PiExtensionErrorCode) {
153
+ super("Pi extension resolution failed");
154
+ this.name = "PiExtensionError";
155
+ this.code = code;
156
+ }
157
+ }
158
+ ```
159
+
160
+ - [ ] **Step 4: Run the spec tests to verify they pass**
161
+
162
+ Run: `npx vitest run test/extension-spec.test.ts`
163
+ Expected: PASS.
164
+
165
+ - [ ] **Step 5: Write the failing configuration tests**
166
+
167
+ Append to `test/config.test.ts`:
168
+
169
+ ```ts
170
+ describe("execution profile extensions", () => {
171
+ const safe = { default: true, permissionProfile: "read-only", provider: "p", model: "m", thinkingLevel: "off" };
172
+
173
+ async function loadProfiles(executionProfiles: Record<string, unknown>) {
174
+ const root = await temporaryRoot("pi-session-mcp-extensions-");
175
+ const path = join(root, "config.json");
176
+ await writeFile(path, JSON.stringify({ workspaces: { repo: "." }, executionProfiles }));
177
+ return loadConfig(path);
178
+ }
179
+
180
+ it("parses pinned extensions on read-only and coding profiles", async () => {
181
+ const config = await loadProfiles({
182
+ safe: { ...safe, extensions: ["npm:pi-cursor-sdk@0.5.3"] },
183
+ code: { ...safe, default: false, permissionProfile: "coding", extensions: ["npm:@scope/ext@1.2.3", "npm:other@2.0.0"] },
184
+ });
185
+ expect(config.executionProfiles.get("safe")?.extensions).toEqual([{ spec: "npm:pi-cursor-sdk@0.5.3", name: "pi-cursor-sdk", version: "0.5.3" }]);
186
+ expect(config.executionProfiles.get("code")?.extensions?.map((extension) => extension.name)).toEqual(["@scope/ext", "other"]);
187
+ expect(Object.isFrozen(config.executionProfiles.get("code")?.extensions)).toBe(true);
188
+ });
189
+
190
+ it("keeps profiles without extensions free of the field", async () => {
191
+ const config = await loadProfiles({ safe });
192
+ expect(config.executionProfiles.get("safe")).not.toHaveProperty("extensions");
193
+ });
194
+
195
+ it.each([
196
+ [["npm:pi-cursor-sdk@^0.5.3"]],
197
+ [["npm:pi-cursor-sdk@latest"]],
198
+ [["git:github.com/owner/repo@1.0.0"]],
199
+ [["/absolute/extension"]],
200
+ [["npm:dup@1.0.0", "npm:dup@1.0.1"]],
201
+ [Array.from({ length: 9 }, (_, index) => `npm:ext-${index}@1.0.0`)],
202
+ ["npm:not-an-array@1.0.0"],
203
+ ])("rejects invalid extensions %j", async (extensions) => {
204
+ await expect(loadProfiles({ safe: { ...safe, extensions } })).rejects.toMatchObject({ code: "invalid_execution_profile_config" });
205
+ });
206
+ });
207
+ ```
208
+
209
+ - [ ] **Step 6: Run them to verify they fail**
210
+
211
+ Run: `npx vitest run test/config.test.ts -t "execution profile extensions"`
212
+ Expected: FAIL. The strict schema rejects `extensions` as an unknown key, so the "parses pinned extensions" case throws `invalid_execution_profile_config`.
213
+
214
+ - [ ] **Step 7: Add the field to the profile types**
215
+
216
+ In `src/execution-profile.ts`, add the import:
217
+
218
+ ```ts
219
+ import type { ExtensionSpec } from "./extension-spec.js";
220
+ ```
221
+
222
+ Add this line after `readonly mcpServers?: McpServers;` in both `ConfiguredExecutionProfile` and `ResolvedExecutionProfile`:
223
+
224
+ ```ts
225
+ readonly extensions?: readonly ExtensionSpec[];
226
+ ```
227
+
228
+ In `toResolvedExecutionProfile`, add after the `mcpServers` spread:
229
+
230
+ ```ts
231
+ ...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
232
+ ```
233
+
234
+ - [ ] **Step 8: Validate and map the field in `src/config.ts`**
235
+
236
+ Add the import:
237
+
238
+ ```ts
239
+ import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js";
240
+ ```
241
+
242
+ Replace `profileSchema` with:
243
+
244
+ ```ts
245
+ const extensionSpec = z.string().refine((value) => parseExtensionSpec(value) !== undefined);
246
+ const profileSchema = z.object({
247
+ default: z.boolean(), permissionProfile, provider: z.string().trim().min(1).max(256),
248
+ model: z.string().trim().min(1).max(256), thinkingLevel,
249
+ mcpServers: z.custom<McpServers>((value) => isMcpServers(value)).optional(),
250
+ extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
251
+ }).strict().superRefine((profile, context) => {
252
+ if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
253
+ context.addIssue({ code: "custom", path: ["mcpServers"], message: "MCP tool grants do not match the permission profile" });
254
+ }
255
+ if (profile.extensions !== undefined) {
256
+ const names = profile.extensions.map((spec) => parseExtensionSpec(spec)!.name);
257
+ if (new Set(names).size !== names.length) {
258
+ context.addIssue({ code: "custom", path: ["extensions"], message: "An extension package may appear only once per profile" });
259
+ }
260
+ }
261
+ });
262
+ ```
263
+
264
+ In `loadConfig`, replace the `executionProfiles` mapping with:
265
+
266
+ ```ts
267
+ const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, ...value }]) => [key, Object.freeze({
268
+ alias: key, ...value,
269
+ ...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
270
+ ...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec)!)) }),
271
+ })] as const));
272
+ ```
273
+
274
+ - [ ] **Step 9: Run the configuration tests to verify they pass**
275
+
276
+ Run: `npx vitest run test/config.test.ts`
277
+ Expected: PASS.
278
+
279
+ - [ ] **Step 10: Write the failing capability projection test**
280
+
281
+ In `test/capabilities.test.ts`, in the first test:
282
+ - Give `zeta-profile` the field `extensions: [{ spec: "npm:ext@1.0.0", name: "ext", version: "1.0.0" }]`.
283
+ - Add `extensions: []` to the expected `alpha-profile` projection.
284
+ - Add `extensions: ["npm:ext@1.0.0"]` to the expected `zeta-profile` projection.
285
+
286
+ In `test/mcp-contract.test.ts` around lines 113-120, add `extensions: []` to both expected profile projections.
287
+
288
+ - [ ] **Step 11: Run them to verify they fail**
289
+
290
+ Run: `npx vitest run test/capabilities.test.ts test/mcp-contract.test.ts`
291
+ Expected: FAIL. The projections have no `extensions` key.
292
+
293
+ - [ ] **Step 12: Project the field**
294
+
295
+ In `src/capabilities.ts`, replace the `executionProfiles` mapping with:
296
+
297
+ ```ts
298
+ .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions }) => ({
299
+ alias,
300
+ default: isDefault,
301
+ permissionProfile,
302
+ provider,
303
+ model,
304
+ thinkingLevel,
305
+ extensions: (extensions ?? []).map((extension) => extension.spec),
306
+ })),
307
+ ```
308
+
309
+ In `src/server.ts`, inside the `executionProfiles` object of `capabilitiesSchema`, add after `thinkingLevel: thinkingLevelSchema,`:
310
+
311
+ ```ts
312
+ extensions: z.array(z.string()),
313
+ ```
314
+
315
+ - [ ] **Step 13: Run the full gate**
316
+
317
+ Run: `npm run typecheck && npx vitest run`
318
+ Expected: PASS. Another test may still assert an exact capability profile without `extensions`. If so, add `extensions: []` to that expectation, and to nothing else.
319
+
320
+ - [ ] **Step 14: Document the field**
321
+
322
+ `docs/installation.md`: after the bullet "A profile may grant tools from local stdio MCP servers through `mcpServers`. …" (around line 153), add:
323
+
324
+ ```markdown
325
+ - A profile may load Pi extensions that Pi has installed, through
326
+ `extensions`: a list of at most 8 entries of the form
327
+ `npm:<name>@<exact version>`, each package once. Ranges, tags, `git:`
328
+ sources and paths are rejected. See [Pi extensions](#pi-extensions).
329
+ ```
330
+
331
+ `docs/tool-contracts.md`, capability discovery:
332
+ - Add `"extensions": []` after `"thinkingLevel": "medium"` in the example JSON.
333
+ - Change "the six documented profile fields" to "the seven documented profile fields".
334
+ - Add this sentence after that one: "`extensions` lists the configured extension specs (`npm:<name>@<version>`), empty when a profile loads none; it makes no claim that the package is installed."
335
+
336
+ - [ ] **Step 15: Commit**
337
+
338
+ ```bash
339
+ git add src/extension-spec.ts src/execution-profile.ts src/config.ts src/capabilities.ts src/server.ts test/extension-spec.test.ts test/config.test.ts test/capabilities.test.ts test/mcp-contract.test.ts docs/installation.md docs/tool-contracts.md
340
+ git commit -m "feat: accept pinned Pi extensions on execution profiles
341
+
342
+ Refs #34
343
+
344
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
345
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
346
+ ```
347
+
348
+ ---
349
+
350
+ ### Task 2: Resolve installed extensions without installing
351
+
352
+ **Files:**
353
+ - Create: `src/pi-extensions.ts`
354
+ - Test: `test/pi-extensions.test.ts`
355
+
356
+ **Interfaces:**
357
+ - Consumes (Task 1): `ExtensionSpec`, `PiExtensionError`.
358
+ - Produces:
359
+ - `interface PiExtensionResolverDependencies { readonly agentDir?: string; readonly installedPath?: (source: string, agentDir: string) => string | undefined; readonly readFile?: (path: string) => Promise<string>; readonly realpath?: (path: string) => Promise<string> }`
360
+ - `function resolvePiExtension(extension: ExtensionSpec, dependencies?: PiExtensionResolverDependencies): Promise<string>`
361
+ - Resolves to the real package directory.
362
+ - Rejects only with `PiExtensionError`.
363
+
364
+ - [ ] **Step 1: Write the failing tests**
365
+
366
+ Create `test/pi-extensions.test.ts`:
367
+
368
+ ```ts
369
+ import { mkdir, realpath, symlink, writeFile } from "node:fs/promises";
370
+ import { join } from "node:path";
371
+ import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
372
+ import { afterAll, describe, expect, it, vi } from "vitest";
373
+ import { parseExtensionSpec } from "../src/extension-spec.js";
374
+ import { resolvePiExtension } from "../src/pi-extensions.js";
375
+ import { removeTemporaryRoots, temporaryRoot } from "./temporary-roots.js";
376
+
377
+ afterAll(removeTemporaryRoots);
378
+
379
+ const extension = parseExtensionSpec("npm:fixture-ext@1.2.3")!;
380
+
381
+ async function installed(manifest: unknown, files: Record<string, string> = { "index.js": "export default () => {};\n" }) {
382
+ const root = await temporaryRoot("pi-session-mcp-extension-");
383
+ const dir = join(root, "npm", "node_modules", "fixture-ext");
384
+ await mkdir(dir, { recursive: true });
385
+ await writeFile(join(dir, "package.json"), typeof manifest === "string" ? manifest : JSON.stringify(manifest));
386
+ for (const [name, content] of Object.entries(files)) await writeFile(join(dir, name), content);
387
+ return { root, dir };
388
+ }
389
+
390
+ const at = (dir: string | undefined) => ({ agentDir: "/unused-agent-dir", installedPath: vi.fn((_source: string, _agentDir: string) => dir) });
391
+ const valid = { name: "fixture-ext", version: "1.2.3", pi: { extensions: ["./index.js"] } };
392
+
393
+ describe("resolvePiExtension", () => {
394
+ it("returns the real package directory for the pinned version", async () => {
395
+ const { dir } = await installed(valid);
396
+ const dependencies = at(dir);
397
+ await expect(resolvePiExtension(extension, dependencies)).resolves.toBe(await realpath(dir));
398
+ expect(dependencies.installedPath).toHaveBeenCalledWith("npm:fixture-ext", "/unused-agent-dir");
399
+ });
400
+
401
+ it("finds Pi's user install through the package manager without resolving or installing", async () => {
402
+ const { root, dir } = await installed(valid);
403
+ const resolve = vi.spyOn(DefaultPackageManager.prototype, "resolve");
404
+ await expect(resolvePiExtension(extension, { agentDir: root })).resolves.toBe(await realpath(dir));
405
+ expect(resolve).not.toHaveBeenCalled();
406
+ resolve.mockRestore();
407
+ });
408
+
409
+ it("reports a package Pi has not installed", async () => {
410
+ await expect(resolvePiExtension(extension, at(undefined))).rejects.toMatchObject({ name: "PiExtensionError", code: "extension_not_installed" });
411
+ });
412
+
413
+ it("reports an installed directory without a manifest as not installed", async () => {
414
+ const root = await temporaryRoot("pi-session-mcp-extension-");
415
+ await expect(resolvePiExtension(extension, at(root))).rejects.toMatchObject({ code: "extension_not_installed" });
416
+ });
417
+
418
+ it("reports another installed version", async () => {
419
+ const { dir } = await installed({ ...valid, version: "1.2.4" });
420
+ await expect(resolvePiExtension(extension, at(dir))).rejects.toMatchObject({ code: "extension_version_mismatch" });
421
+ });
422
+
423
+ it.each([
424
+ ["invalid JSON", "{"],
425
+ ["a non-object manifest", "null"],
426
+ ["no pi.extensions", { name: "fixture-ext", version: "1.2.3" }],
427
+ ["an empty pi.extensions", { ...valid, pi: { extensions: [] } }],
428
+ ["an absolute entry", { ...valid, pi: { extensions: ["/etc/passwd"] } }],
429
+ ["a missing entry file", { ...valid, pi: { extensions: ["./missing.js"] } }],
430
+ ["an entry outside the package", { ...valid, pi: { extensions: ["../outside.js"] } }],
431
+ ])("rejects %s as a load failure", async (_label, manifest) => {
432
+ const { root, dir } = await installed(manifest);
433
+ await writeFile(join(root, "npm", "node_modules", "outside.js"), "export default () => {};\n");
434
+ const error = await resolvePiExtension(extension, at(dir)).catch((caught: unknown) => caught);
435
+ expect(error).toMatchObject({ name: "PiExtensionError", code: "extension_load_failed" });
436
+ expect(String((error as Error).message)).not.toContain(root);
437
+ });
438
+
439
+ it("rejects an entry symlink that leaves the package", async () => {
440
+ const { root, dir } = await installed({ ...valid, pi: { extensions: ["./link.js"] } });
441
+ const outside = join(root, "outside.js");
442
+ await writeFile(outside, "export default () => {};\n");
443
+ await symlink(outside, join(dir, "link.js"));
444
+ await expect(resolvePiExtension(extension, at(dir))).rejects.toMatchObject({ code: "extension_load_failed" });
445
+ });
446
+ });
447
+ ```
448
+
449
+ - [ ] **Step 2: Run them to verify they fail**
450
+
451
+ Run: `npx vitest run test/pi-extensions.test.ts`
452
+ Expected: FAIL. `../src/pi-extensions.js` cannot be resolved.
453
+
454
+ - [ ] **Step 3: Implement `src/pi-extensions.ts`**
455
+
456
+ ```ts
457
+ import { readFile, realpath } from "node:fs/promises";
458
+ import { isAbsolute, join, relative, sep } from "node:path";
459
+ import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
460
+ import { PiExtensionError, type ExtensionSpec } from "./extension-spec.js";
461
+
462
+ export interface PiExtensionResolverDependencies {
463
+ /** Pi's agent directory; defaults to the SDK's `getAgentDir()`. */
464
+ readonly agentDir?: string;
465
+ /** Looks up Pi's user-scope install of an npm source without installing anything. */
466
+ readonly installedPath?: (source: string, agentDir: string) => string | undefined;
467
+ readonly readFile?: (path: string) => Promise<string>;
468
+ readonly realpath?: (path: string) => Promise<string>;
469
+ }
470
+
471
+ function installedPathFromPi(source: string, agentDir: string): string | undefined {
472
+ const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
473
+ // `getInstalledPath` only looks the directory up; `resolve()` would run `npm install`.
474
+ return new DefaultPackageManager({ cwd: agentDir, agentDir, settingsManager }).getInstalledPath(source, "user");
475
+ }
476
+
477
+ function inside(root: string, candidate: string): boolean {
478
+ const path = relative(root, candidate);
479
+ return path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
480
+ }
481
+
482
+ function isMissing(error: unknown): boolean {
483
+ return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "ENOENT";
484
+ }
485
+
486
+ /**
487
+ * Resolves a pinned extension to the real directory of Pi's installed package.
488
+ * It never installs, never imports extension code and never reports a path.
489
+ */
490
+ export async function resolvePiExtension(extension: ExtensionSpec, dependencies: PiExtensionResolverDependencies = {}): Promise<string> {
491
+ const agentDir = dependencies.agentDir ?? getAgentDir();
492
+ const read = dependencies.readFile ?? ((path: string) => readFile(path, "utf8"));
493
+ const real = dependencies.realpath ?? realpath;
494
+ let installed: string | undefined;
495
+ try { installed = (dependencies.installedPath ?? installedPathFromPi)(`npm:${extension.name}`, agentDir); }
496
+ catch { throw new PiExtensionError("extension_load_failed"); }
497
+ if (installed === undefined) throw new PiExtensionError("extension_not_installed");
498
+ let root: string;
499
+ let manifest: unknown;
500
+ try {
501
+ root = await real(installed);
502
+ manifest = JSON.parse(await read(join(root, "package.json")));
503
+ } catch (error) {
504
+ throw new PiExtensionError(isMissing(error) ? "extension_not_installed" : "extension_load_failed");
505
+ }
506
+ if (typeof manifest !== "object" || manifest === null) throw new PiExtensionError("extension_load_failed");
507
+ const { version, pi } = manifest as { version?: unknown; pi?: { extensions?: unknown } };
508
+ if (version !== extension.version) throw new PiExtensionError("extension_version_mismatch");
509
+ const entries = pi?.extensions;
510
+ if (!Array.isArray(entries) || entries.length === 0
511
+ || !entries.every((entry) => typeof entry === "string" && entry.length > 0 && !isAbsolute(entry))) {
512
+ throw new PiExtensionError("extension_load_failed");
513
+ }
514
+ for (const entry of entries as string[]) {
515
+ let target: string;
516
+ try { target = await real(join(root, entry)); }
517
+ catch { throw new PiExtensionError("extension_load_failed"); }
518
+ if (!inside(root, target)) throw new PiExtensionError("extension_load_failed");
519
+ }
520
+ return root;
521
+ }
522
+ ```
523
+
524
+ - [ ] **Step 4: Run them to verify they pass**
525
+
526
+ Run: `npx vitest run test/pi-extensions.test.ts`
527
+ Expected: PASS.
528
+
529
+ - [ ] **Step 5: Run the full gate**
530
+
531
+ Run: `npm run typecheck && npx vitest run`
532
+ Expected: PASS.
533
+
534
+ - [ ] **Step 6: Commit**
535
+
536
+ ```bash
537
+ git add src/pi-extensions.ts test/pi-extensions.test.ts
538
+ git commit -m "feat: resolve pinned Pi extensions from Pi's install without installing
539
+
540
+ Refs #34
541
+
542
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
543
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
544
+ ```
545
+
546
+ ---
547
+
548
+ ### Task 3: Doctor checks for configured extensions
549
+
550
+ **Files:**
551
+ - Modify:
552
+ - `src/doctor.ts` (`DoctorCheckId`, `CHECK_ORDER`, `DoctorDependencies`, `runDoctor`)
553
+ - `docs/doctor-design.md`
554
+ - Test: `test/doctor.test.ts`
555
+
556
+ **Interfaces:**
557
+ - Consumes:
558
+ - Task 1: `ExtensionSpec`, `PiExtensionError`.
559
+ - Task 2: `resolvePiExtension`, loaded by dynamic import only when a profile has extensions.
560
+ - Produces:
561
+ - `DoctorCheckId` gains `"extension_missing"` and `"extension_version_mismatch"`. They sort last in `CHECK_ORDER`, in that order.
562
+ - `DoctorDependencies.resolveExtension?: (extension: ExtensionSpec) => Promise<string>`.
563
+
564
+ - [ ] **Step 1: Write the failing tests**
565
+
566
+ In `test/doctor.test.ts`, change the vitest import to `import { afterAll, describe, expect, it, vi } from "vitest";` and add:
567
+
568
+ ```ts
569
+ import { parseExtensionSpec, PiExtensionError } from "../src/extension-spec.js";
570
+ ```
571
+
572
+ Append inside `describe("offline doctor", …)`:
573
+
574
+ ```ts
575
+ const extension = parseExtensionSpec("npm:fixture-ext@1.2.3")!;
576
+ const configWithExtension = async () => {
577
+ const base = await healthyConfig();
578
+ return { ...base, executionProfiles: new Map([["safe", { ...base.executionProfiles.get("safe")!, extensions: [extension] }]]) };
579
+ };
580
+ const extensionFs = () => fsFor({ files: ["/root/dist/main.js"], dirs: ["/hidden/workspace"] });
581
+
582
+ it("reports installed pinned extensions as ok", async () => {
583
+ const resolveExtension = vi.fn(async () => "/hidden/extension");
584
+ const result = await runDoctor({ packageRoot: "/root", fileSystem: extensionFs(), loadConfig: configWithExtension, resolveExtension });
585
+ expect(resolveExtension).toHaveBeenCalledWith(extension);
586
+ expect(result.checks).toContainEqual({ id: "extension_missing", severity: "ok", subject: "safe:fixture-ext" });
587
+ expect(result.checks).toContainEqual({ id: "extension_version_mismatch", severity: "ok", subject: "safe:fixture-ext" });
588
+ expect(result.checks.slice(-2).map((check) => check.id)).toEqual(["extension_missing", "extension_version_mismatch"]);
589
+ });
590
+
591
+ it.each([
592
+ ["extension_not_installed", "extension_missing"],
593
+ ["extension_version_mismatch", "extension_version_mismatch"],
594
+ ["extension_load_failed", "extension_version_mismatch"],
595
+ ] as const)("maps %s to a failing %s check without a path", async (code, id) => {
596
+ const result = await runDoctor({
597
+ packageRoot: "/root", fileSystem: extensionFs(), loadConfig: configWithExtension,
598
+ resolveExtension: async () => { throw new PiExtensionError(code); },
599
+ });
600
+ expect(result.checks).toContainEqual(expect.objectContaining({ id, severity: "error", subject: "safe:fixture-ext" }));
601
+ expect(result.exitCode).toBe(2);
602
+ expect(JSON.stringify(result)).not.toContain("/hidden/extension");
603
+ });
604
+
605
+ it("never resolves extensions when no profile has any", async () => {
606
+ const resolveExtension = vi.fn(async () => "/hidden/extension");
607
+ await runDoctor({ packageRoot: "/root", fileSystem: extensionFs(), loadConfig: healthyConfig, resolveExtension });
608
+ expect(resolveExtension).not.toHaveBeenCalled();
609
+ });
610
+ ```
611
+
612
+ - [ ] **Step 2: Run them to verify they fail**
613
+
614
+ Run: `npx vitest run test/doctor.test.ts`
615
+ Expected: FAIL. No `extension_*` checks are produced, and TypeScript reports the unknown `resolveExtension` dependency.
616
+
617
+ - [ ] **Step 3: Implement the checks**
618
+
619
+ In `src/doctor.ts`:
620
+
621
+ 1. Add the import:
622
+
623
+ ```ts
624
+ import { PiExtensionError, type ExtensionSpec } from "./extension-spec.js";
625
+ ```
626
+
627
+ 2. Extend the union:
628
+
629
+ ```ts
630
+ | "workspace_not_writable" | "extension_missing" | "extension_version_mismatch";
631
+ ```
632
+
633
+ 3. Append to `CHECK_ORDER`, after `"workspace_not_writable"`:
634
+
635
+ ```ts
636
+ "extension_missing", "extension_version_mismatch"
637
+ ```
638
+
639
+ 4. Add to `DoctorDependencies`:
640
+
641
+ ```ts
642
+ /** Reads the installed manifest of a pinned extension; never imports extension code. */
643
+ readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
644
+ ```
645
+
646
+ 5. In `runDoctor`, insert directly after the workspace `if (config) { … }` block and before `const rank = …`:
647
+
648
+ ```ts
649
+ if (config) {
650
+ const resolveExtension = dependencies.resolveExtension
651
+ ?? (async (extension: ExtensionSpec) => (await import("./pi-extensions.js")).resolvePiExtension(extension));
652
+ for (const [alias, profile] of [...config.executionProfiles].sort(([a], [b]) => a.localeCompare(b))) {
653
+ for (const extension of profile.extensions ?? []) {
654
+ const subject = `${alias}:${extension.name}`;
655
+ try {
656
+ await resolveExtension(extension);
657
+ checks.push(ok("extension_missing", subject), ok("extension_version_mismatch", subject));
658
+ } catch (error) {
659
+ const code = error instanceof PiExtensionError ? error.code : "extension_load_failed";
660
+ if (code === "extension_not_installed") checks.push(failure("extension_missing", subject, "install the pinned extension version with Pi"));
661
+ else checks.push(ok("extension_missing", subject), failure("extension_version_mismatch", subject, "install the pinned extension version with Pi"));
662
+ }
663
+ }
664
+ }
665
+ }
666
+ ```
667
+
668
+ - [ ] **Step 4: Run them to verify they pass**
669
+
670
+ Run: `npx vitest run test/doctor.test.ts`
671
+ Expected: PASS.
672
+
673
+ - [ ] **Step 5: Document the checks**
674
+
675
+ In `docs/doctor-design.md`, append these two rows to the check table, directly after the `workspace_not_writable` row:
676
+
677
+ ```markdown
678
+ | `extension_missing` | Pi has no user install of a package that a profile's `extensions` names | `<profile>:<package>` |
679
+ | `extension_version_mismatch` | The installed manifest's version differs from the pinned version, or the manifest or its `pi.extensions` entries are invalid | `<profile>:<package>` |
680
+ ```
681
+
682
+ Directly below the table, add this sentence: "Doctor reads extension manifests through Pi's package-manager lookup only; it never installs packages and never imports extension code."
683
+
684
+ - [ ] **Step 6: Run the full gate**
685
+
686
+ Run: `npm run typecheck && npx vitest run`
687
+ Expected: PASS.
688
+
689
+ - [ ] **Step 7: Commit**
690
+
691
+ ```bash
692
+ git add src/doctor.ts test/doctor.test.ts docs/doctor-design.md
693
+ git commit -m "feat: check configured Pi extensions offline in Doctor
694
+
695
+ Refs #34
696
+
697
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
698
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
699
+ ```
700
+
701
+ ---
702
+
703
+ ### Task 4: stdout guard
704
+
705
+ **Files:**
706
+ - Create:
707
+ - `src/stdout-guard.ts`
708
+ - `src/stdout-guard-install.ts`
709
+ - Modify: `src/main.ts`
710
+ - Test: `test/stdout-guard.test.ts`
711
+
712
+ **Interfaces:**
713
+ - Consumes: nothing.
714
+ - Produces:
715
+ - `interface StdoutGuardOptions { readonly stream?: NodeJS.WriteStream; readonly console?: Pick<Console, "log" | "info" | "debug">; readonly report?: (line: string) => void; readonly intervalMs?: number; readonly onExit?: (flush: () => void) => void }`
716
+ - `interface StdoutGuard { readonly transportStdout: Writable; flush(): void }`
717
+ - `function installStdoutGuard(options?: StdoutGuardOptions): StdoutGuard`
718
+ - `src/stdout-guard-install.ts` exports `const stdoutGuard: StdoutGuard`, installed when the module is evaluated.
719
+
720
+ - [ ] **Step 1: Write the failing tests**
721
+
722
+ Create `test/stdout-guard.test.ts`:
723
+
724
+ ```ts
725
+ import { Writable } from "node:stream";
726
+ import { afterEach, describe, expect, it, vi } from "vitest";
727
+ import { installStdoutGuard } from "../src/stdout-guard.js";
728
+
729
+ afterEach(() => { vi.useRealTimers(); });
730
+
731
+ function harness(intervalMs = 60_000) {
732
+ const written: Buffer[] = [];
733
+ const stream = new Writable({ write(chunk, _encoding, callback) { written.push(Buffer.from(chunk as Buffer)); callback(); } }) as unknown as NodeJS.WriteStream;
734
+ const reports: string[] = [];
735
+ const fakeConsole = { log: vi.fn(), info: vi.fn(), debug: vi.fn() };
736
+ let exitFlush: (() => void) | undefined;
737
+ const guard = installStdoutGuard({ stream, console: fakeConsole, report: (line) => { reports.push(line); }, intervalMs, onExit: (flush) => { exitFlush = flush; } });
738
+ return { guard, stream, written, reports, fakeConsole, exit: () => exitFlush?.() };
739
+ }
740
+
741
+ const transportWrite = (guard: ReturnType<typeof installStdoutGuard>, text: string) => new Promise<void>((resolve, reject) => {
742
+ guard.transportStdout.write(text, (error) => { if (error) reject(error); else resolve(); });
743
+ });
744
+
745
+ describe("stdout guard", () => {
746
+ it("lets only the transport reach stdout and reports foreign output without content", async () => {
747
+ const h = harness();
748
+ await transportWrite(h.guard, '{"jsonrpc":"2.0"}\n');
749
+ h.stream.write("foreign text\n");
750
+ h.fakeConsole.log("secret prompt", 42);
751
+ expect(Buffer.concat(h.written).toString("utf8")).toBe('{"jsonrpc":"2.0"}\n');
752
+ h.guard.flush();
753
+ expect(h.reports).toHaveLength(1);
754
+ expect(JSON.parse(h.reports[0]!)).toEqual({
755
+ level: "warning", event: "foreign_stdout_suppressed",
756
+ bytes: Buffer.byteLength("foreign text\n") + Buffer.byteLength("secret prompt 42\n"), writes: 2,
757
+ });
758
+ expect(h.reports[0]).not.toContain("secret");
759
+ });
760
+
761
+ it("completes foreign writes and counts Buffer bytes", async () => {
762
+ const h = harness();
763
+ const done = vi.fn();
764
+ expect(h.stream.write(Buffer.from([1, 2, 3]), done)).toBe(true);
765
+ const doneWithEncoding = vi.fn();
766
+ h.stream.write("ab", "utf8", doneWithEncoding);
767
+ await new Promise((resolve) => setImmediate(resolve));
768
+ expect(done).toHaveBeenCalledTimes(1);
769
+ expect(doneWithEncoding).toHaveBeenCalledTimes(1);
770
+ h.guard.flush();
771
+ expect(JSON.parse(h.reports[0]!)).toMatchObject({ bytes: 5, writes: 2 });
772
+ });
773
+
774
+ it("reports at most once per interval and flushes on exit", async () => {
775
+ vi.useFakeTimers();
776
+ const h = harness(1_000);
777
+ h.stream.write("a");
778
+ h.fakeConsole.info("b");
779
+ expect(h.reports).toHaveLength(0);
780
+ await vi.advanceTimersByTimeAsync(1_000);
781
+ expect(h.reports).toHaveLength(1);
782
+ expect(JSON.parse(h.reports[0]!)).toMatchObject({ writes: 2 });
783
+ h.fakeConsole.debug("c");
784
+ h.exit();
785
+ expect(h.reports).toHaveLength(2);
786
+ h.guard.flush();
787
+ expect(h.reports).toHaveLength(2);
788
+ });
789
+
790
+ it("forwards stdout errors to the transport stream", async () => {
791
+ const h = harness();
792
+ const onError = vi.fn();
793
+ h.guard.transportStdout.on("error", onError);
794
+ const failure = Object.assign(new Error("hidden"), { code: "EPIPE" });
795
+ h.stream.emit("error", failure);
796
+ // `destroy(error)` emits the error on a later tick.
797
+ await new Promise((resolve) => setImmediate(resolve));
798
+ expect(onError).toHaveBeenCalledWith(failure);
799
+ });
800
+ });
801
+ ```
802
+
803
+ - [ ] **Step 2: Run them to verify they fail**
804
+
805
+ Run: `npx vitest run test/stdout-guard.test.ts`
806
+ Expected: FAIL. `../src/stdout-guard.js` cannot be resolved.
807
+
808
+ - [ ] **Step 3: Implement `src/stdout-guard.ts`**
809
+
810
+ ```ts
811
+ import { Writable } from "node:stream";
812
+ import { format } from "node:util";
813
+
814
+ export interface StdoutGuardOptions {
815
+ readonly stream?: NodeJS.WriteStream;
816
+ readonly console?: Pick<Console, "log" | "info" | "debug">;
817
+ readonly report?: (line: string) => void;
818
+ readonly intervalMs?: number;
819
+ readonly onExit?: (flush: () => void) => void;
820
+ }
821
+
822
+ export interface StdoutGuard {
823
+ /** The only writer that reaches the real stdout: hand it to the MCP transport. */
824
+ readonly transportStdout: Writable;
825
+ /** Writes the pending suppression counts to stderr. */
826
+ flush(): void;
827
+ }
828
+
829
+ type WriteCallback = (error?: Error | null) => void;
830
+
831
+ /**
832
+ * Reserves stdout for MCP frames. Any other write, an extension's `console.log`
833
+ * included, is dropped; stderr gets only a byte and write count, never the content,
834
+ * because the content may hold prompts or secrets.
835
+ */
836
+ export function installStdoutGuard(options: StdoutGuardOptions = {}): StdoutGuard {
837
+ const stream = options.stream ?? process.stdout;
838
+ const target = options.console ?? console;
839
+ const report = options.report ?? ((line: string) => { process.stderr.write(line); });
840
+ const intervalMs = options.intervalMs ?? 1_000;
841
+ const realWrite = stream.write.bind(stream) as (chunk: unknown, callback?: WriteCallback) => boolean;
842
+ let bytes = 0;
843
+ let writes = 0;
844
+ let timer: NodeJS.Timeout | undefined;
845
+
846
+ const flush = (): void => {
847
+ if (timer) { clearTimeout(timer); timer = undefined; }
848
+ if (writes === 0) return;
849
+ report(JSON.stringify({ level: "warning", event: "foreign_stdout_suppressed", bytes, writes }) + "\n");
850
+ bytes = 0;
851
+ writes = 0;
852
+ };
853
+ const suppress = (chunk: unknown, encoding?: unknown): void => {
854
+ bytes += typeof chunk === "string"
855
+ ? Buffer.byteLength(chunk, typeof encoding === "string" ? encoding as BufferEncoding : "utf8")
856
+ : chunk instanceof Uint8Array ? chunk.byteLength : 0;
857
+ writes += 1;
858
+ if (!timer) {
859
+ timer = setTimeout(flush, intervalMs);
860
+ timer.unref();
861
+ }
862
+ };
863
+
864
+ stream.write = ((chunk: unknown, encodingOrCallback?: unknown, callback?: unknown): boolean => {
865
+ suppress(chunk, encodingOrCallback);
866
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
867
+ if (typeof done === "function") queueMicrotask(() => { (done as WriteCallback)(); });
868
+ return true;
869
+ }) as typeof stream.write;
870
+ for (const method of ["log", "info", "debug"] as const) {
871
+ target[method] = (...args: unknown[]): void => { suppress(`${format(...args)}\n`); };
872
+ }
873
+
874
+ const transportStdout = new Writable({
875
+ write(chunk: Buffer, _encoding, callback) { realWrite(chunk, callback); },
876
+ });
877
+ stream.on("error", (error: Error) => { transportStdout.destroy(error); });
878
+ (options.onExit ?? ((exitFlush: () => void) => { process.once("exit", exitFlush); }))(flush);
879
+ return { transportStdout, flush };
880
+ }
881
+ ```
882
+
883
+ - [ ] **Step 4: Run them to verify they pass**
884
+
885
+ Run: `npx vitest run test/stdout-guard.test.ts`
886
+ Expected: PASS.
887
+
888
+ - [ ] **Step 5: Install the guard first in the server entry**
889
+
890
+ Create `src/stdout-guard-install.ts`:
891
+
892
+ ```ts
893
+ import { installStdoutGuard } from "./stdout-guard.js";
894
+
895
+ /**
896
+ * Installed when this module is evaluated. `main.ts` imports it first, so the guard is
897
+ * in place before any other module, the Pi SDK or an extension, can write to stdout.
898
+ */
899
+ export const stdoutGuard = installStdoutGuard();
900
+ ```
901
+
902
+ In `src/main.ts`:
903
+ - Make `import { stdoutGuard } from "./stdout-guard-install.js";` the first import, directly after the shebang line.
904
+ - Change the MCP import to `import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";`.
905
+ - Replace the options object passed to `serveStdio` with:
906
+
907
+ ```ts
908
+ {
909
+ transport: new StdioServerTransport(process.stdin, stdoutGuard.transportStdout),
910
+ onerror: () => writeDiagnostic({ level: "error", event: "mcp_transport_error", stage: "transport", code: "mcp_transport_error" }),
911
+ },
912
+ ```
913
+
914
+ - [ ] **Step 6: Run the full gate, including the process-level tests that start the real `main.js`**
915
+
916
+ Run: `npm run typecheck && npx vitest run`
917
+ Expected: PASS. In particular, `test/process-stdin-eof.test.ts`, `test/entry-point.test.ts` and `test/mcp-sdk-integration.test.ts` must stay green.
918
+
919
+ - [ ] **Step 7: Commit**
920
+
921
+ ```bash
922
+ git add src/stdout-guard.ts src/stdout-guard-install.ts src/main.ts test/stdout-guard.test.ts
923
+ git commit -m "feat: reserve stdout for MCP frames with an always-on guard
924
+
925
+ Refs #34
926
+
927
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
928
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
929
+ ```
930
+
931
+ ---
932
+
933
+ ### Task 5: Plain-text prompts
934
+
935
+ **Files:**
936
+ - Modify: `src/sdk-pi-adapter.ts:148-164` (`admitsTurn` comment) and `:209` (`SdkHandle.prompt`)
937
+ - Test: `test/sdk-pi-adapter.test.ts`
938
+
939
+ **Interfaces:**
940
+ - Consumes: nothing new.
941
+ - Produces: every `session.prompt()` call passes `expandPromptTemplates: false`.
942
+
943
+ - [ ] **Step 1: Write the failing test**
944
+
945
+ Append to `test/sdk-pi-adapter.test.ts`:
946
+
947
+ ```ts
948
+ describe("plain-text prompts", () => {
949
+ it("sends every prompt as plain text so extension commands never run", async () => {
950
+ const fixture = runtimeFixture();
951
+ const { session } = sessionFixture();
952
+ const adapter = new SdkPiSessionAdapter({ modelRuntimeFactory: async () => fixture.runtime, createSession: createSessionFixture(session) });
953
+ const handle = await create(adapter);
954
+ await handle.prompt("/fixture-cmd hello", () => undefined);
955
+ expect(session.prompt).toHaveBeenCalledWith("/fixture-cmd hello", expect.objectContaining({ expandPromptTemplates: false }));
956
+ });
957
+ });
958
+ ```
959
+
960
+ - [ ] **Step 2: Run it to verify it fails**
961
+
962
+ Run: `npx vitest run test/sdk-pi-adapter.test.ts -t "plain-text prompts"`
963
+ Expected: FAIL. The options object lacks `expandPromptTemplates`.
964
+
965
+ - [ ] **Step 3: Implement**
966
+
967
+ In `SdkHandle.prompt`, change the prompt call to:
968
+
969
+ ```ts
970
+ promptResult = this.session.prompt(text, {
971
+ // Client text is content, never a command: no extension command, skill or template dispatch.
972
+ expandPromptTemplates: false,
973
+ preflightResult: (disposition) => signalPreflight(admitsTurn(disposition)),
974
+ });
975
+ ```
976
+
977
+ In `admitsTurn`, replace the two comment lines above `case "handled":` with:
978
+
979
+ ```ts
980
+ // An extension input handler consumed the prompt without a model turn. Commands never
981
+ // dispatch (`expandPromptTemplates: false`), but allowlisted extensions may handle input.
982
+ ```
983
+
984
+ - [ ] **Step 4: Run the full gate**
985
+
986
+ Run: `npm run typecheck && npx vitest run`
987
+ Expected: PASS.
988
+
989
+ - [ ] **Step 5: Commit**
990
+
991
+ ```bash
992
+ git add src/sdk-pi-adapter.ts test/sdk-pi-adapter.test.ts
993
+ git commit -m "fix: send client prompts as plain text without command dispatch
994
+
995
+ Refs #34
996
+
997
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
998
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
999
+ ```
1000
+
1001
+ ---
1002
+
1003
+ ### Task 6: Extension session path in the adapter
1004
+
1005
+ **Files:**
1006
+ - Create:
1007
+ - `src/session-tools.ts`
1008
+ - `test/session-tools.test.ts`
1009
+ - Modify:
1010
+ - `src/pi-adapter.ts` (error codes and messages)
1011
+ - `src/sdk-pi-adapter.ts`
1012
+ - `docs/tool-contracts.md:56-72` (error table)
1013
+ - Test: `test/sdk-pi-adapter.test.ts`
1014
+
1015
+ **Interfaces:**
1016
+ - Consumes:
1017
+ - Task 1: `ExtensionSpec`, `PiExtensionError`.
1018
+ - Task 2: `resolvePiExtension`.
1019
+ - Task 5: plain-text prompts.
1020
+ - Produces:
1021
+ - `PROFILE_TOOLS` moves to `src/session-tools.ts` and is re-exported unchanged from `src/sdk-pi-adapter.ts`.
1022
+ - `function composeSessionTools(permissionProfile: PermissionProfile, mcpToolNames: readonly string[], extensionToolNames: readonly (readonly string[])[]): string[] | undefined`
1023
+ - `interface ExtensionSdk { readonly createServices: typeof createAgentSessionServices; readonly createSessionFromServices: typeof createAgentSessionFromServices; readonly createRuntime: typeof createAgentSessionRuntime }`, exported from `src/sdk-pi-adapter.ts`.
1024
+ - `SdkPiSessionAdapterOptions` gains:
1025
+ - `resolveExtension?: (extension: ExtensionSpec) => Promise<string>`
1026
+ - `extensionSdk?: ExtensionSdk`
1027
+ - `SdkPiAdapterDiagnostic.stage` gains `"extension_resolve" | "extension_load" | "extension_bind"`.
1028
+ - `SdkPiAdapterDiagnostic` gains the optional field `extension?: string`, formatted as `<name>@<version>`.
1029
+ - `PiSessionCreationErrorCode` gains `"extension_not_installed" | "extension_version_mismatch" | "extension_load_failed" | "extension_tool_conflict"`.
1030
+
1031
+ - [ ] **Step 1: Write the failing tool-composition tests**
1032
+
1033
+ Create `test/session-tools.test.ts`:
1034
+
1035
+ ```ts
1036
+ import { describe, expect, it } from "vitest";
1037
+ import { composeSessionTools, PROFILE_TOOLS } from "../src/session-tools.js";
1038
+
1039
+ describe("composeSessionTools", () => {
1040
+ it("activates extension tools only in coding profiles", () => {
1041
+ expect(composeSessionTools("read-only", ["mcp_note"], [["fixture_tool"]])).toEqual([...PROFILE_TOOLS["read-only"], "mcp_note"]);
1042
+ expect(composeSessionTools("coding", ["mcp_note"], [["fixture_tool"], ["other_tool"]])).toEqual([...PROFILE_TOOLS.coding, "mcp_note", "fixture_tool", "other_tool"]);
1043
+ });
1044
+
1045
+ it.each([
1046
+ ["read-only", [], [["read"]]],
1047
+ ["read-only", [], [["bash"]]],
1048
+ ["coding", ["mcp_note"], [["mcp_note"]]],
1049
+ ["coding", [], [["dup"], ["dup"]]],
1050
+ ] as const)("fails closed on a %s conflict", (permissionProfile, mcp, extension) => {
1051
+ expect(composeSessionTools(permissionProfile, mcp, extension)).toBeUndefined();
1052
+ });
1053
+ });
1054
+ ```
1055
+
1056
+ - [ ] **Step 2: Run them to verify they fail**
1057
+
1058
+ Run: `npx vitest run test/session-tools.test.ts`
1059
+ Expected: FAIL. `../src/session-tools.js` cannot be resolved.
1060
+
1061
+ - [ ] **Step 3: Implement `src/session-tools.ts` and move `PROFILE_TOOLS`**
1062
+
1063
+ ```ts
1064
+ import type { PermissionProfile } from "./execution-profile.js";
1065
+
1066
+ export const PROFILE_TOOLS: Readonly<Record<PermissionProfile, readonly string[]>> = {
1067
+ "read-only": ["read", "grep", "find", "ls"],
1068
+ coding: ["read", "bash", "edit", "write", "grep", "find", "ls"],
1069
+ };
1070
+
1071
+ /**
1072
+ * The tool allowlist of a session, or undefined when an extension tool name conflicts.
1073
+ * Extension tools are active only in coding profiles, but a conflict fails closed in both,
1074
+ * so an extension can never shadow a built-in or granted tool.
1075
+ */
1076
+ export function composeSessionTools(
1077
+ permissionProfile: PermissionProfile,
1078
+ mcpToolNames: readonly string[],
1079
+ extensionToolNames: readonly (readonly string[])[],
1080
+ ): string[] | undefined {
1081
+ const reserved = new Set([...PROFILE_TOOLS.coding, ...PROFILE_TOOLS["read-only"], ...mcpToolNames]);
1082
+ const extensionTools: string[] = [];
1083
+ for (const names of extensionToolNames) {
1084
+ for (const name of names) {
1085
+ if (reserved.has(name)) return undefined;
1086
+ reserved.add(name);
1087
+ extensionTools.push(name);
1088
+ }
1089
+ }
1090
+ return [...PROFILE_TOOLS[permissionProfile], ...mcpToolNames, ...(permissionProfile === "coding" ? extensionTools : [])];
1091
+ }
1092
+ ```
1093
+
1094
+ In `src/sdk-pi-adapter.ts`:
1095
+ - Delete the `PROFILE_TOOLS` constant.
1096
+ - Add `import { composeSessionTools, PROFILE_TOOLS } from "./session-tools.js";`.
1097
+ - Add `export { PROFILE_TOOLS } from "./session-tools.js";` so existing importers keep working.
1098
+
1099
+ Run: `npx vitest run test/session-tools.test.ts`
1100
+ Expected: PASS.
1101
+
1102
+ - [ ] **Step 4: Write the failing adapter tests**
1103
+
1104
+ Add to the imports of `test/sdk-pi-adapter.test.ts`:
1105
+
1106
+ ```ts
1107
+ import type { AgentSessionRuntime, AgentSessionServices, CreateAgentSessionRuntimeFactory } from "@earendil-works/pi-coding-agent";
1108
+ import { parseExtensionSpec, PiExtensionError } from "../src/extension-spec.js";
1109
+ import type { ExtensionSdk } from "../src/sdk-pi-adapter.js";
1110
+ ```
1111
+
1112
+ Append:
1113
+
1114
+ ```ts
1115
+ const fixtureExtension = parseExtensionSpec("npm:fixture-ext@1.2.3")!;
1116
+ const extensionProfile: ResolvedExecutionProfile = Object.freeze({ ...profile, alias: "with-extension", extensions: Object.freeze([fixtureExtension]) });
1117
+
1118
+ function extensionHarness(options: {
1119
+ permissionProfile?: "read-only" | "coding";
1120
+ extensionTools?: string[][];
1121
+ loadErrors?: number;
1122
+ diagnosticError?: boolean;
1123
+ activeTools?: string[];
1124
+ } = {}) {
1125
+ const permissionProfile = options.permissionProfile ?? "read-only";
1126
+ const extensionTools = options.extensionTools ?? [["fixture_tool"]];
1127
+ const expectedTools = permissionProfile === "coding" ? [...PROFILE_TOOLS.coding, ...extensionTools.flat()] : [...PROFILE_TOOLS["read-only"]];
1128
+ const fixture = runtimeFixture();
1129
+ const { session, dispose: sessionDispose } = sessionFixture();
1130
+ const bindExtensions = vi.fn(async (_bindings: unknown) => undefined);
1131
+ Object.assign(session as object, { bindExtensions, getActiveToolNames: vi.fn(() => options.activeTools ?? expectedTools) });
1132
+ const services = {
1133
+ cwd: "/hidden/workspace", agentDir: "/hidden/workspace", modelRuntime: fixture.runtime, settingsManager: {},
1134
+ diagnostics: options.diagnosticError ? [{ type: "error", message: "hidden" }] : [],
1135
+ resourceLoader: {
1136
+ getExtensions: () => ({
1137
+ extensions: extensionTools.map((names) => ({ tools: new Map(names.map((name) => [name, {}])) })),
1138
+ errors: Array.from({ length: options.loadErrors ?? 0 }, () => ({ path: "/hidden", error: "hidden" })),
1139
+ }),
1140
+ },
1141
+ } as unknown as AgentSessionServices;
1142
+ const runtimeDispose = vi.fn(async () => undefined);
1143
+ const sessionRuntime = { session, dispose: runtimeDispose } as unknown as AgentSessionRuntime;
1144
+ const createServices = vi.fn(async (_options: unknown) => services);
1145
+ const createSessionFromServices = vi.fn(async (_options: unknown) => ({ session }) as unknown as CreateAgentSessionResult);
1146
+ const createRuntime = vi.fn(async (factory: CreateAgentSessionRuntimeFactory, runtimeOptions: Parameters<CreateAgentSessionRuntimeFactory>[0]) => {
1147
+ await factory(runtimeOptions);
1148
+ return sessionRuntime;
1149
+ });
1150
+ const resolveExtension = vi.fn(async (_extension: unknown) => "/hidden/extension/fixture-ext");
1151
+ const runtimeFactory = vi.fn(async () => fixture.runtime);
1152
+ const createSession = createSessionFixture(session);
1153
+ const diagnostics: SdkPiAdapterDiagnostic[] = [];
1154
+ const adapter = new SdkPiSessionAdapter({
1155
+ modelRuntimeFactory: runtimeFactory, createSession, resolveExtension,
1156
+ extensionSdk: { createServices, createSessionFromServices, createRuntime } as unknown as ExtensionSdk,
1157
+ diagnosticSink: (diagnostic) => { diagnostics.push(diagnostic); },
1158
+ });
1159
+ const executionProfile: ResolvedExecutionProfile = { ...extensionProfile, permissionProfile };
1160
+ return {
1161
+ adapter, fixture, session, sessionDispose, bindExtensions, services, sessionRuntime, runtimeDispose, createServices,
1162
+ createSessionFromServices, createRuntime, resolveExtension, runtimeFactory, createSession, diagnostics, expectedTools,
1163
+ create: (override: ResolvedExecutionProfile = executionProfile) => adapter.create({ cwd: "/hidden/workspace", executionProfile: override }),
1164
+ };
1165
+ }
1166
+
1167
+ describe("SdkPiSessionAdapter allowlisted extensions", () => {
1168
+ beforeEach(() => vi.restoreAllMocks());
1169
+
1170
+ it("loads extensions through services before verifying the selection", async () => {
1171
+ const h = extensionHarness();
1172
+ const handle = await h.create();
1173
+ expect(h.resolveExtension).toHaveBeenCalledWith(fixtureExtension);
1174
+ expect(h.createServices).toHaveBeenCalledWith(expect.objectContaining({
1175
+ cwd: "/hidden/workspace", agentDir: "/hidden/workspace", modelRuntime: h.fixture.runtime,
1176
+ resourceLoaderOptions: expect.objectContaining({
1177
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true,
1178
+ additionalExtensionPaths: ["/hidden/extension/fixture-ext"],
1179
+ }),
1180
+ }));
1181
+ expect(h.createServices.mock.invocationCallOrder[0]!).toBeLessThan(h.fixture.getProvider.mock.invocationCallOrder[0]!);
1182
+ expect(h.createSession).not.toHaveBeenCalled();
1183
+ expect(h.createSessionFromServices).toHaveBeenCalledWith(expect.objectContaining({
1184
+ services: h.services, model: h.fixture.resolvedModel, thinkingLevel: profile.thinkingLevel, tools: PROFILE_TOOLS["read-only"],
1185
+ }));
1186
+ expect(h.bindExtensions).toHaveBeenCalledWith({ mode: "print" });
1187
+ expect(handle.appliedSelection).toEqual({ provider: profile.provider, model: profile.model, thinkingLevel: profile.thinkingLevel });
1188
+ });
1189
+
1190
+ it("gives every extension session its own model runtime", async () => {
1191
+ const h = extensionHarness();
1192
+ await Promise.all([h.create(), h.create()]);
1193
+ expect(h.runtimeFactory).toHaveBeenCalledTimes(2);
1194
+ expect(h.createServices).toHaveBeenCalledTimes(2);
1195
+ });
1196
+
1197
+ it("keeps profiles without extensions on the unchanged path", async () => {
1198
+ const h = extensionHarness();
1199
+ await h.create(profile);
1200
+ await h.create({ ...profile, extensions: [] });
1201
+ expect(h.createServices).not.toHaveBeenCalled();
1202
+ expect(h.createSession).toHaveBeenCalledTimes(2);
1203
+ expect(h.runtimeFactory).toHaveBeenCalledTimes(1);
1204
+ });
1205
+
1206
+ it("activates extension tools in coding profiles", async () => {
1207
+ const h = extensionHarness({ permissionProfile: "coding" });
1208
+ await h.create();
1209
+ expect(h.createSessionFromServices).toHaveBeenCalledWith(expect.objectContaining({ tools: [...PROFILE_TOOLS.coding, "fixture_tool"] }));
1210
+ });
1211
+
1212
+ it.each([
1213
+ ["read-only", [["read"]]],
1214
+ ["coding", [["bash"]]],
1215
+ ["coding", [["dup"], ["dup"]]],
1216
+ ] as const)("fails closed on a %s tool conflict %j", async (permissionProfile, extensionTools) => {
1217
+ const h = extensionHarness({ permissionProfile, extensionTools: extensionTools.map((names) => [...names]) });
1218
+ await expect(h.create()).rejects.toMatchObject({ code: "extension_tool_conflict" });
1219
+ expect(h.createRuntime).not.toHaveBeenCalled();
1220
+ });
1221
+
1222
+ it.each(["extension_not_installed", "extension_version_mismatch", "extension_load_failed"] as const)("reports a %s resolution failure", async (code) => {
1223
+ const h = extensionHarness();
1224
+ h.resolveExtension.mockRejectedValueOnce(new PiExtensionError(code));
1225
+ await expect(h.create()).rejects.toMatchObject({ name: "PiSessionCreationError", code });
1226
+ expect(h.diagnostics).toContainEqual(expect.objectContaining({ stage: "extension_resolve", code, extension: "fixture-ext@1.2.3" }));
1227
+ expect(JSON.stringify(h.diagnostics)).not.toContain("/hidden");
1228
+ expect(h.runtimeFactory).not.toHaveBeenCalled();
1229
+ expect(h.createServices).not.toHaveBeenCalled();
1230
+ });
1231
+
1232
+ it("classifies a foreign resolver failure as a load failure", async () => {
1233
+ const h = extensionHarness();
1234
+ h.resolveExtension.mockRejectedValueOnce(new Error("/hidden/path"));
1235
+ await expect(h.create()).rejects.toMatchObject({ code: "extension_load_failed" });
1236
+ });
1237
+
1238
+ it.each([{ loadErrors: 1 }, { diagnosticError: true }])("fails closed on extension load errors %j", async (options) => {
1239
+ const h = extensionHarness(options);
1240
+ await expect(h.create()).rejects.toMatchObject({ code: "extension_load_failed" });
1241
+ expect(h.diagnostics).toContainEqual(expect.objectContaining({ stage: "extension_load", code: "extension_load_failed" }));
1242
+ expect(h.createRuntime).not.toHaveBeenCalled();
1243
+ });
1244
+
1245
+ it("disposes the session runtime when binding fails", async () => {
1246
+ const h = extensionHarness();
1247
+ h.bindExtensions.mockRejectedValueOnce(new Error("hidden"));
1248
+ await expect(h.create()).rejects.toMatchObject({ code: "extension_load_failed" });
1249
+ expect(h.diagnostics).toContainEqual(expect.objectContaining({ stage: "extension_bind" }));
1250
+ expect(h.runtimeDispose).toHaveBeenCalledTimes(1);
1251
+ });
1252
+
1253
+ it("rejects an active tool set that differs after binding and disposes the runtime", async () => {
1254
+ const h = extensionHarness({ activeTools: [...PROFILE_TOOLS["read-only"], "fixture_tool"] });
1255
+ await expect(h.create()).rejects.toMatchObject({ code: "execution_selection_mismatch" });
1256
+ expect(h.runtimeDispose).toHaveBeenCalledTimes(1);
1257
+ });
1258
+
1259
+ it("closes through the session runtime so session_shutdown runs", async () => {
1260
+ const h = extensionHarness();
1261
+ const handle = await h.create();
1262
+ await handle.dispose();
1263
+ expect(h.runtimeDispose).toHaveBeenCalledTimes(1);
1264
+ expect(h.sessionDispose).not.toHaveBeenCalled();
1265
+ });
1266
+
1267
+ it("disposes a session runtime that resolves after the creation deadline", async () => {
1268
+ vi.useFakeTimers();
1269
+ try {
1270
+ const h = extensionHarness();
1271
+ const late = deferred<AgentSessionRuntime>();
1272
+ h.createRuntime.mockImplementationOnce(() => late.promise);
1273
+ const outcome = expect(h.create()).rejects.toMatchObject({ code: "pi_session_creation_failed" });
1274
+ await vi.advanceTimersByTimeAsync(SESSION_CREATION_TIMEOUT_MS);
1275
+ await outcome;
1276
+ late.resolve(h.sessionRuntime);
1277
+ await vi.advanceTimersByTimeAsync(0);
1278
+ expect(h.runtimeDispose).toHaveBeenCalledTimes(1);
1279
+ } finally {
1280
+ vi.useRealTimers();
1281
+ }
1282
+ });
1283
+ });
1284
+ ```
1285
+
1286
+ - [ ] **Step 5: Run them to verify they fail**
1287
+
1288
+ Run: `npx vitest run test/sdk-pi-adapter.test.ts -t "allowlisted extensions"`
1289
+ Expected: FAIL. TypeScript reports that `ExtensionSdk` is not exported, and that `resolveExtension` and `extensionSdk` are unknown options.
1290
+
1291
+ - [ ] **Step 6: Add the error codes**
1292
+
1293
+ In `src/pi-adapter.ts`, extend `PiSessionCreationErrorCode` before `"pi_session_creation_failed"`:
1294
+
1295
+ ```ts
1296
+ | "extension_not_installed"
1297
+ | "extension_version_mismatch"
1298
+ | "extension_load_failed"
1299
+ | "extension_tool_conflict"
1300
+ ```
1301
+
1302
+ Add the matching entries to `PI_SESSION_CREATION_MESSAGES`:
1303
+
1304
+ ```ts
1305
+ extension_not_installed: "Configured Pi extension is not installed",
1306
+ extension_version_mismatch: "Configured Pi extension version is not installed",
1307
+ extension_load_failed: "Configured Pi extension could not be loaded",
1308
+ extension_tool_conflict: "Configured Pi extension tools conflict",
1309
+ ```
1310
+
1311
+ - [ ] **Step 7: Restructure `SdkPiSessionAdapter` and add the extension path**
1312
+
1313
+ All edits are in `src/sdk-pi-adapter.ts`.
1314
+
1315
+ 1. Change the SDK import to:
1316
+
1317
+ ```ts
1318
+ import {
1319
+ type AgentSession,
1320
+ type AgentSessionEvent,
1321
+ type AgentSessionRuntime,
1322
+ type AgentSessionServices,
1323
+ type CreateAgentSessionOptions,
1324
+ type CreateAgentSessionResult,
1325
+ type CreateAgentSessionServicesOptions,
1326
+ createAgentSession,
1327
+ createAgentSessionFromServices,
1328
+ createAgentSessionRuntime,
1329
+ createAgentSessionServices,
1330
+ DefaultResourceLoader,
1331
+ ModelRuntime,
1332
+ type PromptOptions,
1333
+ SessionManager,
1334
+ SettingsManager,
1335
+ } from "@earendil-works/pi-coding-agent";
1336
+ ```
1337
+
1338
+ Add these imports:
1339
+
1340
+ ```ts
1341
+ import { PiExtensionError, type ExtensionSpec } from "./extension-spec.js";
1342
+ import { resolvePiExtension } from "./pi-extensions.js";
1343
+ ```
1344
+
1345
+ 2. Extend the diagnostic and the options:
1346
+
1347
+ ```ts
1348
+ export interface SdkPiAdapterDiagnostic {
1349
+ readonly level: "error";
1350
+ readonly event: "pi_session_creation_failed";
1351
+ readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind";
1352
+ readonly code: PiSessionCreationErrorCode;
1353
+ readonly executionProfile: string;
1354
+ readonly provider: string;
1355
+ readonly model: string;
1356
+ readonly thinkingLevel: ThinkingLevel;
1357
+ readonly serverAlias?: string;
1358
+ readonly toolAlias?: string;
1359
+ readonly discoveredToolCount?: number;
1360
+ /** `<name>@<version>` of the extension that failed to resolve; never a path. */
1361
+ readonly extension?: string;
1362
+ }
1363
+
1364
+ /** The Pi SDK session-lifecycle API used for profiles with extensions; injectable for tests. */
1365
+ export interface ExtensionSdk {
1366
+ readonly createServices: typeof createAgentSessionServices;
1367
+ readonly createSessionFromServices: typeof createAgentSessionFromServices;
1368
+ readonly createRuntime: typeof createAgentSessionRuntime;
1369
+ }
1370
+
1371
+ const DEFAULT_EXTENSION_SDK: ExtensionSdk = {
1372
+ createServices: createAgentSessionServices,
1373
+ createSessionFromServices: createAgentSessionFromServices,
1374
+ createRuntime: createAgentSessionRuntime,
1375
+ };
1376
+
1377
+ export interface SdkPiSessionAdapterOptions {
1378
+ readonly modelRuntimeFactory?: () => Promise<ModelRuntime>;
1379
+ readonly createSession?: CreateSession;
1380
+ readonly diagnosticSink?: (diagnostic: SdkPiAdapterDiagnostic) => void;
1381
+ readonly mcpConnectionFactory?: McpConnectionFactory;
1382
+ /** Resolves a pinned extension to its installed package directory. */
1383
+ readonly resolveExtension?: (extension: ExtensionSpec) => Promise<string>;
1384
+ readonly extensionSdk?: ExtensionSdk;
1385
+ }
1386
+ ```
1387
+
1388
+ 3. Replace the `createSessionWithDeadline` method with this module-level helper, placed after `withAuthDeadline`:
1389
+
1390
+ ```ts
1391
+ /** Races an SDK operation against the session-creation bound; a late success goes to `disposeLate`. */
1392
+ async function withCreationDeadline<T>(start: () => Promise<T>, disposeLate: (value: T) => void = () => undefined): Promise<T> {
1393
+ let operation: Promise<T>;
1394
+ try {
1395
+ operation = Promise.resolve(start());
1396
+ } catch {
1397
+ throw new SessionCreationDeadlineError();
1398
+ }
1399
+ let timedOut = false;
1400
+ // This continuation is installed immediately and owns late cleanup. Its rejection
1401
+ // branch is required even when the deadline wins the race.
1402
+ const observed = operation.then(
1403
+ (value) => {
1404
+ if (timedOut) disposeLate(value);
1405
+ return value;
1406
+ },
1407
+ (error: unknown) => { throw error; },
1408
+ );
1409
+ let timer: NodeJS.Timeout | undefined;
1410
+ const timeout = new Promise<never>((_, reject) => {
1411
+ timer = setTimeout(() => {
1412
+ timedOut = true;
1413
+ reject(new SessionCreationDeadlineError());
1414
+ }, SESSION_CREATION_TIMEOUT_MS);
1415
+ });
1416
+ try {
1417
+ return await Promise.race([observed, timeout]);
1418
+ } finally {
1419
+ if (timer) clearTimeout(timer);
1420
+ }
1421
+ }
1422
+ ```
1423
+
1424
+ 4. Share the resource-loader options:
1425
+
1426
+ ```ts
1427
+ /** Resource-loader options for every embedded session: no discovery, skills, templates or themes. */
1428
+ function phase0ResourceLoaderOptions(): NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]> {
1429
+ return {
1430
+ noExtensions: true,
1431
+ noSkills: true,
1432
+ noPromptTemplates: true,
1433
+ noThemes: true,
1434
+ noContextFiles: false,
1435
+ systemPrompt: "phase-0-system-prompt-disabled",
1436
+ appendSystemPrompt: [],
1437
+ systemPromptOverride: () => undefined,
1438
+ appendSystemPromptOverride: () => [],
1439
+ };
1440
+ }
1441
+ ```
1442
+
1443
+ Change `createPhase0ResourceLoader` to:
1444
+
1445
+ ```ts
1446
+ const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
1447
+ ```
1448
+
1449
+ 5. Let `SdkHandle` dispose through an injected function. Change the constructor to:
1450
+
1451
+ ```ts
1452
+ constructor(
1453
+ private readonly session: AgentSession,
1454
+ readonly appliedSelection: PiAppliedSelection,
1455
+ private readonly mcpTools: SessionMcpTools,
1456
+ private readonly disposeSession: () => void | Promise<void> = () => disposeSdkSession(session),
1457
+ ) {}
1458
+ ```
1459
+
1460
+ In `dispose()`, replace the two lines `disposeSdkSession(this.session);` and `void closed.then(finish, finish);` with:
1461
+
1462
+ ```ts
1463
+ let sessionClosed: Promise<void>;
1464
+ try { sessionClosed = Promise.resolve(this.disposeSession()).then(() => undefined, () => undefined); }
1465
+ catch { sessionClosed = Promise.resolve(); }
1466
+ void Promise.all([closed.then(() => undefined, () => undefined), sessionClosed]).then(finish, finish);
1467
+ ```
1468
+
1469
+ 6. In the class:
1470
+ - Add the fields `private readonly resolveExtension: (extension: ExtensionSpec) => Promise<string>;` and `private readonly extensionSdk: ExtensionSdk;`.
1471
+ - Initialize them in the constructor:
1472
+
1473
+ ```ts
1474
+ this.resolveExtension = options.resolveExtension ?? ((extension) => resolvePiExtension(extension));
1475
+ this.extensionSdk = options.extensionSdk ?? DEFAULT_EXTENSION_SDK;
1476
+ ```
1477
+
1478
+ 7. Replace the whole `create` method with:
1479
+
1480
+ ```ts
1481
+ async create(input: { cwd: string; executionProfile: ResolvedExecutionProfile; signal?: AbortSignal }): Promise<PiSessionHandle> {
1482
+ const extensions = input.executionProfile.extensions ?? [];
1483
+ if (extensions.length > 0) return this.createWithExtensions(input, extensions);
1484
+ const { executionProfile } = input;
1485
+ let runtime: ModelRuntime;
1486
+ try {
1487
+ runtime = await this.getRuntime();
1488
+ } catch {
1489
+ throw this.failure(executionProfile, "runtime", "pi_session_creation_failed");
1490
+ }
1491
+ const model = await this.resolveVerifiedModel(runtime, executionProfile);
1492
+
1493
+ let resourceLoader: DefaultResourceLoader;
1494
+ let settingsManager: SettingsManager;
1495
+ try {
1496
+ ({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
1497
+ } catch {
1498
+ throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
1499
+ }
1500
+
1501
+ const mcpTools = await this.connectMcpTools(input);
1502
+ const tools = [...PROFILE_TOOLS[executionProfile.permissionProfile], ...mcpTools.tools.map((tool) => tool.name)];
1503
+ let session: AgentSession;
1504
+ try {
1505
+ ({ session } = await withCreationDeadline(
1506
+ () => this.createSession({
1507
+ cwd: input.cwd,
1508
+ modelRuntime: runtime,
1509
+ model,
1510
+ thinkingLevel: executionProfile.thinkingLevel,
1511
+ sessionManager: SessionManager.inMemory(input.cwd),
1512
+ settingsManager,
1513
+ resourceLoader,
1514
+ // With an explicit tools list, Pi also needs custom tool names here to activate them.
1515
+ tools,
1516
+ ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
1517
+ }),
1518
+ (late) => disposeSdkSession(late.session),
1519
+ ));
1520
+ } catch {
1521
+ await mcpTools.close();
1522
+ throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
1523
+ }
1524
+ return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session),
1525
+ mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined);
1526
+ }
1527
+
1528
+ private async createWithExtensions(
1529
+ input: { cwd: string; executionProfile: ResolvedExecutionProfile; signal?: AbortSignal },
1530
+ extensions: readonly ExtensionSpec[],
1531
+ ): Promise<PiSessionHandle> {
1532
+ const { cwd, executionProfile } = input;
1533
+ const directories: string[] = [];
1534
+ for (const extension of extensions) {
1535
+ try {
1536
+ directories.push(await this.resolveExtension(extension));
1537
+ } catch (error) {
1538
+ throw this.failure(executionProfile, "extension_resolve",
1539
+ error instanceof PiExtensionError ? error.code : "extension_load_failed",
1540
+ { extension: `${extension.name}@${extension.version}` });
1541
+ }
1542
+ }
1543
+ // Extension providers register into the runtime they load with, and the last
1544
+ // registration wins, so every extension session gets a runtime of its own.
1545
+ let runtime: ModelRuntime;
1546
+ try {
1547
+ runtime = await this.modelRuntimeFactory();
1548
+ } catch {
1549
+ throw this.failure(executionProfile, "runtime", "pi_session_creation_failed");
1550
+ }
1551
+ let services: AgentSessionServices;
1552
+ let extensionTools: string[][];
1553
+ try {
1554
+ services = await withCreationDeadline(() => this.extensionSdk.createServices({
1555
+ cwd,
1556
+ agentDir: cwd,
1557
+ modelRuntime: runtime,
1558
+ settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
1559
+ resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
1560
+ }));
1561
+ const loaded = services.resourceLoader.getExtensions();
1562
+ if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
1563
+ throw new Error("extension load reported errors");
1564
+ }
1565
+ extensionTools = loaded.extensions.map((extension) => [...extension.tools.keys()]);
1566
+ } catch {
1567
+ throw this.failure(executionProfile, "extension_load", "extension_load_failed");
1568
+ }
1569
+ const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
1570
+ const mcpTools = await this.connectMcpTools(input);
1571
+ const tools = composeSessionTools(executionProfile.permissionProfile, mcpTools.tools.map((tool) => tool.name), extensionTools);
1572
+ if (!tools) {
1573
+ await mcpTools.close();
1574
+ throw this.failure(executionProfile, "extension_load", "extension_tool_conflict");
1575
+ }
1576
+ let sessionRuntime: AgentSessionRuntime;
1577
+ try {
1578
+ sessionRuntime = await withCreationDeadline(
1579
+ () => this.extensionSdk.createRuntime(async ({ sessionManager }) => ({
1580
+ ...(await this.extensionSdk.createSessionFromServices({
1581
+ services,
1582
+ sessionManager,
1583
+ model,
1584
+ thinkingLevel: executionProfile.thinkingLevel,
1585
+ tools,
1586
+ ...(mcpTools.tools.length === 0 ? {} : { customTools: mcpTools.tools }),
1587
+ })),
1588
+ services,
1589
+ diagnostics: services.diagnostics,
1590
+ }), { cwd, agentDir: cwd, sessionManager: SessionManager.inMemory(cwd) }),
1591
+ (late) => { void late.dispose().catch(() => undefined); },
1592
+ );
1593
+ } catch {
1594
+ await mcpTools.close();
1595
+ throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
1596
+ }
1597
+ // `AgentSessionRuntime.dispose()` emits `session_shutdown` before disposing the session.
1598
+ const disposeRuntime = (): Promise<void> => sessionRuntime.dispose().catch(() => undefined);
1599
+ try {
1600
+ // Without a UI context the SDK binds its no-op UI; binding emits `session_start`.
1601
+ await withCreationDeadline(() => sessionRuntime.session.bindExtensions({ mode: "print" }));
1602
+ } catch {
1603
+ await disposeRuntime();
1604
+ await mcpTools.close();
1605
+ throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
1606
+ }
1607
+ return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime,
1608
+ mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch");
1609
+ }
1610
+
1611
+ /** Provider, model, authentication and availability, exactly as configured. */
1612
+ private async resolveVerifiedModel(runtime: ModelRuntime, executionProfile: ResolvedExecutionProfile): Promise<SdkModel> {
1613
+ let provider: ReturnType<ModelRuntime["getProvider"]>;
1614
+ try {
1615
+ provider = runtime.getProvider(executionProfile.provider);
1616
+ } catch {
1617
+ throw this.failure(executionProfile, "provider", "pi_session_creation_failed");
1618
+ }
1619
+ if (!provider) throw this.failure(executionProfile, "provider", "unknown_provider");
1620
+
1621
+ let model: SdkModel | undefined;
1622
+ try {
1623
+ model = runtime.getModel(executionProfile.provider, executionProfile.model);
1624
+ } catch {
1625
+ throw this.failure(executionProfile, "model", "pi_session_creation_failed");
1626
+ }
1627
+ if (!model) throw this.failure(executionProfile, "model", "unknown_model");
1628
+
1629
+ let authenticated: boolean;
1630
+ try {
1631
+ authenticated = Boolean(await withAuthDeadline((signal) => runtime.checkAuth(executionProfile.provider, { signal })));
1632
+ } catch {
1633
+ throw this.failure(executionProfile, "check_auth", "local_authentication_unavailable");
1634
+ }
1635
+ if (!authenticated) throw this.failure(executionProfile, "check_auth", "local_authentication_missing");
1636
+
1637
+ try {
1638
+ // AuthResult can contain credentials, so reduce it immediately.
1639
+ authenticated = Boolean(await withAuthDeadline((signal) => runtime.getAuth(model, { signal })));
1640
+ } catch {
1641
+ throw this.failure(executionProfile, "get_auth", "local_authentication_unavailable");
1642
+ }
1643
+ if (!authenticated) throw this.failure(executionProfile, "get_auth", "local_authentication_missing");
1644
+
1645
+ let available: readonly SdkModel[];
1646
+ try {
1647
+ available = await withAuthDeadline((signal) => runtime.getAvailable(executionProfile.provider, { signal }));
1648
+ } catch {
1649
+ throw this.failure(executionProfile, "availability", "pi_session_creation_failed");
1650
+ }
1651
+ if (!available.some((candidate) => candidate.provider === executionProfile.provider && candidate.id === executionProfile.model)) {
1652
+ throw this.failure(executionProfile, "availability", "model_unavailable");
1653
+ }
1654
+ return model;
1655
+ }
1656
+
1657
+ private async connectMcpTools(input: { cwd: string; executionProfile: ResolvedExecutionProfile; signal?: AbortSignal }): Promise<SessionMcpTools> {
1658
+ try {
1659
+ return await connectSessionMcpTools(
1660
+ input.executionProfile.mcpServers ?? {}, input.executionProfile.permissionProfile, input.cwd, this.mcpConnectionFactory,
1661
+ input.signal === undefined ? {} : { signal: input.signal },
1662
+ );
1663
+ } catch (error) {
1664
+ const failure = classifyMcpStartupError(error);
1665
+ throw this.failure(input.executionProfile, "mcp_tools", failure?.code ?? "external_mcp_unavailable", failure);
1666
+ }
1667
+ }
1668
+
1669
+ /** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
1670
+ private async admit(
1671
+ session: AgentSession,
1672
+ executionProfile: ResolvedExecutionProfile,
1673
+ tools: readonly string[],
1674
+ mcpTools: SessionMcpTools,
1675
+ disposeSession: () => void | Promise<void>,
1676
+ toolMismatch: PiSessionCreationErrorCode | undefined,
1677
+ ): Promise<PiSessionHandle> {
1678
+ try {
1679
+ if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
1680
+ throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
1681
+ }
1682
+ if (session.thinkingLevel !== executionProfile.thinkingLevel) {
1683
+ throw this.failure(executionProfile, "postcheck", "thinking_level_unavailable");
1684
+ }
1685
+ if (toolMismatch !== undefined) {
1686
+ const active = new Set(session.getActiveToolNames());
1687
+ if (active.size !== tools.length || tools.some((name) => !active.has(name))) {
1688
+ throw this.failure(executionProfile, "postcheck", toolMismatch);
1689
+ }
1690
+ }
1691
+ return new SdkHandle(session, {
1692
+ provider: session.model.provider,
1693
+ model: session.model.id,
1694
+ thinkingLevel: session.thinkingLevel,
1695
+ }, mcpTools, disposeSession);
1696
+ } catch (error) {
1697
+ try { await disposeSession(); } catch { /* Disposal is best effort. */ }
1698
+ await mcpTools.close();
1699
+ if (error instanceof PiSessionCreationError) throw error;
1700
+ throw this.failure(executionProfile, "postcheck", "pi_session_creation_failed");
1701
+ }
1702
+ }
1703
+ ```
1704
+
1705
+ 8. Extend `failure()` so it can carry the extension:
1706
+
1707
+ ```ts
1708
+ private failure(
1709
+ executionProfile: ResolvedExecutionProfile,
1710
+ stage: SdkPiAdapterDiagnostic["stage"],
1711
+ code: PiSessionCreationErrorCode,
1712
+ metadata?: Pick<McpStartupFailure, "serverAlias" | "toolAlias" | "discoveredToolCount"> & { readonly extension?: string },
1713
+ ): PiSessionCreationError {
1714
+ ```
1715
+
1716
+ Inside its diagnostic object, add after the `discoveredToolCount` spread:
1717
+
1718
+ ```ts
1719
+ ...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
1720
+ ```
1721
+
1722
+ - [ ] **Step 8: Run the adapter tests to verify they pass**
1723
+
1724
+ Run: `npx vitest run test/sdk-pi-adapter.test.ts test/session-tools.test.ts`
1725
+ Expected: PASS. That includes every pre-existing test in `test/sdk-pi-adapter.test.ts`; the restructuring must not change their behavior.
1726
+
1727
+ - [ ] **Step 9: Document the error codes**
1728
+
1729
+ In `docs/tool-contracts.md`, add these rows to the error table before `pi_session_creation_failed`:
1730
+
1731
+ ```markdown
1732
+ | `extension_not_installed` | Pi has no installed package for a configured extension. |
1733
+ | `extension_version_mismatch` | Pi's installed extension package has another version than the configured one. |
1734
+ | `extension_load_failed` | A configured extension has an invalid manifest, failed to load or failed to bind. |
1735
+ | `extension_tool_conflict` | An extension tool name collides with a built-in, MCP or other extension tool. |
1736
+ ```
1737
+
1738
+ - [ ] **Step 10: Run the full gate**
1739
+
1740
+ Run: `npm run typecheck && npx vitest run`
1741
+ Expected: PASS.
1742
+
1743
+ - [ ] **Step 11: Commit**
1744
+
1745
+ ```bash
1746
+ git add src/session-tools.ts src/pi-adapter.ts src/sdk-pi-adapter.ts test/session-tools.test.ts test/sdk-pi-adapter.test.ts docs/tool-contracts.md
1747
+ git commit -m "feat: load allowlisted Pi extensions per session with their own runtime
1748
+
1749
+ Refs #34
1750
+
1751
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1752
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
1753
+ ```
1754
+
1755
+ ---
1756
+
1757
+ ### Task 7: SDK contract with a real fixture extension
1758
+
1759
+ **Files:**
1760
+ - Create:
1761
+ - `test/fixtures/pi-extension-fixture/package.json`
1762
+ - `test/fixtures/pi-extension-fixture/index.js`
1763
+ - Test: `test/sdk-extension-contract.test.ts`
1764
+
1765
+ **Interfaces:**
1766
+ - Consumes:
1767
+ - Task 2: `resolvePiExtension(extension, { agentDir })`.
1768
+ - Task 6: `SdkPiSessionAdapter` options `resolveExtension` and `modelRuntimeFactory`.
1769
+ - Produces: the fixture extension `pi-extension-fixture@1.0.0`, reused by Task 8.
1770
+ - Provider `fixture-ext` with model `fixture-ext-model`. Every turn answers `FIXTURE-PONG`.
1771
+ - Tool `fixture_tool` and command `fixture-cmd`.
1772
+ - Events go to `globalThis.__piExtensionFixtureEvents`. When `PI_EXTENSION_FIXTURE_LOG` is set, they are also appended to that file as JSON lines `{"event": …}`.
1773
+ - Event names:
1774
+ - `load`, `session_start`, `stream`, `session_shutdown`
1775
+ - `tool`, `command`
1776
+ - `tools:<sorted declared tool names joined by ,>`
1777
+
1778
+ - [ ] **Step 1: Create the fixture extension**
1779
+
1780
+ `test/fixtures/pi-extension-fixture/package.json`:
1781
+
1782
+ ```json
1783
+ {
1784
+ "name": "pi-extension-fixture",
1785
+ "version": "1.0.0",
1786
+ "private": true,
1787
+ "type": "module",
1788
+ "pi": { "extensions": ["./index.js"] }
1789
+ }
1790
+ ```
1791
+
1792
+ `test/fixtures/pi-extension-fixture/index.js`:
1793
+
1794
+ ```js
1795
+ import { appendFileSync } from "node:fs";
1796
+
1797
+ /** Test-only Pi extension: a network-free provider plus probes for tools, commands, stdout and lifecycle. */
1798
+ const events = (globalThis.__piExtensionFixtureEvents ??= []);
1799
+
1800
+ function record(event) {
1801
+ events.push(event);
1802
+ const log = process.env.PI_EXTENSION_FIXTURE_LOG;
1803
+ if (log) appendFileSync(log, `${JSON.stringify({ event })}\n`);
1804
+ }
1805
+
1806
+ function declaredTools(messages = []) {
1807
+ const names = new Set();
1808
+ for (const message of messages) {
1809
+ if (message.role !== "system") continue;
1810
+ for (const tool of message.toolsAdded ?? []) names.add(tool.name);
1811
+ for (const tool of message.toolsRemoved ?? []) names.delete(typeof tool === "string" ? tool : tool.name);
1812
+ }
1813
+ return [...names].sort();
1814
+ }
1815
+
1816
+ function assistant(text) {
1817
+ return {
1818
+ role: "assistant", content: [{ type: "text", text }], api: "fixture-ext-api", provider: "fixture-ext", model: "fixture-ext-model",
1819
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, total: 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
1820
+ stopReason: "stop", timestamp: Date.now(),
1821
+ };
1822
+ }
1823
+
1824
+ function streamOf(final) {
1825
+ let sent = false;
1826
+ return {
1827
+ [Symbol.asyncIterator]() {
1828
+ return {
1829
+ async next() {
1830
+ if (sent) return { value: undefined, done: true };
1831
+ sent = true;
1832
+ return { value: { type: "start", partial: final }, done: false };
1833
+ },
1834
+ };
1835
+ },
1836
+ async result() { return final; },
1837
+ };
1838
+ }
1839
+
1840
+ export default function fixtureExtension(pi) {
1841
+ record("load");
1842
+ console.log("pi-extension-fixture: console.log at load");
1843
+ process.stdout.write("pi-extension-fixture: raw stdout at load\n");
1844
+ pi.registerProvider("fixture-ext", {
1845
+ name: "Fixture extension provider", baseUrl: "http://127.0.0.1:9", apiKey: "fixture-placeholder", api: "fixture-ext-api",
1846
+ models: [{ id: "fixture-ext-model", name: "Fixture", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }],
1847
+ streamSimple: (_model, context) => {
1848
+ record("stream");
1849
+ record(`tools:${declaredTools(context?.messages).join(",")}`);
1850
+ console.log("pi-extension-fixture: console.log during a turn");
1851
+ return streamOf(assistant("FIXTURE-PONG"));
1852
+ },
1853
+ });
1854
+ pi.registerTool({
1855
+ name: "fixture_tool", label: "Fixture tool", description: "Records a call.", parameters: { type: "object", properties: {} },
1856
+ execute: async () => { record("tool"); return { content: [{ type: "text", text: "fixture" }], details: {} }; },
1857
+ });
1858
+ pi.registerCommand("fixture-cmd", { description: "Records a command.", handler: async () => { record("command"); } });
1859
+ pi.on("session_start", async () => {
1860
+ record("session_start");
1861
+ // Tries to widen the tool set past the session allowlist.
1862
+ pi.setActiveTools([...pi.getActiveTools(), "fixture_tool"]);
1863
+ });
1864
+ pi.on("session_shutdown", async () => { record("session_shutdown"); });
1865
+ }
1866
+ ```
1867
+
1868
+ - [ ] **Step 2: Write the contract test**
1869
+
1870
+ Create `test/sdk-extension-contract.test.ts`:
1871
+
1872
+ ```ts
1873
+ /**
1874
+ * Pi SDK contract for allowlisted extensions, against the installed SDK and the local
1875
+ * fixture extension. No provider, credentials or network: the fixture provider answers
1876
+ * from memory. When an SDK upgrade breaks an assumption, the failing test names it.
1877
+ */
1878
+ import { cp, mkdir } from "node:fs/promises";
1879
+ import { join } from "node:path";
1880
+ import { setTimeout as waitFor } from "node:timers/promises";
1881
+ import { fileURLToPath } from "node:url";
1882
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
1883
+ import { afterAll, beforeEach, describe, expect, it } from "vitest";
1884
+ import { parseExtensionSpec } from "../src/extension-spec.js";
1885
+ import type { PermissionProfile, ResolvedExecutionProfile } from "../src/execution-profile.js";
1886
+ import { resolvePiExtension } from "../src/pi-extensions.js";
1887
+ import { SdkPiSessionAdapter } from "../src/sdk-pi-adapter.js";
1888
+ import { SessionRegistry, type TurnView } from "../src/session-registry.js";
1889
+ import { PROFILE_TOOLS } from "../src/session-tools.js";
1890
+ import { removeTemporaryRoots, temporaryRoot } from "./temporary-roots.js";
1891
+
1892
+ const FIXTURE = fileURLToPath(new URL("./fixtures/pi-extension-fixture", import.meta.url));
1893
+ const extension = parseExtensionSpec("npm:pi-extension-fixture@1.0.0")!;
1894
+ const events = (): string[] => ((globalThis as { __piExtensionFixtureEvents?: string[] }).__piExtensionFixtureEvents ??= []);
1895
+
1896
+ afterAll(removeTemporaryRoots);
1897
+ beforeEach(() => { events().length = 0; });
1898
+
1899
+ async function harness(permissionProfile: PermissionProfile) {
1900
+ const root = await temporaryRoot("pi-session-mcp-extension-contract-");
1901
+ const agentDir = join(root, "agent");
1902
+ const workspace = join(root, "workspace");
1903
+ await mkdir(join(agentDir, "npm", "node_modules"), { recursive: true });
1904
+ await mkdir(workspace);
1905
+ await cp(FIXTURE, join(agentDir, "npm", "node_modules", "pi-extension-fixture"), { recursive: true });
1906
+ const adapter = new SdkPiSessionAdapter({
1907
+ modelRuntimeFactory: () => ModelRuntime.create({
1908
+ refreshOnCreate: false, allowModelNetwork: false, modelsPath: null,
1909
+ authPath: join(agentDir, "auth.json"), modelsStorePath: join(agentDir, "models-store.json"),
1910
+ }),
1911
+ resolveExtension: (spec) => resolvePiExtension(spec, { agentDir }),
1912
+ });
1913
+ const registry = new SessionRegistry(adapter, new Map([["repo", workspace]]));
1914
+ const executionProfile: ResolvedExecutionProfile = {
1915
+ alias: "fixture", permissionProfile, provider: "fixture-ext", model: "fixture-ext-model", thinkingLevel: "off", extensions: [extension],
1916
+ };
1917
+ return { registry, executionProfile };
1918
+ }
1919
+
1920
+ async function terminal(registry: SessionRegistry, sessionId: string, turnId: string): Promise<TurnView> {
1921
+ let turn = registry.getTurn(sessionId, turnId);
1922
+ const deadline = Date.now() + 20_000;
1923
+ while (turn.state === "running" && Date.now() < deadline) {
1924
+ await waitFor(5);
1925
+ turn = registry.getTurn(sessionId, turnId);
1926
+ }
1927
+ return turn;
1928
+ }
1929
+
1930
+ describe("Pi SDK contract for allowlisted extensions", () => {
1931
+ it("registers the extension provider before verification and completes a prompt from it", async () => {
1932
+ const { registry, executionProfile } = await harness("read-only");
1933
+ const session = await registry.start("repo", executionProfile);
1934
+ try {
1935
+ expect(session).toMatchObject({ provider: "fixture-ext", model: "fixture-ext-model", state: "idle" });
1936
+ const accepted = await registry.prompt(session.id, "hello");
1937
+ expect(await terminal(registry, session.id, accepted.turn.turnId)).toMatchObject({ state: "completed", assistantText: "FIXTURE-PONG" });
1938
+ expect(events()).toEqual(expect.arrayContaining(["load", "session_start", "stream"]));
1939
+ } finally {
1940
+ await registry.close(session.id);
1941
+ }
1942
+ }, 30_000);
1943
+
1944
+ it("keeps extension tools away from the model in read-only profiles even against setActiveTools", async () => {
1945
+ const { registry, executionProfile } = await harness("read-only");
1946
+ const session = await registry.start("repo", executionProfile);
1947
+ try {
1948
+ const accepted = await registry.prompt(session.id, "list the tools");
1949
+ await terminal(registry, session.id, accepted.turn.turnId);
1950
+ expect(events()).toContain(`tools:${[...PROFILE_TOOLS["read-only"]].sort().join(",")}`);
1951
+ } finally {
1952
+ await registry.close(session.id);
1953
+ }
1954
+ }, 30_000);
1955
+
1956
+ it("declares extension tools to the model in coding profiles", async () => {
1957
+ const { registry, executionProfile } = await harness("coding");
1958
+ const session = await registry.start("repo", executionProfile);
1959
+ try {
1960
+ const accepted = await registry.prompt(session.id, "list the tools");
1961
+ await terminal(registry, session.id, accepted.turn.turnId);
1962
+ expect(events()).toContain(`tools:${[...PROFILE_TOOLS.coding, "fixture_tool"].sort().join(",")}`);
1963
+ } finally {
1964
+ await registry.close(session.id);
1965
+ }
1966
+ }, 30_000);
1967
+
1968
+ it("sends slash-command text to the model instead of running the command", async () => {
1969
+ const { registry, executionProfile } = await harness("read-only");
1970
+ const session = await registry.start("repo", executionProfile);
1971
+ try {
1972
+ const accepted = await registry.prompt(session.id, "/fixture-cmd now");
1973
+ expect(await terminal(registry, session.id, accepted.turn.turnId)).toMatchObject({ state: "completed" });
1974
+ expect(events()).not.toContain("command");
1975
+ expect(events()).toContain("stream");
1976
+ } finally {
1977
+ await registry.close(session.id);
1978
+ }
1979
+ }, 30_000);
1980
+
1981
+ it("runs session_shutdown when the session closes", async () => {
1982
+ const { registry, executionProfile } = await harness("read-only");
1983
+ const session = await registry.start("repo", executionProfile);
1984
+ await registry.close(session.id);
1985
+ expect(events()).toContain("session_shutdown");
1986
+ }, 30_000);
1987
+ });
1988
+ ```
1989
+
1990
+ - [ ] **Step 3: Run it**
1991
+
1992
+ Run: `npx vitest run test/sdk-extension-contract.test.ts`
1993
+ Expected: PASS. It runs against the implementation from Tasks 1-6, which a throwaway spike had already checked against SDK 1.1.0. If an assertion fails, the SDK contradicts the spec:
1994
+ - Stop and report the failing assertion verbatim.
1995
+ - Do not weaken the assertion.
1996
+ - Do not patch around it.
1997
+
1998
+ - [ ] **Step 4: Run the full gate**
1999
+
2000
+ Run: `npm run typecheck && npx vitest run`
2001
+ Expected: PASS.
2002
+
2003
+ - [ ] **Step 5: Commit**
2004
+
2005
+ ```bash
2006
+ git add test/fixtures/pi-extension-fixture test/sdk-extension-contract.test.ts
2007
+ git commit -m "test: pin the Pi SDK contract for allowlisted extensions
2008
+
2009
+ Refs #34
2010
+
2011
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2012
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
2013
+ ```
2014
+
2015
+ ---
2016
+
2017
+ ### Task 8: Process-level evidence for the stdout guard with an extension
2018
+
2019
+ **Files:**
2020
+ - Create:
2021
+ - `test/compiled-entry.ts`
2022
+ - `test/process-extension.test.ts`
2023
+ - Modify: `test/process-stdin-eof.test.ts:50-64` (use the shared compile helper)
2024
+
2025
+ **Interfaces:**
2026
+ - Consumes:
2027
+ - Task 4: the guard, through the real `main.js`.
2028
+ - Task 6: the extension path.
2029
+ - Task 7: the fixture extension.
2030
+ - Produces: `function compileServerEntry(): Promise<string>`, which returns the absolute path of a freshly compiled `main.js`.
2031
+
2032
+ - [ ] **Step 1: Extract the compile helper**
2033
+
2034
+ Create `test/compiled-entry.ts`:
2035
+
2036
+ ```ts
2037
+ import { execFile } from "node:child_process";
2038
+ import { symlink } from "node:fs/promises";
2039
+ import { join } from "node:path";
2040
+ import { fileURLToPath } from "node:url";
2041
+ import { promisify } from "node:util";
2042
+ import { temporaryRoot } from "./temporary-roots.js";
2043
+
2044
+ const REPOSITORY = fileURLToPath(new URL("..", import.meta.url));
2045
+ const execFileAsync = promisify(execFile);
2046
+
2047
+ /**
2048
+ * Compiles the current sources into a temporary directory and returns the built `main.js`.
2049
+ * CI runs the tests before `npm run build`, so an existing `dist/` may be stale.
2050
+ */
2051
+ export async function compileServerEntry(): Promise<string> {
2052
+ const root = await temporaryRoot("pi-session-mcp-entry-build-");
2053
+ // The compiled entry imports `../package.json` and the installed packages.
2054
+ await symlink(join(REPOSITORY, "package.json"), join(root, "package.json"));
2055
+ await symlink(join(REPOSITORY, "node_modules"), join(root, "node_modules"), "dir");
2056
+ await execFileAsync(process.execPath, [
2057
+ join(REPOSITORY, "node_modules", "typescript", "bin", "tsc"),
2058
+ "-p", join(REPOSITORY, "tsconfig.build.json"), "--outDir", join(root, "out"), "--declaration", "false",
2059
+ ], { cwd: REPOSITORY, timeout: 120_000 });
2060
+ return join(root, "out", "main.js");
2061
+ }
2062
+ ```
2063
+
2064
+ In `test/process-stdin-eof.test.ts`:
2065
+ - Replace the body of `beforeAll` with `entry = await compileServerEntry();`.
2066
+ - Keep the `150_000` timeout.
2067
+ - Add `import { compileServerEntry } from "./compiled-entry.js";`.
2068
+ - Remove imports that are now unused (`symlink`, `promisify`, `execFile`, `execFileAsync`), but only if nothing else in the file uses them.
2069
+
2070
+ Run: `npx vitest run test/process-stdin-eof.test.ts`
2071
+ Expected: PASS, unchanged.
2072
+
2073
+ - [ ] **Step 2: Write the process-level test**
2074
+
2075
+ Create `test/process-extension.test.ts`:
2076
+
2077
+ ```ts
2078
+ /**
2079
+ * Process-level evidence for allowlisted Pi extensions and the stdout guard.
2080
+ *
2081
+ * Starts the compiled `main.js` with a temporary Pi agent directory that contains the
2082
+ * fixture extension. The fixture writes to stdout at load and during a turn; every
2083
+ * stdout line must still be an MCP frame. No provider, credentials or network.
2084
+ */
2085
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2086
+ import { cp, mkdir, writeFile } from "node:fs/promises";
2087
+ import { join } from "node:path";
2088
+ import { createInterface } from "node:readline";
2089
+ import { setTimeout as waitFor } from "node:timers/promises";
2090
+ import { fileURLToPath } from "node:url";
2091
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2092
+ import { compileServerEntry } from "./compiled-entry.js";
2093
+ import { readFixtureLog } from "./fixture-log.js";
2094
+ import { removeTemporaryRoots, temporaryRoot } from "./temporary-roots.js";
2095
+
2096
+ const FIXTURE = fileURLToPath(new URL("./fixtures/pi-extension-fixture", import.meta.url));
2097
+ let entry = "";
2098
+ const servers: ChildProcessWithoutNullStreams[] = [];
2099
+
2100
+ beforeAll(async () => { entry = await compileServerEntry(); }, 150_000);
2101
+ afterEach(() => {
2102
+ for (const server of servers.splice(0)) if (server.exitCode === null && server.signalCode === null) server.kill("SIGKILL");
2103
+ });
2104
+ afterAll(removeTemporaryRoots);
2105
+
2106
+ interface ToolResult { structuredContent?: Record<string, unknown> }
2107
+
2108
+ async function startServer() {
2109
+ const root = await temporaryRoot("pi-session-mcp-extension-process-");
2110
+ const agentDir = join(root, "agent");
2111
+ const workspace = join(root, "workspace");
2112
+ await mkdir(join(agentDir, "npm", "node_modules"), { recursive: true });
2113
+ await mkdir(workspace);
2114
+ await cp(FIXTURE, join(agentDir, "npm", "node_modules", "pi-extension-fixture"), { recursive: true });
2115
+ const config = join(root, "config.json");
2116
+ await writeFile(config, JSON.stringify({
2117
+ workspaces: { project: workspace },
2118
+ executionProfiles: {
2119
+ safe: {
2120
+ default: true, permissionProfile: "read-only", provider: "fixture-ext", model: "fixture-ext-model", thinkingLevel: "off",
2121
+ extensions: ["npm:pi-extension-fixture@1.0.0"],
2122
+ },
2123
+ },
2124
+ }));
2125
+ const eventLog = join(root, "extension.jsonl");
2126
+ const server = spawn(process.execPath, [entry], {
2127
+ cwd: workspace,
2128
+ env: { PATH: process.env.PATH ?? "", HOME: root, PI_CODING_AGENT_DIR: agentDir, PI_SESSION_MCP_CONFIG: config, PI_EXTENSION_FIXTURE_LOG: eventLog },
2129
+ stdio: ["pipe", "pipe", "pipe"],
2130
+ });
2131
+ servers.push(server);
2132
+ let stderr = "";
2133
+ server.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; });
2134
+ server.stdin.on("error", () => undefined);
2135
+ const foreignLines: string[] = [];
2136
+ const pending = new Map<number, (message: { result?: ToolResult }) => void>();
2137
+ createInterface({ input: server.stdout }).on("line", (line) => {
2138
+ let message: { id?: unknown; result?: ToolResult };
2139
+ try { message = JSON.parse(line) as typeof message; } catch { foreignLines.push(line); return; }
2140
+ if (typeof message.id === "number") pending.get(message.id)?.(message);
2141
+ });
2142
+ let nextId = 1;
2143
+ const send = (message: Record<string, unknown>) => { server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); };
2144
+ const request = (method: string, params: Record<string, unknown>) => new Promise<{ result?: ToolResult }>((resolve) => {
2145
+ const id = nextId++;
2146
+ pending.set(id, resolve);
2147
+ send({ id, method, params });
2148
+ });
2149
+ await request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "extension-test", version: "0.0.0" } });
2150
+ send({ method: "notifications/initialized" });
2151
+ return {
2152
+ eventLog,
2153
+ foreignLines,
2154
+ diagnostics: () => stderr.split("\n").filter((line) => line.startsWith("{")).map((line) => JSON.parse(line) as Record<string, unknown>),
2155
+ callTool: async (name: string, args: Record<string, unknown>) => (await request("tools/call", { name, arguments: args })).result?.structuredContent ?? {},
2156
+ };
2157
+ }
2158
+
2159
+ describe("allowlisted Pi extension in the server process", () => {
2160
+ it("keeps stdout to MCP frames while an extension writes to it", async () => {
2161
+ const harness = await startServer();
2162
+ expect(await harness.callTool("pi_capabilities_get", {})).toMatchObject({
2163
+ executionProfiles: [{ alias: "safe", extensions: ["npm:pi-extension-fixture@1.0.0"] }],
2164
+ });
2165
+ const started = await harness.callTool("pi_session_start", { workspace: "project" }) as { ok: boolean; session: { id: string } };
2166
+ expect(started).toMatchObject({ ok: true, session: { provider: "fixture-ext", state: "idle" } });
2167
+ const prompted = await harness.callTool("pi_session_prompt", { sessionId: started.session.id, prompt: "/fixture-cmd hello" }) as { turn: { turnId: string } };
2168
+ let turn: Record<string, unknown> = {};
2169
+ for (let attempt = 0; attempt < 400; attempt++) {
2170
+ turn = (await harness.callTool("pi_turn_get", { sessionId: started.session.id, turnId: prompted.turn.turnId }) as { turn: Record<string, unknown> }).turn;
2171
+ if (turn.state !== "running") break;
2172
+ await waitFor(25);
2173
+ }
2174
+ expect(turn).toMatchObject({ state: "completed", assistantText: "FIXTURE-PONG" });
2175
+ expect(await harness.callTool("pi_session_close", { sessionId: started.session.id })).toMatchObject({ ok: true });
2176
+
2177
+ const events = (await readFixtureLog<{ event: string }>(harness.eventLog)).map((record) => record.event);
2178
+ expect(events).toEqual(expect.arrayContaining(["load", "session_start", "stream", "session_shutdown"]));
2179
+ expect(events).not.toContain("command");
2180
+ expect(harness.foreignLines).toEqual([]);
2181
+ // The guard reports at most once per second.
2182
+ await waitFor(1_200);
2183
+ const suppressed = harness.diagnostics().filter((line) => line.event === "foreign_stdout_suppressed");
2184
+ expect(suppressed.length).toBeGreaterThan(0);
2185
+ expect(suppressed.every((line) => typeof line.bytes === "number" && typeof line.writes === "number")).toBe(true);
2186
+ expect(JSON.stringify(harness.diagnostics())).not.toContain("pi-extension-fixture:");
2187
+ }, 60_000);
2188
+ });
2189
+ ```
2190
+
2191
+ - [ ] **Step 3: Run it**
2192
+
2193
+ Run: `npx vitest run test/process-extension.test.ts`
2194
+ Expected: PASS. If `foreignLines` is not empty, the guard misses a write path. Fix that in `src/stdout-guard.ts`, never in the test.
2195
+
2196
+ - [ ] **Step 4: Run the full gate**
2197
+
2198
+ Run: `npm run typecheck && npx vitest run`
2199
+ Expected: PASS.
2200
+
2201
+ - [ ] **Step 5: Commit**
2202
+
2203
+ ```bash
2204
+ git add test/compiled-entry.ts test/process-extension.test.ts test/process-stdin-eof.test.ts
2205
+ git commit -m "test: prove stdout stays MCP-only with an extension in the server process
2206
+
2207
+ Refs #34
2208
+
2209
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2210
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
2211
+ ```
2212
+
2213
+ ---
2214
+
2215
+ ### Task 9: Governance and operator documentation
2216
+
2217
+ **Files:**
2218
+ - Create: `docs/adr/0006-pi-extension-allowlist.md`
2219
+ - Modify:
2220
+ - `docs/threat-model.md`
2221
+ - `docs/architecture.md` (section "Scope")
2222
+ - `docs/installation.md` (new section "Pi extensions")
2223
+ - `AGENTS.md`
2224
+ - `docs/acceptance.md`
2225
+ - `CHANGELOG.md`
2226
+
2227
+ **Interfaces:**
2228
+ - Consumes: the behavior of Tasks 1-8.
2229
+ - Produces: documentation only.
2230
+
2231
+ - [ ] **Step 1: Write ADR 0006**
2232
+
2233
+ Create `docs/adr/0006-pi-extension-allowlist.md`:
2234
+
2235
+ ```markdown
2236
+ # ADR 0006: Execution profiles may load pinned Pi extensions
2237
+
2238
+ **Status:** Accepted (2026-10-11). Issue #34. Design:
2239
+ [Pi extensions design](../pi-extensions-design.md).
2240
+
2241
+ ## Context
2242
+
2243
+ Embedded sessions loaded no Pi extensions. Providers that exist only as Pi
2244
+ extensions, such as `cursor` from `pi-cursor-sdk`, were unusable through Pi
2245
+ Session MCP although Pi had them installed and authenticated.
2246
+
2247
+ ## Decision
2248
+
2249
+ An execution profile may name Pi extensions as
2250
+ `npm:<name>@<exact version>`.
2251
+ - The server resolves each one from Pi's user package install and never
2252
+ installs anything.
2253
+ - A missing package or another installed version fails closed before a session
2254
+ exists.
2255
+ - An extension loads with its full capabilities: providers, tools, hooks and
2256
+ system-prompt changes.
2257
+ - In `read-only` profiles the model's tools stay `read`, `grep`, `find` and
2258
+ `ls`.
2259
+ - In `coding` profiles every extension tool is active.
2260
+ - An extension tool name that collides with another tool fails closed in both
2261
+ profiles.
2262
+
2263
+ A profile with extensions uses the Pi SDK session-lifecycle API with its own
2264
+ model runtime per session:
2265
+ - `createAgentSessionServices`, then the existing verification, then
2266
+ `createAgentSessionFromServices` and `bindExtensions`.
2267
+ - Close goes through `AgentSessionRuntime.dispose()`, which emits
2268
+ `session_shutdown`.
2269
+
2270
+ Profiles without extensions keep the previous path. Client prompts are plain
2271
+ text for every profile: they never dispatch extension commands, skills or
2272
+ templates. An always-on stdout guard gives the MCP transport the only real
2273
+ stdout writer.
2274
+
2275
+ ## Consequences
2276
+
2277
+ - An allowlisted extension is trusted operator code with the user's full
2278
+ rights inside the server process. Version pinning prevents silent updates,
2279
+ not tampering with the installed package.
2280
+ - Extensions may persist their own state; Pi Session MCP itself stays
2281
+ in-memory.
2282
+ - A child process that an extension starts with inherited stdio can still
2283
+ write to the MCP stream directly. This is a documented residual risk.
2284
+ - Doctor checks configured extensions offline by reading their manifests.
2285
+ ```
2286
+
2287
+ - [ ] **Step 2: Extend the threat model**
2288
+
2289
+ In `docs/threat-model.md`, under "Threats and mitigations", add a bullet in the style of the existing bullets:
2290
+
2291
+ ```markdown
2292
+ - **Allowlisted Pi extensions:** a profile's `extensions` load operator-trusted code into the server process with the user's full rights; it may start processes, use the network and write files (for example `pi-cursor-sdk` below `~/.pi/agent`, `~/.cursor/projects/` and `<workspace>/.pi/`). Mitigations:
2293
+ - Only exact `npm:<name>@<version>` specs from the operator's configuration load; the MCP client cannot name an extension.
2294
+ - Resolution never installs, and a different installed version fails closed.
2295
+ - `read-only` profiles never activate extension tools, and no extension tool may shadow a built-in or granted tool.
2296
+ - Client prompts never dispatch extension commands.
2297
+ - Each extension session gets its own model runtime, and close emits `session_shutdown`.
2298
+ - **stdout corruption:** every write to stdout other than the MCP transport's, including `console.log` from extensions or the SDK, is dropped. stderr gets only `foreign_stdout_suppressed` with byte and write counts, never the content.
2299
+ ```
2300
+
2301
+ Under "Residual risks", append this paragraph:
2302
+
2303
+ ```markdown
2304
+ An allowlisted extension is trusted code; pinning its version does not detect
2305
+ modification of the installed package directory. `read-only` limits the
2306
+ model's tools, not extension code or hooks. A child process that an extension
2307
+ starts with inherited stdio writes to the MCP stream directly; Node.js cannot
2308
+ intercept file descriptor 1. Extensions may persist their own state outside Pi
2309
+ Session MCP's in-memory boundary.
2310
+ ```
2311
+
2312
+ - [ ] **Step 3: Update architecture scope and AGENTS.md**
2313
+
2314
+ In `docs/architecture.md`, section "Scope", after the sentence that begins with "The product boundary is local stdio and in-memory-only operation …", add:
2315
+
2316
+ ```markdown
2317
+ Allowlisted Pi extensions may persist state of their own outside this boundary; Pi Session MCP itself keeps no state beyond process memory.
2318
+ ```
2319
+
2320
+ In `AGENTS.md`, after the "current product boundary" bullet, add:
2321
+
2322
+ ```markdown
2323
+ - Pi extensions load only from an execution profile's exact
2324
+ `npm:<name>@<version>` allowlist, resolved from Pi's install without
2325
+ installing. Client prompts never dispatch extension commands, and stdout stays
2326
+ reserved for MCP frames through the stdout guard. Extensions may persist their
2327
+ own state; Pi Session MCP itself stays in-memory.
2328
+ ```
2329
+
2330
+ - [ ] **Step 4: Add the operator section**
2331
+
2332
+ Append a section to `docs/installation.md` before the "Upgrade" section, or at the end if there is none. Use the anchor `pi-extensions`, which Task 1 links to:
2333
+
2334
+ ````markdown
2335
+ ## Pi extensions
2336
+
2337
+ An execution profile can load Pi extensions that Pi has installed. Install them
2338
+ with Pi (`pi install npm:<name>`; update with `pi update --extensions`), then
2339
+ pin the installed version in the profile:
2340
+
2341
+ ```json
2342
+ "code-cursor": {
2343
+ "default": false,
2344
+ "permissionProfile": "coding",
2345
+ "provider": "cursor",
2346
+ "model": "<model id from Pi's cursor catalog>",
2347
+ "thinkingLevel": "high",
2348
+ "extensions": ["npm:pi-cursor-sdk@0.5.3"]
2349
+ }
2350
+ ```
2351
+
2352
+ - **Version pin.** The server never installs or updates extensions. If Pi has
2353
+ another version installed, sessions of that profile fail with
2354
+ `extension_version_mismatch` and Doctor reports it. After `pi update
2355
+ --extensions`, update the pinned version and restart the client.
2356
+ - **Full capabilities.** An extension loads with its providers, tools, hooks
2357
+ and system-prompt changes. In `read-only` profiles the model still gets only
2358
+ `read`, `grep`, `find` and `ls`; in `coding` profiles it also gets every
2359
+ extension tool. Extension commands never run from client prompts.
2360
+ - **Trust.** Only list extensions you trust like any other program you run:
2361
+ they execute inside the server process with your rights.
2362
+ - **Cursor sign-in limit.** Without a stored Cursor key, `pi-cursor-sdk`
2363
+ reports a placeholder key. Session start succeeds and the first prompt fails
2364
+ with `turn_failed`. Store the key with Pi first.
2365
+ ````
2366
+
2367
+ - [ ] **Step 5: Record the manual acceptance and the changelog**
2368
+
2369
+ In `docs/acceptance.md`, add a section before "Evidence rules":
2370
+
2371
+ ```markdown
2372
+ ## Pi extension live check
2373
+
2374
+ Manual, on a machine with Pi, `pi-cursor-sdk@0.5.3` and a stored Cursor key. Run it from a working tree (`npm run build`, then `node dist/main.js`) with a temporary configuration whose profile names `"extensions": ["npm:pi-cursor-sdk@0.5.3"]` and a Cursor model from Pi's catalog.
2375
+
2376
+ 1. `pi_session_start`, a prompt answered with "PONG", `pi_turn_get` completed, `pi_session_close`.
2377
+ 2. Two parallel sessions of that profile, each completing a prompt.
2378
+ 3. After close, no Cursor child process of the server remains.
2379
+ 4. stderr shows no `foreign_stdout_suppressed` storm and no stdout frame fails to parse.
2380
+ 5. Record how the Cursor local runtime starts its child processes (inherited or piped stdio).
2381
+
2382
+ Record date, versions and outcome under Status. No secret, path or prompt content goes into the record.
2383
+ ```
2384
+
2385
+ In `CHANGELOG.md`, under `## Unreleased`, add as the first bullet:
2386
+
2387
+ ```markdown
2388
+ - Execution profiles can load Pi extensions that Pi has installed, pinned as
2389
+ `npm:<name>@<exact version>` in a new `extensions` field. Such a session
2390
+ loads the extension with its providers, tools, hooks and system-prompt
2391
+ changes, in a model runtime of its own; `read-only` profiles never activate
2392
+ extension tools. New errors: `extension_not_installed`,
2393
+ `extension_version_mismatch`, `extension_load_failed`,
2394
+ `extension_tool_conflict`. Doctor checks configured extensions offline.
2395
+ `pi_capabilities_get` lists each profile's `extensions`. Client prompts no
2396
+ longer dispatch extension commands, and an always-on guard keeps stdout
2397
+ reserved for MCP frames. See ADR 0006 and #34.
2398
+ ```
2399
+
2400
+ - [ ] **Step 6: Run the full gate and a link check**
2401
+
2402
+ Run: `npm run typecheck && npx vitest run && grep -n "pi-extensions" docs/installation.md docs/adr/0006-pi-extension-allowlist.md`
2403
+ Expected: PASS. The grep shows the `#pi-extensions` link target and the ADR link.
2404
+
2405
+ - [ ] **Step 7: Commit**
2406
+
2407
+ ```bash
2408
+ git add docs/adr/0006-pi-extension-allowlist.md docs/threat-model.md docs/architecture.md docs/installation.md AGENTS.md docs/acceptance.md CHANGELOG.md
2409
+ git commit -m "docs: record the Pi extension allowlist and its trust boundary
2410
+
2411
+ Refs #34
2412
+
2413
+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2414
+ Claude-Session: https://claude.ai/code/session_01Fuzo8ySrHK1TLhaQ12LxaJ"
2415
+ ```
2416
+
2417
+ ---
2418
+
2419
+ ### Task 10: Working-tree live acceptance with pi-cursor-sdk (hub only)
2420
+
2421
+ This task uses real credentials and a real provider. The hub runs it itself and never delegates it to a subagent or an external model. Nothing secret, no path and no prompt content goes into any record.
2422
+
2423
+ **Files:**
2424
+ - Modify: `docs/acceptance.md` (Status section)
2425
+
2426
+ - [ ] **Step 1: Build and prepare a temporary configuration**
2427
+
2428
+ Run:
2429
+ - `npm run build`
2430
+ - `pi --list-models cursor | head -20`
2431
+
2432
+ Pick one model id. Write a temporary config into the session scratchpad. It must have the `pi-session-mcp` workspace and the profile `code-cursor` (coding, `cursor`, the model id, a thinking level the model supports, `extensions: ["npm:pi-cursor-sdk@0.5.3"]`), plus a read-only default profile.
2433
+
2434
+ - [ ] **Step 2: Run the live check steps 1-5 from `docs/acceptance.md`**
2435
+
2436
+ Drive `node dist/main.js` over stdio with a small JSON-RPC client in the scratchpad, for example a variant of the earlier profile probe. The JSON-RPC client:
2437
+ - prompts "Reply with exactly the word PONG",
2438
+ - starts two sessions in parallel,
2439
+ - closes both.
2440
+
2441
+ After close, check for leftover Cursor child processes with `ps -o pid,ppid,command --ppid <server pid>`. Note how the Cursor runtime's children were started.
2442
+
2443
+ - [ ] **Step 3: Record the outcome**
2444
+
2445
+ Add a dated line under "Status" in `docs/acceptance.md`, with:
2446
+ - versions (Pi SDK 1.1.0, pi-cursor-sdk 0.5.3, Node),
2447
+ - the outcome of each of the five steps,
2448
+ - the observed child stdio mode.
2449
+
2450
+ Commit it as `docs: record the pi-cursor-sdk live check` with the trailers. If a step fails, stop: report the failure and record nothing.