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.
Files changed (75) hide show
  1. package/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +6 -2
  2. package/.pi-session-mcp-cli/doctor.js +89 -4
  3. package/.pi-session-mcp-cli/execution-modes.js +19 -5
  4. package/.pi-session-mcp-cli/override-lock.js +4 -4
  5. package/.pi-session-mcp-cli/setup-process.js +5 -4
  6. package/.pi-session-mcp-cli/writer-boundary.js +10 -4
  7. package/.pi-session-mcp-cli/writer-fence.js +50 -0
  8. package/.pi-session-mcp-cli/writer-lease.js +140 -0
  9. package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
  10. package/.pi-session-mcp-cli/writer-state.js +4 -2
  11. package/CHANGELOG.md +91 -0
  12. package/README.md +3 -2
  13. package/dist/capabilities.d.ts +2 -0
  14. package/dist/capabilities.js +4 -1
  15. package/dist/child-stdio-transport.d.ts +21 -0
  16. package/dist/child-stdio-transport.js +88 -0
  17. package/dist/client-adapters/mcp-verifier.js +6 -2
  18. package/dist/client-adapters/types.d.ts +1 -0
  19. package/dist/doctor.d.ts +3 -1
  20. package/dist/doctor.js +89 -4
  21. package/dist/execution-modes.d.ts +14 -2
  22. package/dist/execution-modes.js +19 -5
  23. package/dist/main.js +9 -2
  24. package/dist/override-lock.d.ts +2 -0
  25. package/dist/override-lock.js +4 -4
  26. package/dist/pi-adapter.d.ts +30 -0
  27. package/dist/pi-adapter.js +11 -0
  28. package/dist/refusal-server.d.ts +1 -1
  29. package/dist/refusal-server.js +24 -2
  30. package/dist/server.d.ts +2 -2
  31. package/dist/server.js +18 -53
  32. package/dist/session-registry.d.ts +9 -2
  33. package/dist/session-registry.js +17 -12
  34. package/dist/setup-cli.js +7 -2
  35. package/dist/setup-hooks.js +0 -2
  36. package/dist/setup-modes.js +7 -1
  37. package/dist/setup-process.d.ts +3 -1
  38. package/dist/setup-process.js +5 -4
  39. package/dist/setup-writers.d.ts +5 -0
  40. package/dist/setup-writers.js +203 -94
  41. package/dist/tool-schemas.d.ts +339 -0
  42. package/dist/tool-schemas.js +49 -0
  43. package/dist/wake-hook.d.ts +4 -5
  44. package/dist/wake-hook.js +3 -6
  45. package/dist/writer-acceptance.js +10 -1
  46. package/dist/writer-boundary.d.ts +6 -4
  47. package/dist/writer-boundary.js +10 -4
  48. package/dist/writer-fence.d.ts +12 -0
  49. package/dist/writer-fence.js +50 -0
  50. package/dist/writer-launch.d.ts +9 -1
  51. package/dist/writer-launch.js +127 -30
  52. package/dist/writer-lease.d.ts +28 -0
  53. package/dist/writer-lease.js +140 -0
  54. package/dist/writer-lifecycle.d.ts +7 -1
  55. package/dist/writer-lifecycle.js +55 -23
  56. package/dist/writer-relay.d.ts +33 -0
  57. package/dist/writer-relay.js +359 -0
  58. package/dist/writer-state.d.ts +13 -1
  59. package/dist/writer-state.js +4 -2
  60. package/docs/acceptance.md +172 -0
  61. package/docs/adr/0012-leased-writer-pool.md +357 -0
  62. package/docs/architecture.md +61 -13
  63. package/docs/client-setup.md +11 -15
  64. package/docs/doctor-design.md +10 -4
  65. package/docs/execution-modes.md +87 -44
  66. package/docs/installation.md +43 -22
  67. package/docs/isolation-setup.md +88 -102
  68. package/docs/roadmap.md +6 -0
  69. package/docs/threat-model.md +7 -4
  70. package/docs/tool-contracts.md +29 -12
  71. package/package.json +1 -1
  72. package/scripts/isolation-acceptance.mjs +7 -1
  73. package/skills/pi-session-implementation/SKILL.md +1 -0
  74. package/skills/pi-session-orchestration/SKILL.md +6 -5
  75. 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
+ }
@@ -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;
@@ -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
  }
@@ -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