pi-session-mcp 0.13.1 → 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.
@@ -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;
package/CHANGELOG.md CHANGED
@@ -2,6 +2,54 @@
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
+
5
53
  ## 0.13.1 — 2026-10-29
6
54
 
7
55
  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.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
 
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>;
@@ -0,0 +1,164 @@
1
+ import { toResolvedExecutionProfile } from "./execution-profile.js";
2
+ import { PiSessionCreationError } from "./pi-adapter.js";
3
+ import { GLOBAL_ERRORS } from "./setup-writers-common.js";
4
+ export const PROBE_PROMPT = "Reply with exactly the word PONG and nothing else.";
5
+ export const PROBE_DEADLINES = { startMs: 60_000, turnMs: 120_000, cleanupMs: 10_000 };
6
+ export function isPong(reply) {
7
+ return reply.trim().replace(/\.$/, "").toUpperCase() === "PONG";
8
+ }
9
+ class ProbeTimeout extends Error {
10
+ }
11
+ class ProbeInterrupted extends Error {
12
+ }
13
+ /** Always detach the timer and listener, including when the operation rejects. */
14
+ async function bounded(operation, ms, signal) {
15
+ if (signal?.aborted)
16
+ throw new ProbeInterrupted();
17
+ let timer;
18
+ let interrupt;
19
+ const stop = new Promise((_resolve, reject) => {
20
+ timer = setTimeout(() => reject(new ProbeTimeout()), ms);
21
+ interrupt = () => reject(new ProbeInterrupted());
22
+ signal?.addEventListener("abort", interrupt, { once: true });
23
+ });
24
+ try {
25
+ return await Promise.race([Promise.resolve().then(operation), stop]);
26
+ }
27
+ finally {
28
+ clearTimeout(timer);
29
+ if (interrupt)
30
+ signal?.removeEventListener("abort", interrupt);
31
+ }
32
+ }
33
+ async function dispose(handle, ms, abort) {
34
+ // An uncooperative abort must not prevent disposal or directory removal.
35
+ if (abort)
36
+ await bounded(() => handle.abort(), ms).catch(() => undefined);
37
+ await bounded(() => handle.dispose(), ms).catch(() => undefined);
38
+ }
39
+ async function probe(profile, adapter, deps) {
40
+ const deadlines = deps.deadlines ?? PROBE_DEADLINES;
41
+ const now = deps.now ?? Date.now;
42
+ const started = now();
43
+ const controller = new AbortController();
44
+ const interrupt = () => controller.abort();
45
+ deps.signal?.addEventListener("abort", interrupt, { once: true });
46
+ if (deps.signal?.aborted)
47
+ controller.abort();
48
+ let cwd;
49
+ let handle;
50
+ let abandoned = false;
51
+ let providerTurn = false;
52
+ let code = "probe_start_failed";
53
+ let startCode;
54
+ let abort = false;
55
+ try {
56
+ cwd = await deps.makeTempDir();
57
+ const input = { cwd, workspace: "profile-probe", executionProfile: toResolvedExecutionProfile(profile), signal: controller.signal };
58
+ handle = await bounded(async () => {
59
+ const created = await adapter.create(input);
60
+ // A create that ignores cancellation may finish after its deadline.
61
+ if (abandoned) {
62
+ await dispose(created, deadlines.cleanupMs, true);
63
+ throw new ProbeTimeout();
64
+ }
65
+ return created;
66
+ }, deadlines.startMs, deps.signal);
67
+ code = "probe_turn_failed";
68
+ const outcome = await bounded(() => handle.prompt(PROBE_PROMPT, (accepted) => {
69
+ if (accepted)
70
+ providerTurn = true;
71
+ }), deadlines.turnMs, deps.signal);
72
+ code = outcome.status === "completed"
73
+ ? isPong(outcome.assistantText) ? "probe_passed" : "probe_unexpected_reply"
74
+ : "probe_turn_failed";
75
+ }
76
+ catch (error) {
77
+ if (error instanceof ProbeTimeout || error instanceof ProbeInterrupted) {
78
+ abandoned = true;
79
+ controller.abort();
80
+ abort = true;
81
+ if (error instanceof ProbeTimeout)
82
+ code = "probe_timeout";
83
+ }
84
+ else if (handle === undefined) {
85
+ startCode = error instanceof PiSessionCreationError ? error.code : "pi_session_creation_failed";
86
+ }
87
+ }
88
+ finally {
89
+ deps.signal?.removeEventListener("abort", interrupt);
90
+ try {
91
+ if (handle)
92
+ await dispose(handle, deadlines.cleanupMs, abort || deps.signal?.aborted === true);
93
+ }
94
+ finally {
95
+ if (cwd !== undefined)
96
+ await deps.removeDir(cwd);
97
+ }
98
+ }
99
+ return {
100
+ profile: profile.alias, status: code === "probe_passed" ? "ok" : "failed", code,
101
+ ...(startCode === undefined ? {} : { startCode }), providerTurn, durationMs: Math.max(0, now() - started),
102
+ };
103
+ }
104
+ export async function runProfileProbe(request, deps) {
105
+ const profiles = [...new Set(request.profiles)];
106
+ const doctor = { blocking: [], other: [] };
107
+ const findings = [];
108
+ const report = (refusal) => {
109
+ const status = refusal !== undefined || findings.some((finding) => finding.status === "failed")
110
+ ? "failed" : request.apply ? "ok" : "planned";
111
+ return {
112
+ schemaVersion: 1, operation: request.apply ? "apply" : "dry-run", status,
113
+ ...(refusal === undefined ? {} : { refusal }), doctor, findings, usage: "unknown",
114
+ exitCode: refusal === "operation_interrupted" ? 130 : status === "failed" ? 1 : 0,
115
+ };
116
+ };
117
+ if (deps.signal?.aborted)
118
+ return report("operation_interrupted");
119
+ const diagnosis = await deps.doctor();
120
+ for (const check of diagnosis.checks) {
121
+ if (check.severity === "ok")
122
+ continue;
123
+ const list = check.severity === "error" && (GLOBAL_ERRORS.has(check.id) || profiles.includes(check.subject.split(":")[0]))
124
+ ? doctor.blocking : doctor.other;
125
+ if (!list.includes(check.id))
126
+ list.push(check.id);
127
+ }
128
+ if (deps.signal?.aborted)
129
+ return report("operation_interrupted");
130
+ if (doctor.blocking.length)
131
+ return report("doctor_failed");
132
+ let config;
133
+ try {
134
+ config = await deps.loadConfig(deps.configPath);
135
+ }
136
+ catch {
137
+ return report(deps.signal?.aborted ? "operation_interrupted" : "config_unusable");
138
+ }
139
+ if (deps.signal?.aborted)
140
+ return report("operation_interrupted");
141
+ const catalog = await deps.catalog();
142
+ let adapter;
143
+ for (const alias of profiles) {
144
+ if (deps.signal?.aborted)
145
+ return report("operation_interrupted");
146
+ const profile = config.executionProfiles.get(alias);
147
+ const provider = profile && catalog.providers().find((entry) => entry.id === profile.provider);
148
+ const code = !profile ? "probe_profile_unknown"
149
+ : !provider ? "probe_provider_unknown"
150
+ : !provider.models.includes(profile.model) ? "probe_model_unknown"
151
+ : !provider.authConfigured ? "probe_auth_missing" : "probe_planned";
152
+ if (code !== "probe_planned" || !request.apply) {
153
+ findings.push({ profile: alias, status: code === "probe_planned" ? "planned" : "failed", code, providerTurn: false });
154
+ continue;
155
+ }
156
+ adapter ??= await deps.adapter();
157
+ if (deps.signal?.aborted)
158
+ return report("operation_interrupted");
159
+ findings.push(await probe(profile, adapter, deps));
160
+ if (deps.signal?.aborted)
161
+ return report("operation_interrupted");
162
+ }
163
+ return report();
164
+ }
@@ -19,6 +19,13 @@ export interface SetupCliRequest extends SetupRequest {
19
19
  alias: string;
20
20
  apply: boolean;
21
21
  };
