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.
- package/.pi-session-mcp-cli/config.js +21 -3
- package/.pi-session-mcp-cli/doctor.js +6 -2
- package/.pi-session-mcp-cli/skill-library.js +1 -1
- package/.pi-session-mcp-cli/writer-boundary.js +2 -1
- package/.pi-session-mcp-cli/writer-lifecycle.js +18 -4
- package/CHANGELOG.md +87 -0
- package/README.md +1 -1
- package/dist/capabilities.d.ts +13 -0
- package/dist/capabilities.js +17 -13
- package/dist/config.d.ts +3 -1
- package/dist/config.js +21 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +6 -2
- package/dist/model-catalog.d.ts +40 -0
- package/dist/model-catalog.js +38 -0
- package/dist/profile-probe.d.ts +28 -0
- package/dist/profile-probe.js +164 -0
- package/dist/sdk-pi-adapter.d.ts +1 -1
- package/dist/sdk-pi-adapter.js +25 -6
- package/dist/setup-cli.d.ts +7 -0
- package/dist/setup-cli.js +36 -0
- package/dist/setup-main.js +10 -3
- package/dist/setup-probe-main.d.ts +14 -0
- package/dist/setup-probe-main.js +76 -0
- package/dist/setup-probe-result.d.ts +39 -0
- package/dist/setup-probe-result.js +62 -0
- package/dist/setup-writers-common.js +1 -1
- package/dist/skill-library.js +1 -1
- package/dist/writer-boundary.d.ts +1 -1
- package/dist/writer-boundary.js +2 -1
- package/dist/writer-guard.js +1 -1
- package/dist/writer-lifecycle.d.ts +1 -0
- package/dist/writer-lifecycle.js +18 -4
- package/dist/writer-relay.js +12 -1
- package/docs/acceptance.md +313 -1
- package/docs/adr/0012-leased-writer-pool.md +10 -6
- package/docs/adr/0013-hardened-read-only-profiles.md +30 -0
- package/docs/adr/0014-profile-probe-and-model-catalog.md +89 -0
- package/docs/client-setup.md +18 -12
- package/docs/doctor-design.md +1 -0
- package/docs/execution-modes.md +25 -3
- package/docs/installation.md +87 -0
- package/docs/isolation-setup.md +8 -2
- package/docs/roadmap.md +20 -1
- package/docs/skills.md +42 -25
- package/docs/sprints/2026-10-24-s8-writer-migration.md +5 -3
- package/docs/sprints/2026-10-29-s12-audit-fixes.md +172 -0
- package/docs/threat-model.md +10 -1
- package/docs/tool-contracts.md +1 -1
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +1 -1
- package/skills/pi-session-profiles/SKILL.md +124 -0
|
@@ -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
|
+
}
|
package/dist/sdk-pi-adapter.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export type PromptDisposition = Parameters<NonNullable<PromptOptions["preflightR
|
|
|
74
74
|
* typecheck (and therefore the Pi SDK canary) instead of being mapped silently.
|
|
75
75
|
*/
|
|
76
76
|
export declare function admitsTurn(disposition: PromptDisposition): boolean;
|
|
77
|
-
export declare function createPhase0ResourceLoader(cwd: string, skills?: readonly Skill[]): Promise<{
|
|
77
|
+
export declare function createPhase0ResourceLoader(cwd: string, skills?: readonly Skill[], permissionProfile?: ResolvedExecutionProfile["permissionProfile"]): Promise<{
|
|
78
78
|
resourceLoader: DefaultResourceLoader;
|
|
79
79
|
settingsManager: SettingsManager;
|
|
80
80
|
}>;
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import { homedir, userInfo } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
5
|
import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
|
|
5
6
|
import { PiExtensionError } from "./extension-spec.js";
|
|
6
7
|
import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-tools.js";
|
|
@@ -298,7 +299,7 @@ function isTextBlock(block) {
|
|
|
298
299
|
return candidate.type === "text" && typeof candidate.text === "string";
|
|
299
300
|
}
|
|
300
301
|
/** Independently validated skills replace package and extension discovery on every reload. */
|
|
301
|
-
function phase0ResourceLoaderOptions(skills) {
|
|
302
|
+
function phase0ResourceLoaderOptions(skills, confinedCwd) {
|
|
302
303
|
// Keep the closure's snapshot private: the SDK may annotate or mutate returned objects.
|
|
303
304
|
const selected = skills.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } }));
|
|
304
305
|
return {
|
|
@@ -307,6 +308,24 @@ function phase0ResourceLoaderOptions(skills) {
|
|
|
307
308
|
noPromptTemplates: true,
|
|
308
309
|
noThemes: true,
|
|
309
310
|
noContextFiles: false,
|
|
311
|
+
...(confinedCwd === undefined ? {} : {
|
|
312
|
+
agentsFilesOverride: (current) => ({
|
|
313
|
+
agentsFiles: current.agentsFiles.flatMap((file) => {
|
|
314
|
+
try {
|
|
315
|
+
const workspace = realpathSync(confinedCwd), real = realpathSync(file.path);
|
|
316
|
+
const path = relative(workspace, real);
|
|
317
|
+
if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path))
|
|
318
|
+
return [];
|
|
319
|
+
// Re-read from the verified real path: the SDK read the link before this check.
|
|
320
|
+
return [{ path: file.path, content: readFileSync(real, "utf-8").replace(/^\uFEFF/, "") }];
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Unresolvable context files never enter a read-only session's model context.
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
}),
|
|
327
|
+
}),
|
|
328
|
+
}),
|
|
310
329
|
systemPrompt: "phase-0-system-prompt-disabled",
|
|
311
330
|
appendSystemPrompt: [],
|
|
312
331
|
systemPromptOverride: () => undefined,
|
|
@@ -316,9 +335,9 @@ function phase0ResourceLoaderOptions(skills) {
|
|
|
316
335
|
themesOverride: () => ({ themes: [], diagnostics: [] }),
|
|
317
336
|
};
|
|
318
337
|
}
|
|
319
|
-
export async function createPhase0ResourceLoader(cwd, skills = []) {
|
|
338
|
+
export async function createPhase0ResourceLoader(cwd, skills = [], permissionProfile) {
|
|
320
339
|
const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
|
|
321
|
-
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
|
|
340
|
+
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills, permissionProfile === "read-only" ? cwd : undefined) });
|
|
322
341
|
await resourceLoader.reload();
|
|
323
342
|
return { resourceLoader, settingsManager };
|
|
324
343
|
}
|
|
@@ -367,7 +386,7 @@ export class SdkPiSessionAdapter {
|
|
|
367
386
|
let resourceLoader;
|
|
368
387
|
let settingsManager;
|
|
369
388
|
try {
|
|
370
|
-
({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
|
|
389
|
+
({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills, executionProfile.permissionProfile));
|
|
371
390
|
}
|
|
372
391
|
catch {
|
|
373
392
|
throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
|
|
@@ -426,7 +445,7 @@ export class SdkPiSessionAdapter {
|
|
|
426
445
|
agentDir: cwd,
|
|
427
446
|
modelRuntime: runtime,
|
|
428
447
|
settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
|
|
429
|
-
resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
|
|
448
|
+
resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills, executionProfile.permissionProfile === "read-only" ? cwd : undefined), additionalExtensionPaths: directories },
|
|
430
449
|
}));
|
|
431
450
|
this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
|
|
432
451
|
const loaded = services.resourceLoader.getExtensions();
|
package/dist/setup-cli.d.ts
CHANGED
|
@@ -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)
|
package/dist/setup-main.js
CHANGED
|
@@ -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) });
|
package/dist/skill-library.js
CHANGED
|
@@ -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;
|
|
@@ -2,7 +2,7 @@ import type { CommandRunner } from "./client-adapters/types.js";
|
|
|
2
2
|
import { writerPaths, type WriterState } from "./writer-state.js";
|
|
3
3
|
export declare const NODE_IMAGE_REF = "docker.io/library/node@sha256:c4d5523090a817b7aa86d2111241fdd4f66d1e27782b44160e6aa63b357ecb2d";
|
|
4
4
|
export declare const IMAGE_PULL_TIMEOUT_MS = 900000;
|
|
5
|
-
export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null";
|
|
5
|
+
export declare const BOUNDARY_GIT = "git -c safe.directory=* -c core.fsmonitor=false -c core.hooksPath=/dev/null -c core.useReplaceRefs=false";
|
|
6
6
|
type WriterPaths = ReturnType<typeof writerPaths>;
|
|
7
7
|
export declare class BoundaryError extends Error {
|
|
8
8
|
readonly code: "image_unavailable" | "clone_verification_failed" | "writer_fenced";
|
package/dist/writer-boundary.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/writer-guard.js
CHANGED
|
@@ -13,7 +13,7 @@ const guardSchema = z.object({
|
|
|
13
13
|
}).strict();
|
|
14
14
|
const run = promisify(execFile);
|
|
15
15
|
const defaultGit = async (cwd) => {
|
|
16
|
-
const git = async (...args) => (await run("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args], { cwd, timeout: 10_000, encoding: "utf8" })).stdout;
|
|
16
|
+
const git = async (...args) => (await run("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "core.useReplaceRefs=false", ...args], { cwd, timeout: 10_000, encoding: "utf8" })).stdout;
|
|
17
17
|
const [status, head, refs] = await Promise.all([
|
|
18
18
|
git("status", "--porcelain=v1", "-uall"),
|
|
19
19
|
git("rev-parse", "HEAD"),
|
|
@@ -66,6 +66,7 @@ export declare function provisionWriter(ctx: WriterContext, input: {
|
|
|
66
66
|
export declare function inspectWork(ctx: WriterContext, name: string): Promise<{
|
|
67
67
|
baseline: boolean;
|
|
68
68
|
digest: string;
|
|
69
|
+
head?: string;
|
|
69
70
|
branches: readonly {
|
|
70
71
|
name: string;
|
|
71
72
|
commit: string;
|