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
package/dist/doctor.js CHANGED
@@ -6,6 +6,7 @@ import { createProcessRunner } from "./setup-process.js";
6
6
  import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
7
7
  import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
8
8
  import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
9
+ import { LEASE_DIRECTORY } from "./writer-lease.js";
9
10
  import { loadConfig } from "./config.js";
10
11
  import { containerRuntimeUsable } from "./container-runtime.js";
11
12
  import { PiExtensionError } from "./extension-spec.js";
@@ -31,7 +32,7 @@ async function readPrefix(path, maxBytes) {
31
32
  }
32
33
  }
33
34
  const defaultFileSystem = { readFile, stat, access };
34
- const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "workspace_gitfile", "workspace_mode_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_not_ready", "writer_dirty", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "event_spool_root"];
35
+ const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "workspace_gitfile", "workspace_mode_unset", "workspace_base_unset", "workspace_host_writes_accepted", "container_runtime_unavailable", "container_profile_refused", "writer_lease_directory", "writer_not_ready", "writer_not_migrated", "writer_off_default_base", "writer_dirty", "writer_git_identity_missing", "writer_uncollected_bundle", "extension_missing", "extension_version_mismatch", "extension_unpinned", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated", "event_spool_root"];
35
36
  function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
36
37
  function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
37
38
  function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
@@ -147,7 +148,7 @@ async function hasGitfilePointer(fs, workspace) {
147
148
  return false;
148
149
  }
149
150
  }
