pi-session-mcp 0.0.0-stage → 0.6.2

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 (88) hide show
  1. package/.pi-session-mcp-cli/config.js +88 -0
  2. package/.pi-session-mcp-cli/doctor-cli.js +11 -0
  3. package/.pi-session-mcp-cli/doctor.js +178 -0
  4. package/.pi-session-mcp-cli/execution-profile.js +29 -0
  5. package/.pi-session-mcp-cli/mcp-config.js +70 -0
  6. package/.pi-session-mcp-cli/package-metadata.js +32 -0
  7. package/CHANGELOG.md +62 -0
  8. package/CONTRIBUTING.md +57 -0
  9. package/LICENSE +21 -0
  10. package/README.md +183 -2
  11. package/SECURITY.md +37 -0
  12. package/dist/capabilities.d.ts +25 -0
  13. package/dist/capabilities.js +26 -0
  14. package/dist/client-adapters/claude-code.d.ts +4 -0
  15. package/dist/client-adapters/claude-code.js +33 -0
  16. package/dist/client-adapters/codex.d.ts +4 -0
  17. package/dist/client-adapters/codex.js +73 -0
  18. package/dist/client-adapters/command.d.ts +12 -0
  19. package/dist/client-adapters/command.js +25 -0
  20. package/dist/client-adapters/index.d.ts +4 -0
  21. package/dist/client-adapters/index.js +3 -0
  22. package/dist/client-adapters/mcp-verifier.d.ts +20 -0
  23. package/dist/client-adapters/mcp-verifier.js +174 -0
  24. package/dist/client-adapters/types.d.ts +54 -0
  25. package/dist/client-adapters/types.js +1 -0
  26. package/dist/config.d.ts +50 -0
  27. package/dist/config.js +88 -0
  28. package/dist/doctor-cli.d.ts +1 -0
  29. package/dist/doctor-cli.js +11 -0
  30. package/dist/doctor.d.ts +47 -0
  31. package/dist/doctor.js +178 -0
  32. package/dist/entry-point.d.ts +5 -0
  33. package/dist/entry-point.js +16 -0
  34. package/dist/execution-profile.d.ts +37 -0
  35. package/dist/execution-profile.js +29 -0
  36. package/dist/main.d.ts +2 -0
  37. package/dist/main.js +51 -0
  38. package/dist/mcp-config.d.ts +22 -0
  39. package/dist/mcp-config.js +70 -0
  40. package/dist/mcp-session-tools.d.ts +54 -0
  41. package/dist/mcp-session-tools.js +323 -0
  42. package/dist/mcp-stdio-client.d.ts +3 -0
  43. package/dist/mcp-stdio-client.js +59 -0
  44. package/dist/package-metadata.d.ts +18 -0
  45. package/dist/package-metadata.js +32 -0
  46. package/dist/pi-adapter.d.ts +42 -0
  47. package/dist/pi-adapter.js +22 -0
  48. package/dist/process-diagnostics.d.ts +8 -0
  49. package/dist/process-diagnostics.js +9 -0
  50. package/dist/sdk-pi-adapter.d.ts +75 -0
  51. package/dist/sdk-pi-adapter.js +410 -0
  52. package/dist/server.d.ts +5 -0
  53. package/dist/server.js +102 -0
  54. package/dist/session-registry.d.ts +104 -0
  55. package/dist/session-registry.js +373 -0
  56. package/dist/setup-cli.d.ts +11 -0
  57. package/dist/setup-cli.js +38 -0
  58. package/dist/setup-command-guardian.d.ts +2 -0
  59. package/dist/setup-command-guardian.js +297 -0
  60. package/dist/setup-main.d.ts +8 -0
  61. package/dist/setup-main.js +60 -0
  62. package/dist/setup-ownership.d.ts +53 -0
  63. package/dist/setup-ownership.js +228 -0
  64. package/dist/setup-process.d.ts +30 -0
  65. package/dist/setup-process.js +385 -0
  66. package/dist/setup-result.d.ts +22 -0
  67. package/dist/setup-result.js +8 -0
  68. package/dist/setup.d.ts +55 -0
  69. package/dist/setup.js +327 -0
  70. package/dist/shutdown.d.ts +10 -0
  71. package/dist/shutdown.js +52 -0
  72. package/docs/acceptance.md +227 -0
  73. package/docs/adr/0001-stdio-first.md +12 -0
  74. package/docs/adr/0002-embedded-pi-sdk.md +11 -0
  75. package/docs/adr/0003-post-mvp-maintenance.md +60 -0
  76. package/docs/adr/0004-client-contract-not-version.md +31 -0
  77. package/docs/architecture.md +82 -0
  78. package/docs/client-setup-design.md +90 -0
  79. package/docs/client-setup.md +207 -0
  80. package/docs/doctor-design.md +47 -0
  81. package/docs/installation.md +352 -0
  82. package/docs/releasing.md +216 -0
  83. package/docs/session-mcp.md +260 -0
  84. package/docs/threat-model.md +89 -0
  85. package/docs/tool-contracts.md +143 -0
  86. package/package.json +64 -4
  87. package/pi-session-mcp.example.json +21 -0
  88. package/scripts/doctor.mjs +72 -0
