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.
Files changed (73) hide show
  1. package/.pi-session-mcp-cli/doctor.js +89 -4
  2. package/.pi-session-mcp-cli/execution-modes.js +16 -4
  3. package/.pi-session-mcp-cli/override-lock.js +4 -4
  4. package/.pi-session-mcp-cli/setup-process.js +5 -4
  5. package/.pi-session-mcp-cli/writer-boundary.js +10 -4
  6. package/.pi-session-mcp-cli/writer-fence.js +50 -0
  7. package/.pi-session-mcp-cli/writer-lease.js +140 -0
  8. package/.pi-session-mcp-cli/writer-lifecycle.js +55 -23
  9. package/.pi-session-mcp-cli/writer-state.js +4 -2
  10. package/CHANGELOG.md +69 -0
  11. package/README.md +3 -2
  12. package/dist/capabilities.d.ts +2 -0
  13. package/dist/capabilities.js +4 -1
  14. package/dist/child-stdio-transport.d.ts +21 -0
  15. package/dist/child-stdio-transport.js +88 -0
  16. package/dist/client-adapters/types.d.ts +1 -0
  17. package/dist/doctor.d.ts +3 -1
  18. package/dist/doctor.js +89 -4
  19. package/dist/execution-modes.d.ts +12 -2
  20. package/dist/execution-modes.js +16 -4
  21. package/dist/main.js +9 -2
  22. package/dist/override-lock.d.ts +2 -0
  23. package/dist/override-lock.js +4 -4
  24. package/dist/pi-adapter.d.ts +30 -0
  25. package/dist/pi-adapter.js +11 -0
  26. package/dist/refusal-server.d.ts +1 -1
  27. package/dist/refusal-server.js +24 -2
  28. package/dist/server.d.ts +2 -2
  29. package/dist/server.js +18 -53
  30. package/dist/session-registry.d.ts +9 -2
  31. package/dist/session-registry.js +17 -12
  32. package/dist/setup-cli.js +7 -2
  33. package/dist/setup-hooks.js +0 -2
  34. package/dist/setup-modes.js +7 -1
  35. package/dist/setup-process.d.ts +3 -1
  36. package/dist/setup-process.js +5 -4
  37. package/dist/setup-writers.d.ts +5 -0
  38. package/dist/setup-writers.js +200 -92
  39. package/dist/tool-schemas.d.ts +339 -0
  40. package/dist/tool-schemas.js +49 -0
  41. package/dist/wake-hook.d.ts +4 -5
  42. package/dist/wake-hook.js +3 -6
  43. package/dist/writer-acceptance.js +10 -1
  44. package/dist/writer-boundary.d.ts +6 -4
  45. package/dist/writer-boundary.js +10 -4
  46. package/dist/writer-fence.d.ts +12 -0
  47. package/dist/writer-fence.js +50 -0
  48. package/dist/writer-launch.d.ts +9 -1
  49. package/dist/writer-launch.js +127 -30
  50. package/dist/writer-lease.d.ts +28 -0
  51. package/dist/writer-lease.js +140 -0
  52. package/dist/writer-lifecycle.d.ts +7 -1
  53. package/dist/writer-lifecycle.js +55 -23
  54. package/dist/writer-relay.d.ts +33 -0
  55. package/dist/writer-relay.js +359 -0
  56. package/dist/writer-state.d.ts +13 -1
  57. package/dist/writer-state.js +4 -2
  58. package/docs/acceptance.md +47 -0
  59. package/docs/adr/0012-leased-writer-pool.md +357 -0
  60. package/docs/architecture.md +61 -13
  61. package/docs/client-setup.md +11 -15
  62. package/docs/doctor-design.md +10 -4
  63. package/docs/execution-modes.md +87 -44
  64. package/docs/installation.md +34 -22
  65. package/docs/isolation-setup.md +88 -102
  66. package/docs/roadmap.md +5 -0
  67. package/docs/threat-model.md +7 -4
  68. package/docs/tool-contracts.md +29 -12
  69. package/package.json +1 -1
  70. package/scripts/isolation-acceptance.mjs +7 -1
  71. package/skills/pi-session-implementation/SKILL.md +1 -0
  72. package/skills/pi-session-orchestration/SKILL.md +6 -5
  73. package/skills/pi-session-orchestration/references/start-errors.md +5 -1
