pi-pstack-adapter 0.3.0 → 0.4.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.
@@ -1,9 +1,18 @@
1
- import type { CapabilityId, CapabilityStatus } from "./types.ts";
1
+ import {
2
+ DEFAULT_DELEGATION,
3
+ type DelegationHost,
4
+ callableTool,
5
+ delegationHost,
6
+ parameterNames,
7
+ resolveDelegation,
8
+ } from "./delegation.ts";
9
+ import type { CapabilityId, CapabilityStatus, DelegationProvider } from "./types.ts";
2
10
 
3
11
  export interface ToolDescriptor {
4
12
  readonly name: string;
13
+ readonly exposure?: string;
5
14
  readonly parameters?: unknown;
6
- readonly sourceInfo?: { readonly source?: string };
15
+ readonly sourceInfo?: { readonly source?: string; readonly path?: string };
7
16
  }
8
17
 
9
18
  interface CapabilityProbe {
@@ -14,13 +23,10 @@ interface CapabilityProbe {
14
23
  readonly recommendation: string;
15
24
  }
16
25
 
26
+ const DELEGATION_RECOMMENDATION =
27
+ "Run Pi inside T3 Code, whose `delegate_task` tool starts child agents, or install a delegation provider with named background agents and model selection, for example `pi install npm:pi-subagents`.";
28
+
17
29
  const PROBES: readonly CapabilityProbe[] = [
18
- {
19
- id: "delegation",
20
- toolName: "subagent",
21
- requiredParameters: ["agent", "task", "async", "model"],
22
- recommendation: "Install a delegation provider that exposes named background agents with model selection, for example `pi install npm:pi-subagents`.",
23
- },
24
30
  {
25
31
  id: "structured-question",
26
32
  toolName: "ask_user",
@@ -29,31 +35,50 @@ const PROBES: readonly CapabilityProbe[] = [
29
35
  },
30
36
  ];
31
37
 
32
- export function detectCapabilities(tools: readonly ToolDescriptor[]): CapabilityStatus[] {
33
- return PROBES.map((probe) => {
34
- const tool = tools.find((candidate) => candidate.name === probe.toolName);
35
- if (!tool) {
36
- return { id: probe.id, available: false, recommendation: probe.recommendation };
37
- }
38
- const properties = parameterNames(tool.parameters);
39
- const missing = probe.requiredParameters.filter((name) => !properties.has(name));
40
- if (missing.length > 0) {
41
- return {
42
- id: probe.id,
43
- available: false,
44
- provider: tool.sourceInfo?.source,
45
- recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
46
- };
47
- }
48
- return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
49
- });
38
+ export function detectCapabilities(
39
+ tools: readonly ToolDescriptor[],
40
+ preferred: DelegationProvider = DEFAULT_DELEGATION,
41
+ host: DelegationHost = delegationHost(),
42
+ ): CapabilityStatus[] {
43
+ return [detectDelegation(tools, preferred, host), ...PROBES.map((probe) => detectTool(tools, probe))];
50
44
  }
51
45
 
52
- function parameterNames(parameters: unknown): Set<string> {
53
- if (typeof parameters !== "object" || parameters === null) return new Set();
54
- const properties = (parameters as { properties?: unknown }).properties;
55
- if (typeof properties !== "object" || properties === null) return new Set();
56
- return new Set(Object.keys(properties as Record<string, unknown>));
46
+ /** The first usable backend in preference order satisfies `delegation`. */
47
+ function detectDelegation(
48
+ tools: readonly ToolDescriptor[],
49
+ preferred: DelegationProvider,
50
+ host: DelegationHost,
51
+ ): CapabilityStatus {
52
+ const resolution = resolveDelegation(tools, preferred, host);
53
+ if (resolution.kind === "unavailable") {
54
+ return { id: "delegation", available: false, recommendation: [...resolution.problems, DELEGATION_RECOMMENDATION].join(" ") };
55
+ }
56
+ return {
57
+ id: "delegation",
58
+ available: true,
59
+ provider: resolution.backend.id === "t3" ? resolution.backend.label : resolution.tool.sourceInfo?.source ?? resolution.backend.label,
60
+ backend: resolution.backend.id,
61
+ tools: [resolution.backend.tool, ...resolution.companions],
62
+ recommendation: DELEGATION_RECOMMENDATION,
63
+ };
64
+ }
65
+
66
+ function detectTool(tools: readonly ToolDescriptor[], probe: CapabilityProbe): CapabilityStatus {
67
+ const tool = callableTool(tools, probe.toolName);
68
+ if (!tool) {
69
+ return { id: probe.id, available: false, recommendation: probe.recommendation };
70
+ }
71
+ const properties = parameterNames(tool.parameters);
72
+ const missing = probe.requiredParameters.filter((name) => !properties.has(name));
73
+ if (missing.length > 0) {
74
+ return {
75
+ id: probe.id,
76
+ available: false,
77
+ provider: tool.sourceInfo?.source,
78
+ recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
79
+ };
80
+ }
81
+ return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
57
82
  }
58
83
 
59
84
  export interface PrerequisiteReport {
package/src/certified.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  export const CERTIFIED = {
6
6
  repo: "https://github.com/cursor/plugins",
7
7
  ref: "main",
8
- commit: "e43c7ee26e0038c6c1fa8380dd34ce86ff94cb2a",
8
+ commit: "dbf49813a39c1a5389c5e28cb8e4c1c2379e2454",
9
9
  pluginPath: "pstack",
10
- pstackVersion: "0.15.9",
11
- skillCount: 50,
10
+ pstackVersion: "0.15.23",
11
+ skillCount: 53,
12
12
  agentCount: 2,
13
13
  } as const;
package/src/config.ts CHANGED
@@ -3,7 +3,8 @@ import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { CERTIFIED } from "./certified.ts";
4
4
  import { DEFAULT_NAMESPACE, validateNamespace } from "./namespace.ts";
5
5
  import { expandHome } from "./paths.ts";
6
- import type { SourceSelection, Trust } from "./types.ts";
6
+ import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
7
+ import type { DelegationProvider, SourceSelection, Trust } from "./types.ts";
7
8
 
8
9
  export interface AdapterConfig {
9
10
  readonly namespace: string;
@@ -17,6 +18,8 @@ export interface AdapterConfig {
17
18
  readonly allowBootstrap: boolean;
18
19
  readonly cacheDir?: string;
19
20
  readonly models: Readonly<Record<string, string>>;
21
+ /** The delegation backend tried first. The other one is the fallback. */
22
+ readonly delegation: DelegationProvider;
20
23
  readonly sources: readonly string[];
21
24
  }
22
25
 
@@ -30,6 +33,7 @@ export interface ConfigFile {
30
33
  allowBootstrap?: boolean;
31
34
  cacheDir?: string;
32
35
  models?: Record<string, string>;
36
+ delegation?: string;
33
37
  }
34
38
 
35
39
  export interface ConfigLayer {
@@ -49,6 +53,7 @@ const ENV = {
49
53
  allowBootstrap: "PISTACK_ALLOW_BOOTSTRAP",
50
54
  cacheDir: "PISTACK_CACHE_DIR",
51
55
  models: "PISTACK_MODELS",
56
+ delegation: "PISTACK_DELEGATION",
52
57
  } as const;
53
58
 
54
59
  export function readConfigFile(path: string): ConfigLayer | undefined {
@@ -107,6 +112,7 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
107
112
  if (env[ENV.cacheDir]) values.cacheDir = env[ENV.cacheDir];
108
113
  if (env[ENV.allowBootstrap]) values.allowBootstrap = !NEGATIVE.has((env[ENV.allowBootstrap] as string).trim().toLowerCase());
109
114
  if (env[ENV.models]) values.models = parseModelRoles(env[ENV.models] as string);
115
+ if (env[ENV.delegation]) values.delegation = (env[ENV.delegation] as string).trim().toLowerCase();
110
116
  return { label: "environment", values };
111
117
  }
112
118
 
@@ -151,6 +157,7 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
151
157
  allowBootstrap: merged.allowBootstrap ?? false,
152
158
  cacheDir: merged.cacheDir,
153
159
  models: merged.models ?? {},
160
+ delegation: validateDelegation(merged.delegation, layers),
154
161
  sources,
155
162
  };
156
163
  }
@@ -167,6 +174,15 @@ function resolvePin(merged: ConfigFile): string | undefined {
167
174
  return CERTIFIED.commit;
168
175
  }
169
176
 
177
+ function validateDelegation(value: string | undefined, layers: readonly ConfigLayer[]): DelegationProvider {
178
+ if (value === undefined) return DEFAULT_DELEGATION;
179
+ if (isDelegationProvider(value)) return value;
180
+ const owner = [...layers].reverse().find((layer) => layer.values.delegation === value);
181
+ throw new Error(
182
+ `${owner?.label ?? "config"} sets \`delegation\` to ${JSON.stringify(value)}. Use one of ${DELEGATION_PROVIDERS.map((id) => `"${id}"`).join(", ")}, or remove it to prefer "${DEFAULT_DELEGATION}".`,
183
+ );
184
+ }
185
+
170
186
  function resolveLayerPath(value: string, layers: readonly ConfigLayer[]): string {
171
187
  const expanded = expandHome(value);
172
188
  if (isAbsolute(expanded)) return expanded;
@@ -0,0 +1,143 @@
1
+ import { posix, win32 } from "node:path";
2
+ import type { DelegationProvider } from "./types.ts";
3
+
4
+ /** T3 Code sets this to the path of the Pi extension that owns the `mcp__t3-code__` tools. */
5
+ export const T3_BRIDGE_ENV = "T3_PI_MCP_EXTENSION_PATH";
6
+
7
+ /** The pi-subagents tool a delegation skill calls. */
8
+ export const SUBAGENT_TOOL = "subagent";
9
+ /** The pi-subagents loader that exposes a registered but inactive `subagent` tool. */
10
+ export const SUBAGENT_LOADER = "subagents_enable";
11
+
12
+ /**
13
+ * T3 Code's Pi bridge registers its MCP tools under this public prefix. It also registers a
14
+ * `mcp__t3_code__` copy with hidden exposure to reserve the name, which the model cannot call.
15
+ */
16
+ export const T3_TOOL_PREFIX = "mcp__t3-code__";
17
+ export const T3_DELEGATE_TOOL = `${T3_TOOL_PREFIX}delegate_task`;
18
+ export const T3_STATUS_TOOL = `${T3_TOOL_PREFIX}task_status`;
19
+ export const T3_CAPABILITIES_TOOL = `${T3_TOOL_PREFIX}orchestrator_capabilities`;
20
+
21
+ export interface DelegationBackend {
22
+ readonly id: DelegationProvider;
23
+ readonly label: string;
24
+ /** The tool that starts a child. Its presence decides whether the backend exists. */
25
+ readonly tool: string;
26
+ /** Tools a delegating workflow also needs, activated alongside `tool` when registered. */
27
+ readonly companions: readonly string[];
28
+ /** Parameter names `tool` must accept for the backend to be usable. */
29
+ readonly requiredParameters: readonly string[];
30
+ }
31
+
32
+ export const DELEGATION_BACKENDS: Readonly<Record<DelegationProvider, DelegationBackend>> = {
33
+ "t3": {
34
+ id: "t3",
35
+ label: "T3 Code",
36
+ tool: T3_DELEGATE_TOOL,
37
+ companions: [T3_CAPABILITIES_TOOL, T3_STATUS_TOOL],
38
+ requiredParameters: ["task", "target", "mode"],
39
+ },
40
+ "pi-subagents": {
41
+ id: "pi-subagents",
42
+ label: "pi-subagents",
43
+ tool: SUBAGENT_TOOL,
44
+ companions: [],
45
+ requiredParameters: ["agent", "task", "async", "model"],
46
+ },
47
+ };
48
+
49
+ export const DELEGATION_PROVIDERS: readonly DelegationProvider[] = ["t3", "pi-subagents"];
50
+ export const DEFAULT_DELEGATION: DelegationProvider = "t3";
51
+
52
+ export function isDelegationProvider(value: unknown): value is DelegationProvider {
53
+ return typeof value === "string" && (DELEGATION_PROVIDERS as readonly string[]).includes(value);
54
+ }
55
+
56
+ /** The preferred backend first, then the fallback. */
57
+ export function delegationOrder(preferred: DelegationProvider): readonly DelegationBackend[] {
58
+ return [preferred, ...DELEGATION_PROVIDERS.filter((id) => id !== preferred)].map((id) => DELEGATION_BACKENDS[id]);
59
+ }
60
+
61
+ export interface ToolPresence {
62
+ readonly name: string;
63
+ readonly exposure?: string;
64
+ readonly parameters?: unknown;
65
+ readonly sourceInfo?: { readonly source?: string; readonly path?: string };
66
+ }
67
+
68
+ /** A hidden registration only reserves a name, so it does not count as a callable tool. */
69
+ export function callableTool<T extends ToolPresence>(tools: readonly T[], name: string): T | undefined {
70
+ return tools.find((tool) => tool.name === name && tool.exposure !== "hidden");
71
+ }
72
+
73
+ export interface DelegationHost {
74
+ /** `T3_PI_MCP_EXTENSION_PATH`, set only when T3 Code launched this Pi process. */
75
+ readonly t3BridgePath?: string;
76
+ readonly platform?: NodeJS.Platform;
77
+ }
78
+
79
+ export function delegationHost(env: NodeJS.ProcessEnv = process.env): DelegationHost {
80
+ return { t3BridgePath: env[T3_BRIDGE_ENV] || undefined, platform: process.platform };
81
+ }
82
+
83
+ export type DelegationResolution =
84
+ | {
85
+ readonly kind: "resolved";
86
+ readonly backend: DelegationBackend;
87
+ readonly tool: ToolPresence;
88
+ /** Companion tools that pass the same ownership check as `tool`. */
89
+ readonly companions: readonly string[];
90
+ }
91
+ | { readonly kind: "unavailable"; readonly problems: readonly string[] };
92
+
93
+ /**
94
+ * The first usable backend in preference order. Detection and activation both use this, so the
95
+ * status report always names the backend a workflow actually gets. A T3 tool counts only when
96
+ * T3 Code's own bridge registered it, because any extension or MCP server can claim the name.
97
+ */
98
+ export function resolveDelegation(
99
+ tools: readonly ToolPresence[],
100
+ preferred: DelegationProvider,
101
+ host: DelegationHost,
102
+ ): DelegationResolution {
103
+ const problems: string[] = [];
104
+ for (const backend of delegationOrder(preferred)) {
105
+ const tool = callableTool(tools, backend.tool);
106
+ if (!tool) continue;
107
+ if (backend.id === "t3" && !fromT3Bridge(tool, host)) {
108
+ problems.push(
109
+ `The \`${backend.tool}\` tool was not registered by T3 Code's bridge, so the adapter ignores it. T3 Code sets ${T3_BRIDGE_ENV} to the bridge it launches.`,
110
+ );
111
+ continue;
112
+ }
113
+ const properties = parameterNames(tool.parameters);
114
+ const missing = backend.requiredParameters.filter((name) => !properties.has(name));
115
+ if (missing.length > 0) {
116
+ problems.push(`The \`${backend.tool}\` tool is present but does not accept ${missing.join(", ")}.`);
117
+ continue;
118
+ }
119
+ const companions = backend.companions.filter((name) => {
120
+ const companion = callableTool(tools, name);
121
+ return companion !== undefined && (backend.id !== "t3" || fromT3Bridge(companion, host));
122
+ });
123
+ return { kind: "resolved", backend, tool, companions };
124
+ }
125
+ return { kind: "unavailable", problems };
126
+ }
127
+
128
+ function fromT3Bridge(tool: ToolPresence, host: DelegationHost): boolean {
129
+ const owner = tool.sourceInfo?.path;
130
+ if (!host.t3BridgePath || !owner) return false;
131
+ const platform = host.platform ?? process.platform;
132
+ const paths = platform === "win32" ? win32 : posix;
133
+ // Windows paths are case-insensitive, so compare them after resolving both sides.
134
+ const key = (value: string) => (platform === "win32" ? paths.resolve(value).toLowerCase() : paths.resolve(value));
135
+ return key(owner) === key(host.t3BridgePath);
136
+ }
137
+
138
+ export function parameterNames(parameters: unknown): Set<string> {
139
+ if (typeof parameters !== "object" || parameters === null) return new Set();
140
+ const properties = (parameters as { properties?: unknown }).properties;
141
+ if (typeof properties !== "object" || properties === null) return new Set();
142
+ return new Set(Object.keys(properties as Record<string, unknown>));
143
+ }