pi-session-mcp 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.pi-session-mcp-cli/config.js +21 -3
  2. package/.pi-session-mcp-cli/doctor.js +6 -2
  3. package/.pi-session-mcp-cli/skill-library.js +1 -1
  4. package/.pi-session-mcp-cli/writer-boundary.js +2 -1
  5. package/.pi-session-mcp-cli/writer-lifecycle.js +18 -4
  6. package/CHANGELOG.md +87 -0
  7. package/README.md +1 -1
  8. package/dist/capabilities.d.ts +13 -0
  9. package/dist/capabilities.js +17 -13
  10. package/dist/config.d.ts +3 -1
  11. package/dist/config.js +21 -3
  12. package/dist/doctor.d.ts +1 -1
  13. package/dist/doctor.js +6 -2
  14. package/dist/model-catalog.d.ts +40 -0
  15. package/dist/model-catalog.js +38 -0
  16. package/dist/profile-probe.d.ts +28 -0
  17. package/dist/profile-probe.js +164 -0
  18. package/dist/sdk-pi-adapter.d.ts +1 -1
  19. package/dist/sdk-pi-adapter.js +25 -6
  20. package/dist/setup-cli.d.ts +7 -0
  21. package/dist/setup-cli.js +36 -0
  22. package/dist/setup-main.js +10 -3
  23. package/dist/setup-probe-main.d.ts +14 -0
  24. package/dist/setup-probe-main.js +76 -0
  25. package/dist/setup-probe-result.d.ts +39 -0
  26. package/dist/setup-probe-result.js +62 -0
  27. package/dist/setup-writers-common.js +1 -1
  28. package/dist/skill-library.js +1 -1
  29. package/dist/writer-boundary.d.ts +1 -1
  30. package/dist/writer-boundary.js +2 -1
  31. package/dist/writer-guard.js +1 -1
  32. package/dist/writer-lifecycle.d.ts +1 -0
  33. package/dist/writer-lifecycle.js +18 -4
  34. package/dist/writer-relay.js +12 -1
  35. package/docs/acceptance.md +313 -1
  36. package/docs/adr/0012-leased-writer-pool.md +10 -6
  37. package/docs/adr/0013-hardened-read-only-profiles.md +30 -0
  38. package/docs/adr/0014-profile-probe-and-model-catalog.md +89 -0
  39. package/docs/client-setup.md +18 -12
  40. package/docs/doctor-design.md +1 -0
  41. package/docs/execution-modes.md +25 -3
  42. package/docs/installation.md +87 -0
  43. package/docs/isolation-setup.md +8 -2
  44. package/docs/roadmap.md +20 -1
  45. package/docs/skills.md +42 -25
  46. package/docs/sprints/2026-10-24-s8-writer-migration.md +5 -3
  47. package/docs/sprints/2026-10-29-s12-audit-fixes.md +172 -0
  48. package/docs/threat-model.md +10 -1
  49. package/docs/tool-contracts.md +1 -1
  50. package/package.json +1 -1
  51. package/skills/pi-session-orchestration/SKILL.md +1 -1
  52. 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);
@@ -74,13 +74,25 @@ 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
76
  locations;
77
- constructor(code, 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 = []) {
78
80
  super(CONFIG_ERROR_MESSAGES[code]);
79
81
  this.name = "ConfigError";
80
82
  this.code = code;
81
83
  this.locations = Object.freeze([...locations]);
84
+ this.refusedControllerSkills = Object.freeze([...refusedControllerSkills]);
82
85
  }
83
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
+ }
84
96
  export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
