pi-session-mcp 0.13.0 → 0.13.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 (39) hide show
  1. package/.pi-session-mcp-cli/config.js +21 -2
  2. package/.pi-session-mcp-cli/doctor.js +8 -3
  3. package/CHANGELOG.md +73 -0
  4. package/CONTRIBUTING.md +8 -1
  5. package/README.md +4 -2
  6. package/dist/config.d.ts +11 -1
  7. package/dist/config.js +21 -2
  8. package/dist/confined-tools.d.ts +1 -0
  9. package/dist/confined-tools.js +6 -2
  10. package/dist/doctor.js +8 -3
  11. package/dist/main.js +6 -2
  12. package/dist/sdk-pi-adapter.js +3 -2
  13. package/dist/setup-cli.d.ts +1 -2
  14. package/dist/setup-cli.js +0 -3
  15. package/dist/setup-writers-common.d.ts +78 -0
  16. package/dist/setup-writers-common.js +177 -0
  17. package/dist/setup-writers-plan.d.ts +34 -0
  18. package/dist/setup-writers-plan.js +105 -0
  19. package/dist/setup-writers-reconcile.d.ts +8 -0
  20. package/dist/setup-writers-reconcile.js +169 -0
  21. package/dist/setup-writers.d.ts +6 -30
  22. package/dist/setup-writers.js +18 -382
  23. package/dist/wake-hook.d.ts +11 -0
  24. package/dist/wake-hook.js +51 -8
  25. package/docs/acceptance.md +130 -0
  26. package/docs/adr/0009-execution-modes.md +3 -2
  27. package/docs/adr/0012-leased-writer-pool.md +3 -3
  28. package/docs/adr/0013-hardened-read-only-profiles.md +6 -4
  29. package/docs/architecture.md +1 -1
  30. package/docs/client-setup.md +3 -3
  31. package/docs/doctor-design.md +1 -1
  32. package/docs/execution-modes.md +1 -1
  33. package/docs/roadmap.md +13 -5
  34. package/docs/session-mcp.md +5 -3
  35. package/docs/threat-model.md +2 -1
  36. package/docs/tool-contracts.md +4 -2
  37. package/package.json +1 -1
  38. package/skills/pi-session-orchestration/SKILL.md +2 -2
  39. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
@@ -72,12 +72,31 @@ const CONFIG_ERROR_MESSAGES = {
72
72
  };
