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.
@@ -26,9 +26,14 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
26
26
  const failureTargets = request.writer ? [{ client: "writer", scope: request.writer.name.slice(0, request.writer.name.lastIndexOf("-w")), alias: request.writer.name }] : request.isolationControl ? [{ client: "writer", scope: "host", alias: "control" }] : request.repo !== undefined ? [{ client: "modes", scope: "workspace", alias: request.repo.alias }] : request.customizeSkill === undefined ? request.targets : [skillTarget(request.customizeSkill)];
27
27
  if (!currentSetupPlatform())
28
28
  return write(cliFailure(operation, failureTargets, "platform_unsupported", 1), request.json);
29
+ if (request.listModels)
30
+ return (await import("./setup-probe-main.js")).runListModels(request.json);
29
31
  const config = process.env.PI_SESSION_MCP_CONFIG;
30
- if (!config)
32
+ if (!config) {
33
+ if (request.probe)
34
+ return (await import("./setup-probe-main.js")).runProbe(request.probe, request.json, "", new AbortController().signal);
31
35
  return write(cliFailure(operation, failureTargets, "config_required", 1), request.json);
36
+ }
32
37
  if (request.customizeSkill !== undefined) {
33
38
  const name = request.customizeSkill;
34
39
  try {
@@ -41,12 +46,14 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
41
46
  const root = dependencies.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
42
47
  const controller = new AbortController();
43
48
  let interrupted;
44
- const onInterrupt = () => { interrupted ??= 130; controller.abort(); };
45
- const onTerminate = () => { interrupted ??= 143; controller.abort(); };
49
+ const onInterrupt = () => { interrupted ??= 130; controller.abort(interrupted); };
50
+ const onTerminate = () => { interrupted ??= 143; controller.abort(interrupted); };
46
51
  process.on("SIGINT", onInterrupt);
47
52
  process.on("SIGTERM", onTerminate);
48
53
  let setup;
49
54
  try {
55
+ if (request.probe)
56
+ return await (await import("./setup-probe-main.js")).runProbe(request.probe, request.json, resolve(config), controller.signal, { packageRoot: root });
50
57
  const writerDeps = { configPath: resolve(config), releaseRoot: root, signal: controller.signal };
51
58
  if (request.repo !== undefined) {
52
59
  try {
@@ -0,0 +1,14 @@
1
+ import { type DoctorResult } from "./doctor.js";
2
+ import { type ModelCatalog } from "./model-catalog.js";
3
+ import type { PiSessionAdapter } from "./pi-adapter.js";
4
+ export interface ProbeMainDependencies {
5
+ readonly catalog?: () => Promise<ModelCatalog>;
6
+ readonly adapter?: () => Promise<PiSessionAdapter>;
7
+ readonly doctor?: () => Promise<DoctorResult>;
8
+ readonly packageRoot?: string;
9
+ }
10
+ export declare function runListModels(json: boolean, deps?: ProbeMainDependencies): Promise<number>;
11
+ export declare function runProbe(request: {
12
+ profiles: readonly string[];
13
+ apply: boolean;
14
+ }, json: boolean, configPath: string, signal: AbortSignal, deps?: ProbeMainDependencies): Promise<number>;
@@ -0,0 +1,76 @@
1
+ import { stdoutGuard } from "./stdout-guard-install.js";
2
+ import { chmod, mkdtemp, realpath, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadConfig } from "./config.js";
7
+ import { runDoctor } from "./doctor.js";
8
+ import { createModelCatalog } from "./model-catalog.js";
9
+ import { runProfileProbe } from "./profile-probe.js";
10
+ import { SdkPiSessionAdapter } from "./sdk-pi-adapter.js";
11
+ import { catalogReport, formatCatalogHuman, formatProbeHuman } from "./setup-probe-result.js";
12
+ export async function runListModels(json, deps = {}) {
13
+ let report;
14
+ try {
15
+ report = catalogReport(await (deps.catalog ?? createModelCatalog)());
16
+ }
17
+ catch {
18
+ report = catalogReport(undefined);
19
+ }
20
+ stdoutGuard.transportStdout.write(json ? JSON.stringify(report) + "\n" : formatCatalogHuman(report));
21
+ return report.exitCode;
22
+ }
23
+ function refused(apply, refusal) {
24
+ return {
25
+ schemaVersion: 1, operation: apply ? "apply" : "dry-run", status: "failed", refusal,
26
+ doctor: { blocking: [], other: [] }, findings: [], usage: "unknown", exitCode: refusal === "operation_interrupted" ? 130 : 1,
27
+ };
28
+ }
29
+ export async function runProbe(request, json, configPath, signal, deps = {}) {
30
+ let report;
31
+ let catalogFailed = false;
32
+ try {
33
+ if (!configPath)
34
+ report = refused(request.apply, "config_required");
35
+ else {
36
+ const configFile = resolve(configPath);
37
+ const packageRoot = deps.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
38
+ report = await runProfileProbe(request, {
39
+ configPath: configFile, loadConfig, signal,
40
+ doctor: deps.doctor ?? (() => runDoctor({ packageRoot, configPath: configFile, loadConfig: () => loadConfig(configFile) })),
41
+ catalog: async () => {
42
+ try {
43
+ return await (deps.catalog ?? createModelCatalog)();
44
+ }
45
+ catch (error) {
46
+ catalogFailed = true;
47
+ throw error;
48
+ }
49
+ },
50
+ adapter: deps.adapter ?? (async () => new SdkPiSessionAdapter({
51
+ diagnosticSink: (diagnostic) => { process.stderr.write(JSON.stringify(diagnostic) + "\n"); },
52
+ protectedReadPaths: [dirname(configFile), dirname(await realpath(configFile))],
53
+ })),
54
+ makeTempDir: async () => {
55
+ const directory = await mkdtemp(join(tmpdir(), "pi-session-mcp-probe-"));
56
+ try {
57
+ await chmod(directory, 0o700);
58
+ return directory;
59
+ }
60
+ catch (error) {
61
+ await rm(directory, { recursive: true, force: true });
62
+ throw error;
63
+ }
64
+ },
65
+ removeDir: (directory) => rm(directory, { recursive: true, force: true }),
66
+ });
67
+ }
68
+ }
69
+ catch {
70
+ report = refused(request.apply, signal.aborted ? "operation_interrupted" : catalogFailed ? "catalog_unavailable" : "operation_failed");
71
+ }
72
+ if (signal.aborted)
73
+ report = { ...report, status: "failed", refusal: "operation_interrupted", exitCode: signal.reason === 143 ? 143 : 130 };
74
+ stdoutGuard.transportStdout.write(json ? JSON.stringify(report) + "\n" : formatProbeHuman(report));
75
+ return report.exitCode;
76
+ }
@@ -0,0 +1,39 @@
1
+ import { type CatalogProvider, type ModelCatalog } from "./model-catalog.js";
2
+ import type { PiSessionCreationErrorCode } from "./pi-adapter.js";
3
+ /** Public, path-free `--list-models` report. */
4
+ export interface ModelCatalogReport {
5
+ readonly schemaVersion: 1;
6
+ readonly status: "ok" | "failed";
7
+ readonly code?: "catalog_unavailable";
8
+ readonly providers: readonly CatalogProvider[];
9
+ readonly omittedIds: number;
10
+ readonly note: "extension_providers_not_listed";
11
+ readonly exitCode: 0 | 1;
12
+ }
13
+ /** Fails closed when no catalog could be created; drops ids that are not plain catalog ids. */
14
+ export declare function catalogReport(catalog: ModelCatalog | undefined): ModelCatalogReport;
15
+ /** One line per provider with its auth presence and model ids, then the fixed extension note. */
16
+ export declare function formatCatalogHuman(report: ModelCatalogReport): string;
17
+ export type ProbeCode = "probe_planned" | "probe_profile_unknown" | "probe_provider_unknown" | "probe_model_unknown" | "probe_auth_missing" | "probe_passed" | "probe_unexpected_reply" | "probe_turn_failed" | "probe_start_failed" | "probe_timeout";
18
+ export interface ProbeFinding {
19
+ readonly profile: string;
20
+ readonly status: "planned" | "ok" | "failed";
21
+ readonly code: ProbeCode;
22
+ readonly startCode?: PiSessionCreationErrorCode;
23
+ readonly providerTurn: boolean;
24
+ readonly durationMs?: number;
25
+ }
26
+ export interface ProbeReport {
27
+ readonly schemaVersion: 1;
28
+ readonly operation: "dry-run" | "apply";
29
+ readonly status: "planned" | "ok" | "failed";
30
+ readonly refusal?: "doctor_failed" | "config_required" | "config_unusable" | "operation_interrupted" | "catalog_unavailable" | "operation_failed";
31
+ readonly doctor: {
32
+ readonly blocking: readonly string[];
33
+ readonly other: readonly string[];
34
+ };
35
+ readonly findings: readonly ProbeFinding[];
36
+ readonly usage: "unknown";
37
+ readonly exitCode: 0 | 1 | 130 | 143;
38
+ }
39
+ export declare function formatProbeHuman(report: ProbeReport): string;
@@ -0,0 +1,62 @@
1
+ import { CATALOG_ID_PATTERN } from "./model-catalog.js";
2
+ const compareIds = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
3
+ const providersOf = (catalog) => {
4
+ let omittedIds = 0;
5
+ const providers = [];
6
+ for (const provider of [...catalog.providers()].sort((left, right) => compareIds(left.id, right.id))) {
7
+ if (!CATALOG_ID_PATTERN.test(provider.id)) {
8
+ omittedIds += 1;
9
+ continue;
10
+ }
11
+ const models = new Set();
12
+ for (const model of provider.models) {
13
+ if (!CATALOG_ID_PATTERN.test(model)) {
14
+ omittedIds += 1;
15
+ continue;
16
+ }
17
+ models.add(model);
18
+ }
19
+ providers.push(Object.freeze({
20
+ id: provider.id,
21
+ authConfigured: provider.authConfigured === true,
22
+ models: Object.freeze([...models].sort(compareIds)),
23
+ }));
24
+ }
25
+ return Object.freeze({ providers: Object.freeze(providers), omittedIds });
26
+ };
27
+ /** Fails closed when no catalog could be created; drops ids that are not plain catalog ids. */
28
+ export function catalogReport(catalog) {
29
+ if (catalog === undefined) {
30
+ return Object.freeze({
31
+ schemaVersion: 1,
32
+ status: "failed",
33
+ code: "catalog_unavailable",
34
+ providers: Object.freeze([]),
35
+ omittedIds: 0,
36
+ note: "extension_providers_not_listed",
37
+ exitCode: 1,
38
+ });
39
+ }
40
+ return Object.freeze({
41
+ schemaVersion: 1,
42
+ status: "ok",
43
+ ...providersOf(catalog),
44
+ note: "extension_providers_not_listed",
45
+ exitCode: 0,
46
+ });
47
+ }
48
+ /** One line per provider with its auth presence and model ids, then the fixed extension note. */
49
+ export function formatCatalogHuman(report) {
50
+ const lines = report.providers.map((provider) => `${provider.id} (auth: ${provider.authConfigured ? "yes" : "no"}): ${provider.models.join(", ")}`);
51
+ if (report.status === "failed")
52
+ lines.unshift("Model catalog unavailable.");
53
+ lines.push("Providers registered only by Pi extensions are not listed.");
54
+ return `${lines.join("\n")}\n`;
55
+ }
56
+ export function formatProbeHuman(report) {
57
+ const lines = report.findings.map((finding) => `${finding.profile}: ${finding.status} (${finding.code}${finding.startCode === undefined ? "" : ` ${finding.startCode}`})`);
58
+ if (report.refusal !== undefined)
59
+ lines.unshift(`Probe refused: ${report.refusal}`);
60
+ lines.push(`provider turns: ${report.findings.filter((finding) => finding.providerTurn).length}, usage: unknown`);
61
+ return `${lines.join("\n")}\n`;
62
+ }
@@ -24,7 +24,7 @@ export const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavai
24
24
  * store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
25
25
  */
26
26
  export const INFRASTRUCTURE_CODE = /^(?:ownership_(?!unavailable$|diverged$)[a-z_]+|config_[a-z_]+|operation_aborted|platform_unsupported)$/;
27
- export const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid"]);
27
+ export const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "skill_controller_selected"]);
28
28
  const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
29
29
  export const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
30
30
  export const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
@@ -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;
@@ -112,7 +112,9 @@ env -i HOME="$DISPOSABLE_HOME" PATH="$PATH" PI_SESSION_MCP_CONFIG="$ACCEPTANCE_C
112
112
  `skill_already_equivalent`, `skill_verified`, `skill_removed`,
113
113
  `skill_installed`, `skill_verified`, `skill_removed`); the skill file exists
114
114
  after steps 3 and 7 and is gone after steps 6 and 9; a foreign skill placed
115
- in the same skill directory stays unchanged throughout.
115
+ in the same skill directory stays unchanged throughout. From 0.14.0 on
116
+ (#139), that holds for both controller skills, `pi-session-orchestration`
117
+ and `pi-session-profiles`, each with its own finding in that order.
116
118
  - Steps 1 and 2 leave the client state unchanged.
117
119
  - After step 9 the `pi-session-mcp` registration is absent, the sentinel is still
118
120
  present, and the ownership record set is empty. The ownership directory has
@@ -2535,6 +2537,127 @@ Not established for `0.13.0`: the sandbox on a host that restricts unprivileged
2535
2537
  user namespaces; Git inside the sandbox on a linked worktree in live use (the
2536
2538
  bubblewrap-gated tests cover it); the macOS upgrade; Level 3.
2537
2539
 
2540
+ ### v0.13.1 post-release evidence
2541
+
2542
+ Recorded on 2026-10-29 for the published release `0.13.1`.
2543
+
2544
+ Tag binding: a fresh clone of the annotated tag `v0.13.1` (tag object
2545
+ `57c0786f1db3f732db797c8204e338ce17cf7fb2`) peels to the merge commit
2546
+ `c25c7687ecb66043a001c689c64e77be3cd25952` of #185, and
2547
+ `git describe --tags --exact-match` reports `v0.13.1`. CI on the merge commit
2548
+ (run 40832546742) and on the tag (run 40833087074) passed on the first attempt.
2549
+
2550
+ - Level 1 on the tag clone after `npm ci`, from 07:32 to 07:34 UTC with
2551
+ Node.js `26.12.1` and npm `12.2.1`: typecheck, `build:cli`, the test run
2552
+ (136 files, 3948 passed, 14 skipped, with
2553
+ `NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
2554
+ `git diff --check` exited with `0`, and the status stayed clean. Doctor
2555
+ reported `packageVersion` `0.13.1` and exited with `2` because no
2556
+ configuration was set (`config_missing`). The manifests were unchanged
2557
+ afterwards.
2558
+ - `pi-session-mcp@0.13.1` on npm: 922 files, shasum
2559
+ `e2e0e2652a2cab6bf891e61bcfa14754258eb59b`, `gitHead` `c25c768` (the merge
2560
+ commit). It was staged and approved by the maintainer; `npm pack` of the tag
2561
+ clone and the dry run of the publishing clone give the same shasum and file
2562
+ count.
2563
+ - Published npm package, Level 2 from a fresh prefix, 07:34 to 07:36 UTC:
2564
+ 160 of 160 with delivery off and 228 of 228 with `--events spool`; real
2565
+ registrations and built files unchanged, no process left, leak check passed.
2566
+ Clients: Codex CLI `0.162.1` and Claude Code `2.1.312`. The install smoke on
2567
+ the published package reported `smoke_passed` with version `0.13.1`.
2568
+ - The GitHub release `v0.13.1` was published the same day.
2569
+
2570
+ Operator upgrade of the workstation from `0.13.0` to `0.13.1` on 2026-10-29
2571
+ from 09:35 UTC, following
2572
+ [Installation](installation.md#container-writers): no writer container ran at
2573
+ the start; controller sessions without a coding session stayed open and need a
2574
+ reconnect. Both client targets were removed with `0.13.0`, then
2575
+ `npm install -g pi-session-mcp@0.13.1`. Doctor reported only
2576
+ `writer_not_ready` for the 18 writers. A dry run of each provisioning command
2577
+ with unchanged settings reported `mode_unchanged` and `writer_planned`; with
2578
+ `--apply`, five container repositories with 18 writers (2 with
2579
+ `--tags reachable`, 4, 4 with `--cache go`, 4 with base `dev`, 4 with
2580
+ `--cache uv`, three of them with operator images) reported `writer_accepted`
2581
+ from 09:36 to 09:39 UTC. Client-target `--apply` and `--verify` passed for
2582
+ `claude-code:user` and `codex:user` (`mcp_verified`, `skill_verified`, and
2583
+ `hook_verified` for the wake hook); `--writer <name> --verify` passed for all
2584
+ 18 writers. Doctor reported no error, only the known `workspace_gitfile` and
2585
+ `workspace_mode_unset` warnings of the worktree aliases.
2586
+
2587
+ Operator upgrade of the second machine (Linux) from `0.13.0` to `0.13.1` on
2588
+ 2026-10-29 from 09:47 UTC, with Node.js `24.22.0` and the same steps: no
2589
+ writer container and no server process of a client session ran at the start.
2590
+ Both client targets were removed with `0.13.0`, then
2591
+ `npm install -g pi-session-mcp@0.13.1`; Doctor reported only
2592
+ `writer_not_ready` for the 14 writers. The dry runs reported `mode_unchanged`
2593
+ and `writer_planned`; with `--apply`, four container repositories with 14
2594
+ writers (4, 2, 4 with an operator image and `--cache go`, 4 with an operator
2595
+ image) reported `writer_accepted` from 09:48 to 09:51 UTC. Client-target
2596
+ `--apply` and `--verify` passed for `claude-code:user` and `codex:user`
2597
+ (`mcp_verified`, `skill_verified`), and `--writer <name> --verify` passed for
2598
+ all 14 writers. Doctor reported no error, only `workspace_mode_unset` for one
2599
+ workspace without a mode.
2600
+
2601
+ ### v0.14.0 pre-release acceptance
2602
+
2603
+ `v0.14.0` was accepted on 2026-10-29 from 17:56 to 18:01 UTC on its release
2604
+ candidate `6e18659`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
2605
+ Codex CLI `0.162.1` and Claude Code `2.1.312`. Level 2 passed in the working
2606
+ tree and for the packed tarball, each with delivery off and with delivery on.
2607
+ Level 1 passed except `npm audit`. Later commits on the release pull request
2608
+ change only this record.
2609
+
2610
+ The minor release ships #139 ([ADR 0014](adr/0014-profile-probe-and-model-catalog.md))
2611
+ and #188. The MCP tool contract is unchanged. Setup gains `--list-models` and
2612
+ `--probe-profile`, installs a second controller skill (`pi-session-profiles`)
2613
+ and so reports one more skill finding per client target; Doctor gains the
2614
+ check `skill_controller_selected`.
2615
+
2616
+ The first working-tree run on the earlier candidate `08fc620` failed 36 of 160
2617
+ checks: the Level 2 harness still expected one controller skill finding, so
2618
+ every step saw one finding too many. Setup itself reported the expected
2619
+ findings. The candidate `6e18659` teaches the harness both controller skills
2620
+ with the same number of checks; every result below is from that candidate.
2621
+
2622
+ - Level 1 in a clean worktree of the candidate after `npm ci`: every command
2623
+ exited with `0`, except `npm audit`; `git status --short` printed nothing.
2624
+ - The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
2625
+ 139 files, 4005 passed and 14 skipped, and no unhandled error.
2626
+ - `npm audit` and `npm audit --omit=dev` exited with `1` and name only the
2627
+ known `undici` 8.10.2 finding through `@earendil-works/pi-coding-agent`
2628
+ 1.4.0, the latest SDK release, with eight advisories
2629
+ ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
2630
+ - `npm pack` listed 932 files (tarball shasum
2631
+ `4eb9b77c9484292a8533c6be0fda44ac18656787`, identical in three packs). A
2632
+ scan of the unpacked files for private names, hosts and the maintainer's
2633
+ home path found none.
2634
+ - Doctor reported `packageVersion` `0.14.0`. It exited with `2` because no
2635
+ configuration was set (`config_missing`).
2636
+ - Level 2 in the working tree: 160 of 160 checks passed with delivery off and
2637
+ 228 of 228 with `--events spool`.
2638
+ - Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
2639
+ 228 of 228 with `--events spool`. The install smoke from another fresh prefix
2640
+ reported `smoke_passed` with version `0.14.0`.
2641
+ - In every Level 2 run the real client registrations were unchanged, no process
2642
+ remained, the leak check passed and the disposable directories were removed.
2643
+ - Podman-gated writer tests on the candidate (Podman `6.1.4`): 7 of 7 passed.
2644
+ - Bubblewrap-gated sandbox tests (`test/sandbox-shell.bwrap.test.ts`, bubblewrap
2645
+ `0.13.1`): 12 of 12 passed, none skipped.
2646
+ - Live checks before the candidate, on the merged #139 code: `--list-models`
2647
+ against a real local Pi installation reported authentication for the
2648
+ configured providers, the probe dry run planned two profiles, and one
2649
+ `--probe-profile <alias> --apply` returned `probe_passed` with one provider
2650
+ turn.
2651
+ - Review: #139 had three cross-family reviews (author family OpenAI and
2652
+ Anthropic, reviewer family GLM) and #188 one, each without blocking findings;
2653
+ their findings were fixed or declined with a reason in #187 and #189. The
2654
+ harness fix in this release was checked by its live run above.
2655
+
2656
+ Not established for `0.14.0`: an agent-driven profile change end to end with
2657
+ the `pi-session-profiles` skill, a container writer acceptance of a newly
2658
+ created profile, client-target `--verify` through the installed package, which
2659
+ follows the upgrade; Level 3.
2660
+
2538
2661
  ### v0.13.1 pre-release acceptance
2539
2662
 
2540
2663
  `v0.13.1` was accepted on 2026-10-29 from 06:40 to 06:48 UTC on its release
@@ -0,0 +1,89 @@
1
+ # ADR 0014: Profile probe and offline model catalog
2
+
3
+ **Status:** Accepted (2026-10-29); unreleased. Issue #139.
4
+
5
+ ## Context
6
+
7
+ The running MCP server reads its configuration once at startup. A controlling
8
+ agent cannot reconnect its own server to prove a changed execution profile.
9
+ Asking for a manual reconnect prevents autonomous profile setup; starting a
10
+ nested controller would add another agent without testing a different boundary.
11
+ The agent also needs an authoritative offline catalog rather than guessing
12
+ provider and model ids or inspecting credential files.
13
+
14
+ ## Decision
15
+
16
+ ### 1. Offline model catalog
17
+
18
+ `pi-session-mcp-setup --list-models` reports provider ids, model ids and only
19
+ whether authentication is configured. Package-root `ModelRuntime` access sits
20
+ behind injectable `ModelCatalog`. Creation disables model network access and
21
+ refresh (`allowModelNetwork: false`, `refreshOnCreate: false`); the catalog
22
+ uses only `getProviders`, `getModels` and `checkAuth`. `checkAuth` reads the
23
+ local auth configuration offline and returns only its source and kind; the
24
+ catalog reduces that to a boolean and bounds each lookup. `hasConfiguredAuth`
25
+ is not used because it reads a snapshot that only an online availability
26
+ refresh fills, so offline it always reports no authentication. The catalog
27
+ never calls `getAuth` or `getAvailable`. This is a second narrow exception
28
+ next to the writer wrapper's credential refresh (#142), not general permission
29
+ to bypass `PiSessionAdapter`.
30
+
31
+ Providers registered only by extensions are not listed; every catalog report
32
+ states this limitation. Invalid catalog ids are omitted, never echoed.
33
+
34
+ ### 2. Opt-in profile probe
35
+
36
+ `--probe-profile review,code` runs Doctor and offline profile, provider, model
37
+ and authentication-presence checks. Only adding `--apply` authorizes one fixed
38
+ PONG turn per passing profile, sequentially, through `PiSessionAdapter`. The
39
+ production dependency is `SdkPiSessionAdapter`, resolving the profile as the
40
+ server does and protecting both the named and canonical configuration
41
+ directories from read-root expansion through skills.
42
+
43
+ Doctor blocks on global configuration/build/version errors and errors about
44
+ the requested profiles. Other errors and warnings are reported by check id
45
+ without blocking this request. Tests inject a fake catalog, adapter and Doctor;
46
+ no provider credentials are needed.
47
+
48
+ The session uses a private empty temporary directory, mode 0700, created
49
+ internally and removed after bounded session cleanup. This is a narrow
50
+ exception to the configured-workspace rule: the directory is not a workspace
51
+ alias and is never supplied as a tool or CLI argument. It avoids exposing a
52
+ real repository as the probe's working directory; it does not sandbox trusted
53
+ extensions or change coding profiles' existing authority.
54
+
55
+ Start, turn and cleanup deadlines are 60, 120 and 10 seconds respectively.
56
+ Interrupts abort and dispose the current session and skip remaining profiles.
57
+ The fixed prompt is not configurable, the reply text is never reported, usage
58
+ is `unknown`, and each finding records whether a provider turn was accepted.
59
+ No event spool is enabled.
60
+
61
+ ### 3. Guarded, lazy CLI entry
62
+
63
+ Ordinary setup requests never import the probe entry or install its stdout
64
+ guard. The catalog and probe load it dynamically after the platform gate;
65
+ the catalog needs no configuration. Its first import installs the same stdout
66
+ guard as the server before SDK evaluation. Only the guard's transport writer
67
+ emits reports; foreign stdout, including extension `console.log`, is dropped.
68
+ Adapter diagnostics are structured JSON on stderr without prompts or secrets.
69
+ Operational exceptions produce fixed refusal codes rather than raw errors.
70
+
71
+ ## Contract change and versioning
72
+
73
+ The new setup flags and report schemas are additive public CLI contract
74
+ changes under [ADR 0003](0003-post-mvp-maintenance.md#versioning). This decision
75
+ does not itself bump the version or authorize publication.
76
+
77
+ ## Consequences
78
+
79
+ - Doctor and `--verify` remain provider-free. Only
80
+ `--probe-profile … --apply` uses a provider among setup operations.
81
+ - Catalog and probe run on Linux and macOS without acquiring the setup lock
82
+ or changing registrations or configuration.
83
+ - The probe proves the profile on the host using the host Pi agent directory.
84
+ Container acceptance remains Doctor's `container_profile_refused` check and
85
+ writer provisioning, not this probe.
86
+ - A successful probe does not reload any running controller's configuration;
87
+ an MCP server restart or reconnect is still required.
88
+ - Reports expose only sanitized findings, durations and provider-turn presence,
89
+ not paths, credential values, environment values, prompts or reply text.
@@ -53,6 +53,9 @@ npm run --silent setup -- --apply --target codex:user:pi-session-mcp
53
53
  npm run --silent setup -- --verify --target codex:user:pi-session-mcp
54
54
  npm run --silent setup -- --rollback --target codex:user:pi-session-mcp
55
55
  npm run --silent setup -- --remove --target codex:user:pi-session-mcp
56
+ npm run --silent setup -- --list-models --json
57
+ npm run --silent setup -- --probe-profile review,code --json
58
+ npm run --silent setup -- --probe-profile review,code --apply --json
56
59
  ```
57
60
 
58
61
  Setup runs the previously built `dist/setup-main.js`. The dry-run operation
@@ -109,27 +112,30 @@ inspected:
109
112
  None of the failure rows changes a registration or starts the handshake, and
110
113
  each exits with `1`.
111
114
 
112
- ### Controller skill
115
+ ### Controller skills
113
116
 
114
- Besides the registration, `--apply` installs the controller skill
115
- `pi-session-orchestration` for each target, as a real copy of the effective
116
- skill: your override in the skill library if you have one, else the skill
117
- shipped in the package ([Pi skills](skills.md)). Clients discover it without
118
- further steps.
117
+ Besides the registration, `--apply` installs the controller skills
118
+ `pi-session-orchestration` (delegating work to Pi workers) and
119
+ `pi-session-profiles` (creating, changing and retiring execution profiles) for
120
+ each target, each as a real copy of the effective skill: your override in the
121
+ skill library if you have one, else the skill shipped in the package
122
+ ([Pi skills](skills.md)). Clients discover them without further steps.
119
123
 
120
- | Target | Where the skill goes |
124
+ | Target | Where the skills go |
121
125
  | --- | --- |
122
- | `codex:user:pi-session-mcp` | `~/.agents/skills/pi-session-orchestration` |
123
- | `claude-code:user:pi-session-mcp`, `claude-code:local:pi-session-mcp` | `~/.claude/skills/pi-session-orchestration` (one shared directory) |
124
- | `claude-code:project:pi-session-mcp` | `<project>/.claude/skills/pi-session-orchestration`, where `<project>` is the directory in which setup runs |
126
+ | `codex:user:pi-session-mcp` | `~/.agents/skills/<skill>` |
127
+ | `claude-code:user:pi-session-mcp`, `claude-code:local:pi-session-mcp` | `~/.claude/skills/<skill>` (one shared directory per skill) |
128
+ | `claude-code:project:pi-session-mcp` | `<project>/.claude/skills/<skill>`, where `<project>` is the directory in which setup runs |
125
129
 
126
- Every result therefore has a second finding per target, with the same client
127
- and scope and the alias `pi-session-orchestration`:
130
+ `<skill>` is `pi-session-orchestration` or `pi-session-profiles`. Every result
131
+ therefore has one more finding per skill and target, with the same client and
132
+ scope and the skill name as alias:
128
133
 
129
134
  ```text
130
135
  apply: ok
131
136
  claude-code/user/pi-session-mcp: ok (applied)
132
137
  claude-code/user/pi-session-orchestration: ok (skill_installed)
138
+ claude-code/user/pi-session-profiles: ok (skill_installed)
133
139
  ```
134
140
 
135
141
  Skill findings per operation:
@@ -28,6 +28,7 @@ and owners are:
28
28
  | `config_missing` | `PI_SESSION_MCP_CONFIG` is not set, or the file it names is absent | `configuration` |
29
29
  | `config_unreadable` | The supported configuration path cannot be read | `configuration` |
30
30
  | `config_invalid` | Production `loadConfig` rejects the configuration. One check per schema issue location, at most five, sorted | `configuration:<location>`, a dotted path of schema field names, aliases and array indexes such as `configuration:executionProfiles.review.default`; an unknown key that is not a schema field name, and any other key that is not a valid alias, is shown as `<key>`, because it could be a path or a secret. `configuration` when the error has no location |
31
+ | `skill_controller_selected` | Production `loadConfig` rejects the configuration because a `skillCatalog` value names a controller skill (`builtin:pi-session-orchestration` or `builtin:pi-session-profiles`). Reported instead of `config_invalid` for that location and blocks like it | `configuration:skillCatalog.<alias>` |
31
32
  | `workspace_missing` | A resolved configured workspace does not exist | validated workspace alias |
32
33
  | `workspace_not_directory` | A resolved workspace is not a directory | validated workspace alias |
33
34
  | `workspace_unreadable` | A resolved workspace cannot be read | validated workspace alias |
@@ -89,6 +89,89 @@ local user's existing Pi authentication and never prints it. Automated writers
89
89
  keep selected provider credentials in their private boundary agent copy; see
90
90
  [Execution modes](execution-modes.md#writer-storage-launch-and-readiness).
91
91
 
92
+ ## Check profiles: model catalog and probe
93
+
94
+ List the providers and model ids Pi knows locally, with authentication presence
95
+ only (never credential values):
96
+
97
+ ```bash
98
+ pi-session-mcp-setup --list-models
99
+ pi-session-mcp-setup --list-models --json
100
+ ```
101
+
102
+ This is offline and read-only: no provider request, credential refresh, model
103
+ network access or setup lock. It requires no `PI_SESSION_MCP_CONFIG`.
104
+ Providers registered only by Pi extensions are not listed; the report includes
105
+ that note. Invalid catalog ids are omitted and counted in `omittedIds`.
106
+ Catalog creation failures report `catalog_unavailable`.
107
+
108
+ With `PI_SESSION_MCP_CONFIG` already set to the configuration to check:
109
+
110
+ ```bash
111
+ pi-session-mcp-setup --probe-profile review,code --json
112
+ pi-session-mcp-setup --probe-profile review,code --apply --json
113
+ ```
114
+
115
+ The first command is a dry run: Doctor runs offline, followed by profile,
116
+ provider, model and authentication-presence checks. Only `--apply` sends a
117
+ provider turn: one fixed PONG request per profile that passes the offline
118
+ checks, sequentially. Duplicate aliases are checked once in request order.
119
+ Point the variable at a candidate configuration to check it before going live;
120
+ the commands do not write configuration or registrations. The running MCP
121
+ server still needs a restart or reconnect to read changes.
122
+
123
+ The Doctor gate blocks global configuration/build/version errors and errors
124
+ whose subject is a requested profile. Unrelated errors and all warnings are
125
+ listed by check id in `doctor.other`, without blocking. A blocked request
126
+ reports `doctor_failed`; missing or unusable configuration reports
127
+ `config_required` or `config_unusable`. Unexpected operational failures report
128
+ `operation_failed`, and catalog creation failures report `catalog_unavailable`.
129
+ No raw exception text is printed.
130
+
131
+ Each applied probe starts the normal adapter with the resolved profile in a
132
+ private empty temporary directory (mode 0700), not a real repository. This
133
+ directory is created internally, never supplied as an argument or workspace
134
+ alias, and removed after cleanup. Start and turn deadlines are 60 and 120
135
+ seconds; session cleanup is bounded to 10 seconds. Interrupts abort and dispose
136
+ the current session and skip the remaining profiles. Reports never include
137
+ paths, credential values, environment values, the prompt or reply text.
138
+ Foreign stdout from profiles/extensions is suppressed so JSON remains intact;
139
+ sanitized structured diagnostics go to stderr. No turn event spool is used.
140
+
141
+ | Code | Status | Meaning |
142
+ | --- | --- | --- |
143
+ | `probe_planned` | planned | Dry run: offline checks passed |
144
+ | `probe_profile_unknown` | failed | Alias not in the configuration |
145
+ | `probe_provider_unknown` | failed | Provider not in the catalog (offline) |
146
+ | `probe_model_unknown` | failed | Model not in the provider's catalog (offline) |
147
+ | `probe_auth_missing` | failed | Provider has no configured authentication (offline) |
148
+ | `probe_passed` | ok | Session started, reply was `PONG` |
149
+ | `probe_unexpected_reply` | failed | Turn completed with another reply |
150
+ | `probe_turn_failed` | failed | Turn failed or was aborted |
151
+ | `probe_start_failed` | failed | Session creation refused; carries the existing sanitized `PiSessionCreationErrorCode` |
152
+ | `probe_timeout` | failed | Start or turn deadline passed; the session was aborted and disposed |
153
+
154
+ A profile that fails an offline check is not started; the other profiles are
155
+ still checked. Apply findings include `durationMs`. `providerTurn` records
156
+ whether a provider turn was accepted, including turns that failed or timed out;
157
+ a `false` finding did not spend a provider turn. Provider usage and monetary
158
+ cost remain `unknown`.
159
+
160
+ Exit codes: `0` for a successful catalog, a clean dry run (`planned`) or an
161
+ apply where every profile passed; `1` for a failed check or refusal; `64` for
162
+ invalid usage; `130` for SIGINT and `143` for SIGTERM during a probe (both
163
+ report `operation_interrupted`). Both operations run on Linux and macOS and
164
+ need no setup lock. Probe requests cannot be combined with `--target`,
165
+ `--repo`, `--writer`, `--customize-skill`, `--list-models`, `--verify`,
166
+ `--rollback` or `--remove`. `--list-models` is standalone apart from `--json` and the default `--dry-run`.
167
+ Doctor and `--verify` remain provider-free.
168
+
169
+ **Container repositories:** the probe proves the profile on the host, with
170
+ the host's Pi agent directory. Writer acceptance stays with Doctor's
171
+ `container_profile_refused` check and writer provisioning; a host probe does
172
+ not prove a container writer. See [Execution modes](execution-modes.md) and
173
+ [ADR 0014](adr/0014-profile-probe-and-model-catalog.md).
174
+
92
175
  ## 1. Install
93
176
 
94
177
  ### From npm
@@ -701,6 +784,10 @@ choose skills.
701
784
  directory, so concurrent runs cannot lose a recorded base.
702
785
  - A `builtin:` name with no override and no shipped skill makes Doctor report
703
786
  `skill_missing` and the session start fail with `skill_load_failed`.
787
+ - The controller skills `pi-session-orchestration` and `pi-session-profiles`
788
+ are not worker skills: a `builtin:` catalog entry for either makes the
789
+ configuration invalid, and Doctor reports `skill_controller_selected` for
790
+ `configuration:skillCatalog.<alias>`.
704
791
  - An extension allowlist alone makes none of a package's skills available. If a
705
792
  profile relied on them, add a catalog entry with the absolute path of the
706
793
  package's `SKILL.md` and select it; see the migration section of [Pi