85
97
  /**
86
98
  * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
@@ -115,6 +127,12 @@ export const configSchema = z.object({
115
127
  }).strict().superRefine((config, context) => {
116
128
  // A profile may select only cataloged skills; there is no implicit or default skill.
117
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
+ }
118
136
  for (const [profileAlias, profile] of Object.entries(config.executionProfiles)) {
119
137
  if ((profile.skills ?? []).some((skill) => !Object.hasOwn(catalog, skill))) {
120
138
  context.addIssue({ code: "custom", path: ["executionProfiles", profileAlias, "skills"], message: "A selected skill must be a skillCatalog alias" });
@@ -170,7 +188,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
170
188
  const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
171
189
  // Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
172
190
  const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
173
- throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
191
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues), controllerSkillAliases(error.issues));
174
192
  }
175
193
  throw error;
176
194
  }
@@ -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 }); }
@@ -426,8 +426,12 @@ export async function runDoctor(dependencies = {}) {
426
426
  else {
427
427
  // Locations name the offending field; an error without one, or at the root, keeps the plain subject.
428
428
  const locations = error instanceof ConfigError ? error.locations : [];
429
+ const controller = new Set((error instanceof ConfigError ? error.refusedControllerSkills : []).map((skillAlias) => `skillCatalog.${skillAlias}`));
429
430
  for (const location of locations.length ? locations : [""]) {
430
- checks.push(failure("config_invalid", location ? `configuration:${location}` : "configuration", "fix configuration using the documented schema"));
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"));
431
435
  }
432
436
  }
433
437
  }
@@ -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;
@@ -6,7 +6,8 @@ import { CONTAINER_EVENTS_DIR, EVENTS_DIR_ENV, writerEventsDir } from "./turn-ev
6
6
  import { writerContainerName, writerLabel } from "./writer-fence.js";
7
7
  export const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
8
8
  export const IMAGE_PULL_TIMEOUT_MS = 900_000;
9
- export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
9
+ // Replace refs in a clone are worker-controlled: they could make unexported work look reachable or clean (#193).
10
+ export const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
10
11
  export class BoundaryError extends Error {
11
12
  code;
12
13
  constructor(code) {
@@ -344,7 +344,7 @@ export async function inspectWork(ctx, name) {
344
344
  });
345
345
  return {
346
346
  baseline: head === state.base.commit && refs.length === 1 && refs[0].name === state.base.ref && refs[0].commit === state.base.commit && tree === baseTree,
347
- dirty: tree !== headTree, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
347
+ dirty: tree !== headTree, head: head, branches: refs.filter(({ name }) => name.startsWith("refs/heads/")),
348
348
  digest: `sha256:${createHash("sha256").update(result.stdout).digest("hex")}`,
349
349
  };
350
350
  }
@@ -412,10 +412,16 @@ if [ "$tree" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ]; then
412
412
  export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
413
413
  commit=$(printf '%s\\n' 'WIP collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$tree" -p HEAD)
414
414
  branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
415
- if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip"; then
415
+ # A taken name, or one that conflicts with an exported branch path, is refused rather than aborting the export.
416
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-wip" \\
417
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit" 2>/dev/null; then
418
+ printf '%s\\n' ref_name_refused
419
+ fi
420
+ elif ! ${BOUNDARY_GIT} symbolic-ref -q HEAD >/dev/null 2>&1 && [ -n "$(${BOUNDARY_GIT} rev-list -n 1 HEAD --not --branches)" ]; then
421
+ # Clean detached HEAD that no branch of the clone reaches: export its commit under a derived name (#193).
422
+ if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$4-detached" \\
423
+ || ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$4-detached" "$(${BOUNDARY_GIT} rev-parse --verify HEAD)" 2>/dev/null; then
416
424
  printf '%s\\n' ref_name_refused
417
- else
418
- ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-wip" "$commit"
419
425
  fi
420
426
  fi
421
427
  if [ -z "$(${BOUNDARY_GIT} --git-dir=/tmp/scratch for-each-ref --format='%(refname)')" ]; then
@@ -450,6 +456,14 @@ fi`;
450
456
  return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
451
457
  }
452
458
  const work = await inspectWork(ctx, name);
459
+ // Defence in depth (#193): whatever the boundary script exported or reported as nothing to collect,
460
+ // every commit the clone's work refers to must exist as a commit object in the operator checkout
461
+ // before a digest is recorded.
462
+ for (const sha of [work.head, ...work.branches.map((branch) => branch.commit)]) {
463
+ if (sha === undefined || await git(ctx, ["cat-file", "-e", `${sha}^{commit}`], true) === undefined) {
464
+ return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
465
+ }
466
+ }
453
467
  await writeWriterState(ctx.home, name, { ...state, collected: { digest: work.digest, at: new Date().toISOString() } });
454
468
  return { status: "ok", code: refs.length === 0 ? "nothing_to_collect" : "writer_collected", refs, refused: findings.length, findings };
455
469
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,93 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.14.1 — 2026-10-30
6
+
7
+ Patch release under ADR 0003 for Sprint 12. The MCP tool contract, result
8
+ codes and configuration schema are unchanged. `npm audit` still reports
9
+ `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release
10
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
11
+
12
+ Sprint 12 fixes three defects that an external audit of `9d9cf8c` reported and
13
+ that were reproduced on that commit
14
+ ([Sprint 12](docs/sprints/2026-10-29-s12-audit-fixes.md)). The MCP tool
15
+ contract, result codes and configuration schema are unchanged.
16
+
17
+ - Read-only sessions no longer load Pi context files (`AGENTS.override.md`,
18
+ `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) from outside the
19
+ workspace into the model context
20
+ ([#192](https://github.com/plagor44-ux/pi-session-mcp/issues/192)). Every
21
+ resource loader of a read-only profile keeps only files whose real path lies
22
+ inside the workspace's real path, on every reload and before the first
23
+ provider request, and reads them from that verified path. Behaviour change:
24
+ context files of directories above the workspace, including a main
25
+ repository above a linked worktree, are no longer loaded for read-only
26
+ profiles. Coding profiles are unchanged
27
+ ([ADR 0013 amendment](docs/adr/0013-hardened-read-only-profiles.md)).
28
+ - Writer collection exports a clean detached `HEAD` whose commit no branch
29
+ reaches as `<base>-detached`, and records its release digest only when the
30
+ clone's `HEAD` and every branch commit exist in the operator checkout;
31
+ otherwise it reports `writer_collect_incomplete`. Before, such a commit was
32
+ reported as `nothing_to_collect` and finish, reset, recreate and remove
33
+ discarded it without `--discard`
34
+ ([#193](https://github.com/plagor44-ux/pi-session-mcp/issues/193)).
35
+ Clone-side Git, including the writer guard, now ignores replace refs, and a
36
+ derived `-wip` or `-detached` name that conflicts with an exported branch
37
+ path is refused instead of aborting the export.
38
+ - The writer relay compares every public capability field of a writer's
39
+ profile with the host profile before it starts an inner session, including
40
+ skills, extensions, granted extension tools and shell; a mismatch counts as
41
+ `settingsChanged`. Before, a writer with a different skill selection was
42
+ admitted ([#194](https://github.com/plagor44-ux/pi-session-mcp/issues/194)).
43
+
44
+ ## 0.14.0 — 2026-10-29
45
+
46
+ Minor release under ADR 0003 for
47
+ [ADR 0014](docs/adr/0014-profile-probe-and-model-catalog.md)
48
+ ([#139](https://github.com/plagor44-ux/pi-session-mcp/issues/139)):
49
+ agent-driven execution-profile setup. The MCP tool contract is unchanged. Setup
50
+ gains `--list-models` and `--probe-profile`, setup installs a second controller
51
+ skill, Doctor gains the check `skill_controller_selected`, and a configuration
52
+ whose skill catalog names a controller skill no longer loads
53
+ ([#188](https://github.com/plagor44-ux/pi-session-mcp/issues/188)). `npm audit`
54
+ still reports `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release,
55
+ with eight advisories ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
56
+
57
+ Pre-release acceptance on the candidate: Level 1 except `npm audit`, and Level 2
58
+ in the working tree and for the packed tarball, each with delivery off and on
59
+ ([acceptance record](docs/acceptance.md#v0140-pre-release-acceptance)). The
60
+ Level 2 harness now expects both controller skills. Not established: an
61
+ agent-driven profile change end to end, a container writer acceptance of a new
62
+ profile, and Level 3.
63
+
64
+ Agent-driven execution-profile setup
65
+ ([#139](https://github.com/plagor44-ux/pi-session-mcp/issues/139),
66
+ [ADR 0014](docs/adr/0014-profile-probe-and-model-catalog.md)):
67
+
68
+ - `pi-session-mcp-setup --list-models` prints the provider and model ids Pi
69
+ knows offline, with authentication presence only. Ids that could read as a
70
+ path are omitted and counted; providers registered only by extensions are not
71
+ listed.
72
+ - `pi-session-mcp-setup --probe-profile <alias>[,<alias>...]` checks profiles
73
+ offline (dry run by default); with `--apply` it sends one fixed PONG prompt
74
+ per profile in a private temporary directory and never prints the reply. It
75
+ is the only setup operation that uses a provider.
76
+ - The shipped controller skill `pi-session-profiles` lets a controlling agent
77
+ create, change or retire execution profiles itself. Setup installs it next to
78
+ `pi-session-orchestration`, so every client target reports one more skill
79
+ finding.
80
+
81
+ Controller skills as worker skills
82
+ ([#188](https://github.com/plagor44-ux/pi-session-mcp/issues/188)):
83
+
84
+ - The configuration rejects the controller skills as worker skills: a
85
+ `skillCatalog` value `builtin:pi-session-orchestration` or
86
+ `builtin:pi-session-profiles` makes it invalid
87
+ (`invalid_execution_profile_config`), and Doctor reports the new error check
88
+ `skill_controller_selected` for `configuration:skillCatalog.<alias>`, which
89
+ blocks setup and the probe like `config_invalid`. A
90
+ configuration that used such an entry no longer starts; remove the entry.
91
+
5
92
  ## 0.13.1 — 2026-10-29
6
93
 
7
94
  Patch release under ADR 0003 with an unchanged MCP contract. It ships the
package/README.md CHANGED
@@ -30,7 +30,7 @@ It is not the right tool when:
30
30
 
31
31
  ## Status
32
32
 
33
- The package version is **0.13.1**.
33
+ The package version is **0.14.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
 
@@ -1,6 +1,19 @@
1
1
  import type { ConfigurationMetadata } from "./config.js";
2
2
  import type { WorkspaceModeEntry } from "./execution-modes.js";
3
3
  import type { ConfiguredExecutionProfile } from "./execution-profile.js";
4
+ /** The public capability shape of one profile, exactly as `pi_capabilities_get` publishes it. */
5
+ export declare function projectExecutionProfile({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }: ConfiguredExecutionProfile): {
6
+ alias: string;
7
+ default: boolean;
8
+ permissionProfile: import("./execution-profile.js").PermissionProfile;
9
+ provider: string;
10
+ model: string;
11
+ thinkingLevel: import("./execution-profile.js").ThinkingLevel;
12
+ extensions: string[];
13
+ grantedExtensionTools: string[];
14
+ shell: "none" | "sandboxed";
15
+ skills: string[];
16
+ };
4
17
  /** Pure projection of the snapshot captured at load time; never reads the configuration file. */