73
73
  export class ConfigError extends Error {
74
74
  code;
75
- constructor(code) {
75
+ /** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
76
+ locations;
77
+ constructor(code, locations = []) {
76
78
  super(CONFIG_ERROR_MESSAGES[code]);
77
79
  this.name = "ConfigError";
78
80
  this.code = code;
81
+ this.locations = Object.freeze([...locations]);
79
82
  }
80
83
  }
84
+ export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
85
+ /**
86
+ * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
87
+ * A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
88
+ * form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
89
+ * key that is not a schema field name, could be a path or a secret and becomes `<key>`.
90
+ */
91
+ export function issueLocations(issues) {
92
+ const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
93
+ const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
94
+ const locations = issues.flatMap((issue) => {
95
+ const path = issue.path.map((value) => segment(value, true));
96
+ return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
97
+ });
98
+ return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
99
+ }
81
100
  const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
82
101
  export const configSchema = z.object({
83
102
  events: eventsSchema.optional(),
@@ -151,7 +170,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
151
170
  const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
152
171
  // Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
153
172
  const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
154
- throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config");
173
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
155
174
  }
156
175
  throw error;
157
176
  }
@@ -8,7 +8,7 @@ import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
8
8
  import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
9
9
  import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
10
10
  import { LEASE_DIRECTORY } from "./writer-lease.js";
11
- import { loadConfig } from "./config.js";
11
+ import { ConfigError, loadConfig } from "./config.js";
12
12
  import { containerRuntimeUsable } from "./container-runtime.js";
13
13
  import { PiExtensionError } from "./extension-spec.js";
14
14
  import { readPackageMetadata } from "./package-metadata.js";
@@ -423,8 +423,13 @@ export async function runDoctor(dependencies = {}) {
423
423
  checks.push(failure("config_missing", "configuration", "create PI_SESSION_MCP_CONFIG configuration"));
424
424
  else if (isUnreadable(error))
425
425
  checks.push(failure("config_unreadable", "configuration", "grant configuration read access"));
426
- else
427
- checks.push(failure("config_invalid", "configuration", "fix configuration using the documented schema"));
426
+ else {
427
+ // Locations name the offending field; an error without one, or at the root, keeps the plain subject.
428
+ const locations = error instanceof ConfigError ? error.locations : [];
429
+ for (const location of locations.length ? locations : [""]) {
430
+ checks.push(failure("config_invalid", location ? `configuration:${location}` : "configuration", "fix configuration using the documented schema"));
431
+ }
432
+ }
428
433
  }
429
434
  if (config) {
430
435
  for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
package/CHANGELOG.md CHANGED
@@ -2,6 +2,79 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.1 — 2026-10-29
6
+
7
+ Patch release under ADR 0003 with an unchanged MCP contract. It ships the
8
+ fixes after 0.13.0: Doctor names where the configuration schema rejects a
9
+ file ([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)),
10
+ read-only confinement of Pi's agent directory
11
+ ([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)), a writer
12
+ added without `--base` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)),
13
+ stale waiter-lock reclamation in the wake hook
14
+ ([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)) and
15
+ per-writer reconcile failures
16
+ ([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)). Setup
17
+ reports change for a broken kept writer: it now carries its own code on its
18
+ target instead of `writer_reconcile_failed`. `npm audit` still reports
19
+ `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release, now with
20
+ eight advisories ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
21
+
22
+ Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
23
+ accepted again after the upgrade, as after every release.
24
+
25
+ ### Changed
26
+
27
+ - Doctor's `config_invalid` names where the schema rejects the configuration:
28
+ one check per issue location (at most five) with the subject
29
+ `configuration:<location>`, for example
30
+ `configuration:executionProfiles.review.default`. Locations hold only schema
31
+ field names, aliases and array indexes; an unknown key that is not a schema
32
+ field name, and any other key that is not a valid alias, is shown as
33
+ `<key>`, and values and messages are never reported. An error without a
34
+ location keeps the subject `configuration`
35
+ ([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)).
36
+
37
+ ### Added
38
+
39
+ - Issue forms for bug reports, change proposals and maintenance tasks, and a
40
+ pull request template, written so that a person or a coding agent can carry
41
+ an issue out and a reviewer can check it
42
+ ([#178](https://github.com/plagor44-ux/pi-session-mcp/issues/178)).
43
+
44
+ ### Fixed
45
+
46
+ - Security, in the lockfile only, development path: `@hono/node-server`
47
+ 2.1.1 → 2.1.5 (via MCP Inspector; GHSA-rmxm-3fg6-px4f). Still open:
48
+ `undici` 8.10.2 in the production path, pinned exactly by Pi SDK 1.4.0, now
49
+ with eight advisories (GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472,
50
+ GHSA-xjj6-f33q-929c, GHSA-4w7w-5xrc-9h26, GHSA-2m5v-pch6-74hx,
51
+ GHSA-qrxm-fqhp-jwvm, GHSA-9fm3-97px-rjf6, GHSA-55wc-3pg8-jfw6), so
52
+ `npm audit` still reports it
53
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
54
+ - Writer reconciliation reports a kept writer that fails, for example with
55
+ a missing wrapper after a crash during provisioning, on that writer's
56
+ target with its own code (`writer_state_invalid`) and still reconciles the
57
+ other writers. Only failures of shared setup state (configuration,
58
+ ownership) end the whole step with `writer_reconcile_failed`
59
+ ([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)).
60
+ - Wake hook: reclaiming a stale waiter lock is serialized by an exclusive
61
+ `<lock>.reclaim` guard, and the lock is judged again while holding it. Two
62
+ waiters that both saw the same stale lock can no longer both take the turn
63
+ and send a duplicate wake reminder
64
+ ([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)).
65
+ - Read-only confinement protects Pi's agent directory (`~/.pi/agent`, or
66
+ `PI_CODING_AGENT_DIR`) and, for a symlinked configuration file, its target's
67
+ directory: a skill directory that equals or contains either contributes only
68
+ the skill file as a read root. Before, a skill file placed directly in the
69
+ agent directory made `auth.json` readable. The real file of a symlinked skill
70
+ file is now a read root too, so the model can read a skill it was given
71
+ ([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)).
72
+ - Setup provisions a writer added without `--base` (for example by raising
73
+ `--writers`) on the workspace's recorded default base, as finish and recreate
74
+ already did. Before, it landed on `origin/HEAD` and default starts never
75
+ leased it. A recorded base that is the repository default still provisions
76
+ from `origin/HEAD` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)).
77
+
5
78
  ## 0.13.0 — 2026-10-28
6
79
 
7
80
  Minor release under ADR 0003 for
package/CONTRIBUTING.md CHANGED
@@ -42,7 +42,14 @@ ones:
42
42
  Pull requests are limited to collaborators. If you are not a collaborator and
43
43
  want to contribute a fix, open an issue that describes it.
44
44
 
45
- - Keep a pull request to one focused change, and say which issue it addresses.
45
+ Issues use the forms in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/):
46
+ bug report, change proposal or maintenance task. Each asks for acceptance
47
+ criteria and a way to verify them, so that a person or a coding agent can carry
48
+ the issue out and a reviewer can check it. Pull requests use
49
+ [`.github/pull_request_template.md`](.github/pull_request_template.md).
50
+
51
+ - Keep a pull request to one focused change, and name every issue it addresses
52
+ with its own closing keyword (`Closes #12. Closes #13.`).
46
53
  - Run the checks above and `npm audit` before opening it.
47
54
  - Describe what you verified and how. State only what was run: provider-free
48
55
  tests do not establish live client or model behavior.
package/README.md CHANGED
@@ -30,10 +30,12 @@ It is not the right tool when:
30
30
 
31
31
  ## Status
32
32
 
33
- The package version is **0.13.0**.
33
+ The package version is **0.13.1**.
34
34
 
35
35
  [Per-repository execution modes](docs/execution-modes.md) make host writes explicit and block coding on read-only aliases. On container workspaces, the one host `pi-session-mcp` registration leases a rootless-Podman writer per coding session on Linux; no per-writer registrations or tool prefixes are needed ([ADR 0012](docs/adr/0012-leased-writer-pool.md)). Setup provisions, accepts, collects and resets the pool. Starts normally use the workspace default base; fix rounds can pass a short branch `base`. Start/get report `writer` and `base` for collection, and capabilities report configured `writers` and `defaultBase`. Reset, finish and recreate need no reconnect; finish returns the writer to the default base. See the [container upgrade runbook](docs/installation.md#container-writers).
36
36
 
37
+ Every read-only profile reads only inside its workspace and the directories of its selected skills, and a read-only profile on Linux can opt into a bubblewrap shell without network, home or host writes (`shell: "sandboxed"`, [ADR 0013](docs/adr/0013-hardened-read-only-profiles.md)).
38
+
37
39
  - **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
38
40
  - **Linux and macOS**, Node.js 22.19.0 or newer. macOS is tested in CI on macOS 15 and 26 on Apple silicon; Intel Macs run the same code but are untested. Setup acceptance on a real Mac is recorded for Claude Code only; Codex is still pending. See [Acceptance](docs/acceptance.md#status). On other platforms `setup` refuses with `platform_unsupported`.
39
41
  - **Clients:** Codex CLI and Claude Code. The `setup` command does not pin client versions: it accepts a client whose public `mcp` command output matches the expected contract and fails closed otherwise.
@@ -113,7 +115,7 @@ The [installation guide](docs/installation.md) also covers installing from a rel
113
115
  ```
114
116
 
115
117
  - A workspace alias maps to a directory. Clients can select aliases only.
116
- - An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`.
118
+ - An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`. A read-only profile can add `shell: "sandboxed"` on Linux.
117
119
  - A profile can grant individual tools of local stdio MCP servers to its sessions; see [External MCP tools in embedded sessions](docs/session-mcp.md).
118
120
  - An optional top-level `skillCatalog` maps skill aliases to `builtin:<name>` (a skill of the package or of your override directory) or to a `SKILL.md` file, and a profile's optional `skills` selects aliases from it. A profile without `skills` gets no skills. See [Pi skills](docs/skills.md).
119
121
  - An optional top-level `"events": { "delivery": "spool" }` turns on content-free turn events, so that a Claude Code hook can wake the controller when a turn ends instead of polling. It is off by default and changes nothing for Codex. See [Wake the controller](docs/installation.md#wake-the-controller-claude-code).
package/dist/config.d.ts CHANGED
@@ -4,8 +4,18 @@ import { type WorkspaceModeEntry } from "./execution-modes.js";
4
4
  export type ConfigErrorCode = "config_path_required" | "invalid_execution_profile_config" | "invalid_thinking_level" | "invalid_execution_modes" | "invalid_event_delivery";
5
5
  export declare class ConfigError extends Error {
6
6
  readonly code: ConfigErrorCode;
7
- constructor(code: ConfigErrorCode);
7
+ /** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
8
+ readonly locations: readonly string[];
9
+ constructor(code: ConfigErrorCode, locations?: readonly string[]);
8
10
  }
11
+ export declare const MAX_CONFIG_ISSUE_LOCATIONS = 5;
12
+ /**
13
+ * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
14
+ * A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
15
+ * form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
16
+ * key that is not a schema field name, could be a path or a secret and becomes `<key>`.
17
+ */
18
+ export declare function issueLocations(issues: readonly z.core.$ZodIssue[]): string[];
9
19
  export declare const configSchema: z.ZodObject<{
10
20
  events: z.ZodOptional<z.ZodObject<{
11
21
  delivery: z.ZodEnum<{
package/dist/config.js CHANGED
@@ -72,12 +72,31 @@ const CONFIG_ERROR_MESSAGES = {
72
72
  };
73
73
  export class ConfigError extends Error {
74
74
  code;
75
- constructor(code) {
75
+ /** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
76
+ locations;
77
+ constructor(code, locations = []) {
76
78
  super(CONFIG_ERROR_MESSAGES[code]);
77
79
  this.name = "ConfigError";
78
80
  this.code = code;
81
+ this.locations = Object.freeze([...locations]);
79
82
  }
80
83
  }
84
+ export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
85
+ /**
86
+ * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
87
+ * A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
88
+ * form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
89
+ * key that is not a schema field name, could be a path or a secret and becomes `<key>`.
90
+ */
91
+ export function issueLocations(issues) {
92
+ const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
93
+ const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
94
+ const locations = issues.flatMap((issue) => {
95
+ const path = issue.path.map((value) => segment(value, true));
96
+ return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
97
+ });
98
+ return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
99
+ }
81
100
  const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
82
101
  export const configSchema = z.object({
83
102
  events: eventsSchema.optional(),
@@ -151,7 +170,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
151
170
  const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
152
171
  // Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
153
172
  const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
154
- throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config");
173
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
155
174
  }
156
175
  throw error;
157
176
  }
@@ -7,6 +7,7 @@ export declare const CONFINED_TOOL_NAMES: readonly string[];
7
7
  * The allowed read roots: the workspace and the directory of each selected skill file, as real paths.
8
8
  * A skill directory that equals or contains a protected path (the operator's home, the configuration
9
9
  * directory, the Pi agent directory) would expose it, so only the skill file itself is a root then.
10
+ * A symlinked skill file resolves outside its directory, so its real file is a root as well.
10
11
  */
11
12
  export declare function allowedReadRoots(workspace: string, skills: readonly Skill[], protectedPaths?: readonly string[]): Promise<string[]>;
12
13
  /**
@@ -32,13 +32,17 @@ function isWithin(path, root) {
32
32
  * The allowed read roots: the workspace and the directory of each selected skill file, as real paths.
33
33
  * A skill directory that equals or contains a protected path (the operator's home, the configuration
34
34
  * directory, the Pi agent directory) would expose it, so only the skill file itself is a root then.
35
+ * A symlinked skill file resolves outside its directory, so its real file is a root as well.
35
36
  */
36
37
  export async function allowedReadRoots(workspace, skills, protectedPaths = []) {
37
38
  const roots = new Set([await realpath(workspace)]);
38
39
  const protectedReal = await Promise.all(protectedPaths.map((path) => canonical(path)));
39
40
  for (const skill of skills) {
40
- const directory = await realpath(dirname(skill.filePath));
41
- roots.add(protectedReal.some((path) => isWithin(path, directory)) ? await realpath(skill.filePath) : directory);
41
+ const directory = await realpath(dirname(skill.filePath)), file = await canonical(skill.filePath);
42
+ const root = protectedReal.some((path) => isWithin(path, directory)) ? file : directory;
43
+ roots.add(root);
44
+ if (!isWithin(file, root))
45
+ roots.add(file);
42
46
  }
43
47
  return [...roots];
44
48
  }
package/dist/doctor.js CHANGED
@@ -8,7 +8,7 @@ import { podmanInfo, isSafeRefName } from "./writer-boundary.js";
8
8
  import { containedBuiltinSkills, containedProfileRefused } from "./writer-lifecycle.js";
9
9
  import { readWriterState, writerAcceptanceCurrent, writerName, writerPaths } from "./writer-state.js";
10
10
  import { LEASE_DIRECTORY } from "./writer-lease.js";
11
- import { loadConfig } from "./config.js";
11
+ import { ConfigError, loadConfig } from "./config.js";
12
12
  import { containerRuntimeUsable } from "./container-runtime.js";
13
13
  import { PiExtensionError } from "./extension-spec.js";
14
14
  import { readPackageMetadata } from "./package-metadata.js";
@@ -423,8 +423,13 @@ export async function runDoctor(dependencies = {}) {
423
423
  checks.push(failure("config_missing", "configuration", "create PI_SESSION_MCP_CONFIG configuration"));
424
424
  else if (isUnreadable(error))
425
425
  checks.push(failure("config_unreadable", "configuration", "grant configuration read access"));
426
- else
427
- checks.push(failure("config_invalid", "configuration", "fix configuration using the documented schema"));
426
+ else {
427
+ // Locations name the offending field; an error without one, or at the root, keeps the plain subject.
428
+ const locations = error instanceof ConfigError ? error.locations : [];
429
+ for (const location of locations.length ? locations : [""]) {
430
+ checks.push(failure("config_invalid", location ? `configuration:${location}` : "configuration", "fix configuration using the documented schema"));
431
+ }
432
+ }
428
433
  }
429
434
  if (config) {
430
435
  for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
package/dist/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { stdoutGuard } from "./stdout-guard-install.js";
3
+ import { realpath } from "node:fs/promises";
3
4
  import { dirname, isAbsolute, resolve } from "node:path";
4
5
  import { homedir } from "node:os";
5
6
  import { RoutingPiSessionAdapter, WriterRelayAdapter } from "./writer-relay.js";
@@ -47,10 +48,13 @@ async function main() {
47
48
  spool = TurnEventSpool.open(directory, { diagnostic: writeDiagnostic });
48
49
  }
49
50
  const events = spool;
51
+ // The configuration directory must never become readable through a skill directory: the directory
52
+ // named by the variable and, for a symlinked configuration file, the directory of its target.
53
+ // loadConfig has read the file, so the variable is set and the path exists.
54
+ const configFile = resolve(process.env.PI_SESSION_MCP_CONFIG);
50
55
  const hostAdapter = new SdkPiSessionAdapter({
51
56
  diagnosticSink: writeDiagnostic,
52
- // The configuration directory must never become readable through a skill directory.
53
- ...(process.env.PI_SESSION_MCP_CONFIG === undefined || process.env.PI_SESSION_MCP_CONFIG === "" ? {} : { protectedReadPaths: [dirname(resolve(process.env.PI_SESSION_MCP_CONFIG))] }),
57
+ protectedReadPaths: [dirname(configFile), dirname(await realpath(configFile))],
54
58
  });
55
59
  const relay = guardPath === undefined;
56
60
  const adapter = relay ? new RoutingPiSessionAdapter(hostAdapter, new WriterRelayAdapter({
@@ -1,4 +1,4 @@
1
- import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
2
  import { homedir, userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
@@ -347,7 +347,8 @@ export class SdkPiSessionAdapter {
347
347
  catch {
348
348
  return homedir();
349
349
  } })();
350
- this.protectedReadPaths = [home, join(home, ".pi"), ...(options.protectedReadPaths ?? [])];
350
+ // Pi's agent directory holds its credentials; the SDK resolves it from `PI_CODING_AGENT_DIR` or below `~/.pi`.
351
+ this.protectedReadPaths = [home, join(home, ".pi"), getAgentDir(), ...(options.protectedReadPaths ?? [])];
351
352
  }
352
353
  async create(input) {
353
354
  const extensions = input.executionProfile.extensions ?? [];
@@ -1,4 +1,4 @@
1
- import { SetupOrchestrator, type SetupRequest } from "./setup.js";
1
+ import type { SetupRequest } from "./setup.js";
2
2
  import type { SetupResult } from "./setup-result.js";
3
3
  import type { ModeRequest } from "./setup-modes.js";
4
4
  import type { WriterOperation } from "./setup-writers.js";
@@ -21,7 +21,6 @@ export interface SetupCliRequest extends SetupRequest {
21
21
  };
22
22
  }
23
23
  export declare function parseSetupArgs(argv: readonly string[]): SetupCliRequest;
24
- export declare function runSetupCli(argv: readonly string[], orchestrator: SetupOrchestrator): Promise<SetupResult>;
25
24
  /** Both report modes are projections of the same sanitized result. */
26
25
  export declare function formatSetupResult(setupResult: SetupResult): string;
27
26
  export declare function formatSetupHuman(setupResult: SetupResult): string;
package/dist/setup-cli.js CHANGED
@@ -210,9 +210,6 @@ export function parseSetupArgs(argv) {
210
210
  }
211
211
  return operation ? { operation, targets, json } : { targets, json };
212
212
  }
213
- export async function runSetupCli(argv, orchestrator) {
214
- return orchestrator.run(parseSetupArgs(argv));
215
- }
216
213
  /** Both report modes are projections of the same sanitized result. */
217
214
  export function formatSetupResult(setupResult) {
218
215
  return JSON.stringify(setupResult);
@@ -0,0 +1,78 @@
1
+ import { runDoctor } from "./doctor.js";
2
+ import { type WorkspaceModeEntry } from "./execution-modes.js";
3
+ import { acquireOverrideLock } from "./override-lock.js";
4
+ import { createCommandMcpStdioLauncher } from "./setup-process.js";
5
+ import { DurableOwnershipStore, type OwnershipStore } from "./setup-ownership.js";
6
+ import { type WakeHookOutcome } from "./setup-hooks.js";
7
+ import type { SetupFinding, SetupTarget } from "./setup-result.js";
8
+ import { type WriterRegistrationIntent, type WriterRegistrationAdapter } from "./client-adapters/writer-registration.js";
9
+ import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
10
+ import { acquireWriterLease } from "./writer-lease.js";
11
+ import { ensureWriterAbsent } from "./writer-fence.js";
12
+ import * as lifecycle from "./writer-lifecycle.js";
13
+ import * as acceptance from "./writer-acceptance.js";
14
+ /** Services, fencing, the Doctor gate and removal shared by mode changes, reconciliation and single-writer operations. */
15
+ export declare const SETUP_LEASE_WAIT_MS = 10000;
16
+ export declare const FENCE_CODES: readonly string[];
17
+ /**
18
+ * Failures of shared setup state rather than of one writer: they end the whole reconciliation (#180). The ownership
19
+ * store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
20
+ */
21
+ export declare const INFRASTRUCTURE_CODE: RegExp;
22
+ export declare const GLOBAL_ERRORS: Set<string>;
23
+ export interface WriterSetupDependencies {
24
+ configPath: string;
25
+ releaseRoot: string;
26
+ home?: string;
27
+ platform?: NodeJS.Platform;
28
+ signal?: AbortSignal;
29
+ runner?: lifecycle.WriterContext["runner"];
30
+ lifecycle?: typeof lifecycle;
31
+ acceptance?: typeof acceptance;
32
+ ownership?: OwnershipStore;
33
+ adapter?: WriterRegistrationAdapter;
34
+ doctor?: typeof runDoctor;
35
+ verify?: typeof verifyMcpCapabilities;
36
+ launch?: typeof createCommandMcpStdioLauncher;
37
+ lock?: typeof acquireOverrideLock;
38
+ lease?: typeof acquireWriterLease;
39
+ ensureAbsent?: typeof ensureWriterAbsent;
40
+ containerRuntime?: () => Promise<boolean>;
41
+ }
42
+ export declare const targetFor: (alias: string, name: string) => SetupTarget;
43
+ export declare const modesTarget: (alias: string) => SetupTarget;
44
+ export declare const codeFor: (error: unknown) => string;
45
+ export declare function services(deps: WriterSetupDependencies): {
46
+ home: string;
47
+ runner: import("./client-adapters/types.js").CommandRunner;
48
+ life: typeof lifecycle;
49
+ accept: typeof acceptance;
50
+ ownership: DurableOwnershipStore | OwnershipStore;
51
+ adapter: WriterRegistrationAdapter;
52
+ };
53
+ export declare function context(alias: string, deps: WriterSetupDependencies): Promise<lifecycle.WriterContext>;
54
+ export declare function locked<T>(deps: WriterSetupDependencies, action: () => Promise<T>): Promise<T>;
55
+ export declare function fenced<T>(ctx: lifecycle.WriterContext, name: string, deps: WriterSetupDependencies, action: (ctx: lifecycle.WriterContext) => Promise<T>): Promise<T>;
56
+ export declare function intent(ctx: lifecycle.WriterContext, name: string, client: "claude-code" | "codex"): WriterRegistrationIntent;
57
+ /**
58
+ * The wake hook of a writer's Claude Code registration, in the local settings of the operator's
59
+ * project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
60
+ * whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
61
+ * Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
62
+ */
63
+ export declare function writerHook(ctx: lifecycle.WriterContext, name: string, deps: WriterSetupDependencies, operation: "apply" | "dry-run" | "verify" | "remove", enabled: boolean): Promise<WakeHookOutcome | undefined>;
64
+ export declare class DoctorGateError extends Error {
65
+ readonly findings: readonly SetupFinding[];
66
+ constructor(findings: readonly SetupFinding[]);
67
+ }
68
+ export declare function doctorGate(ctx: lifecycle.WriterContext, deps: WriterSetupDependencies): Promise<void>;
69
+ export declare function preflight(ctx: lifecycle.WriterContext, names: readonly string[], discard: boolean, deps: WriterSetupDependencies): Promise<void>;
70
+ export declare function remove(ctx: lifecycle.WriterContext, name: string, discard: boolean, deps: WriterSetupDependencies, extras?: WakeHookOutcome[]): Promise<lifecycle.WriterOutcome>;
71
+ export declare const writerIndex: (name: string) => number;
72
+ /** Which writers a mode entry keeps, which of those still need provisioning, and which existing writers it no longer wants. */
73
+ export declare function plan(ctx: lifecycle.WriterContext, entry: WorkspaceModeEntry): Promise<{
74
+ keep: string[];
75
+ missing: string[];
76
+ surplus: string[];
77
+ }>;
78
+ export type WriterPlan = Awaited<ReturnType<typeof plan>>;
@@ -0,0 +1,177 @@
1
+ import { chmod, mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { ALIAS_PATTERN } from "./execution-profile.js";
6
+ import { runDoctor } from "./doctor.js";
7
+ import { MAX_WRITERS } from "./execution-modes.js";
8
+ import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
9
+ import { createProcessRunner } from "./setup-process.js";
10
+ import { DurableOwnershipStore } from "./setup-ownership.js";
11
+ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
12
+ import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
13
+ import { IMAGE_PULL_TIMEOUT_MS } from "./writer-boundary.js";
14
+ import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
15
+ import { ensureWriterAbsent } from "./writer-fence.js";
16
+ import * as lifecycle from "./writer-lifecycle.js";
17
+ import * as acceptance from "./writer-acceptance.js";
18
+ import { readWriterState, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
19
+ /** Services, fencing, the Doctor gate and removal shared by mode changes, reconciliation and single-writer operations. */
20
+ export const SETUP_LEASE_WAIT_MS = 10_000;
21
+ export const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
22
+ /**
23
+ * Failures of shared setup state rather than of one writer: they end the whole reconciliation (#180). The ownership
24
+ * store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
25
+ */
26
+ export const INFRASTRUCTURE_CODE = /^(?:ownership_(?!unavailable$|diverged$)[a-z_]+|config_[a-z_]+|operation_aborted|platform_unsupported)$/;
27
+ export const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid"]);
28
+ const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
29
+ export const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
30
+ export const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
31
+ export const codeFor = (error) => error instanceof OverrideLockError || error instanceof WriterLeaseError ? error.code : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message) ? error.message : "writer_operation_failed";
32
+ export function services(deps) {
33
+ const home = deps.home ?? homedir();
34
+ const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
35
+ return { home, runner, life: deps.lifecycle ?? lifecycle, accept: deps.acceptance ?? acceptance,
36
+ ownership: deps.ownership ?? new DurableOwnershipStore(join(home, ".local", "state", "pi-session-mcp", "ownership.json"), { ...(deps.signal ? { signal: deps.signal } : {}) }),
37
+ adapter: deps.adapter ?? createWriterRegistrationAdapter(runner) };
38
+ }
39
+ export async function context(alias, deps) {
40
+ const config = await loadConfig(deps.configPath);
41
+ const checkout = config.workspaces.get(alias);
42
+ if (!checkout)
43
+ throw new Error("alias_unknown");
44
+ const { home, runner } = services(deps);
45
+ return { home, runner, checkout, alias, releaseRoot: deps.releaseRoot, nodePath: process.execPath };
46
+ }
47
+ export async function locked(deps, action) {
48
+ const directory = writersRoot(deps.home ?? homedir());
49
+ await mkdir(directory, { recursive: true, mode: 0o700 });
50
+ await chmod(directory, 0o700);
51
+ const lock = await (deps.lock ?? acquireOverrideLock)(directory, { ...(deps.signal ? { signal: deps.signal } : {}) });
52
+ try {
53
+ return await action();
54
+ }
55
+ finally {
56
+ await lock.release();
57
+ }
58
+ }
59
+ export async function fenced(ctx, name, deps, action) {
60
+ let lease;
61
+ try {
62
+ lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
63
+ }
64
+ catch (error) {
65
+ throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
66
+ }
67
+ try {
68
+ try {
69
+ await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
70
+ }
71
+ catch {
72
+ throw new Error("writer_fenced");
73
+ }
74
+ return await action({ ...ctx, leaseFd: lease.fd });
75
+ }
76
+ finally {
77
+ await lease.release();
78
+ }
79
+ }
80
+ export function intent(ctx, name, client) {
81
+ return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
82
+ }
83
+ /**
84
+ * The wake hook of a writer's Claude Code registration, in the local settings of the operator's
85
+ * project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
86
+ * whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
87
+ * Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
88
+ */
89
+ export async function writerHook(ctx, name, deps, operation, enabled) {
90
+ const { ownership } = services(deps);
91
+ const roots = { packageRoot: deps.releaseRoot, home: ctx.home, cwd: ctx.checkout, env: process.env, nodePath: ctx.nodePath };
92
+ // Nothing wanted and nothing owned: not even the project's settings are looked at.
93
+ if (!enabled && !await ownsWakeHook("local", roots, ownership))
94
+ return undefined;
95
+ const request = (access, op) => runWakeHook({
96
+ operation: op, scope: "local", enabled, ownership: access,
97
+ roots,
98
+ keep: () => localHookNeededByOthers({ ownership: access, home: ctx.home, cwd: ctx.checkout, configPath: deps.configPath, except: { writer: name } }),
99
+ });
100
+ const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
101
+ return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
102
+ }
103
+ export class DoctorGateError extends Error {
104
+ findings;
105
+ constructor(findings) {
106
+ super("doctor_failed");
107
+ this.findings = findings;
108
+ }
109
+ }
110
+ export async function doctorGate(ctx, deps) {
111
+ const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
112
+ const ownSubjects = new Set([ctx.alias, ...Array.from({ length: 16 }, (_, index) => writerName(ctx.alias, index + 1))]);
113
+ // A refused profile of this repository is its own finding: collect, finish and the profile change that fixes it must stay possible (#141).
114
+ // Another repository's writer that is not ready is untouched by this step and expected after an upgrade; counting it would deadlock
115
+ // the repository steps of two container repositories (#148).
116
+ const own = (check) => ownSubjects.has(check.subject)
117
+ || (check.id === "container_profile_refused" && check.subject.startsWith(`${ctx.alias}:`))
118
+ || (check.id === "writer_not_ready" && WRITER_NAME_PATTERN.test(check.subject));
119
+ const blocking = doctor.checks.filter((check) => check.severity === "error" && !own(check));
120
+ // One finding per check and alias: several `config_invalid` locations (#173) all map to the alias `invalid`.
121
+ const findings = blocking.map((check) => ({ status: "failed", code: check.id, target: { client: "doctor", scope: "check", alias: gateAlias(check.id, check.subject) } }));
122
+ const unique = findings.filter((finding, index) => findings.findIndex((other) => other.code === finding.code && other.target.alias === finding.target.alias) === index);
123
+ if (unique.length)
124
+ throw new DoctorGateError(unique);
125
+ }
126
+ /** The alias that leads a Doctor subject: `<profile>:<package>` and writer names report their alias part; global checks have none. */
127
+ function gateAlias(id, subject) {
128
+ if (GLOBAL_ERRORS.has(id))
129
+ return "invalid";
130
+ const head = subject.split(":")[0] ?? "";
131
+ return safeAlias(WRITER_NAME_PATTERN.test(head) ? head.slice(0, head.lastIndexOf("-w")) : head);
132
+ }
133
+ export async function preflight(ctx, names, discard, deps) {
134
+ if (discard)
135
+ return;
136
+ for (const name of names) {
137
+ const state = await readWriterState(ctx.home, name);
138
+ const work = await (deps.lifecycle ?? lifecycle).inspectWork(ctx, name);
139
+ if (!work.baseline && state?.collected?.digest !== work.digest)
140
+ throw new Error("writer_uncollected");
141
+ }
142
+ }
143
+ export async function remove(ctx, name, discard, deps, extras = []) {
144
+ await preflight(ctx, [name], discard, deps);
145
+ const { adapter, ownership, life } = services(deps);
146
+ // Remove registrations before deleting the wrapper used to prove ownership.
147
+ for (const client of ["claude-code", "codex"]) {
148
+ const expected = intent(ctx, name, client);
149
+ const owned = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
150
+ if (owned)
151
+ await unregisterWriter(ownership, adapter, expected);
152
+ else if (["equivalent", "divergent"].includes(await adapter.inspect(expected)))
153
+ throw new Error("ownership_unavailable");
154
+ }
155
+ // After the registration is gone. A hook that cannot be edited is reported, not a reason to keep the
156
+ // writer: the operator's settings are not the writer's to hold hostage, and the record keeps the entry owned.
157
+ const hook = await writerHook(ctx, name, deps, "remove", false).catch(() => ({ status: "failed", code: "hook_install_failed" }));
158
+ if (hook)
159
+ extras.push(hook);
160
+ return life.removeWriter(ctx, name, { discard });
161
+ }
162
+ export const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
163
+ /** Which writers a mode entry keeps, which of those still need provisioning, and which existing writers it no longer wants. */
164
+ export async function plan(ctx, entry) {
165
+ const keep = [], missing = [], surplus = [];
166
+ for (let index = 1; index <= MAX_WRITERS; index++) {
167
+ const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
168
+ if (entry.mode === "container" && index <= entry.container.writers) {
169
+ keep.push(name);
170
+ if (!state || state.phase === "provisioning")
171
+ missing.push(name);
172
+ }
173
+ else if (state)
174
+ surplus.push(name);
175
+ }
176
+ return { keep, missing, surplus };
177
+ }