pi-pstack-adapter 0.3.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/index.ts ADDED
@@ -0,0 +1,472 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { platform } from "node:os";
3
+ import { DEFAULT_NAMESPACE } from "./src/namespace.ts";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Type } from "typebox";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { registerDelegationActivation } from "./src/activation.ts";
9
+ import { registerAgents, type RuntimeRegistration } from "./src/agents.ts";
10
+ import { checkPrerequisites, detectCapabilities, type ToolDescriptor } from "./src/capabilities.ts";
11
+ import {
12
+ type AdapterConfig,
13
+ type ConfigFile,
14
+ type ConfigLayer,
15
+ environmentLayer,
16
+ mergeConfig,
17
+ readConfigFile,
18
+ trustWarning,
19
+ updateConfigFile,
20
+ } from "./src/config.ts";
21
+ import { generate } from "./src/generate.ts";
22
+ import { MODE_ENTRY_TYPE, modeFromEntries, modeInstruction } from "./src/mode.ts";
23
+ import { CONFIG_FILE_NAME, agentDir, cacheRoot } from "./src/paths.ts";
24
+ import { bashToolContext, probeExecutables } from "./src/executables.ts";
25
+ import { entryFor } from "./src/registry.ts";
26
+ import { platformSupport, renderCheck, renderStatus, shellDiagnostics, type ShellState } from "./src/status.ts";
27
+ import { BootstrapRequiredError, resolveRefToCommit, resolveSource } from "./src/upstream.ts";
28
+ import { normalizeRepo } from "./src/config.ts";
29
+ import type { CapabilityStatus, Diagnostic, GenerationResult, ResolvedSource } from "./src/types.ts";
30
+
31
+ const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url));
32
+ const ADAPTER_VERSION = readAdapterVersion();
33
+
34
+ interface AdapterState {
35
+ readonly config: AdapterConfig;
36
+ readonly source: ResolvedSource;
37
+ readonly generation: GenerationResult;
38
+ readonly upstreamVersion: string;
39
+ readonly cacheRoot: string;
40
+ readonly bootstrapped: boolean;
41
+ }
42
+
43
+ export default function (pi: ExtensionAPI) {
44
+ let state: AdapterState | undefined;
45
+ let startupError: Error | undefined;
46
+ let registrations: readonly RuntimeRegistration[] = [];
47
+ let agentDiagnostics: readonly Diagnostic[] = [];
48
+ let modeActive = false;
49
+ const commandNamespace = startupNamespace();
50
+
51
+ pi.on("session_start", async (_event, ctx) => {
52
+ disposeAgents();
53
+ state = undefined;
54
+ startupError = undefined;
55
+ modeActive = modeFromEntries(ctx.sessionManager.getBranch() as never[]);
56
+
57
+ const config = loadConfig(ctx);
58
+ try {
59
+ state = await bringUpUpstream(config, ctx);
60
+ } catch (error) {
61
+ startupError = error as Error;
62
+ if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
63
+ return;
64
+ }
65
+
66
+ const warning = trustWarning(state.source.trust, state.config);
67
+ if (warning && ctx.hasUI) ctx.ui.notify(`pstack adapter: ${warning}`, "warning");
68
+ if (platformSupport(platform()) === "experimental" && ctx.hasUI) {
69
+ ctx.ui.notify(
70
+ `pstack adapter: ${platform()} is experimental. Upstream pstack skills expect POSIX tools that may be missing.`,
71
+ "warning",
72
+ );
73
+ }
74
+ for (const diagnostic of shellDiagnostics(shellState(ctx, pi.getActiveTools()))) {
75
+ if (ctx.hasUI && diagnostic.level === "error") ctx.ui.notify(`pstack adapter: ${diagnostic.message}`, "error");
76
+ }
77
+
78
+ const result = registerAgents(pi.events, state.generation.agents, state.config.namespace);
79
+ registrations = result.registrations;
80
+ const diagnostics: Diagnostic[] = [...result.diagnostics];
81
+ if (result.registrations.length > 0) {
82
+ diagnostics.push({
83
+ level: "info",
84
+ resource: "agents",
85
+ message: `${result.registrations.length} pstack agents registered with the installed delegation provider.`,
86
+ });
87
+ }
88
+ agentDiagnostics = diagnostics;
89
+
90
+ for (const diagnostic of result.diagnostics) {
91
+ if (ctx.hasUI && diagnostic.level === "error") ctx.ui.notify(`pstack adapter: ${diagnostic.message}`, "error");
92
+ }
93
+ });
94
+
95
+ pi.on("resources_discover", async () => {
96
+ if (!state) return {};
97
+ return { skillPaths: [state.generation.skillsDir] };
98
+ });
99
+
100
+ registerDelegationActivation(pi, {
101
+ skills: () => state?.generation.skills ?? [],
102
+ modeActive: () => modeActive && state !== undefined,
103
+ });
104
+
105
+ pi.on("before_agent_start", async (event) => {
106
+ if (!modeActive || !state) return;
107
+ const skillPath = join(state.generation.skillsDir, `${state.config.namespace}-poteto-mode`, "SKILL.md");
108
+ return { systemPrompt: `${event.systemPrompt}\n\n${modeInstruction(state.config.namespace, skillPath)}` };
109
+ });
110
+
111
+ pi.on("session_shutdown", async () => {
112
+ disposeAgents();
113
+ });
114
+
115
+ pi.registerTool({
116
+ name: "pstack_adapter",
117
+ label: "pstack adapter",
118
+ description:
119
+ "Inspect and configure the pstack adapter. Use `status` for the full report, `check` before starting a pstack workflow, `models` to list real Pi model ids, `set_model` to assign a pstack role, and `set_source` to select another upstream revision.",
120
+ parameters: Type.Object({
121
+ action: Type.Union(
122
+ [
123
+ Type.Literal("status"),
124
+ Type.Literal("check"),
125
+ Type.Literal("models"),
126
+ Type.Literal("set_model"),
127
+ Type.Literal("set_source"),
128
+ ],
129
+ { description: "Operation to run" },
130
+ ),
131
+ skill: Type.Optional(Type.String({ description: "Skill name for `check`, with or without the namespace prefix" })),
132
+ role: Type.Optional(Type.String({ description: "pstack model role for `set_model`" })),
133
+ model: Type.Optional(Type.String({ description: "`provider/model` id, or `inherit` to clear the role" })),
134
+ repo: Type.Optional(Type.String({ description: "Upstream repository URL for `set_source`" })),
135
+ ref: Type.Optional(Type.String({ description: "Branch, tag, or commit for `set_source`" })),
136
+ localPath: Type.Optional(Type.String({ description: "Local pstack checkout for `set_source`" })),
137
+ scope: Type.Optional(
138
+ Type.Union([Type.Literal("user"), Type.Literal("project")], {
139
+ description: "Config file to write. Defaults to user.",
140
+ }),
141
+ ),
142
+ }),
143
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
144
+ const text = await runAction(params, ctx);
145
+ return { content: [{ type: "text", text }], details: {} };
146
+ },
147
+ });
148
+
149
+ registerCommands(commandNamespace);
150
+
151
+ function registerCommands(namespace: string): void {
152
+ pi.registerCommand(`${namespace}-status`, {
153
+ description: "Show the pstack adapter status report",
154
+ handler: async (_args, ctx) => {
155
+ pi.sendMessage({ customType: "pistack-status", content: await runAction({ action: "status" }, ctx), display: true });
156
+ },
157
+ });
158
+ pi.registerCommand(`${namespace}-check`, {
159
+ description: "Report whether this environment satisfies one pstack workflow's prerequisites",
160
+ getArgumentCompletions: (prefix) => {
161
+ const names = (state?.generation.skills ?? []).map((skill) => skill.generatedName);
162
+ const matches = names.filter((name) => name.startsWith(prefix)).map((value) => ({ value, label: value }));
163
+ return matches.length > 0 ? matches : null;
164
+ },
165
+ handler: async (args, ctx) => {
166
+ pi.sendMessage({ customType: "pistack-check", content: checkSkill(args.trim(), ctx), display: true });
167
+ if (args.trim() === "") ctx.ui.notify(`Usage: /${namespace}-check <skill>`, "info");
168
+ },
169
+ });
170
+ pi.registerCommand(`${namespace}-mode`, {
171
+ description: "Turn Poteto Mode on or off for this session, or show its state",
172
+ getArgumentCompletions: (prefix) =>
173
+ ["on", "off", "status"].filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
174
+ handler: async (args, ctx) => {
175
+ const requested = args.trim().toLowerCase();
176
+ if (requested === "on" || requested === "off") {
177
+ modeActive = requested === "on";
178
+ pi.appendEntry(MODE_ENTRY_TYPE, { active: modeActive });
179
+ } else if (requested !== "" && requested !== "status") {
180
+ ctx.ui.notify(`Usage: /${namespace}-mode on|off|status`, "error");
181
+ return;
182
+ }
183
+ ctx.ui.notify(`Poteto Mode is ${modeActive ? "active for this session" : "off"}.`, "info");
184
+ },
185
+ });
186
+ }
187
+
188
+ async function runAction(
189
+ params: {
190
+ action: string;
191
+ skill?: string;
192
+ role?: string;
193
+ model?: string;
194
+ repo?: string;
195
+ ref?: string;
196
+ localPath?: string;
197
+ scope?: "user" | "project";
198
+ },
199
+ ctx: ExtensionContext,
200
+ ): Promise<string> {
201
+ if (startupError) return `The pstack adapter did not start: ${startupError.message}`;
202
+ if (!state) return "The pstack adapter has not resolved its upstream checkout yet.";
203
+
204
+ switch (params.action) {
205
+ case "status":
206
+ return renderStatus({
207
+ adapterVersion: ADAPTER_VERSION,
208
+ config: state.config,
209
+ source: state.source,
210
+ upstreamVersion: state.upstreamVersion,
211
+ generation: state.generation,
212
+ capabilities: capabilities(),
213
+ platform: platformSupport(platform()),
214
+ platformName: platform(),
215
+ modeActive,
216
+ cacheRoot: state.cacheRoot,
217
+ bootstrapped: state.bootstrapped,
218
+ runtimeDiagnostics: [...agentDiagnostics, ...shellDiagnostics(probedShellState(ctx, pi.getActiveTools()))],
219
+ });
220
+ case "check":
221
+ return checkSkill(params.skill ?? "", ctx);
222
+ case "models":
223
+ return renderModels(ctx);
224
+ case "set_model":
225
+ return setModel(params.role, params.model, params.scope ?? "user", ctx);
226
+ case "set_source":
227
+ return setSource(params, params.scope ?? "user", ctx);
228
+ default:
229
+ return `Unknown action ${params.action}.`;
230
+ }
231
+ }
232
+
233
+ function capabilities(): CapabilityStatus[] {
234
+ return detectCapabilities(pi.getAllTools() as unknown as ToolDescriptor[]);
235
+ }
236
+
237
+ function checkSkill(requested: string, ctx: ExtensionContext): string {
238
+ if (!state) return "The pstack adapter has not resolved its upstream checkout yet.";
239
+ const prefix = `${state.config.namespace}-`;
240
+ const bare = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
241
+ const skill = state.generation.skills.find(
242
+ (candidate) => candidate.generatedName === requested || candidate.generatedName === `${prefix}${bare}`,
243
+ );
244
+ if (!skill) {
245
+ return `No pstack skill named ${JSON.stringify(requested)}. Run the adapter tool with action "status" for the full list.`;
246
+ }
247
+ const resolved = shellState(ctx, pi.getActiveTools());
248
+ // An inactive bash tool or an unresolved shell is already a blocker, so do not spawn anything.
249
+ const usable = resolved.bashToolActive && resolved.shellError === undefined;
250
+ const executables = probeExecutables(skill.executables, usable ? () => sessionBashContext(ctx) : undefined);
251
+ const shell: ShellState = { ...resolved, runError: executables.shellFailure };
252
+ const shellProblems = shellDiagnostics(shell).map((diagnostic) => `${diagnostic.message} ${diagnostic.action ?? ""}`.trim());
253
+ return renderCheck(
254
+ skill,
255
+ entryFor(skill.upstreamName, skill.generatedName.slice(prefix.length)),
256
+ checkPrerequisites(skill.capabilities, capabilities(), skill.executables, executables.has, shellProblems),
257
+ );
258
+ }
259
+
260
+ function renderModels(ctx: ExtensionContext): string {
261
+ const scoped = ctx.scopedModels ?? [];
262
+ const models = scoped.length > 0 ? scoped.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
263
+ const ids = models.map((model) => `${model.provider}/${model.id}`).sort();
264
+ if (ids.length === 0) return "No models are available in this Pi installation.";
265
+ return `Available model ids.\n\n${ids.map((id) => `- \`${id}\``).join("\n")}`;
266
+ }
267
+
268
+ function setModel(role: string | undefined, model: string | undefined, scope: "user" | "project", ctx: ExtensionContext): string {
269
+ if (!role) return "set_model needs a `role`.";
270
+ if (!model) return "set_model needs a `model`, or `inherit` to clear the role.";
271
+ const clearing = model.trim().toLowerCase() === "inherit";
272
+ if (!clearing && !ctx.modelRegistry.getAvailable().some((candidate) => `${candidate.provider}/${candidate.id}` === model)) {
273
+ return `No model \`${model}\` is available in this Pi installation. Call this tool with action "models" for real ids, or pass "inherit".`;
274
+ }
275
+ const path = configPath(scope, ctx);
276
+ const current = readConfigFile(path)?.values.models ?? {};
277
+ const models = { ...current };
278
+ if (clearing) delete models[role];
279
+ else models[role] = model;
280
+ updateConfigFile(path, { models });
281
+ return clearing
282
+ ? `Role \`${role}\` now inherits the parent session model. Written to ${path}.`
283
+ : `Role \`${role}\` now uses \`${model}\`. Written to ${path}.`;
284
+ }
285
+
286
+ async function setSource(
287
+ params: { repo?: string; ref?: string; localPath?: string },
288
+ scope: "user" | "project",
289
+ ctx: ExtensionContext,
290
+ ): Promise<string> {
291
+ const path = configPath(scope, ctx);
292
+ const scopeNote =
293
+ scope === "project" && !ctx.isProjectTrusted()
294
+ ? " This project is not trusted, so Pi will ignore the file until you trust it."
295
+ : "";
296
+
297
+ if (params.localPath) {
298
+ const target = params.localPath;
299
+ if (!(await approveSource(ctx, `Use the local pstack checkout ${target}? Every skill it contains becomes a Pi skill, unverified.`))) {
300
+ return "Upstream source unchanged.";
301
+ }
302
+ updateConfigFile(path, { localPath: target, pinnedCommit: undefined });
303
+ return `Upstream source is now the local checkout ${target}, written to ${path}.${scopeNote} Its contents are unverified. Restart Pi or run /reload to regenerate.`;
304
+ }
305
+
306
+ const repo = params.repo ?? state?.config.repo;
307
+ if (!repo) return "set_source needs a `repo`, a `ref`, or a `localPath`.";
308
+ if (!params.ref) return "set_source needs a `ref` when selecting a repository.";
309
+ const changingRepo = normalizeRepo(repo) !== normalizeRepo(state?.config.repo ?? repo);
310
+ const question = changingRepo
311
+ ? `Fetch pstack from ${repo}@${params.ref}? This repository is outside the tested trust boundary, and its skills run with your full permissions.`
312
+ : `Fetch pstack from ${repo}@${params.ref}? This revision is untested by this adapter release.`;
313
+ if (!(await approveSource(ctx, question))) return "Upstream source unchanged.";
314
+
315
+ const commit = await resolveRefToCommit(repo, params.ref);
316
+ updateConfigFile(path, { repo, ref: params.ref, pinnedCommit: commit, localPath: undefined });
317
+ return `Upstream source is now ${repo}@${params.ref}, recorded as commit ${commit} in ${path}.${scopeNote} Restart Pi or run /reload to regenerate.`;
318
+ }
319
+
320
+ /**
321
+ * Changing the upstream source decides which third-party content becomes Pi skills and
322
+ * where the adapter reaches on the network. The model may propose it; only a person may
323
+ * approve it, because an untrusted upstream body can ask the model to propose anything.
324
+ */
325
+ async function approveSource(ctx: ExtensionContext, question: string): Promise<boolean> {
326
+ if (!ctx.hasUI) return false;
327
+ return ctx.ui.confirm("Change the pstack source?", question);
328
+ }
329
+
330
+ function disposeAgents(): void {
331
+ for (const registration of registrations) {
332
+ try {
333
+ registration.dispose();
334
+ } catch {
335
+ // A provider that already tore down its registry is not an adapter problem.
336
+ }
337
+ }
338
+ registrations = [];
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Command names are fixed at load time, before any project trust decision, so a project
344
+ * cannot rename another project's commands. Everything else honors the project layer.
345
+ */
346
+ function startupNamespace(): string {
347
+ try {
348
+ const layers: ConfigLayer[] = [];
349
+ const global = readConfigFile(userConfigPath());
350
+ if (global) layers.push(global);
351
+ layers.push(environmentLayer(process.env));
352
+ return mergeConfig(layers).namespace;
353
+ } catch {
354
+ // A broken config must not take the diagnostics commands down with it. session_start
355
+ // reloads the same config inside a try/catch and reports the real error there.
356
+ return DEFAULT_NAMESPACE;
357
+ }
358
+ }
359
+
360
+ function loadConfig(ctx: ExtensionContext): AdapterConfig {
361
+ const layers: ConfigLayer[] = [];
362
+ const global = readConfigFile(userConfigPath());
363
+ if (global) layers.push(global);
364
+ if (ctx.isProjectTrusted()) {
365
+ const project = readConfigFile(projectConfigPath(ctx.cwd));
366
+ // Command names are bound before trust resolves, so a project cannot move the namespace
367
+ // without desynchronizing `/<ns>-status` from the generated `<ns>-*` skills.
368
+ if (project) layers.push({ ...project, values: { ...project.values, namespace: undefined } });
369
+ }
370
+ layers.push(environmentLayer(process.env));
371
+ return mergeConfig(layers);
372
+ }
373
+
374
+ function configPath(scope: "user" | "project", ctx: ExtensionContext): string {
375
+ return scope === "project" ? projectConfigPath(ctx.cwd) : userConfigPath();
376
+ }
377
+
378
+ function userConfigPath(): string {
379
+ return join(agentDir(), CONFIG_FILE_NAME);
380
+ }
381
+
382
+ function projectConfigPath(cwd: string): string {
383
+ return join(cwd, ".pi", CONFIG_FILE_NAME);
384
+ }
385
+
386
+ async function bringUpUpstream(config: AdapterConfig, ctx: ExtensionContext): Promise<AdapterState> {
387
+ const root = cacheRoot(config.cacheDir);
388
+ let bootstrapped = false;
389
+ let source: ResolvedSource;
390
+ try {
391
+ // Probe without permission first, so the report can tell a download apart from a reuse.
392
+ source = await resolveSource({ config, mayBootstrap: false });
393
+ } catch (error) {
394
+ if (!(error instanceof BootstrapRequiredError)) throw error;
395
+ if (config.allowBootstrap) {
396
+ return buildState(config, await resolveSource({ config, mayBootstrap: true }), root, true);
397
+ }
398
+ if (!ctx.hasUI) throw error;
399
+ const approved = await ctx.ui.confirm(
400
+ "Download pstack?",
401
+ `The pstack adapter needs ${config.repo} at ${config.pinnedCommit ?? config.ref}. Download it into ${root} now?`,
402
+ );
403
+ if (!approved) throw new Error("Bootstrap declined. No pstack skills are registered.");
404
+ source = await resolveSource({ config, mayBootstrap: true });
405
+ bootstrapped = true;
406
+ }
407
+ return buildState(config, source, root, bootstrapped);
408
+ }
409
+
410
+ function buildState(
411
+ config: AdapterConfig,
412
+ source: ResolvedSource,
413
+ root: string,
414
+ bootstrapped: boolean,
415
+ ): AdapterState {
416
+ const generation = generate({
417
+ source,
418
+ namespace: config.namespace,
419
+ cacheRoot: root,
420
+ adapterVersion: ADAPTER_VERSION,
421
+ // A local checkout has uncommitted edits the digest cannot see, so never reuse its output.
422
+ force: source.selection.kind === "local",
423
+ });
424
+
425
+ return {
426
+ config,
427
+ source,
428
+ generation,
429
+ upstreamVersion: upstreamVersion(source.pluginDir),
430
+ cacheRoot: root,
431
+ bootstrapped,
432
+ };
433
+ }
434
+
435
+ function sessionBashContext(ctx: ExtensionContext) {
436
+ return bashToolContext(ctx.cwd, agentDir(), ctx.isProjectTrusted());
437
+ }
438
+
439
+ function shellState(ctx: ExtensionContext, activeTools: readonly string[]): ShellState {
440
+ let shellError: string | undefined;
441
+ try {
442
+ sessionBashContext(ctx);
443
+ } catch (error) {
444
+ shellError = (error as Error).message;
445
+ }
446
+ return { shellError, bashToolActive: activeTools.includes("bash") };
447
+ }
448
+
449
+ /** `shellState` plus one real command, for reports that can afford to start the shell. */
450
+ function probedShellState(ctx: ExtensionContext, activeTools: readonly string[]): ShellState {
451
+ const resolved = shellState(ctx, activeTools);
452
+ if (!resolved.bashToolActive || resolved.shellError !== undefined) return resolved;
453
+ return { ...resolved, runError: probeExecutables([], () => sessionBashContext(ctx)).shellFailure };
454
+ }
455
+
456
+ function upstreamVersion(pluginDir: string): string {
457
+ try {
458
+ const raw = JSON.parse(readFileSync(join(pluginDir, ".cursor-plugin", "plugin.json"), "utf8")) as { version?: string };
459
+ return raw.version ?? "unknown";
460
+ } catch {
461
+ return "unknown";
462
+ }
463
+ }
464
+
465
+ function readAdapterVersion(): string {
466
+ try {
467
+ const raw = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string };
468
+ return raw.version ?? "0.0.0";
469
+ } catch {
470
+ return "0.0.0";
471
+ }
472
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "pi-pstack-adapter",
3
+ "version": "0.3.0",
4
+ "description": "Run Cursor's official pstack plugin inside Pi from an unmodified upstream checkout",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Chris 'Freddy' Getsfred (https://github.com/elderfo)",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "pi-extension",
13
+ "pstack",
14
+ "cursor",
15
+ "skills",
16
+ "adapter"
17
+ ],
18
+ "homepage": "https://github.com/elderfo/pi-pstack-adapter#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/elderfo/pi-pstack-adapter/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/elderfo/pi-pstack-adapter.git"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ],
33
+ "skills": []
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ },
43
+ "typebox": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "scripts": {
48
+ "check": "tsc --noEmit",
49
+ "test": "node --experimental-strip-types --test test/*.test.ts",
50
+ "test:isolated": "bash test/isolated-install.sh"
51
+ },
52
+ "devDependencies": {
53
+ "@earendil-works/pi-coding-agent": "^0.86.1",
54
+ "@types/node": "^24.0.0",
55
+ "typebox": "^1.3.34",
56
+ "typescript": "^5.7.0"
57
+ },
58
+ "files": [
59
+ "index.ts",
60
+ "src",
61
+ "adapter-skills",
62
+ "README.md",
63
+ "LICENSE",
64
+ "docs",
65
+ "VERSION",
66
+ "CHANGELOG.md"
67
+ ],
68
+ "dependencies": {
69
+ "yaml": "^2.8.3"
70
+ }
71
+ }
@@ -0,0 +1,144 @@
1
+ import { homedir } from "node:os";
2
+ import { posix, win32 } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type { GeneratedSkill } from "./types.ts";
6
+
7
+ /** The pi-subagents tool a delegation skill calls. */
8
+ export const DELEGATION_TOOL = "subagent";
9
+ /** The pi-subagents loader that exposes a registered but inactive `subagent` tool. */
10
+ export const DELEGATION_LOADER = "subagents_enable";
11
+
12
+ /** The slice of Pi's tool registry that activation reads and writes. */
13
+ export interface ToolLoadout {
14
+ getAllTools(): readonly { readonly name: string }[];
15
+ getActiveTools(): string[];
16
+ setActiveTools(toolNames: string[]): void;
17
+ }
18
+
19
+ export type ActivationResult =
20
+ | { readonly kind: "already-active" }
21
+ | { readonly kind: "activated" }
22
+ | { readonly kind: "unavailable" };
23
+
24
+ export function needsDelegation(skill: GeneratedSkill): boolean {
25
+ return skill.capabilities.includes("delegation");
26
+ }
27
+
28
+ /**
29
+ * Returns the generated skill a `/skill:<name>` prompt starts, if it is one of ours. The name
30
+ * ends at the first space, as in Pi's own skill expansion.
31
+ */
32
+ export function skillFromPrompt(text: string, skills: readonly GeneratedSkill[]): GeneratedSkill | undefined {
33
+ if (!text.startsWith("/skill:")) return undefined;
34
+ const rest = text.slice("/skill:".length);
35
+ const end = rest.indexOf(" ");
36
+ const name = end < 0 ? rest : rest.slice(0, end);
37
+ return skills.find((skill) => skill.generatedName === name);
38
+ }
39
+
40
+ /** Returns the generated skill whose SKILL.md a `read` tool call opens, if it is one of ours. */
41
+ export function skillFromReadPath(
42
+ path: string,
43
+ cwd: string,
44
+ skills: readonly GeneratedSkill[],
45
+ platform: NodeJS.Platform = process.platform,
46
+ ): GeneratedSkill | undefined {
47
+ const paths = platform === "win32" ? win32 : posix;
48
+ // Windows file systems are case-insensitive, so `c:\x` and `C:\X` name the same SKILL.md.
49
+ const key = (value: string) => (platform === "win32" ? value.toLowerCase() : value);
50
+ const resolved = resolveToolPath(path, cwd, platform);
51
+ if (resolved === undefined) return undefined;
52
+ const target = key(resolved);
53
+ return skills.find((skill) => key(paths.resolve(skill.path, "SKILL.md")) === target);
54
+ }
55
+
56
+ /**
57
+ * Adds `subagent` to the active tools, the same selection `subagents_enable` makes, so a
58
+ * delegation skill never depends on the model remembering to call the loader first.
59
+ * Pi applies the new loadout on the next model request.
60
+ */
61
+ export function activateDelegation(loadout: ToolLoadout): ActivationResult {
62
+ if (!loadout.getAllTools().some((tool) => tool.name === DELEGATION_TOOL)) return { kind: "unavailable" };
63
+ const active = loadout.getActiveTools();
64
+ if (active.includes(DELEGATION_TOOL)) return { kind: "already-active" };
65
+ loadout.setActiveTools([...active, DELEGATION_TOOL]);
66
+ return { kind: "activated" };
67
+ }
68
+
69
+ export interface ActivationSources {
70
+ /** Generated skills, or none while the adapter has no upstream checkout. */
71
+ readonly skills: () => readonly GeneratedSkill[];
72
+ readonly modeActive: () => boolean;
73
+ }
74
+
75
+ type ActivationHost = Pick<ExtensionAPI, "on" | "getAllTools" | "getActiveTools" | "setActiveTools">;
76
+
77
+ /**
78
+ * A delegation skill must not depend on the model remembering to call `subagents_enable`,
79
+ * so a slash command, a model-initiated SKILL.md read, and Poteto Mode each activate
80
+ * `subagent` in code.
81
+ */
82
+ export function registerDelegationActivation(pi: ActivationHost, sources: ActivationSources): void {
83
+ // `input` runs before Pi copies the tool list for the prompt, so the first model request
84
+ // already carries `subagent`. It also covers messages queued while the agent is streaming.
85
+ pi.on("input", async (event) => {
86
+ const skill = skillFromPrompt(event.text, sources.skills());
87
+ if (sources.modeActive() || (skill && needsDelegation(skill))) activateDelegation(pi);
88
+ });
89
+
90
+ pi.on("tool_call", async (event, ctx) => {
91
+ if (event.toolName !== "read") return;
92
+ const path = (event.input as { path?: unknown }).path;
93
+ if (typeof path !== "string") return;
94
+ const skill = skillFromReadPath(path, ctx.cwd, sources.skills());
95
+ if (skill && needsDelegation(skill)) activateDelegation(pi);
96
+ });
97
+
98
+ // Backstop for prompts that reach the agent without an `input` event.
99
+ pi.on("before_agent_start", async () => {
100
+ if (sources.modeActive()) activateDelegation(pi);
101
+ });
102
+ }
103
+
104
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
105
+
106
+ /**
107
+ * Mirrors `resolveToCwd` in Pi 0.86 through 1.0 (`utils/paths.js`), which the `read` tool uses: Unicode
108
+ * spaces, the `@` prefix, Git Bash drive paths on Windows, `~`, and `file://` URLs. Pi does not
109
+ * export it, so a change there needs a matching change here. Other forms simply do not match.
110
+ */
111
+ function resolveToolPath(path: string, cwd: string, platform: NodeJS.Platform): string | undefined {
112
+ const paths = platform === "win32" ? win32 : posix;
113
+ const value = normalizeToolPath(path.replace(UNICODE_SPACES, " ").replace(/^@/, ""), platform);
114
+ // Pi normalizes the base the same way, without the space and `@` handling.
115
+ const base = normalizeToolPath(cwd, platform);
116
+ if (value === undefined || base === undefined) return undefined;
117
+ return paths.isAbsolute(value) ? paths.resolve(value) : paths.resolve(base, value);
118
+ }
119
+
120
+ /** Pi's `normalizePath`: Git Bash drive paths on Windows, `~`, and `file://` URLs. */
121
+ function normalizeToolPath(input: string, platform: NodeJS.Platform): string | undefined {
122
+ const paths = platform === "win32" ? win32 : posix;
123
+ const value = platform === "win32" ? windowsShellPath(input) : input;
124
+ if (value === "~") return homedir();
125
+ if (value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))) {
126
+ return paths.join(homedir(), value.slice(2));
127
+ }
128
+ if (value.startsWith("file://")) {
129
+ try {
130
+ return fileURLToPath(value);
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /** Pi's `normalizeWindowsShellPath`: `/c/x`, `/mnt/c/x`, and `/cygdrive/c/x` become `C:\x`. */
139
+ function windowsShellPath(value: string): string {
140
+ if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return value;
141
+ const match = value.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
142
+ if (!match) return value;
143
+ return `${match[1]!.toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
144
+ }