5
18
  export declare function projectCapabilities(workspaces: ReadonlyMap<string, string>, executionProfiles: ReadonlyMap<string, ConfiguredExecutionProfile>, configuration: ConfigurationMetadata, workspaceModes?: ReadonlyMap<string, WorkspaceModeEntry>, eventDelivery?: "spool" | "off"): {
6
19
  ok: true;
@@ -1,5 +1,21 @@
1
1
  import packageMetadata from "../package.json" with { type: "json" };
2
2
  const compareAliases = (left, right) => left < right ? -1 : left > right ? 1 : 0;
3
+ /** The public capability shape of one profile, exactly as `pi_capabilities_get` publishes it. */
4
+ export function projectExecutionProfile({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }) {
5
+ return {
6
+ alias,
7
+ default: isDefault,
8
+ permissionProfile,
9
+ provider,
10
+ model,
11
+ thinkingLevel,
12
+ extensions: (extensions ?? []).map((extension) => extension.spec),
13
+ grantedExtensionTools: [...(grantedExtensionTools ?? [])],
14
+ // Configured intent only: whether the server's probe succeeds is a start-time fact.
15
+ shell: shell ?? "none",
16
+ skills: (skills ?? []).map((skill) => skill.alias),
17
+ };
18
+ }
3
19
  /** Pure projection of the snapshot captured at load time; never reads the configuration file. */
4
20
  export function projectCapabilities(workspaces, executionProfiles, configuration, workspaceModes = new Map(), eventDelivery = "off") {
5
21
  return {
@@ -18,18 +34,6 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
18
34
  }),
19
35
  executionProfiles: [...executionProfiles.values()]
20
36
  .sort((left, right) => compareAliases(left.alias, right.alias))
21
- .map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }) => ({
22
- alias,
23
- default: isDefault,
24
- permissionProfile,
25
- provider,
26
- model,
27
- thinkingLevel,
28
- extensions: (extensions ?? []).map((extension) => extension.spec),
29
- grantedExtensionTools: [...(grantedExtensionTools ?? [])],
30
- // Configured intent only: whether the server's probe succeeds is a start-time fact.
31
- shell: shell ?? "none",
32
- skills: (skills ?? []).map((skill) => skill.alias),
33
- })),
37
+ .map(projectExecutionProfile),
34
38
  };
