pi-session-mcp 0.11.9 → 0.12.1
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/client-adapters/mcp-verifier.js +6 -2
- package/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +19 -5
- package/.pi-session-mcp-cli/override-lock.js +4 -4
- package/.pi-session-mcp-cli/setup-process.js +5 -4
- package/.pi-session-mcp-cli/writer-boundary.js +10 -4
- package/.pi-session-mcp-cli/writer-fence.js +50 -0
- package/.pi-session-mcp-cli/writer-lease.js +140 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
- package/.pi-session-mcp-cli/writer-state.js +4 -2
- package/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +4 -1
- package/dist/child-stdio-transport.d.ts +21 -0
- package/dist/child-stdio-transport.js +88 -0
- package/dist/client-adapters/mcp-verifier.js +6 -2
- package/dist/client-adapters/types.d.ts +1 -0
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +89 -4
- package/dist/execution-modes.d.ts +14 -2
- package/dist/execution-modes.js +19 -5
- package/dist/main.js +9 -2
- package/dist/override-lock.d.ts +2 -0
- package/dist/override-lock.js +4 -4
- package/dist/pi-adapter.d.ts +30 -0
- package/dist/pi-adapter.js +11 -0
- package/dist/refusal-server.d.ts +1 -1
- package/dist/refusal-server.js +24 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +18 -53
- package/dist/session-registry.d.ts +9 -2
- package/dist/session-registry.js +17 -12
- package/dist/setup-cli.js +7 -2
- package/dist/setup-hooks.js +0 -2
- package/dist/setup-modes.js +7 -1
- package/dist/setup-process.d.ts +3 -1
- package/dist/setup-process.js +5 -4
- package/dist/setup-writers.d.ts +5 -0
- package/dist/setup-writers.js +203 -94
- package/dist/tool-schemas.d.ts +339 -0
- package/dist/tool-schemas.js +49 -0
- package/dist/wake-hook.d.ts +4 -5
- package/dist/wake-hook.js +3 -6
- package/dist/writer-acceptance.js +10 -1
- package/dist/writer-boundary.d.ts +6 -4
- package/dist/writer-boundary.js +10 -4
- package/dist/writer-fence.d.ts +12 -0
- package/dist/writer-fence.js +50 -0
- package/dist/writer-launch.d.ts +9 -1
- package/dist/writer-launch.js +127 -30
- package/dist/writer-lease.d.ts +28 -0
- package/dist/writer-lease.js +140 -0
- package/dist/writer-lifecycle.d.ts +7 -1
- package/dist/writer-lifecycle.js +55 -23
- package/dist/writer-relay.d.ts +33 -0
- package/dist/writer-relay.js +359 -0
- package/dist/writer-state.d.ts +13 -1
- package/dist/writer-state.js +4 -2
- package/docs/acceptance.md +172 -0
- package/docs/adr/0012-leased-writer-pool.md +357 -0
- package/docs/architecture.md +61 -13
- package/docs/client-setup.md +11 -15
- package/docs/doctor-design.md +10 -4
- package/docs/execution-modes.md +87 -44
- package/docs/installation.md +43 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +6 -0
- package/docs/threat-model.md +7 -4
- package/docs/tool-contracts.md +29 -12
- package/package.json +1 -1
- package/scripts/isolation-acceptance.mjs +7 -1
- package/skills/pi-session-implementation/SKILL.md +1 -0
- package/skills/pi-session-orchestration/SKILL.md +6 -5
- package/skills/pi-session-orchestration/references/start-errors.md +5 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Transport } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { WorkspaceModeEntry } from "./execution-modes.js";
|
|
3
|
+
import type { ConfiguredExecutionProfile } from "./execution-profile.js";
|
|
4
|
+
import { type PiSessionAdapter, type PiSessionHandle } from "./pi-adapter.js";
|
|
5
|
+
export declare const RELAY_START_BUDGET_MS = 60000;
|
|
6
|
+
export declare const RELAY_POLL_INTERVAL_MS = 1000;
|
|
7
|
+
export declare const RELAY_CALL_TIMEOUT_MS = 10000;
|
|
8
|
+
export declare const RELAY_PREFLIGHT_DEADLINE_MS = 15000;
|
|
9
|
+
export declare const RELAY_ABORT_DEADLINE_MS = 15000;
|
|
10
|
+
export declare const RELAY_CLEANUP_DEADLINE_MS = 10000;
|
|
11
|
+
export declare const RELAY_MAX_RESPONSE_BYTES = 524288;
|
|
12
|
+
export interface WriterProcessFactory {
|
|
13
|
+
spawn(wrapperPath: string, env: Record<string, string>): Transport;
|
|
14
|
+
}
|
|
15
|
+
export declare class WriterRelayAdapter implements PiSessionAdapter {
|
|
16
|
+
private readonly options;
|
|
17
|
+
constructor(options: {
|
|
18
|
+
home: string;
|
|
19
|
+
workspaceModes: ReadonlyMap<string, WorkspaceModeEntry>;
|
|
20
|
+
executionProfiles: ReadonlyMap<string, ConfiguredExecutionProfile>;
|
|
21
|
+
spawn?: WriterProcessFactory;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
pollIntervalMs?: number;
|
|
24
|
+
});
|
|
25
|
+
create(input: Parameters<PiSessionAdapter["create"]>[0]): Promise<PiSessionHandle>;
|
|
26
|
+
}
|
|
27
|
+
export declare class RoutingPiSessionAdapter implements PiSessionAdapter {
|
|
28
|
+
private readonly host;
|
|
29
|
+
private readonly relay;
|
|
30
|
+
private readonly workspaceModes;
|
|
31
|
+
constructor(host: PiSessionAdapter, relay: PiSessionAdapter, workspaceModes: ReadonlyMap<string, WorkspaceModeEntry>);
|
|
32
|
+
create(input: Parameters<PiSessionAdapter["create"]>[0]): Promise<PiSessionHandle>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ChildStdioTransport } from "./child-stdio-transport.js";
|
|
6
|
+
import { PiSessionCreationError, WriterUnavailableError } from "./pi-adapter.js";
|
|
7
|
+
import { RegistryError } from "./session-registry.js";
|
|
8
|
+
import { capabilitiesSchema, errorSchema, promptSuccessSchema, successSchema, turnSuccessSchema } from "./tool-schemas.js";
|
|
9
|
+
import { writerPaths } from "./writer-state.js";
|
|
10
|
+
export const RELAY_START_BUDGET_MS = 60_000;
|
|
11
|
+
export const RELAY_POLL_INTERVAL_MS = 1_000;
|
|
12
|
+
export const RELAY_CALL_TIMEOUT_MS = 10_000;
|
|
13
|
+
export const RELAY_PREFLIGHT_DEADLINE_MS = 15_000;
|
|
14
|
+
export const RELAY_ABORT_DEADLINE_MS = 15_000;
|
|
15
|
+
export const RELAY_CLEANUP_DEADLINE_MS = 10_000;
|
|
16
|
+
// The inner text cap is 64 KiB; JSON escaping can grow it up to sixfold.
|
|
17
|
+
export const RELAY_MAX_RESPONSE_BYTES = 524_288;
|
|
18
|
+
// Read the launch discriminator locally until all writer-state producers have migrated.
|
|
19
|
+
// Unknown fields are deliberately tolerated; no clone inspection is an admission check.
|
|
20
|
+
const relayStateSchema = z.object({
|
|
21
|
+
alias: z.string(), index: z.number().int(), phase: z.enum(["ready", "not_ready", "provisioning"]),
|
|
22
|
+
launch: z.enum(["legacy", "relay"]).default("legacy"),
|
|
23
|
+
acceptance: z.object({ verdict: z.literal("pass"), fingerprint: z.string(), at: z.string(), network: z.enum(["limited", "not_limited"]) }).optional(),
|
|
24
|
+
base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/) }),
|
|
25
|
+
});
|
|
26
|
+
async function readState(home, name) {
|
|
27
|
+
try {
|
|
28
|
+
const file = await open(writerPaths(home, name).state, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
29
|
+
try {
|
|
30
|
+
const stat = await file.stat();
|
|
31
|
+
if (!stat.isFile() || stat.size > RELAY_MAX_RESPONSE_BYTES)
|
|
32
|
+
return undefined;
|
|
33
|
+
const state = relayStateSchema.parse(JSON.parse(await file.readFile("utf8")));
|
|
34
|
+
return `${state.alias}-w${state.index}` === name ? state : undefined;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await file.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Bounds even injected transports which ignore AbortSignal. The losing operation stays observed. */
|
|
45
|
+
async function bounded(operation, ms, signal) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
let timer;
|
|
48
|
+
let cancel;
|
|
49
|
+
try {
|
|
50
|
+
return await Promise.race([
|
|
51
|
+
new Promise((_, reject) => {
|
|
52
|
+
cancel = () => { controller.abort(); reject(new Error("Relay operation cancelled")); };
|
|
53
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
54
|
+
timer = setTimeout(cancel, Math.max(0, ms));
|
|
55
|
+
if (signal?.aborted || ms <= 0)
|
|
56
|
+
cancel();
|
|
57
|
+
}),
|
|
58
|
+
Promise.resolve().then(() => { controller.signal.throwIfAborted(); return operation(controller.signal); }),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
if (cancel)
|
|
64
|
+
signal?.removeEventListener("abort", cancel);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const creationCodes = new Set([
|
|
68
|
+
"unknown_provider", "unknown_model", "local_authentication_missing", "local_authentication_unavailable",
|
|
69
|
+
"model_unavailable", "thinking_level_unavailable", "execution_selection_mismatch", "external_mcp_unavailable",
|
|
70
|
+
"external_mcp_no_tools", "external_mcp_grant_tool_missing", "external_mcp_activation_mismatch",
|
|
71
|
+
"extension_not_installed", "extension_version_mismatch", "extension_load_failed", "extension_tool_conflict",
|
|
72
|
+
"extension_tool_missing", "skill_load_failed", "pi_session_creation_failed",
|
|
73
|
+
]);
|
|
74
|
+
class InnerError extends Error {
|
|
75
|
+
code;
|
|
76
|
+
constructor(code) {
|
|
77
|
+
super("Writer refused the operation");
|
|
78
|
+
this.code = code;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
class Connection {
|
|
82
|
+
transport;
|
|
83
|
+
client = new Client({ name: "pi-session-mcp-relay", version: "1" }, { capabilities: {} });
|
|
84
|
+
lost = false;
|
|
85
|
+
onLost;
|
|
86
|
+
tools = [];
|
|
87
|
+
constructor(transport) {
|
|
88
|
+
this.transport = transport;
|
|
89
|
+
const lost = () => { this.lost = true; this.onLost?.(); };
|
|
90
|
+
this.client.onclose = lost;
|
|
91
|
+
this.client.onerror = lost;
|
|
92
|
+
}
|
|
93
|
+
async connect(ms, signal) {
|
|
94
|
+
await bounded(s => this.client.connect(this.transport, { signal: s, timeout: ms }), ms, signal);
|
|
95
|
+
}
|
|
96
|
+
async discover(ms, signal) {
|
|
97
|
+
const result = await bounded(s => this.client.listTools({}, { signal: s, timeout: ms }), ms, signal);
|
|
98
|
+
this.tools = result.tools;
|
|
99
|
+
}
|
|
100
|
+
async call(name, args, schema, ms = RELAY_CALL_TIMEOUT_MS, signal) {
|
|
101
|
+
const toolDefinition = this.tools.find(tool => tool.name === name);
|
|
102
|
+
if (!toolDefinition)
|
|
103
|
+
throw new Error("Writer tool missing");
|
|
104
|
+
const result = await bounded(s => this.client.callTool({ name, arguments: args }, { signal: s, timeout: ms, maxTotalTimeout: ms, resetTimeoutOnProgress: false, toolDefinition }), ms, signal);
|
|
105
|
+
try {
|
|
106
|
+
const raw = result.structuredContent;
|
|
107
|
+
const encoded = JSON.stringify(raw);
|
|
108
|
+
if (encoded === undefined || Buffer.byteLength(encoded) > RELAY_MAX_RESPONSE_BYTES)
|
|
109
|
+
throw new Error();
|
|
110
|
+
const refusal = errorSchema.safeParse(raw);
|
|
111
|
+
if (refusal.success)
|
|
112
|
+
throw new InnerError(refusal.data.error.code);
|
|
113
|
+
return schema.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof InnerError)
|
|
117
|
+
throw error;
|
|
118
|
+
this.lost = true;
|
|
119
|
+
this.onLost?.();
|
|
120
|
+
throw new Error("Invalid writer response");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async cleanup(sessionId, ms = RELAY_CLEANUP_DEADLINE_MS) {
|
|
124
|
+
// One deadline covers the RPC AND transport close, not two consecutive deadlines.
|
|
125
|
+
const end = Date.now() + ms;
|
|
126
|
+
try {
|
|
127
|
+
if (sessionId && !this.lost)
|
|
128
|
+
await this.call("pi_session_close", { sessionId }, successSchema, Math.max(0, end - Date.now()));
|
|
129
|
+
}
|
|
130
|
+
catch { /* EOF still requests launcher cleanup. */ }
|
|
131
|
+
finally {
|
|
132
|
+
try {
|
|
133
|
+
const closing = this.transport.close();
|
|
134
|
+
void closing.catch(() => undefined);
|
|
135
|
+
await bounded(() => closing, Math.max(0, end - Date.now()));
|
|
136
|
+
}
|
|
137
|
+
catch { /* bounded shutdown */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
class RelayHandle {
|
|
142
|
+
connection;
|
|
143
|
+
placement;
|
|
144
|
+
pollIntervalMs;
|
|
145
|
+
sdkSessionId;
|
|
146
|
+
appliedSelection;
|
|
147
|
+
deadlines = { preflightMs: RELAY_PREFLIGHT_DEADLINE_MS, abortMs: RELAY_ABORT_DEADLINE_MS, cleanupMs: RELAY_CLEANUP_DEADLINE_MS };
|
|
148
|
+
active;
|
|
149
|
+
disposed = false;
|
|
150
|
+
disposal;
|
|
151
|
+
constructor(connection, session, placement, pollIntervalMs) {
|
|
152
|
+
this.connection = connection;
|
|
153
|
+
this.placement = placement;
|
|
154
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
155
|
+
this.sdkSessionId = session.id;
|
|
156
|
+
this.appliedSelection = { provider: session.provider, model: session.model, thinkingLevel: session.thinkingLevel };
|
|
157
|
+
connection.onLost = () => this.settle({ status: "failed" });
|
|
158
|
+
}
|
|
159
|
+
isLost() { return this.connection.lost; }
|
|
160
|
+
settle(outcome) { const active = this.active; this.active = undefined; active?.settle(outcome); }
|
|
161
|
+
lose() { this.connection.lost = true; this.settle({ status: "failed" }); }
|
|
162
|
+
observe(turn) {
|
|
163
|
+
const active = this.active;
|
|
164
|
+
if (!active || turn.turnId !== active.turnId || turn.sessionId !== this.sdkSessionId) {
|
|
165
|
+
this.lose();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (active.updatedAt !== undefined && active.updatedAt !== turn.updatedAt)
|
|
169
|
+
active.activity?.();
|
|
170
|
+
active.updatedAt = turn.updatedAt;
|
|
171
|
+
if (turn.state === "completed")
|
|
172
|
+
this.settle({ status: "completed", assistantText: turn.assistantText, truncated: turn.truncated });
|
|
173
|
+
else if (turn.state === "aborted" || turn.state === "failed")
|
|
174
|
+
this.settle({ status: turn.state });
|
|
175
|
+
}
|
|
176
|
+
async prompt(text, preflight, onActivity) {
|
|
177
|
+
if (this.isLost() || this.disposed)
|
|
178
|
+
throw new RegistryError("writer_session_lost");
|
|
179
|
+
if (this.active)
|
|
180
|
+
throw new Error("Relay turn already running");
|
|
181
|
+
let settle;
|
|
182
|
+
const outcome = new Promise(resolve => { settle = resolve; });
|
|
183
|
+
const active = { settle, activity: onActivity };
|
|
184
|
+
this.active = active;
|
|
185
|
+
try {
|
|
186
|
+
const result = await this.connection.call("pi_session_prompt", { sessionId: this.sdkSessionId, prompt: text }, promptSuccessSchema);
|
|
187
|
+
if (result.turn.state !== "running" || result.session.id !== this.sdkSessionId) {
|
|
188
|
+
this.lose();
|
|
189
|
+
preflight(false);
|
|
190
|
+
}
|
|
191
|
+
else if (this.active === active) {
|
|
192
|
+
active.turnId = result.turn.turnId;
|
|
193
|
+
active.updatedAt = result.turn.updatedAt;
|
|
194
|
+
preflight(true);
|
|
195
|
+
void this.poll(active);
|
|
196
|
+
}
|
|
197
|
+
else
|
|
198
|
+
preflight(false);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
preflight(false);
|
|
202
|
+
if (!(error instanceof InnerError) && !this.isLost()) {
|
|
203
|
+
// Acceptance is uncertain: ask the inner registry to settle before returning failure.
|
|
204
|
+
try {
|
|
205
|
+
await this.abort();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
this.lose();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
this.settle({ status: "failed" });
|
|
212
|
+
}
|
|
213
|
+
return outcome;
|
|
214
|
+
}
|
|
215
|
+
async poll(active) {
|
|
216
|
+
while (this.active === active && !this.isLost() && !this.disposed) {
|
|
217
|
+
await new Promise(resolve => setTimeout(resolve, this.pollIntervalMs));
|
|
218
|
+
if (this.active !== active || this.isLost() || this.disposed)
|
|
219
|
+
return;
|
|
220
|
+
try {
|
|
221
|
+
const { turn } = await this.connection.call("pi_turn_get", { sessionId: this.sdkSessionId, turnId: active.turnId }, turnSuccessSchema);
|
|
222
|
+
// An abort may have settled this turn and a new prompt may already be running.
|
|
223
|
+
if (this.active === active)
|
|
224
|
+
this.observe(turn);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
this.lose();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async abort() {
|
|
232
|
+
if (this.isLost())
|
|
233
|
+
throw new Error("Writer session lost");
|
|
234
|
+
const active = this.active;
|
|
235
|
+
try {
|
|
236
|
+
await bounded(async (signal) => {
|
|
237
|
+
const result = await this.connection.call("pi_session_abort", { sessionId: this.sdkSessionId }, successSchema, RELAY_CALL_TIMEOUT_MS, signal);
|
|
238
|
+
if (result.session.id !== this.sdkSessionId)
|
|
239
|
+
throw new Error("Writer session mismatch");
|
|
240
|
+
if (!active || this.active !== active)
|
|
241
|
+
return;
|
|
242
|
+
// A lost prompt response has no turn ID. It cannot be safely resumed.
|
|
243
|
+
if (!active.turnId)
|
|
244
|
+
throw new Error("Writer turn unknown");
|
|
245
|
+
const { turn } = await this.connection.call("pi_turn_get", { sessionId: this.sdkSessionId, turnId: active.turnId }, turnSuccessSchema, RELAY_CALL_TIMEOUT_MS, signal);
|
|
246
|
+
if (turn.state === "running")
|
|
247
|
+
throw new Error("Writer abort incomplete");
|
|
248
|
+
if (this.active === active)
|
|
249
|
+
this.observe(turn);
|
|
250
|
+
}, RELAY_ABORT_DEADLINE_MS);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
this.lose();
|
|
254
|
+
throw new Error("Writer abort failed");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
dispose() {
|
|
258
|
+
if (!this.disposal) {
|
|
259
|
+
this.disposed = true;
|
|
260
|
+
this.settle({ status: "failed" });
|
|
261
|
+
this.disposal = this.connection.cleanup(this.sdkSessionId);
|
|
262
|
+
}
|
|
263
|
+
return this.disposal;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export class WriterRelayAdapter {
|
|
267
|
+
options;
|
|
268
|
+
constructor(options) {
|
|
269
|
+
this.options = options;
|
|
270
|
+
}
|
|
271
|
+
async create(input) {
|
|
272
|
+
if (process.platform !== "linux")
|
|
273
|
+
throw new WriterUnavailableError("none_ready");
|
|
274
|
+
const now = this.options.now ?? Date.now, end = now() + RELAY_START_BUDGET_MS;
|
|
275
|
+
const left = () => Math.max(0, end - now());
|
|
276
|
+
const remaining = () => Math.min(RELAY_CALL_TIMEOUT_MS, left());
|
|
277
|
+
const settings = this.options.workspaceModes.get(input.workspace)?.container;
|
|
278
|
+
const ready = [];
|
|
279
|
+
for (let i = 1; i <= (settings?.writers ?? 0); i++) {
|
|
280
|
+
const name = `${input.workspace}-w${i}`, state = await readState(this.options.home, name);
|
|
281
|
+
if (state?.phase === "ready" && state.launch === "relay" && state.acceptance)
|
|
282
|
+
ready.push({ name, state });
|
|
283
|
+
}
|
|
284
|
+
if (!ready.length)
|
|
285
|
+
throw new WriterUnavailableError("none_ready");
|
|
286
|
+
const base = input.base ?? settings?.base;
|
|
287
|
+
const candidates = base === undefined ? [] : ready.filter(({ state }) => state.base.ref === `refs/heads/${base}`);
|
|
288
|
+
if (!candidates.length)
|
|
289
|
+
throw new WriterUnavailableError("none_at_base");
|
|
290
|
+
const counts = { busy: 0, notAtBaseline: 0, settingsChanged: 0 };
|
|
291
|
+
const env = { HOME: this.options.home };
|
|
292
|
+
for (const key of ["PATH", "XDG_RUNTIME_DIR"])
|
|
293
|
+
if (process.env[key] !== undefined)
|
|
294
|
+
env[key] = process.env[key];
|
|
295
|
+
const factory = this.options.spawn ?? { spawn: (wrapper, environment) => new ChildStdioTransport(wrapper, environment) };
|
|
296
|
+
for (const candidate of candidates) {
|
|
297
|
+
input.signal?.throwIfAborted();
|
|
298
|
+
if (left() <= 0)
|
|
299
|
+
break;
|
|
300
|
+
let connection, sessionId, admitted = false;
|
|
301
|
+
try {
|
|
302
|
+
connection = new Connection(factory.spawn(writerPaths(this.options.home, candidate.name).wrapper, env));
|
|
303
|
+
// The handshake waits for credential warm-up and the container start: it may use the whole budget.
|
|
304
|
+
await connection.connect(left(), input.signal);
|
|
305
|
+
await connection.discover(remaining(), input.signal);
|
|
306
|
+
const capabilities = await connection.call("pi_capabilities_get", {}, capabilitiesSchema, remaining(), input.signal);
|
|
307
|
+
const inner = capabilities.executionProfiles.find(profile => profile.alias === input.executionProfile.alias);
|
|
308
|
+
const host = this.options.executionProfiles.get(input.executionProfile.alias);
|
|
309
|
+
if (!host || !inner || ["permissionProfile", "provider", "model", "thinkingLevel"].some(key => inner[key] !== host[key])) {
|
|
310
|
+
counts.settingsChanged++;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const result = await connection.call("pi_session_start", { workspace: input.workspace, executionProfile: input.executionProfile.alias }, successSchema, remaining(), input.signal);
|
|
314
|
+
sessionId = result.session.id;
|
|
315
|
+
if (remaining() <= 0 || connection.lost)
|
|
316
|
+
continue;
|
|
317
|
+
const state = await readState(this.options.home, candidate.name);
|
|
318
|
+
if (!state || state.base.ref !== candidate.state.base.ref || state.base.commit !== candidate.state.base.commit)
|
|
319
|
+
continue;
|
|
320
|
+
input.signal?.throwIfAborted();
|
|
321
|
+
admitted = true;
|
|
322
|
+
return new RelayHandle(connection, result.session, { writer: candidate.name, base: state.base }, this.options.pollIntervalMs ?? RELAY_POLL_INTERVAL_MS);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (input.signal?.aborted)
|
|
326
|
+
throw input.signal.reason;
|
|
327
|
+
if (error instanceof InnerError) {
|
|
328
|
+
if (error.code === "writer_busy")
|
|
329
|
+
counts.busy++;
|
|
330
|
+
else if (error.code === "writer_uncollected")
|
|
331
|
+
counts.notAtBaseline++;
|
|
332
|
+
else if (!["writer_fenced", "writer_not_ready", "container_runtime_unavailable"].includes(error.code)) {
|
|
333
|
+
const code = error.code;
|
|
334
|
+
throw new PiSessionCreationError(creationCodes.has(code) ? code : "pi_session_creation_failed");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
// Own bound, not the start budget: a started inner session is always closed explicitly.
|
|
340
|
+
if (!admitted && connection)
|
|
341
|
+
await connection.cleanup(sessionId);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
throw new WriterUnavailableError("none_admitted", counts);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
export class RoutingPiSessionAdapter {
|
|
348
|
+
host;
|
|
349
|
+
relay;
|
|
350
|
+
workspaceModes;
|
|
351
|
+
constructor(host, relay, workspaceModes) {
|
|
352
|
+
this.host = host;
|
|
353
|
+
this.relay = relay;
|
|
354
|
+
this.workspaceModes = workspaceModes;
|
|
355
|
+
}
|
|
356
|
+
create(input) {
|
|
357
|
+
return (input.executionProfile.permissionProfile === "coding" && this.workspaceModes.get(input.workspace)?.mode === "container" ? this.relay : this.host).create(input);
|
|
358
|
+
}
|
|
359
|
+
}
|
package/dist/writer-state.d.ts
CHANGED
|
@@ -17,6 +17,10 @@ export declare const writerStateSchema: z.ZodObject<{
|
|
|
17
17
|
ephemeral: "ephemeral";
|
|
18
18
|
persistent: "persistent";
|
|
19
19
|
}>;
|
|
20
|
+
launch: z.ZodDefault<z.ZodEnum<{
|
|
21
|
+
legacy: "legacy";
|
|
22
|
+
relay: "relay";
|
|
23
|
+
}>>;
|
|
20
24
|
profiles: z.ZodReadonly<z.ZodArray<z.ZodString>>;
|
|
21
25
|
providers: z.ZodReadonly<z.ZodArray<z.ZodObject<{
|
|
22
26
|
provider: z.ZodString;
|
|
@@ -29,7 +33,12 @@ export declare const writerStateSchema: z.ZodObject<{
|
|
|
29
33
|
cache: z.ZodEnum<{
|
|
30
34
|
go: "go";
|
|
31
35
|
none: "none";
|
|
36
|
+
uv: "uv";
|
|
32
37
|
}>;
|
|
38
|
+
tags: z.ZodDefault<z.ZodEnum<{
|
|
39
|
+
none: "none";
|
|
40
|
+
reachable: "reachable";
|
|
41
|
+
}>>;
|
|
33
42
|
imageId: z.ZodString;
|
|
34
43
|
base: z.ZodObject<{
|
|
35
44
|
ref: z.ZodString;
|
|
@@ -49,7 +58,9 @@ export declare const writerStateSchema: z.ZodObject<{
|
|
|
49
58
|
at: z.ZodString;
|
|
50
59
|
}, z.core.$strict>>;
|
|
51
60
|
}, z.core.$strict>;
|
|
52
|
-
export type WriterState = z.infer<typeof writerStateSchema
|
|
61
|
+
export type WriterState = Omit<z.infer<typeof writerStateSchema>, "launch"> & {
|
|
62
|
+
launch?: "legacy" | "relay";
|
|
63
|
+
};
|
|
53
64
|
export declare function writerName(alias: string, index: number): string;
|
|
54
65
|
export declare function writerRegistrationName(name: string): string;
|
|
55
66
|
export declare function writersRoot(home: string): string;
|
|
@@ -61,6 +72,7 @@ export declare function writerPaths(home: string, name: string): {
|
|
|
61
72
|
config: string;
|
|
62
73
|
events: string;
|
|
63
74
|
go: string;
|
|
75
|
+
uv: string;
|
|
64
76
|
wrapper: string;
|
|
65
77
|
state: string;
|
|
66
78
|
acceptanceFailure: string;
|
package/dist/writer-state.js
CHANGED
|
@@ -24,13 +24,15 @@ export const writerStateSchema = z.object({
|
|
|
24
24
|
index: indexSchema,
|
|
25
25
|
phase: z.enum(["provisioning", "ready", "not_ready"]),
|
|
26
26
|
lifecycle: z.enum(["persistent", "ephemeral"]),
|
|
27
|
+
launch: z.enum(["legacy", "relay"]).default("legacy"),
|
|
27
28
|
profiles: z.array(aliasSchema).min(1).readonly(),
|
|
28
29
|
providers: z.array(z.object({
|
|
29
30
|
provider: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
|
|
30
31
|
model: z.string().min(1).max(200),
|
|
31
32
|
}).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
|
|
32
33
|
network: z.enum(["pasta", "none"]),
|
|
33
|
-
cache: z.enum(["none", "go"]),
|
|
34
|
+
cache: z.enum(["none", "go", "uv"]),
|
|
35
|
+
tags: z.enum(["none", "reachable"]).default("none"),
|
|
34
36
|
imageId: hex64,
|
|
35
37
|
base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
|
|
36
38
|
acceptance: z.object({
|
|
@@ -60,7 +62,7 @@ export function writerPaths(home, name) {
|
|
|
60
62
|
const root = join(writersRoot(home), name);
|
|
61
63
|
return {
|
|
62
64
|
root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
|
|
63
|
-
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
65
|
+
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
64
66
|
acceptanceFailure: join(root, "acceptance-failure.json"),
|
|
65
67
|
};
|
|
66
68
|
}
|
package/docs/acceptance.md
CHANGED
|
@@ -2457,6 +2457,178 @@ Not established for `0.11.6`:
|
|
|
2457
2457
|
wrapper's periodic credential copy (#136);
|
|
2458
2458
|
- the VS Code wake; macOS delivery.
|
|
2459
2459
|
|
|
2460
|
+
### v0.12.1 pre-release acceptance
|
|
2461
|
+
|
|
2462
|
+
`v0.12.1` was accepted on 2026-10-28 from 13:12 to 13:17 UTC on its release
|
|
2463
|
+
candidate `c6b472d`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
|
|
2464
|
+
Codex CLI `0.162.1` and Claude Code `2.1.312`. Level 2 passed in the working
|
|
2465
|
+
tree and for the packed tarball, each with delivery off and with delivery on.
|
|
2466
|
+
Level 1 passed except `npm audit`. Later commits on the release pull request
|
|
2467
|
+
change only this record.
|
|
2468
|
+
|
|
2469
|
+
The patch ships #160 on top of 0.12.0. It does not change the MCP contract or
|
|
2470
|
+
the setup report schema.
|
|
2471
|
+
|
|
2472
|
+
- Level 1 in a clean worktree of the candidate after `npm ci`: every command
|
|
2473
|
+
exited with `0`, except `npm audit`; `git status --short` printed nothing.
|
|
2474
|
+
- The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
|
|
2475
|
+
120 files, 3708 passed and 14 skipped, and no unhandled error. It ran on a
|
|
2476
|
+
host with a real writer lease directory, which the Doctor mode-order test
|
|
2477
|
+
no longer reads.
|
|
2478
|
+
- `npm audit` exited with `1` and names only the known `undici` finding
|
|
2479
|
+
through `@earendil-works/pi-coding-agent` 1.4.0
|
|
2480
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
2481
|
+
- `npm pack` listed 907 files (tarball shasum
|
|
2482
|
+
`f206e0eb0818342fb9e6a36c2c147fb690c515ce`); a scan of the packed files for
|
|
2483
|
+
private names, hosts and paths found none.
|
|
2484
|
+
- Doctor reported `packageVersion` `0.12.1`. It exited with `2` because no
|
|
2485
|
+
configuration was set (`config_missing`).
|
|
2486
|
+
- Level 2 in the working tree: 160 of 160 checks passed with delivery off and
|
|
2487
|
+
228 of 228 with `--events spool`.
|
|
2488
|
+
- Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
|
|
2489
|
+
228 of 228 with `--events spool`. The install smoke reported `smoke_passed`
|
|
2490
|
+
with version `0.12.1`.
|
|
2491
|
+
- In every Level 2 run the real client registrations and the built files were
|
|
2492
|
+
unchanged, no process remained, and the leak check passed.
|
|
2493
|
+
- Podman-gated writer tests on the candidate: 4 of 4 passed.
|
|
2494
|
+
- The candidate's setup verifier against its own server with the operator's
|
|
2495
|
+
real configuration, which has five container workspaces, reported
|
|
2496
|
+
`verified`, with initialize, `tools/list` (eight tools) and
|
|
2497
|
+
`pi_capabilities_get`. The same check with the installed 0.12.0 failed at
|
|
2498
|
+
`pi_capabilities_get`.
|
|
2499
|
+
- Review: `review-glm` (another model family than the author) on the fix,
|
|
2500
|
+
verdict `NON_BLOCKING_ONLY`; the shared writer bound and the real-server test
|
|
2501
|
+
with container workspaces were added.
|
|
2502
|
+
|
|
2503
|
+
Not established for `0.12.1`: client-target `--verify` through the installed
|
|
2504
|
+
package, which follows the upgrade; Level 3.
|
|
2505
|
+
|
|
2506
|
+
### v0.12.0 post-release evidence
|
|
2507
|
+
|
|
2508
|
+
Recorded on 2026-10-28 for the published release `0.12.0`.
|
|
2509
|
+
|
|
2510
|
+
Tag binding: a fresh clone of the annotated tag `v0.12.0` (tag object
|
|
2511
|
+
`bb77dfd8a4e49849d5e9c7fd305fb00baf443044`) peels to the merge commit
|
|
2512
|
+
`dfbef8c5efce38eacc8704dbaf93c534cdad0ef8` of #159, and
|
|
2513
|
+
`git describe --tags --exact-match` reports `v0.12.0`. CI on the merge commit
|
|
2514
|
+
passed on Linux, macOS 15, macOS 26 and `verify` after one rerun: the first
|
|
2515
|
+
macOS 26 run hit a timeout in `test/extension-ablation-deps.test.ts`
|
|
2516
|
+
(5044 ms against 5000 ms), a git-heavy evaluation test that the release did not
|
|
2517
|
+
touch. CI on the tag passed on the first attempt.
|
|
2518
|
+
|
|
2519
|
+
- Level 1 on the tag clone after `npm ci`: typecheck, `build:cli`, the test
|
|
2520
|
+
run (120 files, 3693 passed, 14 skipped, with
|
|
2521
|
+
`NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
|
|
2522
|
+
`git diff --check` exited with `0`, and the status stayed clean. Doctor
|
|
2523
|
+
reported `packageVersion` `0.12.0`.
|
|
2524
|
+
- `pi-session-mcp@0.12.0` on npm: 907 files, shasum
|
|
2525
|
+
`4699b05ef849e88c6d56f5ddae6b06984799cea6`, `gitHead` `1fb8a7e` (the head of
|
|
2526
|
+
#159, whose tree equals the merge commit). A local `npm pack` of the merge
|
|
2527
|
+
commit gives the same shasum. A scan of the published files for private
|
|
2528
|
+
names, hosts and paths found none.
|
|
2529
|
+
- Published npm package, Level 2 from a fresh prefix: 160 of 160 with delivery
|
|
2530
|
+
off and 228 of 228 with `--events spool`; the install smoke reported
|
|
2531
|
+
`smoke_passed` with version `0.12.0`. Clients: Codex CLI `0.162.1` and Claude
|
|
2532
|
+
Code `2.1.312`.
|
|
2533
|
+
- The GitHub release `v0.12.0` was published the same day.
|
|
2534
|
+
|
|
2535
|
+
Operator upgrade of the workstation from `0.11.8` to `0.12.0` on 2026-10-28,
|
|
2536
|
+
following [Installation](installation.md#container-writers):
|
|
2537
|
+
|
|
2538
|
+
- Coding sessions were closed and the two pi-session-mcp writer servers were
|
|
2539
|
+
disabled in the controller; one idle controller of another repository still
|
|
2540
|
+
had four writer containers running.
|
|
2541
|
+
- Both host client targets (`claude-code:user`, `codex:user`) were removed with
|
|
2542
|
+
`0.11.8`, then `npm install -g pi-session-mcp@0.12.0`. Doctor reported
|
|
2543
|
+
`packageVersion` `0.12.0` and, as expected, every writer `writer_not_ready`
|
|
2544
|
+
and `writer_not_migrated`.
|
|
2545
|
+
- Five container repositories with 18 writers were migrated by repeating their
|
|
2546
|
+
provisioning commands with unchanged settings, each first as a dry run
|
|
2547
|
+
(`planned`), then with `--apply`: pi-session-mcp (2 writers), node-app (4),
|
|
2548
|
+
go-app (4, operator image, `--cache go`), pynode-app (4, operator image,
|
|
2549
|
+
base `dev`) and py-app (4, operator image). Every writer reported
|
|
2550
|
+
`writer_accepted` and is `launch: "relay"`, `ready`, verdict `pass`. Each
|
|
2551
|
+
repository recorded its shared default base (`main`, and `dev` for
|
|
2552
|
+
pynode-app). All 36 owned writer registrations and their hook entries were
|
|
2553
|
+
removed; the fence removed the four running legacy containers; no writer
|
|
2554
|
+
container remained.
|
|
2555
|
+
- `--apply` of both client targets passed, but `--verify` failed with
|
|
2556
|
+
`mcp_failed` on both:
|
|
2557
|
+
[#160](https://github.com/plagor44-ux/pi-session-mcp/issues/160). A direct
|
|
2558
|
+
handshake with the installed server returned the eight tools and the
|
|
2559
|
+
capabilities, including `writers` and `defaultBase` for each container
|
|
2560
|
+
workspace; the 0.12.0 verifier rejected them. The fix, run with the real
|
|
2561
|
+
configuration against the candidate's own server, reported `verified`.
|
|
2562
|
+
- `--writer <name> --verify` passed for all 18 writers over the relay path
|
|
2563
|
+
(`mcp_verified`).
|
|
2564
|
+
- Live relayed coding session through the one host registration, after
|
|
2565
|
+
reconnecting `pi-session-mcp`: `pi_capabilities_get` listed the container
|
|
2566
|
+
workspaces with `writers` and `defaultBase`; `pi_session_start` with profile
|
|
2567
|
+
`code` on the pi-session-mcp workspace leased `pi-session-mcp-w1` at
|
|
2568
|
+
`refs/heads/main` and returned `writer` and `base`. The worker implemented
|
|
2569
|
+
the #160 fix in two turns. Both turns woke the controller through the wake
|
|
2570
|
+
hook from the host spool, without polling. Close, collect and finish
|
|
2571
|
+
succeeded without a reconnect. A read-only `review-glm` session on the host
|
|
2572
|
+
checkout reviewed the commit (`NON_BLOCKING_ONLY`).
|
|
2573
|
+
- The first turn could not commit: the migrated clones had no commit identity
|
|
2574
|
+
(Doctor `writer_git_identity_missing` on all 18 writers), because migration
|
|
2575
|
+
keeps the clone and only provisioning and reset copy the identity. After
|
|
2576
|
+
`--finish` of the used writer and `--reset --apply` of the other 17, Doctor
|
|
2577
|
+
reported no writer finding, and all 18 writers stayed `relay`, `ready` and
|
|
2578
|
+
accepted. The upgrade runbook now names this step.
|
|
2579
|
+
|
|
2580
|
+
Not established for `0.12.0`: the upgrade of the second machine; the live
|
|
2581
|
+
checks of #151 (`--cache uv`) and #154 (`--tags reachable`), which need a
|
|
2582
|
+
settings change of an existing writer; a busy-pool (`writer_unavailable`) and a
|
|
2583
|
+
`base` fix round in live use; Level 3.
|
|
2584
|
+
|
|
2585
|
+
### v0.12.0 pre-release acceptance
|
|
2586
|
+
|
|
2587
|
+
`v0.12.0` was accepted on 2026-10-28 from 11:40 to 11:45 UTC on its release
|
|
2588
|
+
candidate `169f00c`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
|
|
2589
|
+
Codex CLI `0.162.1` and Claude Code `2.1.312`. Level 2 passed in the working
|
|
2590
|
+
tree and for the packed tarball, each with delivery off and with delivery on.
|
|
2591
|
+
Level 1 passed except `npm audit`. Later commits on the release pull request
|
|
2592
|
+
change only this record and the roadmap.
|
|
2593
|
+
|
|
2594
|
+
The minor release ships ADR 0012 (#157) with #151 and #154. It changes the MCP
|
|
2595
|
+
contract as the changelog lists; the setup report keeps `schemaVersion` `1`.
|
|
2596
|
+
|
|
2597
|
+
- Level 1 in a clean worktree of the candidate after `npm ci`: every command
|
|
2598
|
+
exited with `0`, except `npm audit`; `git status --short` printed nothing.
|
|
2599
|
+
- The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
|
|
2600
|
+
120 files, 3693 passed and 14 skipped, and no unhandled error.
|
|
2601
|
+
- `npm audit` exited with `1` and names only the known `undici` finding
|
|
2602
|
+
through `@earendil-works/pi-coding-agent` 1.4.0
|
|
2603
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
2604
|
+
- `npm pack` listed 907 files (tarball shasum
|
|
2605
|
+
`764e22c8373934ce339cfd76ea1e56b96006a34d`); nothing under
|
|
2606
|
+
`docs/superpowers/` is packed, and a scan of the packed files for private
|
|
2607
|
+
names, hosts and paths found none.
|
|
2608
|
+
- Doctor reported `packageVersion` `0.12.0`. It exited with `2` because no
|
|
2609
|
+
configuration was set (`config_missing`).
|
|
2610
|
+
- Level 2 in the working tree: 160 of 160 checks passed with delivery off and
|
|
2611
|
+
228 of 228 with `--events spool`.
|
|
2612
|
+
- Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
|
|
2613
|
+
228 of 228 with `--events spool`. The install smoke reported `smoke_passed`
|
|
2614
|
+
with version `0.12.0`.
|
|
2615
|
+
- In every Level 2 run the real client registrations and the built files were
|
|
2616
|
+
unchanged, no process remained, and the leak check passed.
|
|
2617
|
+
- Review: every task of #157 had a cross-family read-only review before it was
|
|
2618
|
+
integrated; the findings were fixed in #158.
|
|
2619
|
+
|
|
2620
|
+
Level 2 runs no container writer. The Podman-gated tests in
|
|
2621
|
+
`test/writer-lifecycle.podman.test.ts` ran on the candidate with rootless
|
|
2622
|
+
Podman and the local Node image, separately from the suite: 4 of 4 passed,
|
|
2623
|
+
including the lease descriptor check, the finite-input probe and the release of
|
|
2624
|
+
a writer after its relay parent was killed.
|
|
2625
|
+
|
|
2626
|
+
Not established for `0.12.0`: the migration of an installed release's container
|
|
2627
|
+
repositories, a relayed coding session through the one host registration, the
|
|
2628
|
+
live checks of #151 and #154, which follow the release; Level 3. No
|
|
2629
|
+
post-release evidence was recorded for `0.11.9`, a documentation-only release,
|
|
2630
|
+
beyond its release notes.
|
|
2631
|
+
|
|
2460
2632
|
### v0.11.8 post-release evidence
|
|
2461
2633
|
|
|
2462
2634
|
The published-release acceptance of `v0.11.8` ran on 2026-10-26 from 22:40 to
|