pi-pstack-adapter 0.3.0 → 0.5.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/src/status.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { CERTIFIED } from "./certified.ts";
2
2
  import { CAPABILITY_LABELS, MODEL_ROLES, TIER_ORDER } from "./registry.ts";
3
3
  import type { AdapterConfig } from "./config.ts";
4
- import { trustWarning } from "./config.ts";
4
+ import { splitModelSpec, trustWarning } from "./config.ts";
5
+ import { DELEGATION_BACKENDS, delegationOrder } from "./delegation.ts";
5
6
  import type { PrerequisiteReport } from "./capabilities.ts";
6
7
  import type {
7
8
  CapabilityStatus,
9
+ DelegationProvider,
8
10
  Diagnostic,
9
11
  GeneratedSkill,
10
12
  GenerationResult,
@@ -46,13 +48,18 @@ export function renderStatus(input: StatusInput): string {
46
48
  lines.push(`| certified commit | ${CERTIFIED.commit} |`);
47
49
  lines.push(`| trust | ${source.trust} |`);
48
50
  lines.push(`| checkout | ${source.checkoutDir} |`);
51
+ lines.push(`| plugin directory | ${source.pluginDir} |`);
49
52
  lines.push(`| generated resources | ${input.generation.outDir} |`);
53
+ lines.push(`| generated skills | ${input.generation.skillsDir} |`);
54
+ lines.push(`| agent prompts | ${input.generation.agentPromptsDir} |`);
50
55
  lines.push(`| cache root | ${input.cacheRoot} |`);
51
56
  lines.push(`| cache state | ${input.bootstrapped ? "downloaded this session" : "reused, no network needed"} |`);
52
57
  lines.push(`| namespace | ${config.namespace} |`);
53
58
  lines.push(`| config layers | ${config.sources.length > 0 ? config.sources.join(", ") : "built-in defaults"} |`);
54
59
  lines.push(`| platform | ${input.platformName} (${input.platform}) |`);
55
60
  lines.push(`| Poteto Mode | ${input.modeActive ? "active" : "off"} |`);
61
+ lines.push(`| delegation order | ${delegationOrder(config.delegation).map((backend) => backend.id).join(", then ")} |`);
62
+ lines.push(`| delegation | ${describeDelegation(input.capabilities)} |`);
56
63
  lines.push("");
57
64
 
58
65
  const warning = trustWarning(source.trust, config);
@@ -81,14 +88,17 @@ export function renderStatus(input: StatusInput): string {
81
88
 
82
89
  lines.push(`## Model roles`);
83
90
  lines.push("");
84
- lines.push(`| role | model |`);
85
- lines.push(`| --- | --- |`);
86
- for (const role of MODEL_ROLES) {
87
- lines.push(`| ${role} | ${config.models[role] ?? "inherit (parent session model)"} |`);
88
- }
91
+ lines.push(`| role | model | thinking |`);
92
+ lines.push(`| --- | --- | --- |`);
89
93
  const extra = Object.keys(config.models).filter((role) => !MODEL_ROLES.includes(role)).sort();
90
- for (const role of extra) {
91
- lines.push(`| ${role} | ${config.models[role]} |`);
94
+ for (const role of [...MODEL_ROLES, ...extra]) {
95
+ const configured = config.models[role];
96
+ if (configured === undefined) {
97
+ lines.push(`| ${role} | inherit (parent session model) | inherit (parent session thinking) |`);
98
+ continue;
99
+ }
100
+ const spec = splitModelSpec(configured);
101
+ lines.push(`| ${role} | ${spec.model} | ${spec.thinking ?? "backend default"} |`);
92
102
  }
93
103
  lines.push("");
94
104
 
@@ -124,6 +134,13 @@ export function renderStatus(input: StatusInput): string {
124
134
  return lines.join("\n");
125
135
  }
126
136
 
137
+ function describeDelegation(capabilities: readonly CapabilityStatus[]): string {
138
+ const backend = capabilities.find((capability) => capability.id === "delegation")?.backend;
139
+ if (!backend) return "none available";
140
+ const { label, tool } = DELEGATION_BACKENDS[backend];
141
+ return `${backend} (${label}, \`${tool}\`)`;
142
+ }
143
+
127
144
  function describeSelection(input: StatusInput): string {
128
145
  const { config } = input;
129
146
  if (config.localPath) return `local checkout ${config.localPath}`;
@@ -153,6 +170,8 @@ export function renderCheck(
153
170
  skill: GeneratedSkill,
154
171
  entry: RegistryEntry,
155
172
  report: PrerequisiteReport,
173
+ /** The resolved `delegation` capability, named for a workflow that delegates. */
174
+ delegation?: CapabilityStatus,
156
175
  ): string {
157
176
  const parts = [`\`/skill:${skill.generatedName}\` is tier **${skill.tier}**.`];
158
177
  if (entry.note) parts.push(entry.note);
@@ -166,12 +185,36 @@ export function renderCheck(
166
185
  } else if (skill.tier !== "unsupported") {
167
186
  parts.push("Prerequisites satisfied. Start the workflow.");
168
187
  }
188
+ if (skill.capabilities.includes("delegation")) {
189
+ parts.push(
190
+ delegation?.backend ? delegationInstruction(delegation.backend, delegation.tools) : "No delegation tool is usable in this session, so do not delegate.",
191
+ );
192
+ }
169
193
  if (skill.tier === "experimental" && report.ok) {
170
194
  parts.push("Tier `experimental` means parts of this workflow have no Pi equivalent. Expect gaps.");
171
195
  }
172
196
  return parts.join("\n\n");
173
197
  }
174
198
 
199
+ function delegationInstruction(backend: DelegationProvider, tools: readonly string[] | undefined): string {
200
+ const { tool, companions } = DELEGATION_BACKENDS[backend];
201
+ const vetted = tools ?? [tool];
202
+ const lines = [
203
+ `Delegate only with \`${tool}\` (${backend}). The adapter resolved this backend for the session, so do not use any other delegation tool.`,
204
+ ];
205
+ if (companions.length > 0) {
206
+ const usable = companions.filter((name) => vetted.includes(name));
207
+ const unusable = companions.filter((name) => !vetted.includes(name));
208
+ if (usable.length > 0) lines.push(`You may also call ${usable.map((name) => `\`${name}\``).join(", ")}.`);
209
+ if (unusable.length > 0) {
210
+ lines.push(
211
+ `Do not call ${unusable.map((name) => `\`${name}\``).join(", ")}, even if it is in your tools: the adapter could not confirm it belongs to this backend. Wait for children to report back instead.`,
212
+ );
213
+ }
214
+ }
215
+ return lines.join(" ");
216
+ }
217
+
175
218
  /** Platforms a release certifies. Windows means native Windows with Pi's Git Bash `bash` tool. */
176
219
  export function platformSupport(platform: string): PlatformSupport {
177
220
  return platform === "linux" || platform === "darwin" || platform === "win32" ? "supported" : "experimental";
package/src/types.ts CHANGED
@@ -22,6 +22,9 @@ export type SupportTier =
22
22
 
23
23
  export type CapabilityId = "delegation" | "structured-question";
24
24
 
25
+ /** Where a delegating workflow starts its children. */
26
+ export type DelegationProvider = "t3" | "pi-subagents";
27
+
25
28
  export interface RegistryEntry {
26
29
  readonly tier: SupportTier;
27
30
  readonly capabilities: readonly CapabilityId[];
@@ -85,6 +88,8 @@ export interface GenerationResult {
85
88
  readonly skillsDir: string;
86
89
  readonly skills: readonly GeneratedSkill[];
87
90
  readonly agents: readonly GeneratedAgent[];
91
+ /** One `<generatedName>.md` system prompt per agent, for providers without named agents. */
92
+ readonly agentPromptsDir: string;
88
93
  readonly diagnostics: readonly Diagnostic[];
89
94
  }
90
95
 
@@ -92,6 +97,10 @@ export interface CapabilityStatus {
92
97
  readonly id: CapabilityId;
93
98
  readonly available: boolean;
94
99
  readonly provider?: string;
100
+ /** The delegation backend that satisfies `delegation`, when one does. */
101
+ readonly backend?: DelegationProvider;
102
+ /** Every tool of that backend the adapter vetted: the delegation tool, then its companions. */
103
+ readonly tools?: readonly string[];
95
104
  readonly recommendation: string;
96
105
  }
97
106
 
package/src/upstream.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { MANIFEST_RELATIVE_PATH } from "./manifest.ts";
6
6
  import { cacheRoot, checkoutDir, stateFile, validateContainedPath } from "./paths.ts";
@@ -119,17 +119,19 @@ export async function resolveSource(options: ResolveOptions): Promise<ResolvedSo
119
119
  const git = options.git ?? systemGit;
120
120
  const selection = selectionOf(config);
121
121
  const trust = trustOf(config);
122
+ // Validate before either branch touches the filesystem, so a local checkout cannot be
123
+ // escaped through the plugin path any more than a Git one can.
124
+ const pluginPath = validateContainedPath(config.pluginPath, "the upstream plugin path");
122
125
 
123
126
  if (selection.kind === "local") {
124
127
  const checkout = resolve(selection.path);
125
- const pluginDir = locatePluginDir(checkout, config.pluginPath);
128
+ const pluginDir = locatePluginDir(checkout, pluginPath);
126
129
  const head = await git(["rev-parse", "HEAD"], checkout);
127
130
  return { selection, trust, commit: head.code === 0 ? head.stdout.trim() : "", checkoutDir: checkout, pluginDir };
128
131
  }
129
132
 
130
133
  const repo = validateRepo(config.repo);
131
134
  const wanted = validateRef(selection.ref);
132
- const pluginPath = validateContainedPath(config.pluginPath, "the upstream plugin path");
133
135
  const root = cacheRoot(config.cacheDir);
134
136
  const dir = checkoutDir(root, repo);
135
137
 
@@ -337,7 +339,19 @@ async function expect(result: Promise<GitResult>, what: string): Promise<GitResu
337
339
 
338
340
  function locatePluginDir(checkout: string, pluginPath: string): string {
339
341
  const nested = join(checkout, pluginPath);
340
- if (existsSync(join(nested, MANIFEST_RELATIVE_PATH))) return nested;
342
+ if (existsSync(join(nested, MANIFEST_RELATIVE_PATH))) {
343
+ // The plugin path is textually contained, but a symbolic link inside the checkout could
344
+ // still lead outside it. Compare resolved paths so the link target decides.
345
+ const root = realpathSync(checkout);
346
+ const target = realpathSync(nested);
347
+ const inside = relative(root, target);
348
+ if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
349
+ throw new Error(
350
+ `Refusing the upstream plugin path ${JSON.stringify(pluginPath)}. It resolves to ${target}, outside the checkout ${root}. Point it at a real directory inside the checkout.`,
351
+ );
352
+ }
353
+ return nested;
354
+ }
341
355
  if (existsSync(join(checkout, MANIFEST_RELATIVE_PATH))) return checkout;
342
356
  throw new Error(
343
357
  `No pstack manifest under ${checkout}. Expected ${join(pluginPath, MANIFEST_RELATIVE_PATH)} or ${MANIFEST_RELATIVE_PATH}.`,