22
+ /** `--list-models`: prints the offline provider/model catalog. */
23
+ readonly listModels?: true;
24
+ /** `--probe-profile <alias>[,<alias>...]`: checks profiles offline, then probes them with `--apply`. */
25
+ readonly probe?: {
26
+ readonly profiles: readonly string[];
27
+ readonly apply: boolean;
28
+ };
22
29
  }
23
30
  export declare function parseSetupArgs(argv: readonly string[]): SetupCliRequest;
24
31
  /** Both report modes are projections of the same sanitized result. */
package/dist/setup-cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cpusSchema, cpuSharesSchema, memorySchema } from "./writer-limits.js";
2
2
  import { WRITER_NAME_PATTERN } from "./writer-state.js";
3
+ import { ALIAS_PATTERN } from "./execution-profile.js";
3
4
  export function parseSetupArgs(argv) {
4
5
  let operation;
5
6
  let json = false;
@@ -10,6 +11,8 @@ export function parseSetupArgs(argv) {
10
11
  let acceptHostWrites = false;
11
12
  let writer, writerOperation, baseRef;
12
13
  let discard = false, codex = false, isolationControl = false;
14
+ let listModels = false;
15
+ let probeProfiles;
13
16
  const container = {};
14
17
  const modeFlags = new Set();
15
18
  const clearLimits = [];
@@ -60,6 +63,24 @@ export function parseSetupArgs(argv) {
60
63
  json = true;
61
64
  continue;
62
65
  }
66
+ if (argument === "--list-models") {
67
+ if (listModels)
68
+ throw new Error("usage_invalid");
69
+ listModels = true;
70
+ continue;
71
+ }
72
+ if (argument === "--probe-profile") {
73
+ if (probeProfiles !== undefined)
74
+ throw new Error("usage_invalid");
75
+ const value = argv[++index];
76
+ if (value === undefined || value === "")
77
+ throw new Error("usage_invalid");
78
+ const aliases = value.split(",");
79
+ if (aliases.some((alias) => !ALIAS_PATTERN.test(alias)))
80
+ throw new Error("usage_invalid");
81
+ probeProfiles = [...new Set(aliases)];
82
+ continue;
83
+ }
63
84
  if (argument === "--dry-run" || argument === "--apply" || argument === "--verify" || argument === "--rollback" || argument === "--remove") {
64
85
  if (operation)
65
86
  throw new Error("operation_repeated");
@@ -173,6 +194,21 @@ export function parseSetupArgs(argv) {
173
194
  }
174
195
  throw new Error("argument_unsupported");
175
196
  }
197
+ if (listModels || probeProfiles !== undefined) {
198
+ if (listModels && probeProfiles !== undefined)
199
+ throw new Error("usage_invalid");
200
+ if (targets.length > 0 || modeFlags.size > 0 || customizeSkill !== undefined || writer !== undefined || writerOperation !== undefined || isolationControl || discard || baseRef || codex) {
201
+ throw new Error(listModels ? "list_models_combination" : "probe_combination");
202
+ }
203
+ if (listModels) {
204
+ if (operation !== undefined && operation !== "dry-run")
205
+ throw new Error("list_models_combination");
206
+ return { ...(operation ? { operation } : {}), targets, json, listModels: true };
207
+ }
208
+ if (operation !== undefined && operation !== "dry-run" && operation !== "apply")
209
+ throw new Error("probe_combination");
210
+ return { ...(operation ? { operation } : {}), targets, json, probe: { profiles: probeProfiles, apply: operation === "apply" } };
211
+ }
176
212
  if (writer !== undefined) {
177
213
  if (operation === "remove" || operation === "verify") {
178
214
  if (writerOperation)