pi-session-mcp 0.11.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/doctor.js +89 -4
- package/.pi-session-mcp-cli/execution-modes.js +16 -4
- 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 +69 -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/types.d.ts +1 -0
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +89 -4
- package/dist/execution-modes.d.ts +12 -2
- package/dist/execution-modes.js +16 -4
- 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 +200 -92
- 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 +47 -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 +34 -22
- package/docs/isolation-setup.md +88 -102
- package/docs/roadmap.md +5 -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,53 @@ 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.0 pre-release acceptance
|
|
2461
|
+
|
|
2462
|
+
`v0.12.0` was accepted on 2026-10-28 from 11:40 to 11:45 UTC on its release
|
|
2463
|
+
candidate `169f00c`, 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 and the roadmap.
|
|
2468
|
+
|
|
2469
|
+
The minor release ships ADR 0012 (#157) with #151 and #154. It changes the MCP
|
|
2470
|
+
contract as the changelog lists; the setup report keeps `schemaVersion` `1`.
|
|
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, 3693 passed and 14 skipped, and no unhandled error.
|
|
2476
|
+
- `npm audit` exited with `1` and names only the known `undici` finding
|
|
2477
|
+
through `@earendil-works/pi-coding-agent` 1.4.0
|
|
2478
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
2479
|
+
- `npm pack` listed 907 files (tarball shasum
|
|
2480
|
+
`764e22c8373934ce339cfd76ea1e56b96006a34d`); nothing under
|
|
2481
|
+
`docs/superpowers/` is packed, and a scan of the packed files for private
|
|
2482
|
+
names, hosts and paths found none.
|
|
2483
|
+
- Doctor reported `packageVersion` `0.12.0`. It exited with `2` because no
|
|
2484
|
+
configuration was set (`config_missing`).
|
|
2485
|
+
- Level 2 in the working tree: 160 of 160 checks passed with delivery off and
|
|
2486
|
+
228 of 228 with `--events spool`.
|
|
2487
|
+
- Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
|
|
2488
|
+
228 of 228 with `--events spool`. The install smoke reported `smoke_passed`
|
|
2489
|
+
with version `0.12.0`.
|
|
2490
|
+
- In every Level 2 run the real client registrations and the built files were
|
|
2491
|
+
unchanged, no process remained, and the leak check passed.
|
|
2492
|
+
- Review: every task of #157 had a cross-family read-only review before it was
|
|
2493
|
+
integrated; the findings were fixed in #158.
|
|
2494
|
+
|
|
2495
|
+
Level 2 runs no container writer. The Podman-gated tests in
|
|
2496
|
+
`test/writer-lifecycle.podman.test.ts` ran on the candidate with rootless
|
|
2497
|
+
Podman and the local Node image, separately from the suite: 4 of 4 passed,
|
|
2498
|
+
including the lease descriptor check, the finite-input probe and the release of
|
|
2499
|
+
a writer after its relay parent was killed.
|
|
2500
|
+
|
|
2501
|
+
Not established for `0.12.0`: the migration of an installed release's container
|
|
2502
|
+
repositories, a relayed coding session through the one host registration, the
|
|
2503
|
+
live checks of #151 and #154, which follow the release; Level 3. No
|
|
2504
|
+
post-release evidence was recorded for `0.11.9`, a documentation-only release,
|
|
2505
|
+
beyond its release notes.
|
|
2506
|
+
|
|
2460
2507
|
### v0.11.8 post-release evidence
|
|
2461
2508
|
|
|
2462
2509
|
The published-release acceptance of `v0.11.8` ran on 2026-10-26 from 22:40 to
|