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.
- package/.pi-session-mcp-cli/config.js +88 -0
- package/.pi-session-mcp-cli/doctor-cli.js +11 -0
- package/.pi-session-mcp-cli/doctor.js +178 -0
- package/.pi-session-mcp-cli/execution-profile.js +29 -0
- package/.pi-session-mcp-cli/mcp-config.js +70 -0
- package/.pi-session-mcp-cli/package-metadata.js +32 -0
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +57 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/SECURITY.md +37 -0
- package/dist/capabilities.d.ts +25 -0
- package/dist/capabilities.js +26 -0
- package/dist/client-adapters/claude-code.d.ts +4 -0
- package/dist/client-adapters/claude-code.js +33 -0
- package/dist/client-adapters/codex.d.ts +4 -0
- package/dist/client-adapters/codex.js +73 -0
- package/dist/client-adapters/command.d.ts +12 -0
- package/dist/client-adapters/command.js +25 -0
- package/dist/client-adapters/index.d.ts +4 -0
- package/dist/client-adapters/index.js +3 -0
- package/dist/client-adapters/mcp-verifier.d.ts +20 -0
- package/dist/client-adapters/mcp-verifier.js +174 -0
- package/dist/client-adapters/types.d.ts +54 -0
- package/dist/client-adapters/types.js +1 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +88 -0
- package/dist/doctor-cli.d.ts +1 -0
- package/dist/doctor-cli.js +11 -0
- package/dist/doctor.d.ts +47 -0
- package/dist/doctor.js +178 -0
- package/dist/entry-point.d.ts +5 -0
- package/dist/entry-point.js +16 -0
- package/dist/execution-profile.d.ts +37 -0
- package/dist/execution-profile.js +29 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +51 -0
- package/dist/mcp-config.d.ts +22 -0
- package/dist/mcp-config.js +70 -0
- package/dist/mcp-session-tools.d.ts +54 -0
- package/dist/mcp-session-tools.js +323 -0
- package/dist/mcp-stdio-client.d.ts +3 -0
- package/dist/mcp-stdio-client.js +59 -0
- package/dist/package-metadata.d.ts +18 -0
- package/dist/package-metadata.js +32 -0
- package/dist/pi-adapter.d.ts +42 -0
- package/dist/pi-adapter.js +22 -0
- package/dist/process-diagnostics.d.ts +8 -0
- package/dist/process-diagnostics.js +9 -0
- package/dist/sdk-pi-adapter.d.ts +75 -0
- package/dist/sdk-pi-adapter.js +410 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +102 -0
- package/dist/session-registry.d.ts +104 -0
- package/dist/session-registry.js +373 -0
- package/dist/setup-cli.d.ts +11 -0
- package/dist/setup-cli.js +38 -0
- package/dist/setup-command-guardian.d.ts +2 -0
- package/dist/setup-command-guardian.js +297 -0
- package/dist/setup-main.d.ts +8 -0
- package/dist/setup-main.js +60 -0
- package/dist/setup-ownership.d.ts +53 -0
- package/dist/setup-ownership.js +228 -0
- package/dist/setup-process.d.ts +30 -0
- package/dist/setup-process.js +385 -0
- package/dist/setup-result.d.ts +22 -0
- package/dist/setup-result.js +8 -0
- package/dist/setup.d.ts +55 -0
- package/dist/setup.js +327 -0
- package/dist/shutdown.d.ts +10 -0
- package/dist/shutdown.js +52 -0
- package/docs/acceptance.md +227 -0
- package/docs/adr/0001-stdio-first.md +12 -0
- package/docs/adr/0002-embedded-pi-sdk.md +11 -0
- package/docs/adr/0003-post-mvp-maintenance.md +60 -0
- package/docs/adr/0004-client-contract-not-version.md +31 -0
- package/docs/architecture.md +82 -0
- package/docs/client-setup-design.md +90 -0
- package/docs/client-setup.md +207 -0
- package/docs/doctor-design.md +47 -0
- package/docs/installation.md +352 -0
- package/docs/releasing.md +216 -0
- package/docs/session-mcp.md +260 -0
- package/docs/threat-model.md +89 -0
- package/docs/tool-contracts.md +143 -0
- package/package.json +64 -4
- package/pi-session-mcp.example.json +21 -0
- 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>;
|
package/dist/shutdown.js
ADDED
|
@@ -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.
|