pi-session-mcp 0.7.1 → 0.8.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 +47 -7
- package/.pi-session-mcp-cli/doctor.js +75 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/override-lock.js +125 -0
- package/.pi-session-mcp-cli/setup-platform.js +195 -0
- package/.pi-session-mcp-cli/setup-result.js +8 -0
- package/.pi-session-mcp-cli/skill-catalog.js +35 -0
- package/.pi-session-mcp-cli/skill-customize.js +268 -0
- package/.pi-session-mcp-cli/skill-library.js +188 -0
- package/CHANGELOG.md +176 -0
- package/README.md +15 -3
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +6 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +47 -7
- package/dist/doctor.d.ts +7 -1
- package/dist/doctor.js +75 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/override-lock.d.ts +34 -0
- package/dist/override-lock.js +125 -0
- package/dist/pi-adapter.d.ts +7 -5
- package/dist/pi-adapter.js +1 -0
- package/dist/pi-skills.d.ts +17 -0
- package/dist/pi-skills.js +64 -0
- package/dist/sdk-pi-adapter.d.ts +7 -3
- package/dist/sdk-pi-adapter.js +82 -16
- package/dist/server.js +1 -0
- package/dist/session-registry.d.ts +8 -6
- package/dist/session-registry.js +3 -2
- package/dist/setup-cli.d.ts +2 -0
- package/dist/setup-cli.js +16 -0
- package/dist/setup-main.d.ts +1 -1
- package/dist/setup-main.js +18 -3
- package/dist/setup-ownership.d.ts +9 -0
- package/dist/setup-ownership.js +32 -5
- package/dist/setup-skills.d.ts +76 -0
- package/dist/setup-skills.js +586 -0
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +49 -6
- package/dist/skill-catalog.d.ts +26 -0
- package/dist/skill-catalog.js +35 -0
- package/dist/skill-customize.d.ts +41 -0
- package/dist/skill-customize.js +268 -0
- package/dist/skill-library.d.ts +106 -0
- package/dist/skill-library.js +188 -0
- package/docs/acceptance.md +593 -1
- package/docs/adr/0006-pi-extension-allowlist.md +3 -0
- package/docs/adr/0007-profile-skill-selection.md +228 -0
- package/docs/architecture.md +10 -6
- package/docs/client-setup-design.md +219 -1
- package/docs/client-setup.md +86 -3
- package/docs/doctor-design.md +9 -1
- package/docs/installation.md +128 -5
- package/docs/pi-extensions-design.md +28 -4
- package/docs/releasing.md +18 -9
- package/docs/session-mcp.md +7 -2
- package/docs/skills.md +616 -0
- package/docs/threat-model.md +19 -2
- package/docs/tool-contracts.md +9 -5
- package/package.json +2 -1
- package/pi-session-mcp.example.json +9 -3
- package/skills/pi-session-implementation/SKILL.md +42 -0
- package/skills/pi-session-orchestration/SKILL.md +94 -0
- package/skills/pi-session-review/SKILL.md +58 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { loadSkills } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { PiSessionCreationError } from "./pi-adapter.js";
|
|
6
|
+
import { MAX_SKILL_FILE_BYTES } from "./skill-catalog.js";
|
|
7
|
+
export class PiSkillError extends PiSessionCreationError {
|
|
8
|
+
alias;
|
|
9
|
+
constructor(alias) {
|
|
10
|
+
super("skill_load_failed");
|
|
11
|
+
this.alias = alias;
|
|
12
|
+
this.name = "PiSkillError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Load only the configured files, independently of package discovery and SDK deduplication. */
|
|
16
|
+
export async function loadSelectedSkills(selection, cwd, dependencies = {}) {
|
|
17
|
+
if (selection.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const fs = { stat, access, realpath, loadSkills, ...dependencies };
|
|
20
|
+
const paths = [];
|
|
21
|
+
const canonical = new Set();
|
|
22
|
+
try {
|
|
23
|
+
for (const selected of selection) {
|
|
24
|
+
try {
|
|
25
|
+
const path = resolve(selected.path);
|
|
26
|
+
const info = await fs.stat(path);
|
|
27
|
+
if (!info.isFile() || info.size > MAX_SKILL_FILE_BYTES)
|
|
28
|
+
throw new PiSkillError(selected.alias);
|
|
29
|
+
await fs.access(path, constants.R_OK);
|
|
30
|
+
const real = await fs.realpath(path);
|
|
31
|
+
if (canonical.has(real))
|
|
32
|
+
throw new PiSkillError(selected.alias);
|
|
33
|
+
canonical.add(real);
|
|
34
|
+
paths.push(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new PiSkillError(selected.alias);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const result = fs.loadSkills({ cwd, agentDir: cwd, skillPaths: paths, includeDefaults: false });
|
|
41
|
+
if (result.diagnostics.length > 0) {
|
|
42
|
+
const aliases = new Set(result.diagnostics.map((diagnostic) => {
|
|
43
|
+
const index = paths.indexOf(diagnostic.path ?? "");
|
|
44
|
+
return index < 0 ? undefined : selection[index].alias;
|
|
45
|
+
}));
|
|
46
|
+
throw new PiSkillError(aliases.size === 1 ? [...aliases][0] : undefined);
|
|
47
|
+
}
|
|
48
|
+
if (result.skills.length !== paths.length) {
|
|
49
|
+
const missing = selection.filter((_selected, index) => !result.skills.some((skill) => skill.filePath === paths[index]));
|
|
50
|
+
throw new PiSkillError(missing.length === 1 ? missing[0].alias : undefined);
|
|
51
|
+
}
|
|
52
|
+
return paths.map((path, index) => {
|
|
53
|
+
const matches = result.skills.filter((skill) => skill.filePath === path);
|
|
54
|
+
if (matches.length !== 1 || matches[0].disableModelInvocation === true)
|
|
55
|
+
throw new PiSkillError(selection[index].alias);
|
|
56
|
+
return matches[0];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof PiSkillError)
|
|
61
|
+
throw error;
|
|
62
|
+
throw new PiSkillError();
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/sdk-pi-adapter.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentSessionEvent, type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, type PromptOptions, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { type AgentSessionEvent, type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, DefaultResourceLoader, ModelRuntime, type PromptOptions, type Skill, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type ExtensionSpec } from "./extension-spec.js";
|
|
3
3
|
import type { ResolvedExecutionProfile, ThinkingLevel } from "./execution-profile.js";
|
|
4
4
|
import { type McpConnectionFactory } from "./mcp-session-tools.js";
|
|
@@ -11,7 +11,7 @@ export declare const SESSION_CREATION_TIMEOUT_MS = 10000;
|
|
|
11
11
|
export interface SdkPiAdapterDiagnostic {
|
|
12
12
|
readonly level: "error";
|
|
13
13
|
readonly event: "pi_session_creation_failed";
|
|
14
|
-
readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind";
|
|
14
|
+
readonly stage: "runtime" | "provider" | "model" | "check_auth" | "get_auth" | "availability" | "resource_loader" | "mcp_tools" | "create_session" | "postcheck" | "extension_resolve" | "extension_load" | "extension_bind" | "skill_load";
|
|
15
15
|
readonly code: PiSessionCreationErrorCode;
|
|
16
16
|
readonly executionProfile: string;
|
|
17
17
|
readonly provider: string;
|
|
@@ -22,6 +22,8 @@ export interface SdkPiAdapterDiagnostic {
|
|
|
22
22
|
readonly discoveredToolCount?: number;
|
|
23
23
|
/** `<name>@<version>` of the extension that failed to resolve; never a path. */
|
|
24
24
|
readonly extension?: string;
|
|
25
|
+
/** The failing configured catalog alias, never a skill-file name or path. */
|
|
26
|
+
readonly skill?: string;
|
|
25
27
|
}
|
|
26
28
|
/** The Pi SDK session-lifecycle API used for profiles with extensions; injectable for tests. */
|
|
27
29
|
export interface ExtensionSdk {
|
|
@@ -64,7 +66,7 @@ export type PromptDisposition = Parameters<NonNullable<PromptOptions["preflightR
|
|
|
64
66
|
* typecheck (and therefore the Pi SDK canary) instead of being mapped silently.
|
|
65
67
|
*/
|
|
66
68
|
export declare function admitsTurn(disposition: PromptDisposition): boolean;
|
|
67
|
-
export declare function createPhase0ResourceLoader(cwd: string): Promise<{
|
|
69
|
+
export declare function createPhase0ResourceLoader(cwd: string, skills?: readonly Skill[]): Promise<{
|
|
68
70
|
resourceLoader: DefaultResourceLoader;
|
|
69
71
|
settingsManager: SettingsManager;
|
|
70
72
|
}>;
|
|
@@ -83,6 +85,8 @@ export declare class SdkPiSessionAdapter implements PiSessionAdapter {
|
|
|
83
85
|
signal?: AbortSignal;
|
|
84
86
|
}): Promise<PiSessionHandle>;
|
|
85
87
|
private createWithExtensions;
|
|
88
|
+
private loadSkills;
|
|
89
|
+
private checkResources;
|
|
86
90
|
/** Provider, model, authentication and availability, exactly as configured. */
|
|
87
91
|
private resolveVerifiedModel;
|
|
88
92
|
private connectMcpTools;
|
package/dist/sdk-pi-adapter.js
CHANGED
|
@@ -4,6 +4,7 @@ import { classifyMcpStartupError, connectSessionMcpTools, } from "./mcp-session-
|
|
|
4
4
|
import { createStdioMcpConnection } from "./mcp-stdio-client.js";
|
|
5
5
|
import { PiSessionCreationError, } from "./pi-adapter.js";
|
|
6
6
|
import { resolvePiExtension } from "./pi-extensions.js";
|
|
7
|
+
import { loadSelectedSkills, PiSkillError } from "./pi-skills.js";
|
|
7
8
|
import { composeSessionTools, PROFILE_TOOLS } from "./session-tools.js";
|
|
8
9
|
export { PROFILE_TOOLS } from "./session-tools.js";
|
|
9
10
|
export const AUTH_OPERATION_TIMEOUT_MS = 5_000;
|
|
@@ -191,12 +192,31 @@ class SdkHandle {
|
|
|
191
192
|
}
|
|
192
193
|
catch { /* Liveness reporting is best effort and content-free. */ }
|
|
193
194
|
};
|
|
195
|
+
// Activity is the public SDK stream of this prompt: assistant message start, update and end, and
|
|
196
|
+
// tool execution start, update and end. Only the event discriminant and the message role are
|
|
197
|
+
// inspected: delta text, thinking, tool arguments and partial results are never read, stored or
|
|
198
|
+
// forwarded. Streaming is activity, not evidence of finished work; only the last assistant
|
|
199
|
+
// `message_end` is the answer, and the terminal state comes from the completion paths below.
|
|
194
200
|
const unsubscribe = this.session.subscribe((event) => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
switch (event.type) {
|
|
202
|
+
case "tool_execution_start":
|
|
203
|
+
case "tool_execution_update":
|
|
204
|
+
case "tool_execution_end":
|
|
205
|
+
reportActivity();
|
|
206
|
+
break;
|
|
207
|
+
case "message_start":
|
|
208
|
+
case "message_update":
|
|
209
|
+
if (hasAssistantRole(event.message))
|
|
210
|
+
reportActivity();
|
|
211
|
+
break;
|
|
212
|
+
case "message_end":
|
|
213
|
+
if (isAssistantMessage(event.message)) {
|
|
214
|
+
finalAssistant = event.message;
|
|
215
|
+
reportActivity();
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
default:
|
|
219
|
+
break;
|
|
200
220
|
}
|
|
201
221
|
});
|
|
202
222
|
let preflightSeen = false;
|
|
@@ -263,14 +283,20 @@ function isAssistantMessage(message) {
|
|
|
263
283
|
const candidate = message;
|
|
264
284
|
return candidate.role === "assistant" && Array.isArray(candidate.content);
|
|
265
285
|
}
|
|
286
|
+
/** Role check only: streaming messages are never inspected for content. */
|
|
287
|
+
function hasAssistantRole(message) {
|
|
288
|
+
return typeof message === "object" && message !== null && message.role === "assistant";
|
|
289
|
+
}
|
|
266
290
|
function isTextBlock(block) {
|
|
267
291
|
if (!block || typeof block !== "object")
|
|
268
292
|
return false;
|
|
269
293
|
const candidate = block;
|
|
270
294
|
return candidate.type === "text" && typeof candidate.text === "string";
|
|
271
295
|
}
|
|
272
|
-
/**
|
|
273
|
-
function phase0ResourceLoaderOptions() {
|
|
296
|
+
/** Independently validated skills replace package and extension discovery on every reload. */
|
|
297
|
+
function phase0ResourceLoaderOptions(skills) {
|
|
298
|
+
// Keep the closure's snapshot private: the SDK may annotate or mutate returned objects.
|
|
299
|
+
const selected = skills.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } }));
|
|
274
300
|
return {
|
|
275
301
|
noExtensions: true,
|
|
276
302
|
noSkills: true,
|
|
@@ -281,11 +307,14 @@ function phase0ResourceLoaderOptions() {
|
|
|
281
307
|
appendSystemPrompt: [],
|
|
282
308
|
systemPromptOverride: () => undefined,
|
|
283
309
|
appendSystemPromptOverride: () => [],
|
|
310
|
+
skillsOverride: () => ({ skills: selected.map((skill) => ({ ...skill, sourceInfo: { ...skill.sourceInfo } })), diagnostics: [] }),
|
|
311
|
+
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
|
|
312
|
+
themesOverride: () => ({ themes: [], diagnostics: [] }),
|
|
284
313
|
};
|
|
285
314
|
}
|
|
286
|
-
export async function createPhase0ResourceLoader(cwd) {
|
|
315
|
+
export async function createPhase0ResourceLoader(cwd, skills = []) {
|
|
287
316
|
const settingsManager = SettingsManager.inMemory(undefined, { projectTrusted: false });
|
|
288
|
-
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions() });
|
|
317
|
+
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: cwd, settingsManager, ...phase0ResourceLoaderOptions(skills) });
|
|
289
318
|
await resourceLoader.reload();
|
|
290
319
|
return { resourceLoader, settingsManager };
|
|
291
320
|
}
|
|
@@ -310,6 +339,7 @@ export class SdkPiSessionAdapter {
|
|
|
310
339
|
if (extensions.length > 0)
|
|
311
340
|
return this.createWithExtensions(input, extensions);
|
|
312
341
|
const { executionProfile } = input;
|
|
342
|
+
const skills = await this.loadSkills(input);
|
|
313
343
|
let runtime;
|
|
314
344
|
try {
|
|
315
345
|
runtime = await this.getRuntime();
|
|
@@ -321,7 +351,7 @@ export class SdkPiSessionAdapter {
|
|
|
321
351
|
let resourceLoader;
|
|
322
352
|
let settingsManager;
|
|
323
353
|
try {
|
|
324
|
-
({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd));
|
|
354
|
+
({ resourceLoader, settingsManager } = await createPhase0ResourceLoader(input.cwd, skills));
|
|
325
355
|
}
|
|
326
356
|
catch {
|
|
327
357
|
throw this.failure(executionProfile, "resource_loader", "pi_session_creation_failed");
|
|
@@ -347,10 +377,11 @@ export class SdkPiSessionAdapter {
|
|
|
347
377
|
await mcpTools.close();
|
|
348
378
|
throw this.failure(executionProfile, "create_session", "pi_session_creation_failed");
|
|
349
379
|
}
|
|
350
|
-
return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined);
|
|
380
|
+
return this.admit(session, executionProfile, tools, mcpTools, () => disposeSdkSession(session), mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : undefined, "exact", skills);
|
|
351
381
|
}
|
|
352
382
|
async createWithExtensions(input, extensions) {
|
|
353
383
|
const { cwd, executionProfile } = input;
|
|
384
|
+
const skills = await this.loadSkills(input);
|
|
354
385
|
const directories = [];
|
|
355
386
|
for (const extension of extensions) {
|
|
356
387
|
try {
|
|
@@ -377,15 +408,18 @@ export class SdkPiSessionAdapter {
|
|
|
377
408
|
agentDir: cwd,
|
|
378
409
|
modelRuntime: runtime,
|
|
379
410
|
settingsManager: SettingsManager.inMemory(undefined, { projectTrusted: false }),
|
|
380
|
-
resourceLoaderOptions: { ...phase0ResourceLoaderOptions(), additionalExtensionPaths: directories },
|
|
411
|
+
resourceLoaderOptions: { ...phase0ResourceLoaderOptions(skills), additionalExtensionPaths: directories },
|
|
381
412
|
}));
|
|
413
|
+
this.checkResources(services.resourceLoader, skills, executionProfile, "extension_load");
|
|
382
414
|
const loaded = services.resourceLoader.getExtensions();
|
|
383
415
|
if (loaded.errors.length > 0 || services.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
|
|
384
416
|
throw new Error("extension load reported errors");
|
|
385
417
|
}
|
|
386
418
|
extensionTools = registeredExtensionTools(services);
|
|
387
419
|
}
|
|
388
|
-
catch {
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (error instanceof PiSessionCreationError)
|
|
422
|
+
throw error;
|
|
389
423
|
throw this.failure(executionProfile, "extension_load", "extension_load_failed");
|
|
390
424
|
}
|
|
391
425
|
const model = await this.resolveVerifiedModel(services.modelRuntime, executionProfile);
|
|
@@ -434,6 +468,7 @@ export class SdkPiSessionAdapter {
|
|
|
434
468
|
bound = true;
|
|
435
469
|
if (bindErrors > 0)
|
|
436
470
|
throw new Error("extension hook reported errors while binding");
|
|
471
|
+
this.checkResources(sessionRuntime.session.resourceLoader, skills, executionProfile, "extension_bind");
|
|
437
472
|
// A `session_start` handler may register a tool that replaces a built-in or granted tool.
|
|
438
473
|
// Tools an extension registers later, during a turn, are not re-checked: allowlisted
|
|
439
474
|
// extensions are trusted code.
|
|
@@ -441,15 +476,44 @@ export class SdkPiSessionAdapter {
|
|
|
441
476
|
if (!recheck || recheck.length !== tools.length || recheck.some((name, index) => name !== tools[index]))
|
|
442
477
|
conflict = true;
|
|
443
478
|
}
|
|
444
|
-
catch {
|
|
479
|
+
catch (error) {
|
|
445
480
|
await cleanUp();
|
|
481
|
+
if (error instanceof PiSessionCreationError)
|
|
482
|
+
throw error;
|
|
446
483
|
throw this.failure(executionProfile, "extension_bind", "extension_load_failed");
|
|
447
484
|
}
|
|
448
485
|
if (conflict) {
|
|
449
486
|
await cleanUp();
|
|
450
487
|
throw this.failure(executionProfile, "extension_bind", "extension_tool_conflict");
|
|
451
488
|
}
|
|
452
|
-
return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset");
|
|
489
|
+
return this.admit(sessionRuntime.session, executionProfile, tools, mcpTools, disposeRuntime, mcpTools.tools.length > 0 ? "external_mcp_activation_mismatch" : "execution_selection_mismatch", "subset", skills);
|
|
490
|
+
}
|
|
491
|
+
async loadSkills(input) {
|
|
492
|
+
try {
|
|
493
|
+
return await loadSelectedSkills(input.executionProfile.skills ?? [], input.cwd);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
throw this.failure(input.executionProfile, "skill_load", "skill_load_failed", error instanceof PiSkillError && error.alias !== undefined ? { skill: error.alias } : undefined);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
checkResources(loader, skills, executionProfile, stage) {
|
|
500
|
+
let actual;
|
|
501
|
+
try {
|
|
502
|
+
actual = loader.getSkills().skills;
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
throw this.failure(executionProfile, stage, "skill_load_failed");
|
|
506
|
+
}
|
|
507
|
+
// Compare a multiset so duplicate entries cannot satisfy a selected file twice.
|
|
508
|
+
const identity = (skill) => JSON.stringify([skill.filePath, skill.name]);
|
|
509
|
+
const expected = skills.map(identity).sort();
|
|
510
|
+
const received = actual.map(identity).sort();
|
|
511
|
+
if (expected.length !== received.length || expected.some((entry, index) => entry !== received[index])) {
|
|
512
|
+
throw this.failure(executionProfile, stage, "skill_load_failed");
|
|
513
|
+
}
|
|
514
|
+
if (loader.getPrompts().prompts.length > 0 || loader.getThemes().themes.length > 0) {
|
|
515
|
+
throw this.failure(executionProfile, stage, "pi_session_creation_failed");
|
|
516
|
+
}
|
|
453
517
|
}
|
|
454
518
|
/** Provider, model, authentication and availability, exactly as configured. */
|
|
455
519
|
async resolveVerifiedModel(runtime, executionProfile) {
|
|
@@ -513,8 +577,9 @@ export class SdkPiSessionAdapter {
|
|
|
513
577
|
/** Post-verifies a created session and wraps it; a mismatch disposes it before reporting. */
|
|
514
578
|
async admit(session, executionProfile, tools, mcpTools, disposeSession, toolMismatch,
|
|
515
579
|
// `subset` lets an extension narrow its own active tools; nothing outside the allowlist may be active.
|
|
516
|
-
toolCheck = "exact") {
|
|
580
|
+
toolCheck = "exact", skills = []) {
|
|
517
581
|
try {
|
|
582
|
+
this.checkResources(session.resourceLoader, skills, executionProfile, "postcheck");
|
|
518
583
|
if (session.model?.provider !== executionProfile.provider || session.model.id !== executionProfile.model) {
|
|
519
584
|
throw this.failure(executionProfile, "postcheck", "execution_selection_mismatch");
|
|
520
585
|
}
|
|
@@ -570,6 +635,7 @@ export class SdkPiSessionAdapter {
|
|
|
570
635
|
...(metadata?.toolAlias === undefined ? {} : { toolAlias: metadata.toolAlias }),
|
|
571
636
|
...(metadata?.discoveredToolCount === undefined ? {} : { discoveredToolCount: metadata.discoveredToolCount }),
|
|
572
637
|
...(metadata?.extension === undefined ? {} : { extension: metadata.extension }),
|
|
638
|
+
...(metadata?.skill === undefined ? {} : { skill: metadata.skill }),
|
|
573
639
|
});
|
|
574
640
|
}
|
|
575
641
|
catch {
|
package/dist/server.js
CHANGED
|
@@ -16,10 +16,11 @@ export interface SessionView {
|
|
|
16
16
|
lastError?: string;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* `updatedAt` is the
|
|
20
|
-
*
|
|
21
|
-
* transition.
|
|
22
|
-
*
|
|
19
|
+
* `updatedAt` is the time of the last observed public SDK activity of the turn (assistant
|
|
20
|
+
* message start, update and end; tool execution start, update and end), then freezes at the
|
|
21
|
+
* terminal transition. Streaming is activity, not evidence of finished or useful work, and
|
|
22
|
+
* internal work without a public event stays invisible. It is a liveness lower bound, not a
|
|
23
|
+
* progress count, and does not prove that a silent turn is healthy.
|
|
23
24
|
*/
|
|
24
25
|
export interface TurnView {
|
|
25
26
|
turnId: string;
|
|
@@ -81,8 +82,9 @@ export declare class SessionRegistry {
|
|
|
81
82
|
private completeTurn;
|
|
82
83
|
private failTurn;
|
|
83
84
|
/**
|
|
84
|
-
* Liveness only.
|
|
85
|
-
* never another turn or the session.
|
|
85
|
+
* Liveness only. Observed public SDK activity (assistant message start, update and end; tool
|
|
86
|
+
* execution start, update and end) advances this turn, never another turn or the session.
|
|
87
|
+
* Streaming is activity, not evidence of finished or useful work. Activity after accepted preflight but before
|
|
86
88
|
* publication is stamped and applied only if the turn is published; callbacks before
|
|
87
89
|
* acceptance or after terminal cleanup cannot publish or revive a turn.
|
|
88
90
|
*/
|
package/dist/session-registry.js
CHANGED
|
@@ -273,8 +273,9 @@ export class SessionRegistry {
|
|
|
273
273
|
entry.turn = undefined; if (entry.turnPromise)
|
|
274
274
|
entry.turnPromise = undefined; entry.state = "failed"; entry.updatedAt = turn.updatedAt; entry.lastError = "Pi turn failed"; }
|
|
275
275
|
/**
|
|
276
|
-
* Liveness only.
|
|
277
|
-
* never another turn or the session.
|
|
276
|
+
* Liveness only. Observed public SDK activity (assistant message start, update and end; tool
|
|
277
|
+
* execution start, update and end) advances this turn, never another turn or the session.
|
|
278
|
+
* Streaming is activity, not evidence of finished or useful work. Activity after accepted preflight but before
|
|
278
279
|
* publication is stamped and applied only if the turn is published; callbacks before
|
|
279
280
|
* acceptance or after terminal cleanup cannot publish or revive a turn.
|
|
280
281
|
*/
|
package/dist/setup-cli.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { SetupResult } from "./setup-result.js";
|
|
|
3
3
|
/** Small argv adapter; command execution remains injected in SetupClientAdapter. */
|
|
4
4
|
export interface SetupCliRequest extends SetupRequest {
|
|
5
5
|
readonly json: boolean;
|
|
6
|
+
/** `--customize-skill <name>`: copies a shipped skill into the override directory instead of touching clients. */
|
|
7
|
+
readonly customizeSkill?: string;
|
|
6
8
|
}
|
|
7
9
|
export declare function parseSetupArgs(argv: readonly string[]): SetupCliRequest;
|
|
8
10
|
export declare function runSetupCli(argv: readonly string[], orchestrator: SetupOrchestrator): Promise<SetupResult>;
|
package/dist/setup-cli.js
CHANGED
|
@@ -2,6 +2,7 @@ export function parseSetupArgs(argv) {
|
|
|
2
2
|
let operation;
|
|
3
3
|
let json = false;
|
|
4
4
|
const targets = [];
|
|
5
|
+
let customizeSkill;
|
|
5
6
|
for (let index = 0; index < argv.length; index += 1) {
|
|
6
7
|
const argument = argv[index];
|
|
7
8
|
if (argument === "--json") {
|
|
@@ -24,8 +25,23 @@ export function parseSetupArgs(argv) {
|
|
|
24
25
|
targets.push({ client: parts[0], scope: parts[1], alias: parts[2] });
|
|
25
26
|
continue;
|
|
26
27
|
}
|
|
28
|
+
if (argument === "--customize-skill") {
|
|
29
|
+
if (customizeSkill !== undefined)
|
|
30
|
+
throw new Error("customize_skill_repeated");
|
|
31
|
+
const value = argv[++index];
|
|
32
|
+
if (value === undefined || value === "" || value.startsWith("-"))
|
|
33
|
+
throw new Error("customize_skill_invalid");
|
|
34
|
+
customizeSkill = value;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
27
37
|
throw new Error("argument_unsupported");
|
|
28
38
|
}
|
|
39
|
+
if (customizeSkill !== undefined) {
|
|
40
|
+
// Skill customization is a dry-run or an explicit apply; it never targets a client.
|
|
41
|
+
if (targets.length > 0 || (operation !== undefined && operation !== "dry-run" && operation !== "apply"))
|
|
42
|
+
throw new Error("customize_skill_combination");
|
|
43
|
+
return operation ? { operation, targets, json, customizeSkill } : { targets, json, customizeSkill };
|
|
44
|
+
}
|
|
29
45
|
return operation ? { operation, targets, json } : { targets, json };
|
|
30
46
|
}
|
|
31
47
|
export async function runSetupCli(argv, orchestrator) {
|
package/dist/setup-main.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createProductionSetup } from "./setup.js";
|
|
3
|
-
/** Executable entry point: `node dist/setup-main.js [operation] --target client:scope:alias
|
|
3
|
+
/** Executable entry point: `node dist/setup-main.js [operation] --target client:scope:alias` or `[--apply] --customize-skill <name>`. */
|
|
4
4
|
export interface SetupMainDependencies {
|
|
5
5
|
readonly createSetup?: typeof createProductionSetup;
|
|
6
6
|
readonly packageRoot?: string;
|
package/dist/setup-main.js
CHANGED
|
@@ -5,6 +5,8 @@ import { isEntryPoint } from "./entry-point.js";
|
|
|
5
5
|
import { createProcessRunner } from "./setup-process.js";
|
|
6
6
|
import { currentSetupPlatform } from "./setup-platform.js";
|
|
7
7
|
import { createProductionSetup, isSupportedTarget } from "./setup.js";
|
|
8
|
+
import { customizeSkill } from "./skill-customize.js";
|
|
9
|
+
import { SKILL_NAME_PATTERN } from "./skill-library.js";
|
|
8
10
|
import { formatSetupHuman, formatSetupResult, parseSetupArgs } from "./setup-cli.js";
|
|
9
11
|
import { result } from "./setup-result.js";
|
|
10
12
|
export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
@@ -18,11 +20,21 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
18
20
|
return write(cliFailure("dry-run", [], "usage_invalid", 64), json);
|
|
19
21
|
}
|
|
20
22
|
const operation = request.operation ?? "dry-run";
|
|
23
|
+
const failureTargets = request.customizeSkill === undefined ? request.targets : [skillTarget(request.customizeSkill)];
|
|
21
24
|
if (!currentSetupPlatform())
|
|
22
|
-
return write(cliFailure(operation,
|
|
25
|
+
return write(cliFailure(operation, failureTargets, "platform_unsupported", 1), request.json);
|
|
23
26
|
const config = process.env.PI_SESSION_MCP_CONFIG;
|
|
24
27
|
if (!config)
|
|
25
|
-
return write(cliFailure(operation,
|
|
28
|
+
return write(cliFailure(operation, failureTargets, "config_required", 1), request.json);
|
|
29
|
+
if (request.customizeSkill !== undefined) {
|
|
30
|
+
const name = request.customizeSkill;
|
|
31
|
+
try {
|
|
32
|
+
return write(await customizeSkill({ name, configPath: resolve(config), apply: operation === "apply", ...(dependencies.packageRoot === undefined ? {} : { packageRoot: dependencies.packageRoot }) }), request.json);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return write(cliFailure(operation, [skillTarget(name)], "skill_customize_failed", 1), request.json);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
26
38
|
const root = dependencies.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
39
|
const controller = new AbortController();
|
|
28
40
|
let interrupted;
|
|
@@ -51,8 +63,11 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
51
63
|
process.removeListener("SIGTERM", onTerminate);
|
|
52
64
|
}
|
|
53
65
|
}
|
|
66
|
+
/** The skill name is echoed only when it is a valid name; arbitrary input never reaches a report. */
|
|
67
|
+
function skillTarget(name) { return { client: "skills", scope: "override", alias: SKILL_NAME_PATTERN.test(name) ? name : "invalid" }; }
|
|
68
|
+
function isReportableTarget(target) { return isSupportedTarget(target) || (target.client === "skills" && target.scope === "override" && (target.alias === "invalid" || SKILL_NAME_PATTERN.test(target.alias))); }
|
|
54
69
|
function cliFailure(operation, targets, code, exitCode) {
|
|
55
|
-
const safeTargets = targets.filter(
|
|
70
|
+
const safeTargets = targets.filter(isReportableTarget);
|
|
56
71
|
const reported = safeTargets.length === targets.length && safeTargets.length > 0 ? safeTargets : [{ client: "invalid", scope: "invalid", alias: "invalid" }];
|
|
57
72
|
return result(operation, reported.map((target) => ({ target, status: "failed", code })), "failed", exitCode);
|
|
58
73
|
}
|
|
@@ -6,6 +6,14 @@ export interface OwnershipRecord {
|
|
|
6
6
|
readonly previous: "absent" | "equivalent";
|
|
7
7
|
readonly phase: "pending" | "owned";
|
|
8
8
|
readonly transactionId: string;
|
|
9
|
+
/**
|
|
10
|
+
* Skill records only, while pending: the fingerprint of setup's own copy that the pending
|
|
11
|
+
* update replaces, so an interrupted update can still be completed or removed. The
|
|
12
|
+
* transaction id also names the update's intermediate directories, so recovery needs no path.
|
|
13
|
+
* Skill fingerprints are `v2:` values; a bare 64-hex value is a record that 0.8.0 wrote and
|
|
14
|
+
* names a tree only once a trusted reference proves it.
|
|
15
|
+
*/
|
|
16
|
+
readonly replaces?: string;
|
|
9
17
|
}
|
|
10
18
|
export interface RegistrationSnapshot {
|
|
11
19
|
/** Opaque adapter-owned snapshot; implementations must not persist secrets. */
|
|
@@ -51,3 +59,4 @@ export declare class DurableOwnershipStore implements OwnershipStore {
|
|
|
51
59
|
private withLock;
|
|
52
60
|
private write;
|
|
53
61
|
}
|
|
62
|
+
export declare function isRecord(value: unknown): value is OwnershipRecord;
|
package/dist/setup-ownership.js
CHANGED
|
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { acquireSetupGuardian } from "./setup-process.js";
|
|
6
6
|
import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin } from "./setup-platform.js";
|
|
7
|
+
import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN } from "./skill-library.js";
|
|
7
8
|
/** Safe default for one process; callers needing recovery inject a durable store. */
|
|
8
9
|
export class MemoryOwnershipStore {
|
|
9
10
|
entries = new Map();
|
|
@@ -209,10 +210,36 @@ async function syncDirectory(path) {
|
|
|
209
210
|
await handle.close();
|
|
210
211
|
}
|
|
211
212
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
213
|
+
/** `skill:<name>` for a home slot; `skill:<name>@<16 hex>` for a project slot bound to an opaque project identity. */
|
|
214
|
+
const SKILL_RECORD_ALIAS = /^skill:([^@]+)(?:@([0-9a-f]{16}))?$/;
|
|
215
|
+
export function isRecord(value) {
|
|
216
|
+
if (!value || typeof value !== "object")
|
|
217
|
+
return false;
|
|
218
|
+
const x = value;
|
|
219
|
+
const keys = ["target", "fingerprint", "previous", "phase", "transactionId"];
|
|
220
|
+
if (!exactKeys(x, keys) && !exactKeys(x, [...keys, "replaces"]))
|
|
221
|
+
return false;
|
|
222
|
+
if (!x.target || typeof x.target !== "object")
|
|
223
|
+
return false;
|
|
224
|
+
const target = x.target;
|
|
225
|
+
if (!exactKeys(target, ["client", "scope", "alias"]))
|
|
226
|
+
return false;
|
|
227
|
+
const { client, scope, alias } = target;
|
|
228
|
+
const registration = alias === "pi-session-mcp" &&
|
|
229
|
+
(client === "codex" ? scope === "user" : client === "claude-code" && typeof scope === "string" && ["user", "project", "local"].includes(scope));
|
|
230
|
+
const skillAlias = typeof alias === "string" ? SKILL_RECORD_ALIAS.exec(alias) : null;
|
|
231
|
+
const skill = skillAlias !== null && SKILL_NAME_PATTERN.test(skillAlias[1]) &&
|
|
232
|
+
(client === "codex" ? scope === "user" && skillAlias[2] === undefined
|
|
233
|
+
: client === "claude-code" && ((scope === "user" && skillAlias[2] === undefined) || (scope === "project" && skillAlias[2] !== undefined)));
|
|
234
|
+
// A registration fingerprint is a snapshot hash; a skill fingerprint is a version 2 tree value or a 0.8.0 legacy one.
|
|
235
|
+
const validFingerprint = (value) => typeof value === "string" &&
|
|
236
|
+
(skill ? SKILL_TREE_FINGERPRINT_PATTERN.test(value) || LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) : /^[a-f0-9]{64}$/.test(value));
|
|
237
|
+
// `replaces` belongs only to a pending skill record.
|
|
238
|
+
if ("replaces" in x && !(skill && x.phase === "pending" && validFingerprint(x.replaces)))
|
|
239
|
+
return false;
|
|
240
|
+
return (registration || skill) && validFingerprint(x.fingerprint) &&
|
|
241
|
+
(x.phase === "pending" || x.phase === "owned") && x.previous === "absent" &&
|
|
242
|
+
typeof x.transactionId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(x.transactionId);
|
|
243
|
+
}
|
|
217
244
|
function exactKeys(value, expected) { const keys = Object.keys(value).sort(); return keys.length === expected.length && [...expected].sort().every((key, index) => keys[index] === key); }
|
|
218
245
|
const key = (target) => `${target.client}\u0000${target.scope}\u0000${target.alias}`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ReleasedSkillTree } from "./skill-library.js";
|
|
2
|
+
import type { OwnershipAccess } from "./setup-ownership.js";
|
|
3
|
+
import type { SetupFinding, SetupOperation, SetupTarget } from "./setup-result.js";
|
|
4
|
+
export interface SetupSkillInstaller {
|
|
5
|
+
/** Called under the registration's ownership transaction for every mutation. */
|
|
6
|
+
run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Whether setup still owns an installed skill of exactly this registration target, so that a
|
|
9
|
+
* remove may finish the skill cleanup after the registration is already gone.
|
|
10
|
+
*/
|
|
11
|
+
owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
|
|
12
|
+
}
|
|
13
|
+
export interface SetupSkillRoots {
|
|
14
|
+
readonly packageRoot: string;
|
|
15
|
+
readonly configPath: string;
|
|
16
|
+
readonly home: string;
|
|
17
|
+
readonly cwd: string;
|
|
18
|
+
}
|
|
19
|
+
/** The crash-relevant points of an update, in order. Only a test seam observes them. */
|
|
20
|
+
export type SkillCheckpoint = "staged" | "backup-renamed" | "installed" | "trash-renamed" | "trash-entry-removed" | "trash-deleted" | "before-ownership";
|
|
21
|
+
export interface SetupSkillDependencies {
|
|
22
|
+
/** Trusted released trees that prove 0.8.0 records; defaults to the shipped table. */
|
|
23
|
+
readonly released?: readonly ReleasedSkillTree[];
|
|
24
|
+
/** Test seam, called after each step in `SkillCheckpoint` completed; defaults to a no-op. */
|
|
25
|
+
readonly checkpoint?: (step: SkillCheckpoint) => void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** Paths remain internal. Ownership identifies a client slot, never its location. */
|
|
28
|
+
export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
|
|
29
|
+
private readonly roots;
|
|
30
|
+
private readonly dependencies;
|
|
31
|
+
constructor(roots: SetupSkillRoots, dependencies?: SetupSkillDependencies);
|
|
32
|
+
run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
|
|
33
|
+
owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
|
|
34
|
+
private step;
|
|
35
|
+
/**
|
|
36
|
+
* The slot path and its ownership key. A project may be untrusted: only its root is
|
|
37
|
+
* canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
|
|
38
|
+
* slot divergent, so setup never writes outside the project. In the operator's home the
|
|
39
|
+
* client's skills directory may be the operator's own link, for example into a dotfiles
|
|
40
|
+
* checkout, so it is followed. The skill entry itself is never followed in either place.
|
|
41
|
+
* A project slot's ownership is bound to an opaque, path-free identity of the project, so a
|
|
42
|
+
* remove in another project never touches this project's copy.
|
|
43
|
+
*/
|
|
44
|
+
private location;
|
|
45
|
+
private library;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
|
|
48
|
+
* trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
|
|
49
|
+
*/
|
|
50
|
+
private ownsTree;
|
|
51
|
+
private skill;
|
|
52
|
+
/**
|
|
53
|
+
* Installs the new tree through the transaction's own intermediates. A failure leaves them for
|
|
54
|
+
* recovery; nothing here deletes by guess.
|
|
55
|
+
*/
|
|
56
|
+
private replace;
|
|
57
|
+
/**
|
|
58
|
+
* Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
|
|
59
|
+
* transaction's trash name first, so the deletion never leaves an unverifiable partial tree
|
|
60
|
+
* under the backup name.
|
|
61
|
+
*/
|
|
62
|
+
private discardBackup;
|
|
63
|
+
/** Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree. */
|
|
64
|
+
private discardTrash;
|
|
65
|
+
/**
|
|
66
|
+
* Settles an interrupted update of the pending record's transaction, inside the ownership
|
|
67
|
+
* transaction. Only directories with this transaction's exact names are examined, and only
|
|
68
|
+
* verified ones are deleted: the backup must equal the replaced copy, the staging directory
|
|
69
|
+
* the target or an interrupted write of it. Anything else stays and is reported.
|
|
70
|
+
*
|
|
71
|
+
* apply, slot absent: a complete staging directory is renamed into the slot (forward);
|
|
72
|
+
* otherwise a verified backup is moved back. remove and rollback do not restore, they delete
|
|
73
|
+
* what is verified and leave the slot to the caller.
|
|
74
|
+
*/
|
|
75
|
+
private recover;
|
|
76
|
+
}
|