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
@@ -1,10 +1,24 @@
1
1
  import { spawn } from "node:child_process";
2
- import { lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { lstatSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  const DEFAULT_TIMEOUT = 10_000;
5
5
  const DEFAULT_MAX = 65_536;
6
6
  const ABORT_GROUP_CLEANUP_TIMEOUT = 2_000;
7
7
  const GUARDIAN_START_TIMEOUT = 5_000;
8
+ let platformModule;
9
+ /**
10
+ * Source-mode harnesses import this file through Node type stripping, which
11
+ * cannot map a static `./setup-platform.js` import to its `.ts` source.
12
+ */
13
+ function loadSetupPlatform() {
14
+ platformModule ??= import(import.meta.url.endsWith(".ts") ? "./setup-platform.ts" : "./setup-platform.js");
15
+ return platformModule;
16
+ }
17
+ async function currentProcessTable() {
18
+ const platform = await loadSetupPlatform();
19
+ const name = platform.currentSetupPlatform();
20
+ return name ? platform.createProcessTable(name) : undefined;
21
+ }
8
22
  /** Kill a detached process and every descendant in its process group. */
9
23
  function terminateProcessGroup(child, signal) {
10
24
  if (child.pid === undefined)
@@ -21,51 +35,16 @@ function terminateProcessGroup(child, signal) {
21
35
  }
22
36
  catch { /* already exited */ }
23
37
  }
24
- function processGroupState(child) {
25
- if (child.pid === undefined || process.platform !== "linux")
26
- return "unknown";
27
- const group = child.pid;
28
- try {
29
- for (const entry of readdirSync("/proc", { withFileTypes: true })) {
30
- if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
31
- continue;
32
- try {
33
- const stat = readProcStat(Number(entry.name));
34
- if (stat.pgrp === group && stat.state !== "Z")
35
- return "alive";
36
- }
37
- catch (error) {
38
- if (error.code !== "ENOENT")
39
- return "unknown";
40
- }
41
- }
42
- }
43
- catch {
44
- return "unknown";
45
- }
46
- return "gone";
47
- }
48
- function readProcStat(pid) {
49
- const line = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
50
- const close = line.lastIndexOf(") ");
51
- const state = line[close + 2];
52
- if (close < 3 || !state || !/^[A-Za-z]$/.test(state) || line[close + 3] !== " ")
53
- throw new Error("process_stat_invalid");
54
- const fields = line.slice(close + 4).trim().split(/\s+/);
55
- const pgrp = Number(fields[1]);
56
- if (!Number.isSafeInteger(pgrp) || pgrp < 0)
57
- throw new Error("process_group_invalid");
58
- return { pgrp, state };
59
- }
60
38
  const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
61
39
  /** Runs only an explicit executable/argv pair, with bounded output and termination. */
62
40
  export function createProcessRunner(options = {}) {
63
41
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
64
42
  const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX;
65
- return { run(command, args, signal) {
43
+ return { async run(command, args, signal) {
66
44
  const effectiveSignal = options.signal && signal ? AbortSignal.any([options.signal, signal]) : options.signal ?? signal;
67
- if (process.platform !== "linux")
68
- return Promise.resolve({ exitCode: 126, stdout: "", stderr: "" });
45
+ const table = await currentProcessTable();
46
+ if (!table)
47
+ return { exitCode: 126, stdout: "", stderr: "" };
69
48
  return new Promise((resolve) => {
70
49
  const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"], env: options.env, detached: process.platform !== "win32" });
71
50
  let stdout = "";
@@ -92,18 +71,26 @@ export function createProcessRunner(options = {}) {
92
71
  clearTimeout(killTimer); if (!timedOut && hardTimer)
93
72
  clearTimeout(hardTimer); if (groupPoll)
94
73
  clearTimeout(groupPoll); resolve({ exitCode, stdout: overflow ? Buffer.from(stdout).subarray(0, maxOutputBytes).toString("utf8") : stdout, stderr: overflow ? "output_limit" : stderr }); };
74
+ let groupCheck = false;
95
75
  const finishAfterGroup = (successCode) => {
96
- if (done)
76
+ if (done || groupCheck)
97
77
  return;
98
- if (processGroupState(child) === "gone") {
99
- finish(successCode);
100
- return;
101
- }
102
- if (Date.now() >= groupDeadline) {
103
- finish(126);
104
- return;
105
- }
106
- groupPoll = setTimeout(() => finishAfterGroup(successCode), 10);
78
+ groupCheck = true;
79
+ const group = child.pid;
80
+ void (group === undefined ? Promise.resolve("unknown") : table.groupState(group)).then((state) => {
81
+ groupCheck = false;
82
+ if (done)
83
+ return;
84
+ if (state === "gone") {
85
+ finish(successCode);
86
+ return;
87
+ }
88
+ if (Date.now() >= groupDeadline) {
89
+ finish(126);
90
+ return;
91
+ }
92
+ groupPoll = setTimeout(() => finishAfterGroup(successCode), table.pollMs);
93
+ });
107
94
  };
108
95
  const abort = () => { if (done)
109
96
  return; aborted = true; groupDeadline = Date.now() + ABORT_GROUP_CLEANUP_TIMEOUT; terminateProcessGroup(child, "SIGTERM"); killTimer = setTimeout(() => { terminateProcessGroup(child, "SIGKILL"); finishAfterGroup(125); }, 100); };
@@ -137,6 +124,7 @@ export function createProcessRunner(options = {}) {
137
124
  /** Starts the local MCP server with only the controlled configuration environment. */
138
125
  export function createMcpStdioLauncher(options) {
139
126
  return async (signal) => {
127
+ const table = await currentProcessTable();
140
128
  const child = spawn(options.nodePath, [options.entryPath], { stdio: ["pipe", "pipe", "pipe"], env: { PI_SESSION_MCP_CONFIG: options.configPath }, detached: process.platform !== "win32" });
141
129
  const max = options.maxFrameBytes ?? DEFAULT_MAX;
142
130
  let buffer = "";
@@ -194,33 +182,35 @@ export function createMcpStdioLauncher(options) {
194
182
  await delay(100);
195
183
  terminateProcessGroup(child, "SIGKILL");
196
184
  const childClosed = await waitForClose(1_000);
185
+ const group = child.pid;
186
+ const groupState = async () => table && group !== undefined ? table.groupState(group) : "unknown";
197
187
  const groupDeadline = Date.now() + 1_000;
198
- while (processGroupState(child) === "alive" && Date.now() < groupDeadline)
199
- await delay(10);
200
- if (!childClosed || processGroupState(child) !== "gone")
188
+ let state = await groupState();
189
+ while (state !== "gone" && Date.now() < groupDeadline) {
190
+ await delay(table?.pollMs ?? 10);
191
+ state = await groupState();
192
+ }
193
+ if (!childClosed || state !== "gone")
201
194
  throw new Error("mcp_process_cleanup_failed");
202
195
  },
203
196
  };
204
197
  };
205
198
  }
206
199
  /**
207
- * Acquires the Linux kernel fence and starts the transaction guardian. The
208
- * guardian inherits the locked file description, so parent death cannot release
209
- * the fence while a client mutation or descendant is still live.
200
+ * Starts the transaction guardian with the kernel fence held. On Linux the
201
+ * trusted util-linux `flock` locks the caller's open file description first;
202
+ * on macOS the caller passes a descriptor from `openLockedDarwin`, which is
203
+ * locked at open time. The guardian inherits the locked file description, so
204
+ * parent death cannot release the fence while a client mutation or
205
+ * descendant is still live.
210
206
  */
211
207
  export async function acquireSetupGuardian(lockFd, timeoutMs, signal, validateFence) {
212
- if (process.platform !== "linux")
208
+ const platform = await loadSetupPlatform();
209
+ const platformName = platform.currentSetupPlatform();
210
+ if (!platformName)
213
211
  throw new Error("platform_unsupported");
214
- const flockPath = ["/usr/bin/flock", "/bin/flock"].find((candidate) => {
215
- try {
216
- const file = statSync(candidate);
217
- return file.isFile() && file.uid === 0 && (file.mode & 0o022) === 0 && (file.mode & 0o111) !== 0;
218
- }
219
- catch {
220
- return false;
221
- }
222
- });
223
- if (!flockPath)
212
+ const flockPath = platformName === "linux" ? ["/usr/bin/flock", "/bin/flock"].find((candidate) => platform.isTrustedSystemBinary(candidate)) : undefined;
213
+ if (platformName === "linux" && !flockPath)
224
214
  throw new Error("ownership_lock_unavailable");
225
215
  const modulePath = fileURLToPath(import.meta.url);
226
216
  const guardianPath = fileURLToPath(new URL(modulePath.endsWith(".ts") ? "./setup-command-guardian.ts" : "./setup-command-guardian.js", import.meta.url));
@@ -231,34 +221,36 @@ export async function acquireSetupGuardian(lockFd, timeoutMs, signal, validateFe
231
221
  catch {
232
222
  throw new Error("ownership_lock_unavailable");
233
223
  }
234
- const waitSeconds = String(Math.ceil(Math.max(1_000, Math.min(timeoutMs, 120_000)) / 1_000));
235
- let locker;
236
- try {
237
- locker = spawn(flockPath, ["-E", "75", "-w", waitSeconds, "3"], {
238
- stdio: ["ignore", "ignore", "ignore", lockFd],
239
- detached: true,
240
- });
241
- }
242
- catch {
243
- throw new Error("ownership_lock_unavailable");
244
- }
245
- const stopAcquisition = () => { if (locker.pid !== undefined) {
224
+ if (flockPath) {
225
+ const waitSeconds = String(Math.ceil(Math.max(1_000, Math.min(timeoutMs, 120_000)) / 1_000));
226
+ let locker;
246
227
  try {
247
- process.kill(-locker.pid, "SIGKILL");
228
+ locker = spawn(flockPath, ["-E", "75", "-w", waitSeconds, "3"], {
229
+ stdio: ["ignore", "ignore", "ignore", lockFd],
230
+ detached: true,
231
+ });
232
+ }
233
+ catch {
234
+ throw new Error("ownership_lock_unavailable");
235
+ }
236
+ const stopAcquisition = () => { if (locker.pid !== undefined) {
237
+ try {
238
+ process.kill(-locker.pid, "SIGKILL");
239
+ }
240
+ catch { /* already gone */ }
241
+ } };
242
+ if (signal?.aborted)
243
+ stopAcquisition();
244
+ signal?.addEventListener("abort", stopAcquisition, { once: true });
245
+ try {
246
+ await new Promise((resolve, reject) => {
247
+ locker.once("error", () => reject(new Error("ownership_lock_unavailable")));
248
+ locker.once("close", (code) => code === 0 ? resolve() : reject(new Error(code === 75 ? "ownership_lock_busy" : signal?.aborted ? "operation_aborted" : "ownership_lock_unavailable")));
249
+ });
250
+ }
251
+ finally {
252
+ signal?.removeEventListener("abort", stopAcquisition);
248
253
  }
249
- catch { /* already gone */ }
250
- } };
251
- if (signal?.aborted)
252
- stopAcquisition();
253
- signal?.addEventListener("abort", stopAcquisition, { once: true });
254
- try {
255
- await new Promise((resolve, reject) => {
256
- locker.once("error", () => reject(new Error("ownership_lock_unavailable")));
257
- locker.once("close", (code) => code === 0 ? resolve() : reject(new Error(code === 75 ? "ownership_lock_busy" : signal?.aborted ? "operation_aborted" : "ownership_lock_unavailable")));
258
- });
259
- }
260
- finally {
261
- signal?.removeEventListener("abort", stopAcquisition);
262
254
  }
263
255
  if (signal?.aborted)
264
256
  throw new Error("operation_aborted");
package/dist/setup.js CHANGED
@@ -254,7 +254,8 @@ export async function immutableReleaseBinding(packageRoot) {
254
254
  throw new Error("release_version_mismatch");
255
255
  const runtimeRoot = join(packageRoot, "dist");
256
256
  const runtimeFiles = await listRuntimeFiles(runtimeRoot);
257
- for (const required of ["main.js", "setup-command-guardian.js"]) {
257
+ // The guardian loads setup-platform.js at runtime, so it is mandatory too.
258
+ for (const required of ["main.js", "setup-command-guardian.js", "setup-platform.js"]) {
258
259
  if (!runtimeFiles.includes(required))
259
260
  throw new Error("release_entry_missing");
260
261
  const entry = await lstat(join(runtimeRoot, required));
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Installed when this module is evaluated. `main.ts` imports it first, so the guard is
3
+ * in place before any other module, the Pi SDK or an extension, can write to stdout.
4
+ */
5
+ export declare const stdoutGuard: import("./stdout-guard.js").StdoutGuard;
@@ -0,0 +1,6 @@
1
+ import { installStdoutGuard } from "./stdout-guard.js";
2
+ /**
3
+ * Installed when this module is evaluated. `main.ts` imports it first, so the guard is
4
+ * in place before any other module, the Pi SDK or an extension, can write to stdout.
5
+ */
6
+ export const stdoutGuard = installStdoutGuard();
@@ -0,0 +1,20 @@
1
+ import { Writable } from "node:stream";
2
+ export interface StdoutGuardOptions {
3
+ readonly stream?: NodeJS.WriteStream;
4
+ readonly console?: Pick<Console, "log" | "info" | "debug">;
5
+ readonly report?: (line: string) => void;
6
+ readonly intervalMs?: number;
7
+ readonly onExit?: (flush: () => void) => void;
8
+ }
9
+ export interface StdoutGuard {
10
+ /** The only writer that reaches the real stdout: hand it to the MCP transport. */
11
+ readonly transportStdout: Writable;
12
+ /** Writes the pending suppression counts to stderr. */
13
+ flush(): void;
14
+ }
15
+ /**
16
+ * Reserves stdout for MCP frames. Any other write, an extension's `console.log`
17
+ * included, is dropped; stderr gets only a byte and write count, never the content,
18
+ * because the content may hold prompts or secrets.
19
+ */
20
+ export declare function installStdoutGuard(options?: StdoutGuardOptions): StdoutGuard;
@@ -0,0 +1,72 @@
1
+ import { format } from "node:util";
2
+ /**
3
+ * Reserves stdout for MCP frames. Any other write, an extension's `console.log`
4
+ * included, is dropped; stderr gets only a byte and write count, never the content,
5
+ * because the content may hold prompts or secrets.
6
+ */
7
+ export function installStdoutGuard(options = {}) {
8
+ const stream = options.stream ?? process.stdout;
9
+ const target = options.console ?? console;
10
+ const report = options.report ?? ((line) => { process.stderr.write(line); });
11
+ const intervalMs = options.intervalMs ?? 1_000;
12
+ const realWrite = stream.write.bind(stream);
13
+ const realEnd = stream.end.bind(stream);
14
+ let bytes = 0;
15
+ let writes = 0;
16
+ let timer;
17
+ const flush = () => {
18
+ if (timer) {
19
+ clearTimeout(timer);
20
+ timer = undefined;
21
+ }
22
+ if (writes === 0)
23
+ return;
24
+ report(JSON.stringify({ level: "warning", event: "foreign_stdout_suppressed", bytes, writes }) + "\n");
25
+ bytes = 0;
26
+ writes = 0;
27
+ };
28
+ const suppress = (chunk, encoding) => {
29
+ bytes += typeof chunk === "string"
30
+ ? Buffer.byteLength(chunk, typeof encoding === "string" ? encoding : "utf8")
31
+ : chunk instanceof Uint8Array ? chunk.byteLength : 0;
32
+ writes += 1;
33
+ if (!timer) {
34
+ timer = setTimeout(flush, intervalMs);
35
+ timer.unref();
36
+ }
37
+ };
38
+ stream.write = ((chunk, encodingOrCallback, callback) => {
39
+ suppress(chunk, encodingOrCallback);
40
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
41
+ if (typeof done === "function")
42
+ queueMicrotask(() => { done(); });
43
+ return true;
44
+ });
45
+ // A foreign `end` must neither leak content nor close the real stdout, which would break every later frame.
46
+ stream.end = ((...args) => {
47
+ const [first, second] = args;
48
+ if (typeof first === "string" || first instanceof Uint8Array)
49
+ suppress(first, second);
50
+ const done = args.find((arg) => typeof arg === "function");
51
+ if (typeof done === "function")
52
+ queueMicrotask(() => { done(); });
53
+ return stream;
54
+ });
55
+ for (const method of ["log", "info", "debug"]) {
56
+ target[method] = (...args) => { suppress(`${format(...args)}\n`); };
57
+ }
58
+ // Write-through, not a buffering wrapper: queued frames must reach stdout before `process.exit`,
59
+ // and a wrapper `Writable` would hold all but one of them until a later tick.
60
+ const transportStdout = new Proxy(stream, {
61
+ get(target, property) {
62
+ if (property === "write")
63
+ return realWrite;
64
+ if (property === "end")
65
+ return realEnd;
66
+ const value = Reflect.get(target, property, target);
67
+ return typeof value === "function" ? value.bind(target) : value;
68
+ },
69
+ });
70
+ (options.onExit ?? ((exitFlush) => { process.once("exit", exitFlush); }))(flush);
71
+ return { transportStdout, flush };
72
+ }
@@ -124,6 +124,54 @@ that checkout with Doctor reporting the release version, and confirms that the
124
124
  manifests and built runtime files are byte-identical before and after the
125
125
  client cycles.
126
126
 
127
+ ### macOS
128
+
129
+ macOS uses the same isolation, sequence and pass criteria, with the additions
130
+ below. A run for Claude Code is recorded under [Status](#status). macOS is
131
+ CI-tested, and it has not passed Level 2 for Codex.
132
+
133
+ - **Environment.** Record:
134
+ - the macOS version and architecture (`sw_vers`, `uname -m`);
135
+ - the Node.js version;
136
+ - the Codex CLI and Claude Code versions.
137
+ - **Disposable home.** It must be on the APFS volume type of `/`. A directory
138
+ below the user's home qualifies.
139
+ - For an ownership directory on another filesystem, `--apply`, `--verify`,
140
+ `--rollback` and `--remove` fail closed with `failed (operation_failed)`
141
+ and exit `1`.
142
+ - Keep the Codex home outside both `$TMPDIR` (normally below `/var/folders`)
143
+ and `/tmp`, for the reason given under [Isolation](#isolation).
144
+ - **Before the sequence:** run `npm run --silent doctor`.
145
+ - **Lock contention.** After step 9, start two `--apply` runs for
146
+ `claude-code:user:pi-session-mcp` at the same time.
147
+ - One run reports `ok (applied)`. The other waits for the lock, then
148
+ reports `unchanged (already_equivalent)`.
149
+ - Both exit with `0`.
150
+ - A following `--remove` reports `ok (removed)`.
151
+ - A waiting run that reports `failed (operation_failed)` with exit `1` did
152
+ not get the lock within the 30 s acquisition bound. That counts as a
153
+ failure of the run.
154
+ - **Interruption.** Start `--apply` for one target and press Ctrl-C while it
155
+ runs.
156
+ - The run exits with `130` and reports `operation_interrupted`.
157
+ - Afterwards `ps -A -o pid=,command=` lists no `claude mcp` or `codex mcp`
158
+ command and no `setup-main.js` or `setup-command-guardian.js` process of
159
+ the build under test.
160
+ - A following `--apply` reports one of three outcomes, depending on where
161
+ the interrupt landed:
162
+ - it reconciles the pending record (`ok (pending_recovered)`);
163
+ - it applies again (`ok (applied)`);
164
+ - it finds the registration already owned
165
+ (`unchanged (already_equivalent)`).
166
+ - A following `--remove` reports `ok (removed)`.
167
+ - If the operation completes before the interrupt arrives, record that and
168
+ repeat. An interrupt that never lands is not a pass.
169
+ - **Processes.** Check that no process remains with `ps` instead of `/proc`.
170
+
171
+ The evidence record names the build (commit or tag), the environment above,
172
+ every command with its exit status, and the sanitized JSON results. It
173
+ contains no paths, environment values or secrets.
174
+
127
175
  ## Level 3: live two-client acceptance
128
176
 
129
177
  Live acceptance delegates real work from Codex and from Claude Code to Pi
@@ -213,6 +261,18 @@ external MCP grants, and client restart or resume.
213
261
  A narrower live check can cover a single path. It must state its scope and does
214
262
  not count as a ten-task cycle.
215
263
 
264
+ ## Pi extension live check
265
+
266
+ 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, passed through `PI_SESSION_MCP_CONFIG`, whose profile names `"extensions": ["npm:pi-cursor-sdk@0.5.3"]` and a Cursor model from Pi's catalog.
267
+
268
+ 1. `pi_session_start`, a prompt answered with "PONG", `pi_turn_get` completed, `pi_session_close`.
269
+ 2. Two parallel sessions of that profile, each completing a prompt.
270
+ 3. After close, no Cursor child process of the server remains.
271
+ 4. stderr shows no `foreign_stdout_suppressed` storm and no stdout frame fails to parse.
272
+ 5. Record how the Cursor local runtime starts its child processes (inherited or piped stdio).
273
+
274
+ Record date, source commit, versions and outcome under Status. No secret, path or prompt content goes into the record.
275
+
216
276
  ## Evidence rules
217
277
 
218
278
  - Record only sanitized evidence: statuses, booleans, counts, durations,
@@ -269,3 +329,159 @@ Before its release, `v0.6.4` passed Level 1 and the working-tree Level 2 on its
269
329
  release candidate, with Codex CLI `0.162.0` and Claude Code `2.1.296`. Its
270
330
  server entry point `dist/main.js` is byte-identical to that of `4d333da` and of
271
331
  `v0.6.3`.
332
+
333
+ After its release, `v0.6.4` passed Level 1 on a fresh clone of its tag. It also
334
+ passed Level 2 as published release and as published npm package, with Codex
335
+ CLI `0.162.0` and Claude Code `2.1.296`, on 2026-10-10. The installed
336
+ `pi-session-mcp-doctor` reported a healthy package when run from an unrelated
337
+ working directory.
338
+
339
+ macOS: `setup` supports macOS from the change that tracks #32. That change is
340
+ tested in CI on macOS 15 and macOS 26 (Apple silicon) next to Linux.
341
+
342
+ On 2026-10-11, the build of `d89740c`, the head of the pull request branch,
343
+ passed the working-tree Level 2 on a real Mac with Claude Code as the only
344
+ client:
345
+ - Environment: macOS 26.7.1 (`25G241`) on `arm64`, Node.js `22.23.3` and Claude
346
+ Code `2.1.294`. The disposable home was on the APFS data volume that the
347
+ root volume belongs to.
348
+ - Level 1 on that checkout: `npm ci`, `npm run typecheck`, `npm run build`,
349
+ `npm run build:cli` and `npm test` exited with `0`. The test run reported 531
350
+ passed and 23 skipped. The skipped tests are the 21 Linux `/proc` fixture
351
+ tests, the Linux-only lock-inode replacement test and the probe-only
352
+ controller output test. `npm audit` reported 0 vulnerabilities, and
353
+ `git diff --check` reported nothing. `git status --short` printed nothing
354
+ before the evidence was written.
355
+ - Doctor with the acceptance configuration reported no error.
356
+ - All three Claude Code targets (`user`, `project` and `local`) completed the
357
+ nine-step sequence. Every step reported the expected overall status, finding
358
+ and exit status.
359
+ - After the sequences the `pi-session-mcp` registration was absent, the sentinel
360
+ was still present and the ownership record set was empty. The ownership
361
+ directory had mode `0700` and the ownership file mode `0600`. No server,
362
+ setup or guardian process of the build remained, and the digest of the real
363
+ Claude Code registrations was unchanged. No report contained a path, an
364
+ environment value or a secret.
365
+ - Lock contention: two simultaneous `--apply` runs for
366
+ `claude-code:user:pi-session-mcp` finished within 5 s. One reported
367
+ `ok (applied)` and the other `unchanged (already_equivalent)`. Both exited
368
+ with `0`, and the following `--remove` reported `ok (removed)`.
369
+ - Interruption: SIGINT was sent to the process group of a running `--apply`
370
+ after 0.2 s, 0.5 s and 0.9 s. Each run exited with `130` and reported
371
+ `operation_interrupted`. None left a `claude mcp` command or a setup or
372
+ guardian process behind. In each case the following `--apply` reported
373
+ `ok (applied)` and the following `--remove` reported `ok (removed)`.
374
+
375
+ This run does not cover:
376
+ - Codex, which was not installed on the machine, so macOS has not passed
377
+ setup acceptance for Codex.
378
+ - A Ctrl-C keypress in an interactive terminal. A script sent the signal from a
379
+ shell without a terminal.
380
+ - The `pending_recovered` and `already_equivalent` outcomes after an interrupt.
381
+ The following `--apply` reported `applied` in every case.
382
+ - Intel Macs, the published-release and published-package variants, and
383
+ Level 3.
384
+
385
+ On 2026-10-11 the same build also passed a narrower live check on that Mac:
386
+ - Scope: Claude Code `2.1.294` as the only client, with Pi SDK `1.1.0` on a
387
+ local `qwen3.6-35b-a3b-splash` model served by LM Studio. The server was the
388
+ build of `d89740c`: the sources are identical to it, and `dist/main.js` has
389
+ the SHA-256 `a69bfec2f9f161b74a6d7b067fda9b76d94c294170bc59a771a79e3f29a7123c`.
390
+ - Method: three sequential headless invocations, one per task group, with a
391
+ temporary per-invocation MCP configuration (`--strict-mcp-config`), a
392
+ credential-free configuration with one `read-only` and one `coding` profile,
393
+ and a disposable Pi agent directory. The client could run `sleep` between
394
+ polls. The plan, prompts, oracles and fixture digests were fixed before the
395
+ first invocation, and no task was repeated.
396
+ - Preflight: one bounded request to the model returned HTTP `200` in about 1 s.
397
+ The loaded model was the same and idle before and after every invocation, and
398
+ nothing was loaded or unloaded.
399
+ - Read-only analysis with a same-session follow-up: both turns ended
400
+ `completed`. The first answer contained the expected marker, and the second
401
+ answer was exactly the expected number. The fixture digest (path, mode and
402
+ content) was identical before and after.
403
+ - Bounded coding change with a same-session follow-up: both turns ended
404
+ `completed`. The oracle passed on a snapshot taken between the turns and on
405
+ the final workspace, and that snapshot did not yet contain the second turn's
406
+ change. Only the one allowed file differed, with no added or removed file and
407
+ unchanged modes.
408
+ - Abort and recovery: the turn was `running`, then `aborted` after
409
+ `pi_session_abort`. `pi_session_close` succeeded, `pi_session_list` returned
410
+ no session, and a fresh session started `idle` and closed.
411
+ - Counts: `pi_turn_get` was called 3, 3 and 2 times in the three invocations,
412
+ and no Pi Session MCP tool returned an error.
413
+ - A scan of every Pi Session MCP tool result in the three transcripts found no
414
+ absolute path, configuration file name, endpoint, key or token marker, or stack
415
+ frame. No server or client process remained after any invocation, each
416
+ client's standard error was empty, and the digest of the real Claude Code
417
+ registrations was unchanged.
418
+
419
+ This check does not cover:
420
+ - Codex, or any task from a second client. It is not a ten-task cycle and does
421
+ not satisfy the Level 3 gate.
422
+ - The check that the first turn's terminal view stays unchanged after a second
423
+ turn started. The transcripts do not poll the first turn again.
424
+ - A git-based check of the allowed diff. The coding fixture is not a Git
425
+ repository, so a path, mode and content digest stood in for it.
426
+ - A coding turn that tries to change a file outside the allowed set.
427
+ - Interactive approval dialogs, external MCP grants, and client restart or
428
+ resume.
429
+
430
+ On 2026-10-11 the working-tree build of `eeaa597` on the `feat/pi-extensions`
431
+ branch (#34) passed the Pi extension live check on Linux:
432
+ - Environment: Node.js `26.11.1`, Pi SDK `1.1.0`, and `pi-cursor-sdk@0.5.3`
433
+ installed by Pi with a stored Cursor key. `dist/main.js` has the SHA-256
434
+ `47875d38886528ace6b242df2c6ad79d624c51e330e63c2f401576eadc449ed7`.
435
+ - Method: a scripted JSON-RPC client drove `node dist/main.js` over stdio, with
436
+ a temporary configuration passed through `PI_SESSION_MCP_CONFIG`. The
437
+ configuration had a `coding` and a `read-only` profile, each with provider
438
+ `cursor`, model `auto`, thinking level `off` and
439
+ `"extensions": ["npm:pi-cursor-sdk@0.5.3"]`.
440
+ - The `coding` profile started, its prompt completed with "PONG", and the
441
+ session closed.
442
+ - Two sessions, one of each profile, started in parallel and both prompts
443
+ completed with "PONG".
444
+ - After close, no descendant process of the server remained.
445
+ - No stdout line failed to parse as an MCP frame. stderr carried two
446
+ `foreign_stdout_suppressed` reports for 21 suppressed writes and no other
447
+ event.
448
+ - During turns the Cursor local runtime started `git`, `ssh`, `gh`, `node` and
449
+ the MCP servers configured for Cursor through `npm exec`, in the `read-only`
450
+ profile as well. None of them inherited the server's stdout: each had its own
451
+ pipe, a socket, `/dev/null` or a file as standard output.
452
+ - The first run, on the earlier build of `6740484`, failed to start the
453
+ `coding` profile with `execution_selection_mismatch`: `pi-cursor-sdk`
454
+ deactivates its own `cursor_activate_skill` tool when the session starts.
455
+ `eeaa597` lets an extension narrow its active tools while still rejecting
456
+ any tool outside the allowlist.
457
+
458
+ This check does not cover macOS, Codex or Claude Code as the client, the Cursor
459
+ cloud runtime, or a session that uses Cursor's own tools.
460
+
461
+ Before its release, `v0.7.0` passed Level 1 and the working-tree Level 2 on its
462
+ release candidate on Linux, with Node.js `26.11.1`, Codex CLI `0.162.1` and
463
+ Claude Code `2.1.296`, on 2026-10-11:
464
+ - The first working-tree Level 2, on `2d05e55`, failed. On all four targets
465
+ `--verify` (steps 5 and 8) reported `failed (mcp_failed)` with exit `1`; every
466
+ other step passed. The verifier rejected the `extensions` field that
467
+ `pi_capabilities_get` lists since #34. This is #36.
468
+ - After the fix, the working-tree Level 2 on `2c5ab3d` passed all nine steps on
469
+ all four targets, 96 of 96 checks.
470
+ - The real client registrations were unchanged, no process remained, and the
471
+ built files were byte-identical before and after the client cycles.
472
+ - The run placed one sentinel in each Claude Code scope instead of one per
473
+ client, so its check count differs from earlier records.
474
+ - Level 1 on `09b32a8`: every command exited with `0`.
475
+ - The test run reported 37 files, 660 passed and 5 skipped.
476
+ - `npm audit` and `npm audit --omit=dev` reported 0 vulnerabilities.
477
+ - Doctor reported `packageVersion` `0.7.0` and no finding.
478
+ - The commits after `2c5ab3d` change only tests and documentation. The built
479
+ `dist/` and `.pi-session-mcp-cli/` are byte-identical to those of `2c5ab3d`,
480
+ and `dist/main.js` has the SHA-256
481
+ `47875d38886528ace6b242df2c6ad79d624c51e330e63c2f401576eadc449ed7`.
482
+ - The Codex CLI `0.162.1` contract was recorded. It differs from `0.162.0` only
483
+ in the version line.
484
+
485
+ The Pi extension live check above ran on `eeaa597`, before the final fixes to
486
+ extension tool names and the `read-only` contract test. It has not been
487
+ repeated on the release candidate.