150
- async function defaultEventSpoolRoot(path) {
151
+ async function defaultRootInfo(path) {
151
152
  try {
152
153
  const info = await lstat(path);
153
154
  return { isDirectory: info.isDirectory(), uid: info.uid, mode: info.mode };
@@ -158,6 +159,30 @@ async function defaultEventSpoolRoot(path) {
158
159
  throw error;
159
160
  }
160
161
  }
162
+ /**
163
+ * Offline check of the host-only writer lease directory. A missing directory is fine because the
164
+ * first lease creates it. An existing entry must be a real directory of the current user with mode
165
+ * 0700: the rule `acquireWriterLease` applies before it opens a lease file. Nothing is created,
166
+ * repaired or read, and no path or lease file name appears in the report.
167
+ */
168
+ async function writerLeaseCheck(home, probe, uid) {
169
+ let info;
170
+ try {
171
+ info = await probe(join(home, ...LEASE_DIRECTORY));
172
+ }
173
+ catch {
174
+ return failure("writer_lease_directory", "writer lease", "grant access to the writer lease directory");
175
+ }
176
+ if (info === undefined)
177
+ return undefined;
178
+ // Lease files must keep their inodes: a moved or recreated directory would let a second holder in.
179
+ if (!info.isDirectory)
180
+ return failure("writer_lease_directory", "writer lease", "close every writer session and setup run, then replace the entry with a private directory owned by the current user");
181
+ if ((uid !== undefined && info.uid !== uid) || (info.mode & 0o7777) !== 0o700) {
182
+ return failure("writer_lease_directory", "writer lease", "repair the lease directory in place (owner and chmod 700); never move or delete it or its lock files");
183
+ }
184
+ return ok("writer_lease_directory", "writer lease");
185
+ }
161
186
  /**
162
187
  * Offline check of the event spool's runtime directories, run only when event delivery is enabled.
163
188
  * Nothing is created or repaired, and no path appears in the report. A missing runtime directory
@@ -208,6 +233,44 @@ async function defaultWriterRuntime() {
208
233
  }
209
234
  } };
210
235
  }
236
+ async function writerGitIdentity(clone) {
237
+ try {
238
+ const handle = await open(join(clone, ".git", "config"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
239
+ try {
240
+ const info = await handle.stat();
241
+ if (!info.isFile() || info.size > 65_536)
242
+ return false;
243
+ const buffer = Buffer.alloc(65_536);
244
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
245
+ if ((await handle.stat()).size > 65_536)
246
+ return false;
247
+ let user = false, name = false, email = false;
248
+ for (const line of buffer.subarray(0, bytesRead).toString("utf8").split("\n")) {
249
+ const section = /^\s*\[([^\]]+)\]/.exec(line);
250
+ if (section) {
251
+ user = section[1].trim().toLowerCase() === "user";
252
+ continue;
253
+ }
254
+ if (!user)
255
+ continue;
256
+ const key = /^\s*(name|email)\s*=\s*(.*?)\s*$/i.exec(line);
257
+ if (!key || !key[2] || /^(?:""|[#;])/.test(key[2]))
258
+ continue;
259
+ if (key[1].toLowerCase() === "name")
260
+ name = true;
261
+ else
262
+ email = true;
263
+ }
264
+ return name && email;
265
+ }
266
+ finally {
267
+ await handle.close();
268
+ }
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ }
211
274
  async function writerDirty(clone, state) {
212
275
  try {
213
276
  const git = join(clone, ".git");
@@ -280,6 +343,7 @@ async function writerDirty(clone, state) {
280
343
  export async function runDoctor(dependencies = {}) {
281
344
  const fs = dependencies.fileSystem ?? defaultFileSystem;
282
345
  const root = dependencies.packageRoot ?? process.cwd();
346
+ const home = dependencies.home ?? homedir();
283
347
  const checks = [];
284
348
  let metadata;
285
349
  try {
@@ -418,9 +482,13 @@ export async function runDoctor(dependencies = {}) {
418
482
  let runtime;
419
483
  let rawConfig, rawConfigLoaded = false, rawConfigReadable = false;
420
484
  let availableBuiltinSkills = new Set();
485
+ const offDefaultBase = new Map();
421
486
  for (const [alias, entry] of config.workspaceModes) {
422
487
  if (entry.mode !== "container" || !entry.container)
423
488
  continue;
489
+ checks.push(entry.container.base === undefined
490
+ ? warning("workspace_base_unset", alias, `record the workspace default base with pi-session-mcp-setup --repo ${alias} --base <branch> --apply`)
491
+ : ok("workspace_base_unset", alias));
424
492
  if (!rawConfigLoaded) {
425
493
  rawConfigLoaded = true;
426
494
  try {
@@ -445,7 +513,7 @@ export async function runDoctor(dependencies = {}) {
445
513
  checks.push(ok("container_profile_refused", alias));
446
514
  }
447
515
  for (let index = 1; index <= entry.container.writers; index++) {
448
- const name = writerName(alias, index), home = dependencies.home ?? homedir(), paths = writerPaths(home, name);
516
+ const name = writerName(alias, index), paths = writerPaths(home, name);
449
517
  const remediation = `run pi-session-mcp-setup --repo ${alias} --apply, or --writer ${name} --recreate --apply`;
450
518
  let state, ready = false;
451
519
  try {
@@ -459,6 +527,11 @@ export async function runDoctor(dependencies = {}) {
459
527
  checks.push(ready ? ok("writer_not_ready", name) : failure("writer_not_ready", name, remediation));
460
528
  if (!state)
461
529
  continue;
530
+ checks.push(state.launch === "relay" ? ok("writer_not_migrated", name)
531
+ : warning("writer_not_migrated", name, `run pi-session-mcp-setup --repo ${alias} --apply to migrate the writer to the relay`));
532
+ if (entry.container.base !== undefined && ready && state.base.ref !== `refs/heads/${entry.container.base}`) {
533
+ offDefaultBase.set(alias, (offDefaultBase.get(alias) ?? 0) + 1);
534
+ }
462
535
  checks.push(await writerDirty(paths.clone, state)
463
536
  ? warning("writer_dirty", name, "collect writer work before resetting or recreating the writer") : ok("writer_dirty", name));
464
537
  let bundles = false;
@@ -471,9 +544,18 @@ export async function runDoctor(dependencies = {}) {
471
544
  catch (error) {
472
545
  bundles = !isMissing(error);
473
546
  }
547
+ checks.push(await writerGitIdentity(paths.clone) ? ok("writer_git_identity_missing", name)
548
+ : warning("writer_git_identity_missing", name, "set user.name and user.email in the operator checkout, then reset the writer"));
474
549
  checks.push(bundles ? warning("writer_uncollected_bundle", name, "collect or review outstanding writer bundles") : ok("writer_uncollected_bundle", name));
475
550
  }
476
551
  }
552
+ for (const [alias, entry] of config.workspaceModes) {
553
+ if (entry.mode !== "container" || !entry.container || entry.container.base === undefined)
554
+ continue;
555
+ const count = offDefaultBase.get(alias) ?? 0;
556
+ checks.push(count === 0 ? ok("writer_off_default_base", alias)
557
+ : warning("writer_off_default_base", alias, `${count} ${count === 1 ? "writer is" : "writers are"} off the workspace default base; finish each with pi-session-mcp-setup --writer <name> --finish --apply once its work is collected`));
558
+ }
477
559
  }
478
560
  if (config) {
479
561
  // This advisory check needs positive evidence, like the gitfile check: unreadable or
@@ -546,7 +628,10 @@ export async function runDoctor(dependencies = {}) {
546
628
  }
547
629
  }
548
630
  if (config?.eventDelivery === "spool")
549
- checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultEventSpoolRoot, dependencies.uid ?? process.getuid?.()));
631
+ checks.push(await eventSpoolCheck(dependencies.env ?? process.env, dependencies.eventSpoolRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.()));
632
+ const lease = await writerLeaseCheck(home, dependencies.writerLeaseRoot ?? defaultRootInfo, dependencies.uid ?? process.getuid?.());
633
+ if (lease)
634
+ checks.push(lease);
550
635
  if (config)
551
636
  checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot), dependencies.releasedSkillTrees));
552
637
  const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
@@ -6,13 +6,17 @@ export interface ContainerSettings {
6
6
  readonly profiles: readonly string[];
7
7
  readonly image: string;
8
8
  readonly network: "pasta" | "none";
9
- readonly cache: "none" | "go";
9
+ readonly cache: "none" | "go" | "uv";
10
+ readonly tags: "none" | "reachable";
11
+ readonly base?: string;
10
12
  }
11
13
  export interface WorkspaceModeEntry {
12
14
  readonly mode: WorkspaceMode;
13
15
  readonly container?: ContainerSettings;
14
16
  }
15
17
  export declare const EXECUTION_MODES_FILE = "execution-modes.json";
18
+ /** A base is a plain local branch name: safe, not `HEAD`, no ref prefix, no commit ID, at most 200 characters. */
19
+ export declare function isShortBranchName(value: string): boolean;
16
20
  export declare function executionModesPath(configPath: string): string;
17
21
  export declare const executionModesSchema: z.ZodObject<{
18
22
  schemaVersion: z.ZodLiteral<1>;
@@ -37,7 +41,13 @@ export declare const executionModesSchema: z.ZodObject<{
37
41
  cache: z.ZodDefault<z.ZodEnum<{
38
42
  go: "go";
39
43
  none: "none";
44
+ uv: "uv";
40
45
  }>>;
46
+ tags: z.ZodDefault<z.ZodEnum<{
47
+ none: "none";
48
+ reachable: "reachable";
49
+ }>>;
50
+ base: z.ZodOptional<z.ZodString>;
41
51
  }, z.core.$strict>], "mode">>;
42
52
  }, z.core.$strict>;
43
53
  type ModeProfile = {
@@ -46,5 +56,5 @@ type ModeProfile = {
46
56
  };
47
57
  export declare function resolveExecutionModes(raw: unknown, workspaceAliases: ReadonlySet<string>, profiles: ReadonlyMap<string, ModeProfile>): ReadonlyMap<string, WorkspaceModeEntry>;
48
58
  export declare function loadExecutionModes(configPath: string, workspaceAliases: ReadonlySet<string>, profiles: ReadonlyMap<string, ModeProfile>): Promise<ReadonlyMap<string, WorkspaceModeEntry>>;
49
- export declare function codingAllowed(entry: WorkspaceModeEntry): boolean;
59
+ export declare function codingAllowed(entry: WorkspaceModeEntry, relay: boolean): boolean;
50
60
  export {};
@@ -3,7 +3,16 @@ import { dirname, join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { ConfigError } from "./config.js";
5
5
  import { ImmutableMap } from "./execution-profile.js";
6
+ import { isSafeRefName } from "./writer-boundary.js";
6
7
  export const EXECUTION_MODES_FILE = "execution-modes.json";
8
+ /** A base is a plain local branch name: safe, not `HEAD`, no ref prefix, no commit ID, at most 200 characters. */
9
+ export function isShortBranchName(value) {
10
+ return value.length <= 200
11
+ && isSafeRefName(value)
12
+ && value !== "HEAD"
13
+ && !value.startsWith("refs/")
14
+ && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
15
+ }
7
16
  export function executionModesPath(configPath) {
8
17
  return join(dirname(configPath), EXECUTION_MODES_FILE);
9
18
  }
@@ -17,7 +26,9 @@ const entrySchema = z.discriminatedUnion("mode", [
17
26
  profiles: z.array(z.string()).min(1),
18
27
  image: z.union([z.literal("node"), z.string().regex(/^[0-9a-f]{64}$/)]).default("node"),
19
28
  network: z.enum(["pasta", "none"]).default("pasta"),
20
- cache: z.enum(["none", "go"]).default("none"),
29
+ cache: z.enum(["none", "go", "uv"]).default("none"),
30
+ tags: z.enum(["none", "reachable"]).default("none"),
31
+ base: z.string().refine(isShortBranchName).optional(),
21
32
  }).strict(),
22
33
  ]);
23
34
  export const executionModesSchema = z.object({
@@ -39,9 +50,10 @@ export function resolveExecutionModes(raw, workspaceAliases, profiles) {
39
50
  throw new ConfigError("invalid_execution_modes");
40
51
  }
41
52
  }
42
- const { mode, ...settings } = entry;
53
+ const { mode, base, ...settings } = entry;
43
54
  entries.set(alias, Object.freeze({ mode, container: Object.freeze({
44
55
  ...settings, profiles: Object.freeze([...settings.profiles]),
56
+ ...(base === undefined ? {} : { base }),
45
57
  }) }));
46
58
  }
47
59
  else {
@@ -66,6 +78,6 @@ export async function loadExecutionModes(configPath, workspaceAliases, profiles)
66
78
  }
67
79
  return resolveExecutionModes(raw, workspaceAliases, profiles);
68
80
  }
