pi-session-mcp 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/config.js +40 -3
- package/.pi-session-mcp-cli/doctor.js +13 -4
- package/.pi-session-mcp-cli/skill-library.js +1 -1
- package/CHANGELOG.md +121 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +4 -2
- package/dist/config.d.ts +13 -1
- package/dist/config.js +40 -3
- package/dist/confined-tools.d.ts +1 -0
- package/dist/confined-tools.js +6 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +13 -4
- package/dist/main.js +6 -2
- package/dist/model-catalog.d.ts +40 -0
- package/dist/model-catalog.js +38 -0
- package/dist/profile-probe.d.ts +28 -0
- package/dist/profile-probe.js +164 -0
- package/dist/sdk-pi-adapter.js +3 -2
- package/dist/setup-cli.d.ts +8 -2
- package/dist/setup-cli.js +36 -3
- package/dist/setup-main.js +10 -3
- package/dist/setup-probe-main.d.ts +14 -0
- package/dist/setup-probe-main.js +76 -0
- package/dist/setup-probe-result.d.ts +39 -0
- package/dist/setup-probe-result.js +62 -0
- package/dist/setup-writers-common.d.ts +78 -0
- package/dist/setup-writers-common.js +177 -0
- package/dist/setup-writers-plan.d.ts +34 -0
- package/dist/setup-writers-plan.js +105 -0
- package/dist/setup-writers-reconcile.d.ts +8 -0
- package/dist/setup-writers-reconcile.js +169 -0
- package/dist/setup-writers.d.ts +6 -30
- package/dist/setup-writers.js +18 -382
- package/dist/skill-library.js +1 -1
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/docs/acceptance.md +254 -1
- package/docs/adr/0009-execution-modes.md +3 -2
- package/docs/adr/0012-leased-writer-pool.md +3 -3
- package/docs/adr/0013-hardened-read-only-profiles.md +6 -4
- package/docs/adr/0014-profile-probe-and-model-catalog.md +89 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +21 -15
- package/docs/doctor-design.md +2 -1
- package/docs/execution-modes.md +1 -1
- package/docs/installation.md +87 -0
- package/docs/roadmap.md +23 -5
- package/docs/session-mcp.md +5 -3
- package/docs/skills.md +42 -25
- package/docs/threat-model.md +2 -1
- package/docs/tool-contracts.md +4 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +2 -2
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
- package/skills/pi-session-profiles/SKILL.md +124 -0
|
@@ -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.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { homedir, userInfo } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { allowedReadRoots, createConfinedReadOnlyTools } from "./confined-tools.js";
|
|
@@ -347,7 +347,8 @@ export class SdkPiSessionAdapter {
|
|
|
347
347
|
catch {
|
|
348
348
|
return homedir();
|
|
349
349
|
} })();
|
|
350
|
-
|
|
350
|
+
// Pi's agent directory holds its credentials; the SDK resolves it from `PI_CODING_AGENT_DIR` or below `~/.pi`.
|
|
351
|
+
this.protectedReadPaths = [home, join(home, ".pi"), getAgentDir(), ...(options.protectedReadPaths ?? [])];
|
|
351
352
|
}
|
|
352
353
|
async create(input) {
|
|
353
354
|
const extensions = input.executionProfile.extensions ?? [];
|
package/dist/setup-cli.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SetupRequest } from "./setup.js";
|
|
2
2
|
import type { SetupResult } from "./setup-result.js";
|
|
3
3
|
import type { ModeRequest } from "./setup-modes.js";
|
|
4
4
|
import type { WriterOperation } from "./setup-writers.js";
|
|
@@ -19,9 +19,15 @@ 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
|
-
export declare function runSetupCli(argv: readonly string[], orchestrator: SetupOrchestrator): Promise<SetupResult>;
|
|
25
31
|
/** Both report modes are projections of the same sanitized result. */
|
|
26
32
|
export declare function formatSetupResult(setupResult: SetupResult): string;
|
|
27
33
|
export declare function formatSetupHuman(setupResult: SetupResult): string;
|
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)
|
|
@@ -210,9 +246,6 @@ export function parseSetupArgs(argv) {
|
|
|
210
246
|
}
|
|
211
247
|
return operation ? { operation, targets, json } : { targets, json };
|
|
212
248
|
}
|
|
213
|
-
export async function runSetupCli(argv, orchestrator) {
|
|
214
|
-
return orchestrator.run(parseSetupArgs(argv));
|
|
215
|
-
}
|
|
216
249
|
/** Both report modes are projections of the same sanitized result. */
|
|
217
250
|
export function formatSetupResult(setupResult) {
|
|
218
251
|
return JSON.stringify(setupResult);
|
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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { runDoctor } from "./doctor.js";
|
|
2
|
+
import { type WorkspaceModeEntry } from "./execution-modes.js";
|
|
3
|
+
import { acquireOverrideLock } from "./override-lock.js";
|
|
4
|
+
import { createCommandMcpStdioLauncher } from "./setup-process.js";
|
|
5
|
+
import { DurableOwnershipStore, type OwnershipStore } from "./setup-ownership.js";
|
|
6
|
+
import { type WakeHookOutcome } from "./setup-hooks.js";
|
|
7
|
+
import type { SetupFinding, SetupTarget } from "./setup-result.js";
|
|
8
|
+
import { type WriterRegistrationIntent, type WriterRegistrationAdapter } from "./client-adapters/writer-registration.js";
|
|
9
|
+
import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
|
|
10
|
+
import { acquireWriterLease } from "./writer-lease.js";
|
|
11
|
+
import { ensureWriterAbsent } from "./writer-fence.js";
|
|
12
|
+
import * as lifecycle from "./writer-lifecycle.js";
|
|
13
|
+
import * as acceptance from "./writer-acceptance.js";
|
|
14
|
+
/** Services, fencing, the Doctor gate and removal shared by mode changes, reconciliation and single-writer operations. */
|
|
15
|
+
export declare const SETUP_LEASE_WAIT_MS = 10000;
|
|
16
|
+
export declare const FENCE_CODES: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Failures of shared setup state rather than of one writer: they end the whole reconciliation (#180). The ownership
|
|
19
|
+
* store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
|
|
20
|
+
*/
|
|
21
|
+
export declare const INFRASTRUCTURE_CODE: RegExp;
|
|
22
|
+
export declare const GLOBAL_ERRORS: Set<string>;
|
|
23
|
+
export interface WriterSetupDependencies {
|
|
24
|
+
configPath: string;
|
|
25
|
+
releaseRoot: string;
|
|
26
|
+
home?: string;
|
|
27
|
+
platform?: NodeJS.Platform;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
runner?: lifecycle.WriterContext["runner"];
|
|
30
|
+
lifecycle?: typeof lifecycle;
|
|
31
|
+
acceptance?: typeof acceptance;
|
|
32
|
+
ownership?: OwnershipStore;
|
|
33
|
+
adapter?: WriterRegistrationAdapter;
|
|
34
|
+
doctor?: typeof runDoctor;
|
|
35
|
+
verify?: typeof verifyMcpCapabilities;
|
|
36
|
+
launch?: typeof createCommandMcpStdioLauncher;
|
|
37
|
+
lock?: typeof acquireOverrideLock;
|
|
38
|
+
lease?: typeof acquireWriterLease;
|
|
39
|
+
ensureAbsent?: typeof ensureWriterAbsent;
|
|
40
|
+
containerRuntime?: () => Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
export declare const targetFor: (alias: string, name: string) => SetupTarget;
|
|
43
|
+
export declare const modesTarget: (alias: string) => SetupTarget;
|
|
44
|
+
export declare const codeFor: (error: unknown) => string;
|
|
45
|
+
export declare function services(deps: WriterSetupDependencies): {
|
|
46
|
+
home: string;
|
|
47
|
+
runner: import("./client-adapters/types.js").CommandRunner;
|
|
48
|
+
life: typeof lifecycle;
|
|
49
|
+
accept: typeof acceptance;
|
|
50
|
+
ownership: DurableOwnershipStore | OwnershipStore;
|
|
51
|
+
adapter: WriterRegistrationAdapter;
|
|
52
|
+
};
|
|
53
|
+
export declare function context(alias: string, deps: WriterSetupDependencies): Promise<lifecycle.WriterContext>;
|
|
54
|
+
export declare function locked<T>(deps: WriterSetupDependencies, action: () => Promise<T>): Promise<T>;
|
|
55
|
+
export declare function fenced<T>(ctx: lifecycle.WriterContext, name: string, deps: WriterSetupDependencies, action: (ctx: lifecycle.WriterContext) => Promise<T>): Promise<T>;
|
|
56
|
+
export declare function intent(ctx: lifecycle.WriterContext, name: string, client: "claude-code" | "codex"): WriterRegistrationIntent;
|
|
57
|
+
/**
|
|
58
|
+
* The wake hook of a writer's Claude Code registration, in the local settings of the operator's
|
|
59
|
+
* project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
|
|
60
|
+
* whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
|
|
61
|
+
* Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
|
|
62
|
+
*/
|
|
63
|
+
export declare function writerHook(ctx: lifecycle.WriterContext, name: string, deps: WriterSetupDependencies, operation: "apply" | "dry-run" | "verify" | "remove", enabled: boolean): Promise<WakeHookOutcome | undefined>;
|
|
64
|
+
export declare class DoctorGateError extends Error {
|
|
65
|
+
readonly findings: readonly SetupFinding[];
|
|
66
|
+
constructor(findings: readonly SetupFinding[]);
|
|
67
|
+
}
|
|
68
|
+
export declare function doctorGate(ctx: lifecycle.WriterContext, deps: WriterSetupDependencies): Promise<void>;
|
|
69
|
+
export declare function preflight(ctx: lifecycle.WriterContext, names: readonly string[], discard: boolean, deps: WriterSetupDependencies): Promise<void>;
|
|
70
|
+
export declare function remove(ctx: lifecycle.WriterContext, name: string, discard: boolean, deps: WriterSetupDependencies, extras?: WakeHookOutcome[]): Promise<lifecycle.WriterOutcome>;
|
|
71
|
+
export declare const writerIndex: (name: string) => number;
|
|
72
|
+
/** Which writers a mode entry keeps, which of those still need provisioning, and which existing writers it no longer wants. */
|
|
73
|
+
export declare function plan(ctx: lifecycle.WriterContext, entry: WorkspaceModeEntry): Promise<{
|
|
74
|
+
keep: string[];
|
|
75
|
+
missing: string[];
|
|
76
|
+
surplus: string[];
|
|
77
|
+
}>;
|
|
78
|
+
export type WriterPlan = Awaited<ReturnType<typeof plan>>;
|