package/dist/setup.js ADDED
@@ -0,0 +1,327 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, readdir, readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createClaudeCodeAdapter, createCodexAdapter } from "./client-adapters/index.js";
6
+ import { runDoctor } from "./doctor.js";
7
+ import { createMcpStdioLauncher } from "./setup-process.js";
8
+ import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
9
+ import { PACKAGE_VERSION, readLockfile, readPackageMetadata } from "./package-metadata.js";
10
+ import { DurableOwnershipStore, MemoryOwnershipStore } from "./setup-ownership.js";
11
+ import { result } from "./setup-result.js";
12
+ export const isSupportedTarget = (target) => target.alias === "pi-session-mcp" &&
13
+ (target.client === "codex" ? target.scope === "user" :
14
+ target.client === "claude-code" && ["user", "project", "local"].includes(target.scope));
15
+ export class SetupOrchestrator {
16
+ dependencies;
17
+ ownership;
18
+ constructor(dependencies) {
19
+ this.dependencies = dependencies;
20
+ this.ownership = dependencies.ownership ?? new MemoryOwnershipStore();
21
+ }
22
+ async run(request) {
23
+ const operation = request.operation ?? "dry-run";
24
+ const targets = request.targets.filter(isSupportedTarget).filter((target, index, all) => all.findIndex((candidate) => candidate.client === target.client && candidate.scope === target.scope && candidate.alias === target.alias) === index);
25
+ if (targets.length !== request.targets.length || targets.length === 0)
26
+ return result(operation, [{ target: { client: "invalid", scope: "invalid", alias: "invalid" }, status: "failed", code: "target_invalid" }], "failed");
27
+ if (operation === "dry-run")
28
+ return this.plan(targets, this.ownership);
29
+ if (operation === "apply")
30
+ return this.ownership.transaction((ownership) => this.apply(targets, ownership));
31
+ if (operation === "verify")
32
+ return this.ownership.transaction((ownership) => this.verify(targets, ownership));
33
+ if (operation === "rollback")
34
+ return this.ownership.transaction((ownership) => this.restore(targets, "rolled_back", ownership));
35
+ return this.ownership.transaction((ownership) => this.restore(targets, "removed", ownership));
36
+ }
37
+ adapter(t) { return this.dependencies.adapters?.find((a) => a.name === t.client && a.supportedScopes.includes(t.scope)); }
38
+ assertOwnershipHealthy(ownership) { ownership.assertHealthy?.(); }
39
+ signal(ownership) { return ownership.signal?.(); }
40
+ execution(ownership) { const signal = this.signal(ownership); return { ...(signal ? { signal } : {}), ...(ownership.mutationRunner ? { runner: ownership.mutationRunner } : {}) }; }
41
+ async plan(targets, ownership) {
42
+ const findings = [];
43
+ if (this.dependencies.doctor) {
44
+ const doctor = await this.safe(() => this.dependencies.doctor.check());
45
+ if (!doctor?.ok)
46
+ return result("dry-run", targets.map((target) => ({ target, status: "failed", code: doctor?.code ?? "doctor_failed" })), "failed");
47
+ }
48
+ for (const target of targets) {
49
+ this.assertOwnershipHealthy(ownership);
50
+ const adapter = this.adapter(target);
51
+ if (!adapter) {
52
+ findings.push({ target, status: "unsupported", code: "client_unsupported" });
53
+ continue;
54
+ }
55
+ const inspected = await this.safe(() => adapter.inspect(target, this.dependencies.intendedFingerprint));
56
+ if (!inspected) {
57
+ findings.push({ target, status: "failed", code: "inspect_failed" });
58
+ continue;
59
+ }
60
+ let owned;
61
+ try {
62
+ owned = await ownership.get(target);
63
+ }
64
+ catch {
65
+ findings.push({ target, status: "failed", code: "ownership_unavailable" });
66
+ continue;
67
+ }
68
+ if (owned && owned.fingerprint !== this.dependencies.intendedFingerprint) {
69
+ findings.push({ target, status: "divergent", code: "release_binding_mismatch" });
70
+ continue;
71
+ }
72
+ if (inspected.state === "absent" && owned?.phase === "owned") {
73
+ findings.push({ target, status: "divergent", code: "ownership_diverged" });
74
+ continue;
75
+ }
76
+ findings.push({ target, status: inspected.state === "equivalent" ? "unchanged" : inspected.state, code: inspected.state });
77
+ }
78
+ const failed = findings.some((finding) => ["failed", "unsupported", "divergent"].includes(finding.status));
79
+ return result("dry-run", findings, failed ? "failed" : "planned");
80
+ }
81
+ async apply(targets, ownership) {
82
+ const plan = await this.plan(targets, ownership);
83
+ if (plan.status === "failed") {
84
+ const findings = plan.findings.map((finding) => finding.status === "divergent" && finding.code === "divergent"
85
+ ? { ...finding, code: "divergence_requires_replacement" }
86
+ : finding);
87
+ return result("apply", findings, "failed");
88
+ }
89
+ const findings = [];
90
+ for (const target of targets) {
91
+ this.assertOwnershipHealthy(ownership);
92
+ const adapter = this.adapter(target);
93
+ if (!adapter) {
94
+ findings.push({ target, status: "unsupported", code: "client_unsupported" });
95
+ continue;
96
+ }
97
+ const current = await this.safe(() => adapter.inspect(target, this.dependencies.intendedFingerprint));
98
+ let owned;
99
+ try {
100
+ owned = await ownership.get(target);
101
+ }
102
+ catch {
103
+ findings.push({ target, status: "failed", code: "ownership_unavailable" });
104
+ continue;
105
+ }
106
+ if (!current) {
107
+ findings.push({ target, status: "failed", code: "inspect_failed" });
108
+ continue;
109
+ }
110
+ if (owned && owned.fingerprint !== this.dependencies.intendedFingerprint) {
111
+ findings.push({ target, status: "failed", code: "release_binding_mismatch" });
112
+ continue;
113
+ }
114
+ if (current.state === "absent" && owned?.phase === "owned") {
115
+ findings.push({ target, status: "failed", code: "ownership_diverged" });
116
+ continue;
117
+ }
118
+ if (current.state === "equivalent") {
119
+ if (current.fingerprint !== this.dependencies.intendedFingerprint) {
120
+ findings.push({ target, status: "failed", code: "registration_fingerprint_mismatch" });
121
+ continue;
122
+ }
123
+ if (owned?.phase === "pending" && owned.fingerprint === this.dependencies.intendedFingerprint) {
124
+ try {
125
+ this.assertOwnershipHealthy(ownership);
126
+ await ownership.put({ ...owned, phase: "owned" });
127
+ this.assertOwnershipHealthy(ownership);
128
+ findings.push({ target, status: "ok", code: "pending_recovered" });
129
+ }
130
+ catch {
131
+ findings.push({ target, status: "failed", code: "ownership_unavailable" });
132
+ }
133
+ }
134
+ else
135
+ findings.push({ target, status: "unchanged", code: "already_equivalent" });
136
+ continue;
137
+ }
138
+ if (current.state !== "absent") {
139
+ findings.push({ target, status: "divergent", code: "divergence_requires_replacement" });
140
+ continue;
141
+ }
142
+ const pending = { target, fingerprint: this.dependencies.intendedFingerprint, previous: "absent", phase: "pending", transactionId: randomUUID() };
143
+ try {
144
+ this.assertOwnershipHealthy(ownership);
145
+ await ownership.put(pending);
146
+ this.assertOwnershipHealthy(ownership);
147
+ await adapter.apply(target, this.execution(ownership));
148
+ this.assertOwnershipHealthy(ownership);
149
+ const after = await adapter.inspect(target, this.dependencies.intendedFingerprint);
150
+ this.assertOwnershipHealthy(ownership);
151
+ if (!after || after.state !== "equivalent" || after.fingerprint !== this.dependencies.intendedFingerprint)
152
+ throw new Error("post_apply_mismatch");
153
+ await ownership.put({ ...pending, phase: "owned" });
154
+ findings.push({ target, status: "ok", code: "applied" });
155
+ }
156
+ catch {
157
+ findings.push({ target, status: "failed", code: "apply_failed_recovery_pending" });
158
+ }
159
+ }
160
+ return result("apply", findings);
161
+ }
162
+ async verify(targets, ownership) {
163
+ const findings = [];
164
+ for (const target of targets) {
165
+ this.assertOwnershipHealthy(ownership);
166
+ const adapter = this.adapter(target);
167
+ if (!adapter) {
168
+ findings.push({ target, status: "unsupported", code: "client_unsupported" });
169
+ continue;
170
+ }
171
+ let owned;
172
+ try {
173
+ owned = await ownership.get(target);
174
+ }
175
+ catch {
176
+ findings.push({ target, status: "failed", code: "ownership_unavailable" });
177
+ continue;
178
+ }
179
+ if (owned && owned.fingerprint !== this.dependencies.intendedFingerprint) {
180
+ findings.push({ target, status: "failed", code: "release_binding_mismatch" });
181
+ continue;
182
+ }
183
+ const doctor = this.dependencies.doctor ? await this.safe(() => this.dependencies.doctor.check()) : { ok: true, code: "doctor_skipped" };
184
+ const inspected = await this.safe(() => adapter.inspect(target, this.dependencies.intendedFingerprint));
185
+ const equivalent = inspected?.state === "equivalent" && inspected.fingerprint === this.dependencies.intendedFingerprint;
186
+ const verified = equivalent ? await this.safe(() => adapter.verify(target)) : undefined;
187
+ findings.push({ target, status: doctor?.ok && equivalent && verified?.ok ? "ok" : "failed", code: !doctor?.ok ? doctor?.code ?? "doctor_failed" : !equivalent ? "registration_not_equivalent" : verified?.code ?? "verify_failed" });
188
+ }
189
+ return result("verify", findings);
190
+ }
191
+ async restore(targets, success, ownership) {
192
+ const findings = [];
193
+ for (const target of targets) {
194
+ this.assertOwnershipHealthy(ownership);
195
+ const adapter = this.adapter(target);
196
+ let owned;
197
+ try {
198
+ owned = await ownership.get(target);
199
+ }
200
+ catch {
201
+ findings.push({ target, status: "failed", code: "ownership_unavailable" });
202
+ continue;
203
+ }
204
+ if (!adapter) {
205
+ findings.push({ target, status: "unsupported", code: "client_unsupported" });
206
+ continue;
207
+ }
208
+ if (!owned || (owned.phase !== "owned" && owned.phase !== "pending")) {
209
+ findings.push({ target, status: "failed", code: "ownership_unavailable" });
210
+ continue;
211
+ }
212
+ const inspected = await this.safe(() => adapter.inspect(target, owned.fingerprint));
213
+ this.assertOwnershipHealthy(ownership);
214
+ if (!inspected || (inspected.state !== "equivalent" && inspected.state !== "absent") || (inspected.state === "equivalent" && inspected.fingerprint !== owned.fingerprint)) {
215
+ findings.push({ target, status: "failed", code: "ownership_diverged" });
216
+ continue;
217
+ }
218
+ try {
219
+ if (inspected.state === "equivalent") {
220
+ this.assertOwnershipHealthy(ownership);
221
+ await adapter.remove(target, this.execution(ownership));
222
+ this.assertOwnershipHealthy(ownership);
223
+ const after = await adapter.inspect(target, owned.fingerprint);
224
+ this.assertOwnershipHealthy(ownership);
225
+ if (!after || after.state !== "absent")
226
+ throw new Error("post_remove_mismatch");
227
+ }
228
+ this.assertOwnershipHealthy(ownership);
229
+ await ownership.delete(target);
230
+ findings.push({ target, status: "ok", code: success });
231
+ }
232
+ catch {
233
+ findings.push({ target, status: "failed", code: "cleanup_failed" });
234
+ }
235
+ }
236
+ return result(success === "removed" ? "remove" : "rollback", findings);
237
+ }
238
+ async safe(call) { try {
239
+ return await call();
240
+ }
241
+ catch {
242
+ return undefined;
243
+ } }
244
+ }
245
+ export function registrationFingerprint(intent, releaseBinding = "unbound") {
246
+ return createHash("sha256")
247
+ .update(JSON.stringify({ node: intent.nodePath, entry: intent.entryPath, config: intent.configPath, releaseBinding }))
248
+ .digest("hex");
249
+ }
250
+ /** Bind setup ownership to the exact package manifests and built runtime tree. */
251
+ export async function immutableReleaseBinding(packageRoot) {
252
+ const metadata = await readPackageMetadata(packageRoot, readFile);
253
+ if (metadata.version !== PACKAGE_VERSION || (metadata.lockVersion !== undefined && metadata.lockVersion !== PACKAGE_VERSION))
254
+ throw new Error("release_version_mismatch");
255
+ const runtimeRoot = join(packageRoot, "dist");
256
+ const runtimeFiles = await listRuntimeFiles(runtimeRoot);
257
+ for (const required of ["main.js", "setup-command-guardian.js"]) {
258
+ if (!runtimeFiles.includes(required))
259
+ throw new Error("release_entry_missing");
260
+ const entry = await lstat(join(runtimeRoot, required));
261
+ if (!entry.isFile() || entry.isSymbolicLink())
262
+ throw new Error("release_entry_invalid");
263
+ }
264
+ const hash = createHash("sha256");
265
+ hash.update("package.json").update("\0").update(await readFile(join(packageRoot, "package.json"))).update("\0");
266
+ // A Git checkout binds its lockfile too; an npm installation has none to bind.
267
+ const lock = await readLockfile(packageRoot, readFile);
268
+ if (lock !== undefined)
269
+ hash.update("package-lock.json").update("\0").update(lock).update("\0");
270
+ for (const relativePath of runtimeFiles) {
271
+ hash.update(relativePath).update("\0").update(await readFile(join(runtimeRoot, ...relativePath.split("/")))).update("\0");
272
+ }
273
+ return hash.digest("hex");
274
+ }
275
+ async function listRuntimeFiles(directory, prefix = "") {
276
+ const files = [];
277
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
278
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
279
+ if (entry.isSymbolicLink())
280
+ throw new Error("release_symlink_invalid");
281
+ if (entry.isDirectory())
282
+ files.push(...await listRuntimeFiles(join(directory, entry.name), relativePath));
283
+ else if (entry.isFile() && entry.name.endsWith(".js"))
284
+ files.push(relativePath);
285
+ }
286
+ return files.sort();
287
+ }
288
+ export async function createProductionSetup(runner, packageRoot, configPath, ownershipPath = join(homedir(), ".local", "state", "pi-session-mcp", "ownership.json"), signal) {
289
+ const nodePath = process.execPath;
290
+ const entryPath = join(packageRoot, "dist", "main.js");
291
+ const makeIntent = (target) => ({ scope: target.scope, nodePath, entryPath, configPath });
292
+ const fingerprintFor = async (target) => registrationFingerprint(makeIntent(target), await immutableReleaseBinding(packageRoot));
293
+ const wrap = (adapter) => ({
294
+ name: adapter.client,
295
+ supportedScopes: adapter.client === "codex" ? ["user"] : ["user", "project", "local"],
296
+ async inspect(target, expectedFingerprint) {
297
+ const capabilities = await adapter.discover();
298
+ if (!capabilities.supportsAdd || !capabilities.supportsRemove || !capabilities.scopes.includes(target.scope))
299
+ return { state: "unsupported" };
300
+ const inspected = await adapter.inspect(target.scope, makeIntent(target));
301
+ if (inspected.state !== "equivalent")
302
+ return { state: inspected.state };
303
+ const currentFingerprint = await fingerprintFor(target);
304
+ return currentFingerprint === expectedFingerprint ? { state: "equivalent", fingerprint: currentFingerprint } : { state: "divergent" };
305
+ },
306
+ async apply(target, execution) { await adapter.add(makeIntent(target), execution); },
307
+ async remove(target, execution) { await adapter.remove(target.scope, execution); },
308
+ async verify() {
309
+ const verification = await verifyMcpCapabilities({ launch: createMcpStdioLauncher({ nodePath, entryPath, configPath }) });
310
+ return { ok: verification.status === "verified", code: verification.status === "verified" ? "mcp_verified" : `mcp_${verification.status}` };
311
+ },
312
+ });
313
+ const referenceTarget = { client: "codex", scope: "user", alias: "pi-session-mcp" };
314
+ const dependencies = {
315
+ adapters: [wrap(createCodexAdapter({ runner })), wrap(createClaudeCodeAdapter({ runner }))],
316
+ doctor: {
317
+ async check() {
318
+ const doctor = await runDoctor({ packageRoot, loadConfig: async () => { const { loadConfig } = await import("./config.js"); return loadConfig(configPath); } });
319
+ return { ok: doctor.ok, code: doctor.ok ? "doctor_ok" : "doctor_failed" };
320
+ },
321
+ },
322
+ ownership: new DurableOwnershipStore(ownershipPath, { ...(signal ? { signal } : {}) }),
323
+ intendedFingerprint: await fingerprintFor(referenceTarget),
324
+ };
325
+ return new SetupOrchestrator(dependencies);
326
+ }
327
+ export { result } from "./setup-result.js";
@@ -0,0 +1,10 @@
1
+ export declare const PROCESS_SHUTDOWN_TIMEOUT_MS = 15000;
2
+ export type ShutdownResult = "completed" | "failed" | "timed_out";
3
+ export interface TerminationHooks {
4
+ readonly report: (result: ShutdownResult) => void;
5
+ readonly terminate: (code: number) => void;
6
+ }
7
+ /** Bounds how long the signal handler waits; it does not prove SDK/provider termination. */
8
+ export declare function shutdownWithDeadline(registryShutdown: () => Promise<void>, transportClose: () => Promise<void>, timeoutMs?: number): Promise<ShutdownResult>;
9
+ /** Reports the bounded result and then applies the explicit process-termination policy. */
10
+ export declare function shutdownAndTerminate(registryShutdown: () => Promise<void>, transportClose: () => Promise<void>, exitCode: number, hooks: TerminationHooks, timeoutMs?: number): Promise<void>;
@@ -0,0 +1,52 @@
1
+ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 15_000;
2
+ /** Bounds how long the signal handler waits; it does not prove SDK/provider termination. */
3
+ export async function shutdownWithDeadline(registryShutdown, transportClose, timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS) {
4
+ let timer;
5
+ let transportPromise;
6
+ const closeTransport = () => {
7
+ transportPromise ??= Promise.resolve().then(transportClose);
8
+ return transportPromise;
9
+ };
10
+ const operation = (async () => {
11
+ let failed = false;
12
+ try {
13
+ await registryShutdown();
14
+ }
15
+ catch {
16
+ failed = true;
17
+ }
18
+ try {
19
+ await closeTransport();
20
+ }
21
+ catch {
22
+ failed = true;
23
+ }
24
+ return failed ? "failed" : "completed";
25
+ })();
26
+ const deadline = new Promise((resolve) => {
27
+ timer = setTimeout(() => {
28
+ void closeTransport().catch(() => undefined);
29
+ resolve("timed_out");
30
+ }, timeoutMs);
31
+ });
32
+ try {
33
+ return await Promise.race([operation, deadline]);
34
+ }
35
+ finally {
36
+ if (timer)
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ /** Reports the bounded result and then applies the explicit process-termination policy. */
41
+ export async function shutdownAndTerminate(registryShutdown, transportClose, exitCode, hooks, timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS) {
42
+ const result = await shutdownWithDeadline(registryShutdown, transportClose, timeoutMs);
43
+ try {
44
+ hooks.report(result);
45
+ }
46
+ catch {
47
+ // Reporting must not prevent the terminal process policy.
48
+ }
49
+ finally {
50
+ hooks.terminate(exitCode);
51
+ }
52
+ }
@@ -0,0 +1,227 @@
1
+ # Acceptance
2
+
3
+ This document defines how Pi Session MCP is accepted before a release, what each
4
+ acceptance level establishes, and which rules apply to evidence.
5
+
6
+ Acceptance has three levels. Each level answers a different question, and a
7
+ higher level never replaces a lower one.
8
+
9
+ | Level | Question | Needs |
10
+ | --- | --- | --- |
11
+ | 1. Repository checks | Does this source build and pass its provider-free tests? | Node.js and npm only |
12
+ | 2. Setup acceptance | Can real clients register, verify and remove this build without touching anything else? | Real Codex and Claude Code clients, no model |
13
+ | 3. Live two-client acceptance | Do real clients complete real delegated work through this build? | Real clients, local Pi authentication and a model endpoint |
14
+
15
+ Provider-free tests exercise the real Pi SDK and real stdio child processes with
16
+ a scripted model. They do not establish live client or model acceptance. State
17
+ only what was run.
18
+
19
+ ## Level 1: repository checks
20
+
21
+ Run on a clean checkout of the exact source under test:
22
+
23
+ ```bash
24
+ npm ci
25
+ npm run typecheck
26
+ npm run build:cli
27
+ npm test
28
+ npm run build
29
+ npm audit
30
+ git diff --check
31
+ git status --short
32
+ ```
33
+
34
+ Pass criteria:
35
+
36
+ - Every command exits with `0`, and `git status --short` prints nothing.
37
+ - `npm audit` reports 0 vulnerabilities. CI does not run `npm audit`, so this is
38
+ a manual gate. The advisory database changes over time: a result is valid for
39
+ the moment it was taken.
40
+
41
+ CI (`.github/workflows/ci.yml`) runs the steps from `npm ci` through
42
+ `npm run build`, without `npm audit` and the two Git checks, on Node.js 22.19.0
43
+ for every push and pull request. A result is valid for the commit it ran on.
44
+
45
+ The nightly Pi SDK canary (`.github/workflows/pi-sdk-canary.yml`) checks the
46
+ unchanged sources against the newest published Pi SDK. It is an early warning
47
+ for upstream drift, not part of accepting a given source; see
48
+ [Releasing](releasing.md#update-the-pi-sdk).
49
+
50
+ ## Level 2: setup acceptance
51
+
52
+ Setup acceptance runs the complete registration lifecycle with real clients
53
+ against isolated client state. It starts no session and needs no provider.
54
+
55
+ ### Isolation
56
+
57
+ - Use a disposable home directory for each client and a separate scratch project
58
+ directory. Run every client and `setup` command with `HOME` pointing at the
59
+ disposable home, and for Codex also `CODEX_HOME` inside it.
60
+ - Place a disposable Codex home outside `/tmp`. Codex CLI `0.159.2` refuses to
61
+ create helper binaries below a temporary directory and adds a warning to its
62
+ version output, which the strict version parser rejects as `unsupported`.
63
+ - Use a separate mode-`0600` configuration with one workspace alias and one
64
+ default `read-only` execution profile with inert provider and model names. It
65
+ must contain no credential and no real provider configuration.
66
+ - Add one harmless, unrelated registration to each disposable client home
67
+ before the first step. It is the sentinel that must survive.
68
+ - Record a digest of the real client registrations before the first step.
69
+
70
+ ### Sequence
71
+
72
+ Run the nine operations in this order for every target under test, each with
73
+ `--json`:
74
+
75
+ | # | Operation | Overall status | Finding: status (code) | Exit |
76
+ | ---: | --- | --- | --- | ---: |
77
+ | 1 | no operation (default) | `planned` | `absent (absent)` | 0 |
78
+ | 2 | `--dry-run` | `planned` | `absent (absent)` | 0 |
79
+ | 3 | `--apply` | `ok` | `ok (applied)` | 0 |
80
+ | 4 | `--apply` again | `ok` | `unchanged (already_equivalent)` | 0 |
81
+ | 5 | `--verify` | `ok` | `ok (mcp_verified)` | 0 |
82
+ | 6 | `--rollback` | `ok` | `ok (rolled_back)` | 0 |
83
+ | 7 | `--apply` | `ok` | `ok (applied)` | 0 |
84
+ | 8 | `--verify` | `ok` | `ok (mcp_verified)` | 0 |
85
+ | 9 | `--remove` | `ok` | `ok (removed)` | 0 |
86
+
87
+ Targets are `codex:user:pi-session-mcp` and the three Claude Code scopes
88
+ `claude-code:user:pi-session-mcp`, `claude-code:project:pi-session-mcp` and
89
+ `claude-code:local:pi-session-mcp`. Call the built entry point directly from the
90
+ scratch project so that the project and local scopes resolve there:
91
+
92
+ ```bash
93
+ env -i HOME="$DISPOSABLE_HOME" PATH="$PATH" PI_SESSION_MCP_CONFIG="$ACCEPTANCE_CONFIG" \
94
+ node /path/to/build/dist/setup-main.js --apply --json --target claude-code:project:pi-session-mcp
95
+ ```
96
+
97
+ ### Pass criteria
98
+
99
+ - All nine operations report the expected overall status, finding and exit
100
+ status.
101
+ - Steps 1 and 2 leave the client state unchanged.
102
+ - After step 9 the `pi-session-mcp` registration is absent, the sentinel is still
103
+ present, and the ownership record set is empty. The ownership directory has
104
+ mode `0700` and the ownership file mode `0600`.
105
+ - No server, setup or guardian process of the build under test remains.
106
+ - The digest of the real client registrations is unchanged.
107
+ - No report contains a path, the configuration location, an environment value
108
+ or a secret.
109
+
110
+ ### Working-tree and published-release variants
111
+
112
+ A working-tree run accepts a change, for example a newly supported client
113
+ version. It proves nothing about published bytes.
114
+
115
+ A published-package run installs the published npm package into a temporary
116
+ prefix (`npm install -g --prefix <dir> pi-session-mcp@<version>`) and runs the
117
+ lifecycle through the installed `pi-session-mcp-setup`, which also proves the
118
+ executables and the entry-point detection through npm's symlinks.
119
+
120
+ A published-release run uses a fresh clone of the published tag instead of an
121
+ existing branch or local tag. It additionally records the tag object and the
122
+ peeled commit, confirms `git describe --tags --exact-match`, passes Level 1 on
123
+ that checkout with Doctor reporting the release version, and confirms that the
124
+ manifests and built runtime files are byte-identical before and after the
125
+ client cycles.
126
+
127
+ ## Level 3: live two-client acceptance
128
+
129
+ Live acceptance delegates real work from Codex and from Claude Code to Pi
130
+ sessions and verifies the outcome independently of the clients' own reports.
131
+
132
+ The harness for this level is not part of this repository: prompts, oracles,
133
+ snapshot and digest tools and the scan for leaked values are prepared per cycle.
134
+
135
+ ### Preconditions
136
+
137
+ - Server: a separate detached checkout of the release under test, built with
138
+ `npm ci`, `npm run build` and `npm run build:cli`. Record the commit, the tag
139
+ object if there is one, and the SHA-256 of `dist/main.js`.
140
+ - Configuration: temporary, credential-free, with synthetic workspace aliases
141
+ and one `read-only` and one `coding` execution profile. Offline Doctor must
142
+ pass for it.
143
+ - Model endpoint: run one bounded preflight before the first task. If the
144
+ endpoint is shared, record the loaded model before and after the cycle and
145
+ never force-load a model that another session is using. If the preflight
146
+ fails, stop and record the cycle as blocked; do not start a turn.
147
+ - Clients: record the exact client versions and how each client reached the
148
+ server. Prefer a temporary per-invocation MCP configuration, so that no
149
+ global registration changes.
150
+ - Pre-registration: fix the plan, the acceptance criteria per task, the prompts,
151
+ the oracles, the tools, the configurations and the fixture digests before the
152
+ first task.
153
+
154
+ ### Task matrix
155
+
156
+ Each client runs the same five tasks on disposable synthetic fixtures:
157
+
158
+ | Task | What it shows |
159
+ | --- | --- |
160
+ | Capability-led read-only analysis | Discovery through `pi_capabilities_get`, explicit workspace and profile selection, prompt, result |
161
+ | Same-session read-only follow-up | A useful second turn in the same session; the first turn's terminal view stays unchanged |
162
+ | Bounded coding change | A `coding` profile changes only the allowed files |
163
+ | Same-session coding follow-up | A second coding turn in the same session |
164
+ | Abort and recovery | Running, abort, `aborted`, close, empty session list, then a fresh session that starts `idle` |
165
+
166
+ Run strictly sequentially: one client invocation at a time, three invocations
167
+ per client (read-only with follow-up, coding with follow-up, abort). Do not
168
+ repeat a task.
169
+
170
+ ### Independent verification
171
+
172
+ - Read-only invariance: a path, mode and content digest of each analysis
173
+ fixture is identical before and after every invocation that used it.
174
+ - Coding barrier: after each coding turn, take a snapshot before the next
175
+ prompt is sent. Run oracles and fixture tests on the snapshot, not on the live
176
+ workspace. Git status must show only the allowed files as modified.
177
+ - Public results: scan every Pi Session MCP tool result in the client transcripts
178
+ for local paths, the endpoint address, key and token markers, stack frames,
179
+ network error strings and configuration file names. Expect zero hits.
180
+ - Cleanup: no Pi Session MCP server of this run exists before each invocation or
181
+ shortly after each client exit, and no outer client process remains.
182
+ - Liveness: a running turn whose `updatedAt` advanced beyond `startedAt` shows
183
+ observable activity. Its absence only means that the turn finished between
184
+ two polls.
185
+
186
+ ### Gate
187
+
188
+ The cycle is accepted when all of the following hold:
189
+
190
+ - At least 8 of the 10 tasks reach their intended outcome without a Pi Session MCP
191
+ defect.
192
+ - Both clients complete discovery, start, prompt, result and close.
193
+ - Both clients complete a useful sequential second turn.
194
+ - Both clients complete a bounded, independently verified coding workflow.
195
+ - No change exists outside the approved diff.
196
+ - No credential, path, raw provider error, prompt or transcript appears in
197
+ public metadata.
198
+ - No public session and no server process remains after a verified close.
199
+ - Lifecycle errors are stable and sanitized.
200
+ - Level 1 is green for the release source.
201
+
202
+ The ten fixture tasks are deliberately small. They qualify the control path, not
203
+ model capability on larger work. Not covered are interactive approval dialogs,
204
+ external MCP grants, and client restart or resume.
205
+
206
+ A narrower live check can cover a single path. It must state its scope and does
207
+ not count as a ten-task cycle.
208
+
209
+ ## Evidence rules
210
+
211
+ - Record only sanitized evidence: statuses, booleans, counts, durations,
212
+ versions, release and build identifiers, digests, and method and tool names.
213
+ - Never publish prompts, assistant responses, transcripts, public session or
214
+ turn identifiers, credentials, authentication locations, absolute local paths,
215
+ the endpoint address, raw provider errors or raw client output.
216
+ - Bind every record to the exact source commit, the commands, the exit codes and
217
+ the observed counts. A result for one commit is not evidence for another.
218
+ - Keep failed, blocked and partial runs on record. A new attempt gets a new
219
+ record; never overwrite an earlier one or merge two attempts into one result.
220
+ - Name what a run does not cover.
221
+
222
+ ## Status
223
+
224
+ Release notes state which levels were run for a release and what was not
225
+ covered. Pi Session MCP has passed all three levels in earlier private cycles. The
226
+ live two-client acceptance has not yet been repeated on a build with Pi SDK
227
+ `1.x`.
@@ -0,0 +1,12 @@
1
+ # ADR 0001: stdio first
2
+
3
+ **Status:** Accepted
4
+
5
+ ## Decision
6
+
7
+ Phase 0 serves MCP exclusively with the server SDK's `serveStdio` helper. stdout is reserved for MCP and diagnostics use stderr.
8
+
9
+ ## Rationale and consequences
10
+
11
+ Inherited stdio keeps the server local, gives the launcher control of process lifetime, and avoids prematurely designing HTTP authentication, origin validation, tenancy, and deployment. Only one trusted local peer is supported. Remote clients and browser access require a future security review and ADR.
12
+
@@ -0,0 +1,11 @@
1
+ # ADR 0002: embedded Pi SDK
2
+
3
+ **Status:** Accepted
4
+
5
+ ## Decision
6
+
7
+ Embed Pi through documented package-root SDK exports and encapsulate it behind `PiSessionAdapter`. Each managed embedded session owns an `AgentSession` created with `SessionManager.inMemory`, `SettingsManager.inMemory`, an explicit profile tool allowlist, and a `DefaultResourceLoader` that disables executable extensions and all project resources except non-executable `AGENTS.md` context.
8
+
9
+ ## Rationale and consequences
10
+
11
+ Embedding avoids subprocess/RPC translation and exposes the documented preflight callback needed for non-blocking MCP prompts. The adapter makes lifecycle tests deterministic and credential-free. Pi shares the MCP process and OS privileges; this is management, not process isolation, and a crash or runaway operation affects the server. `AGENTS.md` remains model-influencing, untrusted context. Undocumented internal imports are prohibited, so transcript/result retrieval remains out of scope until supported publicly.