69
- export function codingAllowed(entry) {
70
- return entry.mode === "unset" || entry.mode === "host";
81
+ export function codingAllowed(entry, relay) {
82
+ return entry.mode === "unset" || entry.mode === "host" || (entry.mode === "container" && relay);
71
83
  }
package/dist/main.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { stdoutGuard } from "./stdout-guard-install.js";
3
3
  import { isAbsolute } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { RoutingPiSessionAdapter, WriterRelayAdapter } from "./writer-relay.js";
4
6
  import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
7
  import { loadConfig } from "./config.js";
6
8
  import { EventSpoolError, TurnEventSpool } from "./event-spool.js";
@@ -45,13 +47,18 @@ async function main() {
45
47
  spool = TurnEventSpool.open(directory, { diagnostic: writeDiagnostic });
46
48
  }
47
49
  const events = spool;
48
- registry = new SessionRegistry(new SdkPiSessionAdapter({ diagnosticSink: writeDiagnostic }), config.workspaces, {
50
+ const hostAdapter = new SdkPiSessionAdapter({ diagnosticSink: writeDiagnostic });
51
+ const relay = guardPath === undefined;
52
+ const adapter = relay ? new RoutingPiSessionAdapter(hostAdapter, new WriterRelayAdapter({
53
+ home: homedir(), workspaceModes: config.workspaceModes, executionProfiles: config.executionProfiles,
54
+ }), config.workspaceModes) : hostAdapter;
55
+ registry = new SessionRegistry(adapter, config.workspaces, {
49
56
  ...(guardPath === undefined ? {} : { writerGuard: createWriterGuard(guardPath, config.workspaces) }),
50
57
  ...(events === undefined ? {} : {
51
58
  onTurnTerminal: (event) => events.write({ kind: "turn_terminal", session: event.sessionId, turn: event.turnId, state: event.state, via: event.via }),
52
59
  }),
53
60
  });
54
- handle = serveStdio(() => createServer(registry, config.workspaces, config.executionProfiles, config.defaultExecutionProfile, config.configuration, config.workspaceModes, delivery), {
61
+ handle = serveStdio(() => createServer(registry, config.workspaces, config.executionProfiles, config.defaultExecutionProfile, config.configuration, config.workspaceModes, delivery, relay), {
55
62
  transport: new StdioServerTransport(process.stdin, stdoutGuard.transportStdout),
56
63
  onerror: () => writeDiagnostic({ level: "error", event: "mcp_transport_error", stage: "transport", code: "mcp_transport_error" }),
57
64
  });
@@ -32,3 +32,5 @@ export interface OverrideLock {
32
32
  * `override_lock_unavailable`.
33
33
  */
34
34
  export declare function acquireOverrideLock(overrideDirectory: string, options?: OverrideLockOptions): Promise<OverrideLock>;
35
+ export declare function flockBinary(options: OverrideLockOptions): string | undefined;
36
+ export declare function lockWithFlock(flockPath: string, lockFd: number, timeoutMs: number, signal: AbortSignal | undefined, nonBlocking?: boolean): Promise<void>;
@@ -72,7 +72,7 @@ export async function acquireOverrideLock(overrideDirectory, options = {}) {
72
72
  },
73
73
  };
74
74
  }
75
- function flockBinary(options) {
75
+ export function flockBinary(options) {
76
76
  const trusted = options.isTrustedBinary ?? ((path) => isTrustedSystemBinary(path));
77
77
  return (options.flockCandidates ?? FLOCK_CANDIDATES).find((candidate) => trusted(candidate));
78
78
  }
@@ -86,7 +86,7 @@ async function openLockFile(lockPath, platform, timeoutMs, signal) {
86
86
  throw new OverrideLockError(error.message === "ownership_lock_busy" ? "override_lock_busy" : "override_lock_unavailable");
87
87
  }
88
88
  }
89
- function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
89
+ export function lockWithFlock(flockPath, lockFd, timeoutMs, signal, nonBlocking = false) {
90
90
  return new Promise((resolve, reject) => {
91
91
  if (signal?.aborted) {
92
92
  reject(new OverrideLockError("override_lock_unavailable"));
@@ -97,7 +97,7 @@ function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
97
97
  // flock parses fractional seconds with the locale's decimal separator, so only whole
98
98
  // seconds go on the command line. The exact bound is the timer below, which stops flock.
99
99
  const wholeSeconds = String(Math.max(1, Math.ceil(timeoutMs / 1_000)));
100
- locker = spawn(flockPath, ["-E", String(FLOCK_BUSY_EXIT), "-w", wholeSeconds, "3"], { stdio: ["ignore", "ignore", "ignore", lockFd], detached: true });
100
+ locker = spawn(flockPath, ["-E", String(FLOCK_BUSY_EXIT), ...(nonBlocking ? ["-n"] : ["-w", wholeSeconds]), "3"], { stdio: ["ignore", "ignore", "ignore", lockFd], detached: true });
101
101
  }
102
102
  catch {
103
103
  reject(new OverrideLockError("override_lock_unavailable"));
@@ -111,7 +111,7 @@ function lockWithFlock(flockPath, lockFd, timeoutMs, signal) {
111
111
  catch { /* already gone */ }
112
112
  } };
113
113
  const cleanup = () => { clearTimeout(bound); signal?.removeEventListener("abort", stop); };
114
- const bound = setTimeout(() => { expired = true; stop(); }, timeoutMs);
114
+ const bound = nonBlocking ? undefined : setTimeout(() => { expired = true; stop(); }, timeoutMs);
115
115
  signal?.addEventListener("abort", stop, { once: true });
116
116
  locker.once("error", () => { cleanup(); reject(new OverrideLockError("override_lock_unavailable")); });
117
117
  locker.once("close", (code) => {
@@ -3,6 +3,7 @@ export type { PermissionProfile };
3
3
  export type PiTurnOutcome = {
4
4
  status: "completed";
5
5
  assistantText: string;
6
+ truncated?: boolean;
6
7
  } | {
7
8
  status: "failed";
8
9
  } | {
@@ -18,7 +19,34 @@ export declare class PiSessionCreationError extends Error {
18
19
  readonly code: PiSessionCreationErrorCode;
19
20
  constructor(code: PiSessionCreationErrorCode);
20
21
  }
22
+ export declare class WriterUnavailableError extends Error {
23
+ readonly reason: "none_ready" | "none_at_base" | "none_admitted";
24
+ readonly counts?: {
25
+ busy: number;
26
+ notAtBaseline: number;
27
+ settingsChanged: number;
28
+ } | undefined;
29
+ readonly code = "writer_unavailable";
30
+ constructor(reason: "none_ready" | "none_at_base" | "none_admitted", counts?: {
31
+ busy: number;
32
+ notAtBaseline: number;
33
+ settingsChanged: number;
34
+ } | undefined);
35
+ }
21
36
  export interface PiSessionHandle {
37
+ readonly placement?: {
38
+ writer: string;
39
+ base: {
40
+ ref: string;
41
+ commit: string;
42
+ };
43
+ } | undefined;
44
+ readonly deadlines?: {
45
+ preflightMs?: number;
46
+ abortMs?: number;
47
+ cleanupMs?: number;
48
+ } | undefined;
49
+ isLost?(): boolean;
22
50
  readonly sdkSessionId: string;
23
51
  /** Only whitelisted, post-verified selection metadata may cross the adapter boundary. */
24
52
  readonly appliedSelection: PiAppliedSelection;
@@ -38,6 +66,8 @@ export interface PiSessionHandle {
38
66
  export interface PiSessionAdapter {
39
67
  create(input: {
40
68
  cwd: string;
69
+ workspace: string;
70
+ base?: string;
41
71
  executionProfile: ResolvedExecutionProfile;
42
72
  signal?: AbortSignal;
43
73
  }): Promise<PiSessionHandle>;
@@ -26,3 +26,14 @@ export class PiSessionCreationError extends Error {
26
26
  this.code = code;
27
27
  }
28
28
  }
29
+ export class WriterUnavailableError extends Error {
30
+ reason;
31
+ counts;
32
+ code = "writer_unavailable";
33
+ constructor(reason, counts) {
34
+ super("No writer is available");
35
+ this.reason = reason;
36
+ this.counts = counts;
37
+ this.name = "WriterUnavailableError";
38
+ }
39
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
- export type RefusalCode = "container_runtime_unavailable" | "writer_not_ready";
2
+ export type RefusalCode = "container_runtime_unavailable" | "writer_not_ready" | "writer_busy" | "writer_fenced";
3
3
  export declare function serveRefusal(code: RefusalCode, io?: {
4
4
  stdin: Readable;
5
5
  stdout: Writable;
@@ -5,10 +5,20 @@ import { PACKAGE_VERSION } from "./package-metadata.js";
5
5
  const messages = {
6
6
  container_runtime_unavailable: "Rootless Podman is not usable on this machine; this writer refuses all work.",
7
7
  writer_not_ready: "This writer is not ready; run pi-session-mcp-setup for its repository.",
8
+ writer_busy: "This writer is already leased; try another writer.",
9
+ writer_fenced: "Container absence could not be confirmed; this writer refuses all work.",
8
10
  };
9
11
  export async function serveRefusal(code, io) {
10
12
  const stdout = io?.stdout ?? (await import("./stdout-guard-install.js")).stdoutGuard.transportStdout;
11
- serveStdio(() => {
13
+ const stdin = io?.stdin ?? process.stdin;
14
+ let finish;
15
+ const ended = new Promise((resolve) => { finish = resolve; });
16
+ stdin.once("end", finish);
17
+ stdin.once("close", finish);
18
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
19
+ for (const signal of signals)
20
+ process.once(signal, finish);
21
+ const handle = serveStdio(() => {
12
22
  const server = new McpServer({ name: "pi-session-mcp", version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
13
23
  for (const name of ["pi_capabilities_get", "pi_session_start", "pi_session_list", "pi_session_get", "pi_session_prompt", "pi_session_abort", "pi_session_close", "pi_turn_get"]) {
14
24
  server.registerTool(name, { inputSchema: z.object({}).passthrough() }, () => {
@@ -18,7 +28,19 @@ export async function serveRefusal(code, io) {
18
28
  }
19
29
  return server;
20
30
  }, {
21
- transport: new StdioServerTransport(io?.stdin ?? process.stdin, stdout),
31
+ transport: new StdioServerTransport(stdin, stdout),
22
32
  onerror: () => { process.stderr.write(JSON.stringify({ level: "error", event: "mcp_transport_error", stage: "transport", code: "mcp_transport_error" }) + "\n"); },
23
33
  });
34
+ try {
35
+ if (stdin.readableEnded || stdin.destroyed)
36
+ finish();
37
+ await ended;
38
+ await handle.close();
39
+ }
40
+ finally {
41
+ stdin.off("end", finish);
42
+ stdin.off("close", finish);
43
+ for (const signal of signals)
44
+ process.off(signal, finish);
45
+ }
24
46
  }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
- import { type ConfigurationMetadata } from "./config.js";
2
+ import type { ConfigurationMetadata } from "./config.js";
3
3
  import { type WorkspaceModeEntry } from "./execution-modes.js";
4
4
  import { type ConfiguredExecutionProfile } from "./execution-profile.js";
5
5
  import { type SessionRegistry } from "./session-registry.js";
6
- export declare function createServer(registry: SessionRegistry, workspaces: ReadonlyMap<string, string>, executionProfiles: ReadonlyMap<string, ConfiguredExecutionProfile>, defaultExecutionProfile: string, configuration: ConfigurationMetadata, workspaceModes?: ReadonlyMap<string, WorkspaceModeEntry>, eventDelivery?: "spool" | "off"): McpServer;
6
+ export declare function createServer(registry: SessionRegistry, workspaces: ReadonlyMap<string, string>, executionProfiles: ReadonlyMap<string, ConfiguredExecutionProfile>, defaultExecutionProfile: string, configuration: ConfigurationMetadata, workspaceModes?: ReadonlyMap<string, WorkspaceModeEntry>, eventDelivery?: "spool" | "off", relay?: boolean): McpServer;
package/dist/server.js CHANGED
@@ -2,55 +2,11 @@ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  import packageMetadata from "../package.json" with { type: "json" };
4
4
  import { projectCapabilities } from "./capabilities.js";
5
- import { CONFIGURATION_FINGERPRINT_PATTERN, RELOAD_POLICY } from "./config.js";
6
- import { codingAllowed } from "./execution-modes.js";
7
- import { PiSessionCreationError } from "./pi-adapter.js";
8
- import { ALIAS_PATTERN, toResolvedExecutionProfile } from "./execution-profile.js";
5
+ import { codingAllowed, isShortBranchName } from "./execution-modes.js";
6
+ import { PiSessionCreationError, WriterUnavailableError } from "./pi-adapter.js";
7
+ import { toResolvedExecutionProfile } from "./execution-profile.js";
9
8
  import { RegistryError } from "./session-registry.js";
10
- const thinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
11
- const viewSchema = z.object({
12
- id: z.string(), workspace: z.string(), executionProfile: z.string(), profile: z.enum(["read-only", "coding"]),
13
- provider: z.string(), model: z.string(), thinkingLevel: thinkingLevelSchema,
14
- state: z.enum(["idle", "running", "failed", "closing", "closed"]), createdAt: z.string(), updatedAt: z.string(),
15
- lastError: z.string().optional(),
16
- });
17
- const aliasSchema = z.string().regex(ALIAS_PATTERN);
18
- // Input IDs: tools/list keeps `format: "uuid"` but omits zod's long repeated `pattern`; the
19
- // server still validates with the unchanged `z.string().uuid()`, so accepted values and issues are identical.
20
- const idInputSchema = z.string().meta({ format: "uuid" }).pipe(z.string().uuid());
21
- const successSchema = z.object({ ok: z.literal(true), session: viewSchema });
22
- const turnBase = { turnId: z.string().uuid(), sessionId: z.string().uuid(), startedAt: z.string(), updatedAt: z.string() };
23
- const turnSchema = z.discriminatedUnion("state", [
24
- z.object({ ...turnBase, state: z.literal("running") }),
25
- z.object({ ...turnBase, state: z.literal("completed"), completedAt: z.string(), assistantText: z.string(), truncated: z.boolean() }),
26
- z.object({ ...turnBase, state: z.literal("failed"), completedAt: z.string(), error: z.object({ code: z.literal("turn_failed"), message: z.literal("Pi turn failed") }) }),
27
- z.object({ ...turnBase, state: z.literal("aborted"), completedAt: z.string() }),
28
- ]);
29
- const promptSuccessSchema = z.object({ ok: z.literal(true), session: viewSchema, turn: turnSchema });
30
- const turnSuccessSchema = z.object({ ok: z.literal(true), turn: turnSchema });
31
- const listSchema = z.object({ ok: z.literal(true), sessions: z.array(viewSchema) });
32
- const errorSchema = z.object({ ok: z.literal(false), error: z.object({ code: z.string(), message: z.string() }) });
33
- const capabilitiesSchema = z.object({
34
- ok: z.literal(true),
35
- server: z.object({ name: z.literal(packageMetadata.name), version: z.literal(packageMetadata.version) }).strict(),
36
- configuration: z.object({
37
- fingerprint: z.string().regex(CONFIGURATION_FINGERPRINT_PATTERN),
38
- reloadPolicy: z.literal(RELOAD_POLICY),
39
- }).strict(),
40
- events: z.object({ delivery: z.enum(["spool", "off"]) }).strict(),
41
- workspaces: z.array(z.object({ alias: aliasSchema, mode: z.enum(["unset", "host", "container", "read-only"]) }).strict()),
42
- executionProfiles: z.array(z.object({
43
- alias: aliasSchema,
44
- default: z.boolean(),
45
- permissionProfile: z.enum(["read-only", "coding"]),
46
- provider: z.string(),
47
- model: z.string(),
48
- thinkingLevel: thinkingLevelSchema,
49
- extensions: z.array(z.string()),
50
- grantedExtensionTools: z.array(z.string()),
51
- skills: z.array(aliasSchema),
52
- }).strict()),
53
- }).strict();
9
+ import { aliasSchema, idInputSchema, successSchema, promptSuccessSchema, turnSuccessSchema, listSchema, errorSchema, capabilitiesSchema } from "./tool-schemas.js";
54
10
  function result(value, isError = false) {
55
11
  return { content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value, ...(isError ? { isError: true } : {}) };
56
12
  }
@@ -59,11 +15,14 @@ async function safe(action) {
59
15
  return result(await action());
60
16
  }
61
17
  catch (error) {
18
+ if (error instanceof WriterUnavailableError) {
19
+ return result({ ok: false, error: { code: error.code, message: error.message, reason: error.reason, ...(error.counts === undefined ? {} : { counts: error.counts }) } }, true);
20
+ }
62
21
  const known = error instanceof RegistryError || error instanceof PiSessionCreationError;
63
22
  return result({ ok: false, error: { code: known ? error.code : "internal_error", message: known ? error.message : "Operation failed" } }, true);
64
23
  }
65
24
  }
66
- export function createServer(registry, workspaces, executionProfiles, defaultExecutionProfile, configuration, workspaceModes = new Map(), eventDelivery = "off") {
25
+ export function createServer(registry, workspaces, executionProfiles, defaultExecutionProfile, configuration, workspaceModes = new Map(), eventDelivery = "off", relay = false) {
67
26
  const server = new McpServer({ name: packageMetadata.name, version: packageMetadata.version }, { capabilities: { tools: {} } });
68
27
  server.registerTool("pi_capabilities_get", {
69
28
  description: "List configured workspace and execution-profile aliases. This reports configured intent, not provider or runtime availability. Call once at the start of a run.",
@@ -73,17 +32,23 @@ export function createServer(registry, workspaces, executionProfiles, defaultExe
73
32
  }, () => result(projectCapabilities(workspaces, executionProfiles, configuration, workspaceModes, eventDelivery)));
74
33
  server.registerTool("pi_session_start", {
75
34
  description: "Start a managed embedded Pi session for a configured workspace alias.",
76
- inputSchema: z.object({ workspace: aliasSchema, executionProfile: aliasSchema.optional() }).strict(), outputSchema: z.union([successSchema, errorSchema]),
35
+ inputSchema: z.object({ workspace: aliasSchema, executionProfile: aliasSchema.optional(), base: z.string().refine(isShortBranchName).optional() }).strict(), outputSchema: z.union([successSchema, errorSchema]),
77
36
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
78
- }, ({ workspace, executionProfile }) => safe(async () => {
37
+ }, ({ workspace, executionProfile, base }) => safe(async () => {
79
38
  const alias = executionProfile ?? defaultExecutionProfile;
80
39
  const configured = executionProfiles.get(alias);
81
40
  if (!configured)
82
41
  throw new RegistryError("unknown_execution_profile");
83
- if (configured.permissionProfile === "coding" && !codingAllowed(workspaceModes.get(workspace) ?? { mode: "unset" })) {
42
+ if (!workspaces.has(workspace))
43
+ throw new RegistryError("unknown_workspace");
44
+ const mode = workspaceModes.get(workspace) ?? { mode: "unset" };
45
+ if (base !== undefined && (configured.permissionProfile !== "coding" || mode.mode !== "container")) {
46
+ throw new RegistryError("base_not_applicable");
47
+ }
48
+ if (configured.permissionProfile === "coding" && !codingAllowed(mode, relay)) {
84
49
  throw new RegistryError("workspace_mode_forbids_coding");
85
50
  }
86
- return { ok: true, session: await registry.start(workspace, toResolvedExecutionProfile(configured)) };
51
+ return { ok: true, session: await registry.start(workspace, toResolvedExecutionProfile(configured), base === undefined ? {} : { base }) };
87
52
  }));
88
53
  server.registerTool("pi_session_list", {
89
54
  description: "List local Pi sessions and their state.", inputSchema: z.object({}).strict(), outputSchema: z.union([listSchema, errorSchema]),
@@ -11,6 +11,11 @@ export interface SessionView {
11
11
  provider: string;
12
12
  model: string;
13
13
  thinkingLevel: ThinkingLevel;
14
+ writer?: string;
15
+ base?: {
16
+ ref: string;
17
+ commit: string;
18
+ };
14
19
  state: SessionState;
15
20
  createdAt: string;
16
21
  updatedAt: string;
@@ -41,7 +46,7 @@ export interface PromptResult {
41
46
  session: SessionView;
42
47
  turn: TurnView;
43
48
  }
44
- export type RegistryErrorCode = "unknown_workspace" | "unknown_session" | "session_running" | "prompt_rejected" | "prompt_timeout" | "abort_failed" | "abort_timeout" | "unknown_turn" | "unknown_execution_profile" | "workspace_mode_forbids_coding" | "writer_uncollected" | "writer_busy" | "server_stopping";
49
+ export type RegistryErrorCode = "writer_session_lost" | "base_not_applicable" | "unknown_workspace" | "unknown_session" | "session_running" | "prompt_rejected" | "prompt_timeout" | "abort_failed" | "abort_timeout" | "unknown_turn" | "unknown_execution_profile" | "workspace_mode_forbids_coding" | "writer_uncollected" | "writer_busy" | "server_stopping";
45
50
  export declare class RegistryError extends Error {
46
51
  readonly code: RegistryErrorCode;
47
52
  constructor(code: RegistryErrorCode);
@@ -84,7 +89,9 @@ export declare class SessionRegistry {
84
89
  private stopping;
85
90
  private readonly shutdownController;
86
91
  constructor(adapter: PiSessionAdapter, workspaces: ReadonlyMap<string, string>, options?: SessionRegistryOptions);
87
- start(workspace: string, executionProfile: ResolvedExecutionProfile): Promise<SessionView>;
92
+ start(workspace: string, executionProfile: ResolvedExecutionProfile, options?: {
93
+ base?: string;
94
+ }): Promise<SessionView>;
88
95
  private guardedCreate;
89
96
  private createAndRegister;
90
97
  list(): SessionView[];