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.
- package/.pi-session-mcp-cli/config.js +21 -2
- package/.pi-session-mcp-cli/doctor.js +8 -3
- package/CHANGELOG.md +73 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +4 -2
- package/dist/config.d.ts +11 -1
- package/dist/config.js +21 -2
- package/dist/confined-tools.d.ts +1 -0
- package/dist/confined-tools.js +6 -2
- package/dist/doctor.js +8 -3
- package/dist/main.js +6 -2
- package/dist/sdk-pi-adapter.js +3 -2
- package/dist/setup-cli.d.ts +1 -2
- package/dist/setup-cli.js +0 -3
- package/dist/setup-writers-common.d.ts +78 -0
- package/dist/setup-writers-common.js +177 -0
- package/dist/setup-writers-plan.d.ts +34 -0
- package/dist/setup-writers-plan.js +105 -0
- package/dist/setup-writers-reconcile.d.ts +8 -0
- package/dist/setup-writers-reconcile.js +169 -0
- package/dist/setup-writers.d.ts +6 -30
- package/dist/setup-writers.js +18 -382
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/docs/acceptance.md +130 -0
- package/docs/adr/0009-execution-modes.md +3 -2
- package/docs/adr/0012-leased-writer-pool.md +3 -3
- package/docs/adr/0013-hardened-read-only-profiles.md +6 -4
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +3 -3
- package/docs/doctor-design.md +1 -1
- package/docs/execution-modes.md +1 -1
- package/docs/roadmap.md +13 -5
- package/docs/session-mcp.md +5 -3
- package/docs/threat-model.md +2 -1
- package/docs/tool-contracts.md +4 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/confined-tools.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/confined-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -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
|
-
|
|
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 ?? [];
|
package/dist/setup-cli.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|