@@ -0,0 +1,339 @@
1
+ import { z } from "zod";
2
+ export declare const thinkingLevelSchema: z.ZodEnum<{
3
+ high: "high";
4
+ low: "low";
5
+ max: "max";
6
+ medium: "medium";
7
+ minimal: "minimal";
8
+ off: "off";
9
+ xhigh: "xhigh";
10
+ }>;
11
+ export declare const viewSchema: z.ZodObject<{
12
+ id: z.ZodString;
13
+ workspace: z.ZodString;
14
+ executionProfile: z.ZodString;
15
+ profile: z.ZodEnum<{
16
+ coding: "coding";
17
+ "read-only": "read-only";
18
+ }>;
19
+ provider: z.ZodString;
20
+ model: z.ZodString;
21
+ thinkingLevel: z.ZodEnum<{
22
+ high: "high";
23
+ low: "low";
24
+ max: "max";
25
+ medium: "medium";
26
+ minimal: "minimal";
27
+ off: "off";
28
+ xhigh: "xhigh";
29
+ }>;
30
+ state: z.ZodEnum<{
31
+ closed: "closed";
32
+ closing: "closing";
33
+ failed: "failed";
34
+ idle: "idle";
35
+ running: "running";
36
+ }>;
37
+ createdAt: z.ZodString;
38
+ updatedAt: z.ZodString;
39
+ lastError: z.ZodOptional<z.ZodString>;
40
+ writer: z.ZodOptional<z.ZodString>;
41
+ base: z.ZodOptional<z.ZodObject<{
42
+ ref: z.ZodString;
43
+ commit: z.ZodString;
44
+ }, z.core.$strip>>;
45
+ }, z.core.$strip>;
46
+ export declare const aliasSchema: z.ZodString;
47
+ export declare const idInputSchema: z.ZodPipe<z.ZodString, z.ZodString>;
48
+ export declare const successSchema: z.ZodObject<{
49
+ ok: z.ZodLiteral<true>;
50
+ session: z.ZodObject<{
51
+ id: z.ZodString;
52
+ workspace: z.ZodString;
53
+ executionProfile: z.ZodString;
54
+ profile: z.ZodEnum<{
55
+ coding: "coding";
56
+ "read-only": "read-only";
57
+ }>;
58
+ provider: z.ZodString;
59
+ model: z.ZodString;
60
+ thinkingLevel: z.ZodEnum<{
61
+ high: "high";
62
+ low: "low";
63
+ max: "max";
64
+ medium: "medium";
65
+ minimal: "minimal";
66
+ off: "off";
67
+ xhigh: "xhigh";
68
+ }>;
69
+ state: z.ZodEnum<{
70
+ closed: "closed";
71
+ closing: "closing";
72
+ failed: "failed";
73
+ idle: "idle";
74
+ running: "running";
75
+ }>;
76
+ createdAt: z.ZodString;
77
+ updatedAt: z.ZodString;
78
+ lastError: z.ZodOptional<z.ZodString>;
79
+ writer: z.ZodOptional<z.ZodString>;
80
+ base: z.ZodOptional<z.ZodObject<{
81
+ ref: z.ZodString;
82
+ commit: z.ZodString;
83
+ }, z.core.$strip>>;
84
+ }, z.core.$strip>;
85
+ }, z.core.$strip>;
86
+ export declare const turnBase: {
87
+ turnId: z.ZodString;
88
+ sessionId: z.ZodString;
89
+ startedAt: z.ZodString;
90
+ updatedAt: z.ZodString;
91
+ };
92
+ export declare const turnSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
93
+ turnId: z.ZodString;
94
+ sessionId: z.ZodString;
95
+ startedAt: z.ZodString;
96
+ updatedAt: z.ZodString;
97
+ state: z.ZodLiteral<"running">;
98
+ }, z.core.$strip>, z.ZodObject<{
99
+ turnId: z.ZodString;
100
+ sessionId: z.ZodString;
101
+ startedAt: z.ZodString;
102
+ updatedAt: z.ZodString;
103
+ state: z.ZodLiteral<"completed">;
104
+ completedAt: z.ZodString;
105
+ assistantText: z.ZodString;
106
+ truncated: z.ZodBoolean;
107
+ }, z.core.$strip>, z.ZodObject<{
108
+ turnId: z.ZodString;
109
+ sessionId: z.ZodString;
110
+ startedAt: z.ZodString;
111
+ updatedAt: z.ZodString;
112
+ state: z.ZodLiteral<"failed">;
113
+ completedAt: z.ZodString;
114
+ error: z.ZodObject<{
115
+ code: z.ZodLiteral<"turn_failed">;
116
+ message: z.ZodLiteral<"Pi turn failed">;
117
+ }, z.core.$strip>;
118
+ }, z.core.$strip>, z.ZodObject<{
119
+ turnId: z.ZodString;
120
+ sessionId: z.ZodString;
121
+ startedAt: z.ZodString;
122
+ updatedAt: z.ZodString;
123
+ state: z.ZodLiteral<"aborted">;
124
+ completedAt: z.ZodString;
125
+ }, z.core.$strip>], "state">;
126
+ export declare const promptSuccessSchema: z.ZodObject<{
127
+ ok: z.ZodLiteral<true>;
128
+ session: z.ZodObject<{
129
+ id: z.ZodString;
130
+ workspace: z.ZodString;
131
+ executionProfile: z.ZodString;
132
+ profile: z.ZodEnum<{
133
+ coding: "coding";
134
+ "read-only": "read-only";
135
+ }>;
136
+ provider: z.ZodString;
137
+ model: z.ZodString;
138
+ thinkingLevel: z.ZodEnum<{
139
+ high: "high";
140
+ low: "low";
141
+ max: "max";
142
+ medium: "medium";
143
+ minimal: "minimal";
144
+ off: "off";
145
+ xhigh: "xhigh";
146
+ }>;
147
+ state: z.ZodEnum<{
148
+ closed: "closed";
149
+ closing: "closing";
150
+ failed: "failed";
151
+ idle: "idle";
152
+ running: "running";
153
+ }>;
154
+ createdAt: z.ZodString;
155
+ updatedAt: z.ZodString;
156
+ lastError: z.ZodOptional<z.ZodString>;
157
+ writer: z.ZodOptional<z.ZodString>;
158
+ base: z.ZodOptional<z.ZodObject<{
159
+ ref: z.ZodString;
160
+ commit: z.ZodString;
161
+ }, z.core.$strip>>;
162
+ }, z.core.$strip>;
163
+ turn: z.ZodDiscriminatedUnion<[z.ZodObject<{
164
+ turnId: z.ZodString;
165
+ sessionId: z.ZodString;
166
+ startedAt: z.ZodString;
167
+ updatedAt: z.ZodString;
168
+ state: z.ZodLiteral<"running">;
169
+ }, z.core.$strip>, z.ZodObject<{
170
+ turnId: z.ZodString;
171
+ sessionId: z.ZodString;
172
+ startedAt: z.ZodString;
173
+ updatedAt: z.ZodString;
174
+ state: z.ZodLiteral<"completed">;
175
+ completedAt: z.ZodString;
176
+ assistantText: z.ZodString;
177
+ truncated: z.ZodBoolean;
178
+ }, z.core.$strip>, z.ZodObject<{
179
+ turnId: z.ZodString;
180
+ sessionId: z.ZodString;
181
+ startedAt: z.ZodString;
182
+ updatedAt: z.ZodString;
183
+ state: z.ZodLiteral<"failed">;
184
+ completedAt: z.ZodString;
185
+ error: z.ZodObject<{
186
+ code: z.ZodLiteral<"turn_failed">;
187
+ message: z.ZodLiteral<"Pi turn failed">;
188
+ }, z.core.$strip>;
189
+ }, z.core.$strip>, z.ZodObject<{
190
+ turnId: z.ZodString;
191
+ sessionId: z.ZodString;
192
+ startedAt: z.ZodString;
193
+ updatedAt: z.ZodString;
194
+ state: z.ZodLiteral<"aborted">;
195
+ completedAt: z.ZodString;
196
+ }, z.core.$strip>], "state">;
197
+ }, z.core.$strip>;
198
+ export declare const turnSuccessSchema: z.ZodObject<{
199
+ ok: z.ZodLiteral<true>;
200
+ turn: z.ZodDiscriminatedUnion<[z.ZodObject<{
201
+ turnId: z.ZodString;
202
+ sessionId: z.ZodString;
203
+ startedAt: z.ZodString;
204
+ updatedAt: z.ZodString;
205
+ state: z.ZodLiteral<"running">;
206
+ }, z.core.$strip>, z.ZodObject<{
207
+ turnId: z.ZodString;
208
+ sessionId: z.ZodString;
209
+ startedAt: z.ZodString;
210
+ updatedAt: z.ZodString;
211
+ state: z.ZodLiteral<"completed">;
212
+ completedAt: z.ZodString;
213
+ assistantText: z.ZodString;
214
+ truncated: z.ZodBoolean;
215
+ }, z.core.$strip>, z.ZodObject<{
216
+ turnId: z.ZodString;
217
+ sessionId: z.ZodString;
218
+ startedAt: z.ZodString;
219
+ updatedAt: z.ZodString;
220
+ state: z.ZodLiteral<"failed">;
221
+ completedAt: z.ZodString;
222
+ error: z.ZodObject<{
223
+ code: z.ZodLiteral<"turn_failed">;
224
+ message: z.ZodLiteral<"Pi turn failed">;
225
+ }, z.core.$strip>;
226
+ }, z.core.$strip>, z.ZodObject<{
227
+ turnId: z.ZodString;
228
+ sessionId: z.ZodString;
229
+ startedAt: z.ZodString;
230
+ updatedAt: z.ZodString;
231
+ state: z.ZodLiteral<"aborted">;
232
+ completedAt: z.ZodString;
233
+ }, z.core.$strip>], "state">;
234
+ }, z.core.$strip>;
235
+ export declare const listSchema: z.ZodObject<{
236
+ ok: z.ZodLiteral<true>;
237
+ sessions: z.ZodArray<z.ZodObject<{
238
+ id: z.ZodString;
239
+ workspace: z.ZodString;
240
+ executionProfile: z.ZodString;
241
+ profile: z.ZodEnum<{
242
+ coding: "coding";
243
+ "read-only": "read-only";
244
+ }>;
245
+ provider: z.ZodString;
246
+ model: z.ZodString;
247
+ thinkingLevel: z.ZodEnum<{
248
+ high: "high";
249
+ low: "low";
250
+ max: "max";
251
+ medium: "medium";
252
+ minimal: "minimal";
253
+ off: "off";
254
+ xhigh: "xhigh";
255
+ }>;
256
+ state: z.ZodEnum<{
257
+ closed: "closed";
258
+ closing: "closing";
259
+ failed: "failed";
260
+ idle: "idle";
261
+ running: "running";
262
+ }>;
263
+ createdAt: z.ZodString;
264
+ updatedAt: z.ZodString;
265
+ lastError: z.ZodOptional<z.ZodString>;
266
+ writer: z.ZodOptional<z.ZodString>;
267
+ base: z.ZodOptional<z.ZodObject<{
268
+ ref: z.ZodString;
269
+ commit: z.ZodString;
270
+ }, z.core.$strip>>;
271
+ }, z.core.$strip>>;
272
+ }, z.core.$strip>;
273
+ export declare const errorSchema: z.ZodObject<{
274
+ ok: z.ZodLiteral<false>;
275
+ error: z.ZodObject<{
276
+ code: z.ZodString;
277
+ message: z.ZodString;
278
+ reason: z.ZodOptional<z.ZodEnum<{
279
+ none_admitted: "none_admitted";
280
+ none_at_base: "none_at_base";
281
+ none_ready: "none_ready";
282
+ }>>;
283
+ counts: z.ZodOptional<z.ZodObject<{
284
+ busy: z.ZodNumber;
285
+ notAtBaseline: z.ZodNumber;
286
+ settingsChanged: z.ZodNumber;
287
+ }, z.core.$strip>>;
288
+ }, z.core.$strip>;
289
+ }, z.core.$strip>;
290
+ export declare const capabilitiesSchema: z.ZodObject<{
291
+ ok: z.ZodLiteral<true>;
292
+ server: z.ZodObject<{
293
+ name: z.ZodLiteral<string>;
294
+ version: z.ZodLiteral<string>;
295
+ }, z.core.$strict>;
296
+ configuration: z.ZodObject<{
297
+ fingerprint: z.ZodString;
298
+ reloadPolicy: z.ZodLiteral<"restart-required">;
299
+ }, z.core.$strict>;
300
+ events: z.ZodObject<{
301
+ delivery: z.ZodEnum<{
302
+ off: "off";
303
+ spool: "spool";
304
+ }>;
305
+ }, z.core.$strict>;
306
+ workspaces: z.ZodArray<z.ZodObject<{
307
+ alias: z.ZodString;
308
+ mode: z.ZodEnum<{
309
+ container: "container";
310
+ host: "host";
311
+ "read-only": "read-only";
312
+ unset: "unset";
313
+ }>;
314
+ writers: z.ZodOptional<z.ZodNumber>;
315
+ defaultBase: z.ZodOptional<z.ZodNullable<z.ZodString>>;
316
+ }, z.core.$strict>>;
317
+ executionProfiles: z.ZodArray<z.ZodObject<{
318
+ alias: z.ZodString;
319
+ default: z.ZodBoolean;
320
+ permissionProfile: z.ZodEnum<{
321
+ coding: "coding";
322
+ "read-only": "read-only";
323
+ }>;
324
+ provider: z.ZodString;
325
+ model: z.ZodString;
326
+ thinkingLevel: z.ZodEnum<{
327
+ high: "high";
328
+ low: "low";
329
+ max: "max";
330
+ medium: "medium";
331
+ minimal: "minimal";
332
+ off: "off";
333
+ xhigh: "xhigh";
334
+ }>;
335
+ extensions: z.ZodArray<z.ZodString>;
336
+ grantedExtensionTools: z.ZodArray<z.ZodString>;
337
+ skills: z.ZodArray<z.ZodString>;
338
+ }, z.core.$strict>>;
339
+ }, z.core.$strict>;
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import packageMetadata from "../package.json" with { type: "json" };
3
+ import { CONFIGURATION_FINGERPRINT_PATTERN, RELOAD_POLICY } from "./config.js";
4
+ import { ALIAS_PATTERN } from "./execution-profile.js";
5
+ export const thinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
6
+ export const viewSchema = z.object({
7
+ id: z.string(), workspace: z.string(), executionProfile: z.string(), profile: z.enum(["read-only", "coding"]),
8
+ provider: z.string(), model: z.string(), thinkingLevel: thinkingLevelSchema,
9
+ state: z.enum(["idle", "running", "failed", "closing", "closed"]), createdAt: z.string(), updatedAt: z.string(),
10
+ lastError: z.string().optional(),
11
+ writer: z.string().optional(), base: z.object({ ref: z.string(), commit: z.string() }).optional(),
12
+ });
13
+ export const aliasSchema = z.string().regex(ALIAS_PATTERN);
14
+ // Input IDs: tools/list keeps `format: "uuid"` but omits zod's long repeated `pattern`; the
15
+ // server still validates with the unchanged `z.string().uuid()`, so accepted values and issues are identical.
16
+ export const idInputSchema = z.string().meta({ format: "uuid" }).pipe(z.string().uuid());
17
+ export const successSchema = z.object({ ok: z.literal(true), session: viewSchema });
18
+ export const turnBase = { turnId: z.string().uuid(), sessionId: z.string().uuid(), startedAt: z.string(), updatedAt: z.string() };
19
+ export const turnSchema = z.discriminatedUnion("state", [
20
+ z.object({ ...turnBase, state: z.literal("running") }),
21
+ z.object({ ...turnBase, state: z.literal("completed"), completedAt: z.string(), assistantText: z.string(), truncated: z.boolean() }),
22
+ z.object({ ...turnBase, state: z.literal("failed"), completedAt: z.string(), error: z.object({ code: z.literal("turn_failed"), message: z.literal("Pi turn failed") }) }),
23
+ z.object({ ...turnBase, state: z.literal("aborted"), completedAt: z.string() }),
24
+ ]);
25
+ export const promptSuccessSchema = z.object({ ok: z.literal(true), session: viewSchema, turn: turnSchema });
26
+ export const turnSuccessSchema = z.object({ ok: z.literal(true), turn: turnSchema });
27
+ export const listSchema = z.object({ ok: z.literal(true), sessions: z.array(viewSchema) });
28
+ export const errorSchema = z.object({ ok: z.literal(false), error: z.object({ code: z.string(), message: z.string(), reason: z.enum(["none_ready", "none_at_base", "none_admitted"]).optional(), counts: z.object({ busy: z.number().int().nonnegative(), notAtBaseline: z.number().int().nonnegative(), settingsChanged: z.number().int().nonnegative() }).optional() }) });
29
+ export const capabilitiesSchema = z.object({
30
+ ok: z.literal(true),
31
+ server: z.object({ name: z.literal(packageMetadata.name), version: z.literal(packageMetadata.version) }).strict(),
32
+ configuration: z.object({
33
+ fingerprint: z.string().regex(CONFIGURATION_FINGERPRINT_PATTERN),
34
+ reloadPolicy: z.literal(RELOAD_POLICY),
35
+ }).strict(),
36
+ events: z.object({ delivery: z.enum(["spool", "off"]) }).strict(),
37
+ workspaces: z.array(z.object({ alias: aliasSchema, mode: z.enum(["unset", "host", "container", "read-only"]), writers: z.number().int().nonnegative().optional(), defaultBase: z.string().nullable().optional() }).strict()),
38
+ executionProfiles: z.array(z.object({
39
+ alias: aliasSchema,
40
+ default: z.boolean(),
41
+ permissionProfile: z.enum(["read-only", "coding"]),
42
+ provider: z.string(),
43
+ model: z.string(),
44
+ thinkingLevel: thinkingLevelSchema,
45
+ extensions: z.array(z.string()),
46
+ grantedExtensionTools: z.array(z.string()),
47
+ skills: z.array(aliasSchema),
48
+ }).strict()),
49
+ }).strict();
@@ -8,11 +8,10 @@ import { type TurnEvent } from "./turn-events.js";
8
8
  * Trust model. An event is a wake trigger only; it carries no instruction and no content. Writer