35
39
  }
package/dist/config.d.ts CHANGED
@@ -6,7 +6,9 @@ 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
8
  readonly locations: readonly string[];
9
- constructor(code: ConfigErrorCode, 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[]);
10
12
  }
11
13
  export declare const MAX_CONFIG_ISSUE_LOCATIONS = 5;
12
14
  /**
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);
@@ -74,13 +74,25 @@ 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
76
  locations;
77
- constructor(code, 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 = []) {
78
80
  super(CONFIG_ERROR_MESSAGES[code]);
79
81
  this.name = "ConfigError";
80
82
  this.code = code;
81
83
  this.locations = Object.freeze([...locations]);
84
+ this.refusedControllerSkills = Object.freeze([...refusedControllerSkills]);
82
85
  }
83
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
+ }
84
96
  export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
85
97
  /**
86
98
  * Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
@@ -115,6 +127,12 @@ export const configSchema = z.object({
115
127
  }).strict().superRefine((config, context) => {
116
128
  // A profile may select only cataloged skills; there is no implicit or default skill.
117
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
+ }
118
136
  for (const [profileAlias, profile] of Object.entries(config.executionProfiles)) {
119
137
  if ((profile.skills ?? []).some((skill) => !Object.hasOwn(catalog, skill))) {
120
138
  context.addIssue({ code: "custom", path: ["executionProfiles", profileAlias, "skills"], message: "A selected skill must be a skillCatalog alias" });
@@ -170,7 +188,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
170
188
  const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
171
189
  // Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
172
190
  const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
173
- throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
191
+ throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues), controllerSkillAliases(error.issues));
174
192
  }
175
193
  throw error;
176
194
  }
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
@@ -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 }); }
@@ -426,8 +426,12 @@ export async function runDoctor(dependencies = {}) {
426
426
  else {
427
427
  // Locations name the offending field; an error without one, or at the root, keeps the plain subject.
428
428
  const locations = error instanceof ConfigError ? error.locations : [];
429
+ const controller = new Set((error instanceof ConfigError ? error.refusedControllerSkills : []).map((skillAlias) => `skillCatalog.${skillAlias}`));
429
430
  for (const location of locations.length ? locations : [""]) {
430
- checks.push(failure("config_invalid", location ? `configuration:${location}` : "configuration", "fix configuration using the documented schema"));
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"));
431
435
  }
432
436
  }
433
437
  }
@@ -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>;