pi-session-mcp 0.13.0 → 0.14.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.
- package/.pi-session-mcp-cli/config.js +40 -3
- package/.pi-session-mcp-cli/doctor.js +13 -4
- package/.pi-session-mcp-cli/skill-library.js +1 -1
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +4 -2
- package/dist/config.d.ts +13 -1
- package/dist/config.js +40 -3
- package/dist/confined-tools.d.ts +1 -0
- package/dist/confined-tools.js +6 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +13 -4
- package/dist/main.js +6 -2
- package/dist/model-catalog.d.ts +40 -0
- package/dist/model-catalog.js +38 -0
- package/dist/profile-probe.d.ts +28 -0
- package/dist/profile-probe.js +164 -0
- package/dist/sdk-pi-adapter.js +3 -2
- package/dist/setup-cli.d.ts +8 -2
- package/dist/setup-cli.js +36 -3
- package/dist/setup-main.js +10 -3
- package/dist/setup-probe-main.d.ts +14 -0
- package/dist/setup-probe-main.js +76 -0
- package/dist/setup-probe-result.d.ts +39 -0
- package/dist/setup-probe-result.js +62 -0
- 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/skill-library.js +1 -1
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/docs/acceptance.md +254 -1
- 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/adr/0014-profile-probe-and-model-catalog.md +89 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +21 -15
- package/docs/doctor-design.md +2 -1
- package/docs/execution-modes.md +1 -1
- package/docs/installation.md +87 -0
- package/docs/roadmap.md +23 -5
- package/docs/session-mcp.md +5 -3
- package/docs/skills.md +42 -25
- 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
- package/skills/pi-session-profiles/SKILL.md +124 -0
|
@@ -6,7 +6,7 @@ import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js"
|
|
|
6
6
|
import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
|
|
7
7
|
import { builtinSkillName, isSkillCatalogPath, MAX_PROFILE_SKILLS, MAX_SKILL_CATALOG_ENTRIES } from "./skill-catalog.js";
|
|
8
8
|
import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
|
|
9
|
-
import { builtinSkillPath, skillOverrideDirectory } from "./skill-library.js";
|
|
9
|
+
import { builtinSkillPath, CONTROLLER_SKILLS, skillOverrideDirectory } from "./skill-library.js";
|
|
10
10
|
import { loadExecutionModes } from "./execution-modes.js";
|
|
11
11
|
import { isAcceptableExtensionToolName, MAX_GRANTED_EXTENSION_TOOLS, PROFILE_TOOLS } from "./session-tools.js";
|
|
12
12
|
const alias = z.string().regex(ALIAS_PATTERN);
|
|
@@ -72,12 +72,43 @@ 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
|
+
/** `skillCatalog` aliases refused because they name a controller skill (#188); Doctor reports them on their own. */
|
|
78
|
+
refusedControllerSkills;
|
|
79
|
+
constructor(code, locations = [], refusedControllerSkills = []) {
|
|
76
80
|
super(CONFIG_ERROR_MESSAGES[code]);
|
|
77
81
|
this.name = "ConfigError";
|
|
78
82
|
this.code = code;
|
|
83
|
+
this.locations = Object.freeze([...locations]);
|
|
84
|
+
this.refusedControllerSkills = Object.freeze([...refusedControllerSkills]);
|
|
79
85
|
}
|
|
80
86
|
}
|
|
87
|
+
const CONTROLLER_SKILL_ISSUE = "controller_skill";
|
|
88
|
+
/** Catalog aliases of controller-skill issues; only alias-shaped keys, so no value or path can pass. */
|
|
89
|
+
function controllerSkillAliases(issues) {
|
|
90
|
+
return [...new Set(issues.flatMap((issue) => {
|
|
91
|
+
const skillAlias = issue.path[1];
|
|
92
|
+
return issue.code === "custom" && issue.params?.reason === CONTROLLER_SKILL_ISSUE && issue.path[0] === "skillCatalog"
|
|
93
|
+
&& typeof skillAlias === "string" && ALIAS_PATTERN.test(skillAlias) ? [skillAlias] : [];
|
|
94
|
+
}))].sort();
|
|
95
|
+
}
|
|
96
|
+
export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
|
|
97
|
+
/**
|
|
98
|
+
* Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
|
|
99
|
+
* A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
|
|
100
|
+
* form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
|
|
101
|
+
* key that is not a schema field name, could be a path or a secret and becomes `<key>`.
|
|
102
|
+
*/
|
|
103
|
+
export function issueLocations(issues) {
|
|
104
|
+
const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
|
|
105
|
+
const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
|
|
106
|
+
const locations = issues.flatMap((issue) => {
|
|
107
|
+
const path = issue.path.map((value) => segment(value, true));
|
|
108
|
+
return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
|
|
109
|
+
});
|
|
110
|
+
return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
|
|
111
|
+
}
|
|
81
112
|
const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
|
|
82
113
|
export const configSchema = z.object({
|
|
83
114
|
events: eventsSchema.optional(),
|
|
@@ -96,6 +127,12 @@ export const configSchema = z.object({
|
|
|
96
127
|
}).strict().superRefine((config, context) => {
|
|
97
128
|
// A profile may select only cataloged skills; there is no implicit or default skill.
|
|
98
129
|
const catalog = config.skillCatalog ?? {};
|
|
130
|
+
// The catalog serves Pi workers, which are never the controlling agent (#188).
|
|
131
|
+
for (const [skillAlias, value] of Object.entries(catalog)) {
|
|
132
|
+
if (CONTROLLER_SKILLS.includes(builtinSkillName(value) ?? "")) {
|
|
133
|
+
context.addIssue({ code: "custom", path: ["skillCatalog", skillAlias], message: "A controller skill cannot be a worker skill", params: { reason: CONTROLLER_SKILL_ISSUE } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
for (const [profileAlias, profile] of Object.entries(config.executionProfiles)) {
|
|
100
137
|
if ((profile.skills ?? []).some((skill) => !Object.hasOwn(catalog, skill))) {
|
|
101
138
|
context.addIssue({ code: "custom", path: ["executionProfiles", profileAlias, "skills"], message: "A selected skill must be a skillCatalog alias" });
|
|
@@ -151,7 +188,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
151
188
|
const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
|
|
152
189
|
// Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
|
|
153
190
|
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");
|
|
191
|
+
throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues), controllerSkillAliases(error.issues));
|
|
155
192
|
}
|
|
156
193
|
throw error;
|
|
157
194
|
}
|
|
@@ -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";
|
|
@@ -33,7 +33,7 @@ async function readPrefix(path, maxBytes) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
const defaultFileSystem = { readFile, stat, access };
|
|
36
|
-
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", "sandbox_unavailable", "workspace_git_unavailable", "event_spool_root"];
|
|
36
|
+
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "skill_controller_selected", "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", "sandbox_unavailable", "workspace_git_unavailable", "event_spool_root"];
|
|
37
37
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
38
38
|
function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
|
|
39
39
|
function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
|
|
@@ -423,8 +423,17 @@ 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
|
+
const controller = new Set((error instanceof ConfigError ? error.refusedControllerSkills : []).map((skillAlias) => `skillCatalog.${skillAlias}`));
|
|
430
|
+
for (const location of locations.length ? locations : [""]) {
|
|
431
|
+
const subject = location ? `configuration:${location}` : "configuration";
|
|
432
|
+
checks.push(controller.has(location)
|
|
433
|
+
? failure("skill_controller_selected", subject, "remove this skillCatalog entry: controller skills are for the controlling agent, not for Pi workers")
|
|
434
|
+
: failure("config_invalid", subject, "fix configuration using the documented schema"));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
428
437
|
}
|
|
429
438
|
if (config) {
|
|
430
439
|
for (const [alias, path] of [...config.workspaces].sort(([a], [b]) => a.localeCompare(b))) {
|
|
@@ -13,7 +13,7 @@ export const SKILL_NAME_PATTERN = /^(?=.{1,64}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
13
13
|
/** A `skillCatalog` value `builtin:<name>` names a library skill instead of a path. */
|
|
14
14
|
export const BUILTIN_SKILL_PREFIX = "builtin:";
|
|
15
15
|
/** Skills for the controlling agent, which setup installs into Claude Code and Codex. */
|
|
16
|
-
export const CONTROLLER_SKILLS = Object.freeze(["pi-session-orchestration"]);
|
|
16
|
+
export const CONTROLLER_SKILLS = Object.freeze(["pi-session-orchestration", "pi-session-profiles"]);
|
|
17
17
|
export const MAX_SKILL_TREE_FILES = 32;
|
|
18
18
|
export const MAX_SKILL_TREE_BYTES = 1024 * 1024;
|
|
19
19
|
const MAX_SKILL_TREE_DEPTH = 8;
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,127 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.14.0 — 2026-10-29
|
|
6
|
+
|
|
7
|
+
Minor release under ADR 0003 for
|
|
8
|
+
[ADR 0014](docs/adr/0014-profile-probe-and-model-catalog.md)
|
|
9
|
+
([#139](https://github.com/plagor44-ux/pi-session-mcp/issues/139)):
|
|
10
|
+
agent-driven execution-profile setup. The MCP tool contract is unchanged. Setup
|
|
11
|
+
gains `--list-models` and `--probe-profile`, setup installs a second controller
|
|
12
|
+
skill, Doctor gains the check `skill_controller_selected`, and a configuration
|
|
13
|
+
whose skill catalog names a controller skill no longer loads
|
|
14
|
+
([#188](https://github.com/plagor44-ux/pi-session-mcp/issues/188)). `npm audit`
|
|
15
|
+
still reports `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release,
|
|
16
|
+
with eight advisories ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
17
|
+
|
|
18
|
+
Pre-release acceptance on the candidate: Level 1 except `npm audit`, and Level 2
|
|
19
|
+
in the working tree and for the packed tarball, each with delivery off and on
|
|
20
|
+
([acceptance record](docs/acceptance.md#v0140-pre-release-acceptance)). The
|
|
21
|
+
Level 2 harness now expects both controller skills. Not established: an
|
|
22
|
+
agent-driven profile change end to end, a container writer acceptance of a new
|
|
23
|
+
profile, and Level 3.
|
|
24
|
+
|
|
25
|
+
Agent-driven execution-profile setup
|
|
26
|
+
([#139](https://github.com/plagor44-ux/pi-session-mcp/issues/139),
|
|
27
|
+
[ADR 0014](docs/adr/0014-profile-probe-and-model-catalog.md)):
|
|
28
|
+
|
|
29
|
+
- `pi-session-mcp-setup --list-models` prints the provider and model ids Pi
|
|
30
|
+
knows offline, with authentication presence only. Ids that could read as a
|
|
31
|
+
path are omitted and counted; providers registered only by extensions are not
|
|
32
|
+
listed.
|
|
33
|
+
- `pi-session-mcp-setup --probe-profile <alias>[,<alias>...]` checks profiles
|
|
34
|
+
offline (dry run by default); with `--apply` it sends one fixed PONG prompt
|
|
35
|
+
per profile in a private temporary directory and never prints the reply. It
|
|
36
|
+
is the only setup operation that uses a provider.
|
|
37
|
+
- The shipped controller skill `pi-session-profiles` lets a controlling agent
|
|
38
|
+
create, change or retire execution profiles itself. Setup installs it next to
|
|
39
|
+
`pi-session-orchestration`, so every client target reports one more skill
|
|
40
|
+
finding.
|
|
41
|
+
|
|
42
|
+
Controller skills as worker skills
|
|
43
|
+
([#188](https://github.com/plagor44-ux/pi-session-mcp/issues/188)):
|
|
44
|
+
|
|
45
|
+
- The configuration rejects the controller skills as worker skills: a
|
|
46
|
+
`skillCatalog` value `builtin:pi-session-orchestration` or
|
|
47
|
+
`builtin:pi-session-profiles` makes it invalid
|
|
48
|
+
(`invalid_execution_profile_config`), and Doctor reports the new error check
|
|
49
|
+
`skill_controller_selected` for `configuration:skillCatalog.<alias>`, which
|
|
50
|
+
blocks setup and the probe like `config_invalid`. A
|
|
51
|
+
configuration that used such an entry no longer starts; remove the entry.
|
|
52
|
+
|
|
53
|
+
## 0.13.1 — 2026-10-29
|
|
54
|
+
|
|
55
|
+
Patch release under ADR 0003 with an unchanged MCP contract. It ships the
|
|
56
|
+
fixes after 0.13.0: Doctor names where the configuration schema rejects a
|
|
57
|
+
file ([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)),
|
|
58
|
+
read-only confinement of Pi's agent directory
|
|
59
|
+
([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)), a writer
|
|
60
|
+
added without `--base` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)),
|
|
61
|
+
stale waiter-lock reclamation in the wake hook
|
|
62
|
+
([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)) and
|
|
63
|
+
per-writer reconcile failures
|
|
64
|
+
([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)). Setup
|
|
65
|
+
reports change for a broken kept writer: it now carries its own code on its
|
|
66
|
+
target instead of `writer_reconcile_failed`. `npm audit` still reports
|
|
67
|
+
`undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release, now with
|
|
68
|
+
eight advisories ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
69
|
+
|
|
70
|
+
Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
|
|
71
|
+
accepted again after the upgrade, as after every release.
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
|
|
75
|
+
- Doctor's `config_invalid` names where the schema rejects the configuration:
|
|
76
|
+
one check per issue location (at most five) with the subject
|
|
77
|
+
`configuration:<location>`, for example
|
|
78
|
+
`configuration:executionProfiles.review.default`. Locations hold only schema
|
|
79
|
+
field names, aliases and array indexes; an unknown key that is not a schema
|
|
80
|
+
field name, and any other key that is not a valid alias, is shown as
|
|
81
|
+
`<key>`, and values and messages are never reported. An error without a
|
|
82
|
+
location keeps the subject `configuration`
|
|
83
|
+
([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)).
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- Issue forms for bug reports, change proposals and maintenance tasks, and a
|
|
88
|
+
pull request template, written so that a person or a coding agent can carry
|
|
89
|
+
an issue out and a reviewer can check it
|
|
90
|
+
([#178](https://github.com/plagor44-ux/pi-session-mcp/issues/178)).
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- Security, in the lockfile only, development path: `@hono/node-server`
|
|
95
|
+
2.1.1 → 2.1.5 (via MCP Inspector; GHSA-rmxm-3fg6-px4f). Still open:
|
|
96
|
+
`undici` 8.10.2 in the production path, pinned exactly by Pi SDK 1.4.0, now
|
|
97
|
+
with eight advisories (GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472,
|
|
98
|
+
GHSA-xjj6-f33q-929c, GHSA-4w7w-5xrc-9h26, GHSA-2m5v-pch6-74hx,
|
|
99
|
+
GHSA-qrxm-fqhp-jwvm, GHSA-9fm3-97px-rjf6, GHSA-55wc-3pg8-jfw6), so
|
|
100
|
+
`npm audit` still reports it
|
|
101
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
102
|
+
- Writer reconciliation reports a kept writer that fails, for example with
|
|
103
|
+
a missing wrapper after a crash during provisioning, on that writer's
|
|
104
|
+
target with its own code (`writer_state_invalid`) and still reconciles the
|
|
105
|
+
other writers. Only failures of shared setup state (configuration,
|
|
106
|
+
ownership) end the whole step with `writer_reconcile_failed`
|
|
107
|
+
([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)).
|
|
108
|
+
- Wake hook: reclaiming a stale waiter lock is serialized by an exclusive
|
|
109
|
+
`<lock>.reclaim` guard, and the lock is judged again while holding it. Two
|
|
110
|
+
waiters that both saw the same stale lock can no longer both take the turn
|
|
111
|
+
and send a duplicate wake reminder
|
|
112
|
+
([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)).
|
|
113
|
+
- Read-only confinement protects Pi's agent directory (`~/.pi/agent`, or
|
|
114
|
+
`PI_CODING_AGENT_DIR`) and, for a symlinked configuration file, its target's
|
|
115
|
+
directory: a skill directory that equals or contains either contributes only
|
|
116
|
+
the skill file as a read root. Before, a skill file placed directly in the
|
|
117
|
+
agent directory made `auth.json` readable. The real file of a symlinked skill
|
|
118
|
+
file is now a read root too, so the model can read a skill it was given
|
|
119
|
+
([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)).
|
|
120
|
+
- Setup provisions a writer added without `--base` (for example by raising
|
|
121
|
+
`--writers`) on the workspace's recorded default base, as finish and recreate
|
|
122
|
+
already did. Before, it landed on `origin/HEAD` and default starts never
|
|
123
|
+
leased it. A recorded base that is the repository default still provisions
|
|
124
|
+
from `origin/HEAD` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)).
|
|
125
|
+
|
|
5
126
|
## 0.13.0 — 2026-10-28
|
|
6
127
|
|
|
7
128
|
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.
|
|
33
|
+
The package version is **0.14.0**.
|
|
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,20 @@ 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
|
+
/** `skillCatalog` aliases refused because they name a controller skill (#188); Doctor reports them on their own. */
|
|
10
|
+
readonly refusedControllerSkills: readonly string[];
|
|
11
|
+
constructor(code: ConfigErrorCode, locations?: readonly string[], refusedControllerSkills?: readonly string[]);
|
|
8
12
|
}
|
|
13
|
+
export declare const MAX_CONFIG_ISSUE_LOCATIONS = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
|
|
16
|
+
* A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
|
|
17
|
+
* form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
|
|
18
|
+
* key that is not a schema field name, could be a path or a secret and becomes `<key>`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function issueLocations(issues: readonly z.core.$ZodIssue[]): string[];
|
|
9
21
|
export declare const configSchema: z.ZodObject<{
|
|
10
22
|
events: z.ZodOptional<z.ZodObject<{
|
|
11
23
|
delivery: z.ZodEnum<{
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,7 @@ import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js"
|
|
|
6
6
|
import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
|
|
7
7
|
import { builtinSkillName, isSkillCatalogPath, MAX_PROFILE_SKILLS, MAX_SKILL_CATALOG_ENTRIES } from "./skill-catalog.js";
|
|
8
8
|
import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
|
|
9
|
-
import { builtinSkillPath, skillOverrideDirectory } from "./skill-library.js";
|
|
9
|
+
import { builtinSkillPath, CONTROLLER_SKILLS, skillOverrideDirectory } from "./skill-library.js";
|
|
10
10
|
import { loadExecutionModes } from "./execution-modes.js";
|
|
11
11
|
import { isAcceptableExtensionToolName, MAX_GRANTED_EXTENSION_TOOLS, PROFILE_TOOLS } from "./session-tools.js";
|
|
12
12
|
const alias = z.string().regex(ALIAS_PATTERN);
|
|
@@ -72,12 +72,43 @@ 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
|
+
/** `skillCatalog` aliases refused because they name a controller skill (#188); Doctor reports them on their own. */
|
|
78
|
+
refusedControllerSkills;
|
|
79
|
+
constructor(code, locations = [], refusedControllerSkills = []) {
|
|
76
80
|
super(CONFIG_ERROR_MESSAGES[code]);
|
|
77
81
|
this.name = "ConfigError";
|
|
78
82
|
this.code = code;
|
|
83
|
+
this.locations = Object.freeze([...locations]);
|
|
84
|
+
this.refusedControllerSkills = Object.freeze([...refusedControllerSkills]);
|
|
79
85
|
}
|
|
80
86
|
}
|
|
87
|
+
const CONTROLLER_SKILL_ISSUE = "controller_skill";
|
|
88
|
+
/** Catalog aliases of controller-skill issues; only alias-shaped keys, so no value or path can pass. */
|
|
89
|
+
function controllerSkillAliases(issues) {
|
|
90
|
+
return [...new Set(issues.flatMap((issue) => {
|
|
91
|
+
const skillAlias = issue.path[1];
|
|
92
|
+
return issue.code === "custom" && issue.params?.reason === CONTROLLER_SKILL_ISSUE && issue.path[0] === "skillCatalog"
|
|
93
|
+
&& typeof skillAlias === "string" && ALIAS_PATTERN.test(skillAlias) ? [skillAlias] : [];
|
|
94
|
+
}))].sort();
|
|
95
|
+
}
|
|
96
|
+
export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
|
|
97
|
+
/**
|
|
98
|
+
* Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
|
|
99
|
+
* A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
|
|
100
|
+
* form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
|
|
101
|
+
* key that is not a schema field name, could be a path or a secret and becomes `<key>`.
|
|
102
|
+
*/
|
|
103
|
+
export function issueLocations(issues) {
|
|
104
|
+
const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
|
|
105
|
+
const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
|
|
106
|
+
const locations = issues.flatMap((issue) => {
|
|
107
|
+
const path = issue.path.map((value) => segment(value, true));
|
|
108
|
+
return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
|
|
109
|
+
});
|
|
110
|
+
return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
|
|
111
|
+
}
|
|
81
112
|
const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
|
|
82
113
|
export const configSchema = z.object({
|
|
83
114
|
events: eventsSchema.optional(),
|
|
@@ -96,6 +127,12 @@ export const configSchema = z.object({
|
|
|
96
127
|
}).strict().superRefine((config, context) => {
|
|
97
128
|
// A profile may select only cataloged skills; there is no implicit or default skill.
|
|
98
129
|
const catalog = config.skillCatalog ?? {};
|
|
130
|
+
// The catalog serves Pi workers, which are never the controlling agent (#188).
|
|
131
|
+
for (const [skillAlias, value] of Object.entries(catalog)) {
|
|
132
|
+
if (CONTROLLER_SKILLS.includes(builtinSkillName(value) ?? "")) {
|
|
133
|
+
context.addIssue({ code: "custom", path: ["skillCatalog", skillAlias], message: "A controller skill cannot be a worker skill", params: { reason: CONTROLLER_SKILL_ISSUE } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
for (const [profileAlias, profile] of Object.entries(config.executionProfiles)) {
|
|
100
137
|
if ((profile.skills ?? []).some((skill) => !Object.hasOwn(catalog, skill))) {
|
|
101
138
|
context.addIssue({ code: "custom", path: ["executionProfiles", profileAlias, "skills"], message: "A selected skill must be a skillCatalog alias" });
|
|
@@ -151,7 +188,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
151
188
|
const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
|
|
152
189
|
// Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
|
|
153
190
|
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");
|
|
191
|
+
throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues), controllerSkillAliases(error.issues));
|
|
155
192
|
}
|
|
156
193
|
throw error;
|
|
157
194
|
}
|
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.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type ExtensionSpec } from "./extension-spec.js";
|
|
|
3
3
|
import { type ReleasedSkillTree } from "./skill-library.js";
|
|
4
4
|
export type DoctorSeverity = "ok" | "warning" | "error";
|
|
5
5
|
export type DoctorExitCode = 0 | 1 | 2 | 64;
|
|
6
|
-
export type DoctorCheckId = "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" | "sandbox_unavailable" | "workspace_git_unavailable" | "event_spool_root";
|
|
6
|
+
export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "skill_controller_selected" | "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" | "sandbox_unavailable" | "workspace_git_unavailable" | "event_spool_root";
|
|
7
7
|
export interface DoctorCheck {
|
|
8
8
|
readonly id: DoctorCheckId;
|
|
9
9
|
readonly severity: DoctorSeverity;
|
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";
|
|
@@ -33,7 +33,7 @@ async function readPrefix(path, maxBytes) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
const defaultFileSystem = { readFile, stat, access };
|
|
36
|
-
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", "sandbox_unavailable", "workspace_git_unavailable", "event_spool_root"];
|
|
36
|
+
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "skill_controller_selected", "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", "sandbox_unavailable", "workspace_git_unavailable", "event_spool_root"];
|
|
37
37
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
38
38
|
function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
|
|
39
39
|
function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
|
|
@@ -423,8 +423,17 @@ 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
|
+
const controller = new Set((error instanceof ConfigError ? error.refusedControllerSkills : []).map((skillAlias) => `skillCatalog.${skillAlias}`));
|
|
430
|
+
for (const location of locations.length ? locations : [""]) {
|
|
431
|
+
const subject = location ? `configuration:${location}` : "configuration";
|
|
432
|
+
checks.push(controller.has(location)
|
|
433
|
+
? failure("skill_controller_selected", subject, "remove this skillCatalog entry: controller skills are for the controlling agent, not for Pi workers")
|
|
434
|
+
: failure("config_invalid", subject, "fix configuration using the documented schema"));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
428
437
|
}
|
|
429
438
|
if (config) {
|
|
430
439
|
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({
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline view of Pi's model registry behind an injectable interface, so tests and callers need no
|
|
3
|
+
* provider credentials. Only documented package-root exports of the SDK are used.
|
|
4
|
+
*/
|
|
5
|
+
export interface CatalogProvider {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly authConfigured: boolean;
|
|
8
|
+
readonly models: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
export interface ModelCatalog {
|
|
11
|
+
providers(): readonly CatalogProvider[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A provider or model id that is safe to print: no whitespace and no leading `/`, so it never starts a path.
|
|
15
|
+
* A leading `@` is allowed for scoped model ids; ids may contain `/` after the first character.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CATALOG_ID_PATTERN: RegExp;
|
|
18
|
+
/** Bound for one offline authentication lookup. */
|
|
19
|
+
export declare const AUTH_CHECK_TIMEOUT_MS = 5000;
|
|
20
|
+
/** The part of the SDK's `ModelRuntime` the catalog reads; created without model network and without a refresh. */
|
|
21
|
+
export interface CatalogRuntime {
|
|
22
|
+
getProviders(): readonly {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
}[];
|
|
25
|
+
getModels(providerId: string): readonly {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
}[];
|
|
28
|
+
/** Resolves the configured authentication's kind, or `undefined` when none is configured. */
|
|
29
|
+
checkAuth(providerId: string, options?: {
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
}): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reads the provider ids Pi knows offline, whether each has configured authentication, and its model
|
|
35
|
+
* ids. `checkAuth` reports only whether and how a provider is configured; its result is reduced to a
|
|
36
|
+
* boolean. `hasConfiguredAuth` is not used: it reads a snapshot that only an online availability refresh
|
|
37
|
+
* fills. Never calls `getAuth` or `getAvailable`, so no credential value is resolved and no provider is
|
|
38
|
+
* contacted. Providers registered only by Pi extensions are not part of the registry.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createModelCatalog(createRuntime?: () => Promise<CatalogRuntime>): Promise<ModelCatalog>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider or model id that is safe to print: no whitespace and no leading `/`, so it never starts a path.
|
|
3
|
+
* A leading `@` is allowed for scoped model ids; ids may contain `/` after the first character.
|
|
4
|
+
*/
|
|
5
|
+
export const CATALOG_ID_PATTERN = /^[A-Za-z0-9@][A-Za-z0-9._:@\/-]{0,255}$/;
|
|
6
|
+
/** Bound for one offline authentication lookup. */
|
|
7
|
+
export const AUTH_CHECK_TIMEOUT_MS = 5_000;
|
|
8
|
+
const createCatalogRuntime = () => import("@earendil-works/pi-coding-agent").then(({ ModelRuntime }) => ModelRuntime.create({ refreshOnCreate: false, allowModelNetwork: false }));
|
|
9
|
+
const compareIds = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
10
|
+
/** True only for a settled, present result; a rejection or a lookup past the bound counts as not configured. */
|
|
11
|
+
const authConfigured = (runtime, providerId) => new Promise((resolve) => {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const timer = setTimeout(() => { controller.abort(); resolve(false); }, AUTH_CHECK_TIMEOUT_MS);
|
|
14
|
+
Promise.resolve()
|
|
15
|
+
.then(() => runtime.checkAuth(providerId, { signal: controller.signal }))
|
|
16
|
+
.then((auth) => resolve(auth !== undefined && auth !== null), () => resolve(false))
|
|
17
|
+
.finally(() => clearTimeout(timer));
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Reads the provider ids Pi knows offline, whether each has configured authentication, and its model
|
|
21
|
+
* ids. `checkAuth` reports only whether and how a provider is configured; its result is reduced to a
|
|
22
|
+
* boolean. `hasConfiguredAuth` is not used: it reads a snapshot that only an online availability refresh
|
|
23
|
+
* fills. Never calls `getAuth` or `getAvailable`, so no credential value is resolved and no provider is
|
|
24
|
+
* contacted. Providers registered only by Pi extensions are not part of the registry.
|
|
25
|
+
*/
|
|
26
|
+
export async function createModelCatalog(createRuntime = createCatalogRuntime) {
|
|
27
|
+
const runtime = await createRuntime();
|
|
28
|
+
const providers = await Promise.all(runtime.getProviders().map(async (provider) => {
|
|
29
|
+
const configured = await authConfigured(runtime, provider.id);
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
id: provider.id,
|
|
32
|
+
authConfigured: configured,
|
|
33
|
+
models: Object.freeze(runtime.getModels(provider.id).map((model) => model.id).sort(compareIds)),
|
|
34
|
+
});
|
|
35
|
+
}));
|
|
36
|
+
providers.sort((left, right) => compareIds(left.id, right.id));
|
|
37
|
+
return Object.freeze({ providers: () => Object.freeze(providers) });
|
|
38
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AppConfig } from "./config.js";
|
|
2
|
+
import type { DoctorResult } from "./doctor.js";
|
|
3
|
+
import type { ModelCatalog } from "./model-catalog.js";
|
|
4
|
+
import { type PiSessionAdapter } from "./pi-adapter.js";
|
|
5
|
+
import type { ProbeReport } from "./setup-probe-result.js";
|
|
6
|
+
export declare const PROBE_PROMPT = "Reply with exactly the word PONG and nothing else.";
|
|
7
|
+
export declare const PROBE_DEADLINES: {
|
|
8
|
+
readonly startMs: 60000;
|
|
9
|
+
readonly turnMs: 120000;
|
|
10
|
+
readonly cleanupMs: 10000;
|
|
11
|
+
};
|
|
12
|
+
export declare function isPong(reply: string): boolean;
|
|
13
|
+
export interface ProbeDependencies {
|
|
14
|
+
readonly configPath: string;
|
|
15
|
+
readonly loadConfig: (path: string) => Promise<AppConfig>;
|
|
16
|
+
readonly doctor: () => Promise<DoctorResult>;
|
|
17
|
+
readonly catalog: () => Promise<ModelCatalog>;
|
|
18
|
+
readonly adapter: () => Promise<PiSessionAdapter>;
|
|
19
|
+
readonly makeTempDir: () => Promise<string>;
|
|
20
|
+
readonly removeDir: (path: string) => Promise<void>;
|
|
21
|
+
readonly now?: () => number;
|
|
22
|
+
readonly deadlines?: typeof PROBE_DEADLINES;
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
export declare function runProfileProbe(request: {
|
|
26
|
+
profiles: readonly string[];
|
|
27
|
+
apply: boolean;
|
|
28
|
+
}, deps: ProbeDependencies): Promise<ProbeReport>;
|