9
9
  * namespaces (`<home>/.local/state/pi-session-mcp/writers/<name>/events/`, see `writerEventsDir`) are writable by the
10
10
  * worker inside that writer's container, which can create any file, directory or symlink there. The spool is therefore read defensively:
11
- * - The accepted namespace follows from the arming tool name: `mcp__pi-session-mcp__*` reads the top-level
12
- * `<events>/inst-*` directories only; `mcp__pi-coding-<name>-wN__*` reads exactly `inst-*` below
13
- * `writerEventsDir(home, "<name>-wN")`. The old `<events>/writer-<name>/` location is not read, no other writer's
14
- * directory is listed, and the claim key carries the namespace, so a forged event in
15
- * one namespace can neither take nor block a claim of another.
11
+ * - Only `mcp__pi-session-mcp__*` arms the hook (ADR 0012: relayed writer sessions report through the host
12
+ * spool), and it reads the top-level `<events>/inst-*` directories only. No writer directory and no old
13
+ * `<events>/writer-<name>/` location is read. The writer namespace type and its scan remain for spools that
14
+ * writers of older releases may still hold; the claim key carries the namespace.
16
15
  * - Every directory is opened with `O_NOFOLLOW|O_DIRECTORY`, checked on the handle (a directory of this user)
17
16
  * and read through that handle (`/proc/self/fd/<fd>/<name>`). Event delivery is Linux-only (ADR 0011): without
18
17
  * `/proc/self/fd` the program does nothing (see `isSupportedPlatform`). Event files are opened with `O_NOFOLLOW`, must be regular and small.
package/dist/wake-hook.js CHANGED
@@ -16,7 +16,7 @@ export const CAPPED_SCAN_GAP_MS = 1000;
16
16
  export const NO_SPOOL_GRACE_MS = 2000;
17
17
  export const CLAIM_MAX_AGE_MS = 24 * 60 * 60 * 1000;
18
18
  export const CLAIM_CLEANUP_MAX_ENTRIES = 4096;
19
- const WAKE_TOOL_PATTERN = /^mcp__(?:(pi-session-mcp)|pi-coding-([a-z][a-z0-9_-]{0,63}-w(?:[1-9]|1[0-6])))__(pi_session_prompt|pi_turn_get)$/;
19
+ const WAKE_TOOL_PATTERN = /^mcp__pi-session-mcp__(pi_session_prompt|pi_turn_get)$/;
20
20
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21
21
  const INSTANCE_DIR_PATTERN = /^inst-[0-9a-f]{16}$/;
22
22
  const MAX_CONTENT_BLOCKS = 32;
@@ -88,11 +88,8 @@ export function parseHookInput(text) {
88
88
  const match = WAKE_TOOL_PATTERN.exec(input.tool_name);
89
89
  if (!match)
90
90
  return undefined;
91
- const isPrompt = match[3] === "pi_session_prompt";
92
- const writer = match[2];
93
- if (writer !== undefined && !WRITER_NAME_PATTERN.test(writer))
94
- return undefined;
95
- const namespace = writer === undefined ? HOST_NAMESPACE : { kind: "writer", name: writer };
91
+ const isPrompt = match[1] === "pi_session_prompt";
92
+ const namespace = HOST_NAMESPACE;
96
93
  for (const payload of payloadCandidates(input.tool_response)) {
97
94
  if (payload.ok !== true || !isObject(payload.turn) || payload.turn.state !== "running")
98
95
  continue;
@@ -3,6 +3,12 @@ import { mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises"
3
3
  import { join } from "node:path";
4
4
  import { podmanInfo } from "./writer-boundary.js";
5
5
  import { currentAcceptanceFingerprint, readWriterState, writerAcceptanceCurrent, writerPaths, writersRoot, writeWriterState, WriterStateError } from "./writer-state.js";
6
+ function leasedContext(ctx) {
7
+ if (ctx.leaseFd === undefined)
8
+ return ctx;
9
+ const runner = ctx.runner;
10
+ return { ...ctx, runner: { run: (command, args, signal, options) => runner.run(command, args, signal, { ...options, inheritFds: [ctx.leaseFd] }) } };
11
+ }
6
12
  export function controlStatePath(home) {
7
13
  return join(home, ".local", "state", "pi-session-mcp", "isolation-control.json");
8
14
  }
@@ -72,6 +78,7 @@ async function recordFailure(root, file, code, result) {
72
78
  catch { /* Diagnostics only. */ }
73
79
  }
74
80
  export async function runIsolationControl(ctx) {
81
+ ctx = leasedContext(ctx);
75
82
  const record = async (verdict) => {
76
83
  await writeFile(controlStatePath(ctx.home), JSON.stringify({ schemaVersion: 1, verdict, at: new Date().toISOString() }) + "\n", { mode: 0o600 });
77
84
  };
@@ -107,6 +114,7 @@ export async function runIsolationControl(ctx) {
107
114
  * would downgrade a ready writer before it found that out, so setup leaves the writer as it is.
108
115
  */
109
116
  export async function acceptanceCurrent(ctx, name) {
117
+ ctx = leasedContext(ctx);
110
118
  let state;
111
119
  try {
112
120
  state = await readWriterState(ctx.home, name);
@@ -135,6 +143,7 @@ export async function acceptanceCurrent(ctx, name) {
135
143
  }
136
144
  }
137
145
  export async function acceptWriter(ctx, name) {
146
+ ctx = leasedContext(ctx);
138
147
  let paths; // Set once the writer state is accepted; failures after it leave a record.
139
148
  let ran;
140
149
  const fail = async (code) => {
@@ -165,7 +174,7 @@ export async function acceptWriter(ctx, name) {
165
174
  // Acceptance makes no provider request: a launch-time refresh of an expired host token would change
166
175
  // the host agent directory inside the harness's digest window (#131).
167
176
  ran = await harness(ctx, ["--mode", "contained", "--agent-dir-copy-writable", "--agent-dir-copy", paths.agent,
168
- "--launch", `${quote(paths.wrapper)} node`, "--workspace", paths.clone], { PI_WRITER_NO_CREDENTIAL_REFRESH: "1" });
177
+ "--launch", `${quote(paths.wrapper)} node`, "--workspace", paths.clone], { PI_WRITER_NO_CREDENTIAL_REFRESH: "1", ...(ctx.leaseFd === undefined ? {} : { PI_WRITER_LEASE_FD: "3" }) });
169
178
  const network = networkOf(ran.data);
170
179
  if (ran.exitCode !== 0 || ran.report !== "present" || ran.data.verdict !== "pass" || (network !== "limited" && network !== "not_limited"))
171
180
  return await fail("acceptance_harness_failed");
@@ -5,11 +5,13 @@ export declare const IMAGE_PULL_TIMEOUT_MS = 900000;
5
5
  export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
6
6
  type WriterPaths = ReturnType<typeof writerPaths>;
7
7
  export declare class BoundaryError extends Error {
8
- readonly code: "image_unavailable" | "clone_verification_failed";
9
- constructor(code: "image_unavailable" | "clone_verification_failed");
8
+ readonly code: "image_unavailable" | "clone_verification_failed" | "writer_fenced";
9
+ constructor(code: "image_unavailable" | "clone_verification_failed" | "writer_fenced");
10
10
  }
11
11
  /** `eventsDir` is the dedicated host spool directory of this writer; pass it only when event delivery is enabled for the writer. */
12
- export declare function serveArgs(paths: WriterPaths, state: WriterState, command: readonly string[], releaseRoot: string, eventsDir?: string): string[];
12
+ export declare function serveArgs(paths: WriterPaths, state: WriterState, command: readonly string[], releaseRoot: string, eventsDir?: string, options?: {
13
+ name?: string;
14
+ }): string[];
13
15
  export declare class EventsDirectoryError extends Error {
14
16
  readonly code = "events_directory_unavailable";
15
17
  constructor();
@@ -36,7 +38,7 @@ export type EventsCleanupSeam = (stage: "inspected" | "emptied", logicalPath: st
36
38
  * (`procDirectory` lets a test stand in for a system without it; writers are Linux-only).
37
39
  */
38
40
  export declare function ensureWriterEventsDirectory(home: string, name: string, uid: number, now?: number, seam?: EventsCleanupSeam, procDirectory?: string): Promise<string>;
39
- export declare function throwawayArgs(paths: WriterPaths, imageId: string, opts: {
41
+ export declare function throwawayArgs(name: string, paths: WriterPaths, imageId: string, opts: {
40
42
  clone: "ro" | "rw";
41
43
  bundles: boolean;
42
44
  }, script: string, args: readonly string[]): string[];
@@ -1,8 +1,9 @@
1
1
  import { constants } from "node:fs";
2
2
  import { chmod, lstat, mkdir, open, opendir, readdir, rmdir, unlink } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
3
+ import { basename, dirname, join } from "node:path";
4
4
  import { DEFAULT_COMMAND_TIMEOUT_MS } from "./client-adapters/command.js";
5
5
  import { CONTAINER_EVENTS_DIR, EVENTS_DIR_ENV, writerEventsDir } from "./turn-events.js";
6
+ import { writerContainerName, writerLabel } from "./writer-fence.js";
6
7
  export const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
7
8
  export const IMAGE_PULL_TIMEOUT_MS = 900_000;
8
9
  export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
@@ -15,10 +16,11 @@ export class BoundaryError extends Error {
15
16
  }
16
17
  }
17
18
  /** `eventsDir` is the dedicated host spool directory of this writer; pass it only when event delivery is enabled for the writer. */
18
- export function serveArgs(paths, state, command, releaseRoot, eventsDir) {
19
+ export function serveArgs(paths, state, command, releaseRoot, eventsDir, options) {
19
20
  const args = [
20
21
  "run", "-i", "--rm", "--pull=never", "--userns=keep-id", "--read-only",
21
22
  "--cap-drop=all", "--security-opt=no-new-privileges", `--network=${state.network}`,
23
+ "--label", writerLabel(options?.name ?? basename(paths.root)),
22
24
  "--tmpfs", "/tmp:rw,nosuid,nodev,size=512m",
23
25
  "--tmpfs", "/pi/home:rw,nosuid,nodev,mode=0700,size=64m,U",
24
26
  "-v", `${paths.clone}:${paths.clone}:rw`,
@@ -33,10 +35,14 @@ export function serveArgs(paths, state, command, releaseRoot, eventsDir) {
33
35
  "-e", "PI_CODING_AGENT_DIR=/pi/agent",
34
36
  "-e", "PI_SESSION_MCP_WRITER_GUARD=/pi/config/writer-guard.json",
35
37
  ];
38
+ if (options?.name !== undefined)
39
+ args.push("--name", writerContainerName(options.name));
36
40
  if (eventsDir !== undefined)
37
41
  args.push("-v", `${eventsDir}:${CONTAINER_EVENTS_DIR}:rw`, "-e", `${EVENTS_DIR_ENV}=${CONTAINER_EVENTS_DIR}`);
38
42
  if (state.cache === "go")
39
43
  args.push("-v", `${paths.go}:/pi/go:rw`, "-e", "GOPATH=/pi/go", "-e", "GOCACHE=/pi/go/cache", "-e", "GOTOOLCHAIN=local");
44
+ if (state.cache === "uv")
45
+ args.push("-v", `${paths.uv}:/pi/uv:rw`, "-e", "UV_CACHE_DIR=/pi/uv/cache", "-e", "npm_config_cache=/pi/uv/npm");
40
46
  return [...args, state.imageId, ...command];
41
47
  }
42
48
  export class EventsDirectoryError extends Error {
@@ -225,10 +231,10 @@ export async function ensureWriterEventsDirectory(home, name, uid, now = Date.no
225
231
  }
226
232
  return target;
227
233
  }
228
- export function throwawayArgs(paths, imageId, opts, script, args) {
234
+ export function throwawayArgs(name, paths, imageId, opts, script, args) {
229
235
  const result = [
230
236
  "run", "--rm", "-i", "--pull=never", "--network=none", "--userns=keep-id", "--read-only",
231
- "--cap-drop=all", "--security-opt=no-new-privileges",
237
+ "--cap-drop=all", "--security-opt=no-new-privileges", "--label", writerLabel(name),
232
238
  "--tmpfs", "/tmp:rw,nosuid,nodev,size=1g", "-e", "HOME=/tmp",
233
239
  "-v", `${paths.clone}:${paths.clone}:${opts.clone}`,
234
240
  "--workdir", paths.clone,
@@ -0,0 +1,12 @@
1
+ import type { CommandRunner } from "./client-adapters/types.js";
2
+ import type { writerPaths } from "./writer-state.js";
3
+ type WriterPaths = ReturnType<typeof writerPaths>;
4
+ export declare const FENCE_STOP_TIMEOUT_MS = 20000;
5
+ export declare function writerContainerName(name: string): string;
6
+ export declare function writerLabel(name: string): string;
7
+ /** The caller holds the lease; no writer data may change until both inventories are empty. */
8
+ export declare function ensureWriterAbsent(runner: CommandRunner, paths: WriterPaths, name: string, options: {
9
+ leaseFd: number;
10
+ stopTimeoutMs?: number;
11
+ }): Promise<void>;
12
+ export {};
@@ -0,0 +1,50 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { z } from "zod";
3
+ import { BoundaryError } from "./writer-boundary.js";
4
+ export const FENCE_STOP_TIMEOUT_MS = 20_000;
5
+ const containersSchema = z.array(z.object({ Id: z.string().min(1), Names: z.array(z.string()) }));
6
+ export function writerContainerName(name) { return `pi-writer-${name}`; }
7
+ export function writerLabel(name) { return `pi-session-mcp.writer=${name}`; }
8
+ /** The caller holds the lease; no writer data may change until both inventories are empty. */
9
+ export async function ensureWriterAbsent(runner, paths, name, options) {
10
+ try {
11
+ const timeoutMs = options.stopTimeoutMs ?? FENCE_STOP_TIMEOUT_MS;
12
+ if (process.platform !== "linux" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
13
+ throw new BoundaryError("writer_fenced");
14
+ const deadline = performance.now() + timeoutMs;
15
+ const remaining = () => {
16
+ const left = Math.ceil(deadline - performance.now());
17
+ if (left <= 0)
18
+ throw new BoundaryError("writer_fenced");
19
+ return left;
20
+ };
21
+ const podman = async (args) => {
22
+ const result = await runner.run("podman", args, AbortSignal.timeout(remaining()), { inheritFds: [options.leaseFd] });
23
+ remaining();
24
+ if (result.exitCode !== 0 || result.stderr === "output_limit")
25
+ throw new BoundaryError("writer_fenced");
26
+ return result.stdout;
27
+ };
28
+ const list = async (filter) => containersSchema.parse(JSON.parse(await podman(["ps", "-a", "--filter", filter, "--format", "json"])));
29
+ const removed = new Set();
30
+ for (;;) {
31
+ const labelled = await list(`label=${writerLabel(name)}`);
32
+ // Podman's volume filter matches the mount destination; every writer container, old releases
33
+ // included, mounts the clone at its own host path, so this also finds unlabelled ones.
34
+ const mounted = await list(`volume=${paths.clone}`);
35
+ const ids = new Set([...labelled, ...mounted].map((container) => container.Id));
36
+ if (ids.size === 0)
37
+ return;
38
+ for (const id of ids) {
39
+ if (removed.has(id))
40
+ continue;
41
+ await podman(["rm", "-f", "-t", String(Math.ceil(remaining() / 1000)), id]);
42
+ removed.add(id);
43
+ }
44
+ await delay(Math.min(100, remaining()));
45
+ }
46
+ }
47
+ catch {
48
+ throw new BoundaryError("writer_fenced");
49
+ }
50
+ }
@@ -1,13 +1,21 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
  import type { CommandRunner } from "./client-adapters/types.js";
3
+ import { acquireWriterLease, adoptInheritedLease } from "./writer-lease.js";
4
+ import { ensureWriterAbsent } from "./writer-fence.js";
3
5
  import { type HostCredentialRefresh } from "./writer-credentials.js";
4
6
  export { currentAcceptanceFingerprint } from "./writer-state.js";
5
7
  export { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
8
+ export declare const LAUNCHER_CLEANUP_BOUND_MS = 40000;
6
9
  export interface LaunchDependencies {
7
10
  home: string;
8
11
  releaseRoot: string;
9
12
  runner: CommandRunner;
10
- spawnPodman(args: string[]): ChildProcess;
13
+ spawnPodman(args: string[], inheritFds: readonly number[]): ChildProcess;
14
+ lease?: {
15
+ acquire: typeof acquireWriterLease;
16
+ adopt: typeof adoptInheritedLease;
17
+ };
18
+ ensureAbsent?: typeof ensureWriterAbsent;
11
19
  env: NodeJS.ProcessEnv;
12
20
  /** The user id that must own the event spool directory; defaults to the process user. */
13
21
  uid?: () => number;