@mystilleef/pi-subagent 0.11.0 → 0.12.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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Generic resource resolution for skills and extensions.
3
+ * Handles caching, discovery via DefaultResourceLoader, and name-to-path mapping.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import {
9
+ DefaultResourceLoader,
10
+ getAgentDir,
11
+ } from "@earendil-works/pi-coding-agent";
12
+
13
+ const RESOURCE_DISCOVERY_CACHE_TTL_MS = 300_000;
14
+
15
+ export const EXTENSION_DISCOVERY_CACHE_TTL_MS = RESOURCE_DISCOVERY_CACHE_TTL_MS;
16
+
17
+ type ResourceCacheEntry<T> = {
18
+ data: T;
19
+ ts: number;
20
+ };
21
+
22
+ class ResourceCache<T> {
23
+ private store = new Map<string, ResourceCacheEntry<T>>();
24
+
25
+ get(key: string): T | undefined {
26
+ const entry = this.store.get(key);
27
+ if (entry && Date.now() - entry.ts <= RESOURCE_DISCOVERY_CACHE_TTL_MS) {
28
+ return entry.data;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ set(key: string, data: T): void {
34
+ this.store.set(key, { data, ts: Date.now() });
35
+ }
36
+
37
+ clear(): void {
38
+ this.store.clear();
39
+ }
40
+ }
41
+
42
+ const skillArgsCache = new ResourceCache<Map<string, string>>();
43
+ const extensionPathsCache = new ResourceCache<Map<string, string>>();
44
+
45
+ export function resetResolvedAgentSkillArgsCache(): void {
46
+ skillArgsCache.clear();
47
+ }
48
+
49
+ export function resetResolvedAgentExtensionPathsCache(): void {
50
+ extensionPathsCache.clear();
51
+ }
52
+
53
+ async function canonicalPath(filePath: string): Promise<string> {
54
+ try {
55
+ return await fs.promises.realpath(filePath);
56
+ } catch {
57
+ /* symlinks or missing paths fall back to absolute path resolution */
58
+ return path.resolve(filePath);
59
+ }
60
+ }
61
+
62
+ async function buildResourceCacheKey(
63
+ cwd: string,
64
+ agentDir: string,
65
+ names: string[],
66
+ ): Promise<string> {
67
+ const sortedNames = [...names].sort();
68
+ return JSON.stringify({
69
+ cwd: await canonicalPath(cwd),
70
+ agentDir: await canonicalPath(agentDir),
71
+ names: sortedNames,
72
+ });
73
+ }
74
+
75
+ type ResourceResolverResult<T> = { data: T } | { error: string };
76
+
77
+ interface ResourceResolverConfig<TResult> {
78
+ cache: ResourceCache<Map<string, string>>;
79
+ loaderOptions: Record<string, boolean>;
80
+ getResources: (loader: DefaultResourceLoader) => {
81
+ items: unknown[];
82
+ errors: Array<{ path: string; error: string }>;
83
+ };
84
+ buildNameToResource: (items: unknown[]) => Map<string, string>;
85
+ buildResult: (
86
+ requested: string[],
87
+ nameToResource: Map<string, string>,
88
+ ) => TResult;
89
+ resourceType: string;
90
+ }
91
+
92
+ async function resolveResources<TResult>(
93
+ cwd: string,
94
+ names: string[],
95
+ config: ResourceResolverConfig<TResult>,
96
+ ): Promise<ResourceResolverResult<TResult>> {
97
+ const requested = Array.from(new Set(names));
98
+ if (requested.length === 0) {
99
+ return { data: config.buildResult(requested, new Map()) };
100
+ }
101
+ const agentDir = getAgentDir();
102
+ const cacheKey = await buildResourceCacheKey(cwd, agentDir, requested);
103
+ const cached = config.cache.get(cacheKey);
104
+ if (cached) {
105
+ return { data: config.buildResult(requested, cached) };
106
+ }
107
+ const loader = new DefaultResourceLoader({
108
+ cwd,
109
+ agentDir,
110
+ ...config.loaderOptions,
111
+ });
112
+ try {
113
+ await loader.reload();
114
+ } catch (error) {
115
+ return {
116
+ error: `Failed to discover ${config.resourceType}s: ${error instanceof Error ? error.message : String(error)}`,
117
+ };
118
+ }
119
+ const { items, errors } = config.getResources(loader);
120
+ if (errors.length > 0) {
121
+ const details = errors.map((e) => ` ${e.path}: ${e.error}`).join("\n");
122
+ return {
123
+ error: `Failed to discover ${config.resourceType}s:\n${details}`,
124
+ };
125
+ }
126
+ const nameToResource = config.buildNameToResource(items);
127
+ const missing = requested.filter((name) => !nameToResource.has(name));
128
+ if (missing.length > 0) {
129
+ const available =
130
+ Array.from(nameToResource.keys()).sort().join(", ") || "none";
131
+ return {
132
+ error: `Unknown ${config.resourceType}${missing.length === 1 ? "" : "s"}: ${missing
133
+ .map((name) => `"${name}"`)
134
+ .join(", ")}. Available ${config.resourceType}s: ${available}.`,
135
+ };
136
+ }
137
+ const result = config.buildResult(requested, nameToResource);
138
+ config.cache.set(cacheKey, nameToResource);
139
+ return { data: result };
140
+ }
141
+
142
+ function buildSkillArgs(
143
+ requested: string[],
144
+ skillPaths: Map<string, string>,
145
+ ): string[] {
146
+ return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
147
+ }
148
+
149
+ export async function resolveAgentSkillArgs(
150
+ cwd: string,
151
+ skillNames: string[],
152
+ ): Promise<{ args: string[] } | { error: string }> {
153
+ const result = await resolveResources(cwd, skillNames, {
154
+ cache: skillArgsCache,
155
+ loaderOptions: {
156
+ noContextFiles: true,
157
+ noPromptTemplates: true,
158
+ noThemes: true,
159
+ },
160
+ getResources: (loader) => {
161
+ const { skills } = loader.getSkills();
162
+ return {
163
+ items: skills,
164
+ errors: [],
165
+ };
166
+ },
167
+ buildNameToResource: (items) => {
168
+ const skillMap = new Map<string, string>();
169
+ for (const item of items) {
170
+ const skill = item as { name: string; filePath: string };
171
+ skillMap.set(skill.name, skill.filePath ?? skill.name);
172
+ }
173
+ return skillMap;
174
+ },
175
+ buildResult: (requested, nameToResource) => {
176
+ return buildSkillArgs(requested, nameToResource);
177
+ },
178
+ resourceType: "skill",
179
+ });
180
+ return "error" in result ? result : { args: result.data };
181
+ }
182
+
183
+ function getTerminalPackageName(source: string): string {
184
+ const spec = source.startsWith("npm:") ? source.slice(4) : source;
185
+ let name: string;
186
+ if (spec.startsWith("@")) {
187
+ const slashIdx = spec.indexOf("/");
188
+ if (slashIdx >= 0) {
189
+ name = spec.slice(slashIdx + 1);
190
+ } else {
191
+ name = spec;
192
+ }
193
+ } else {
194
+ name = spec;
195
+ }
196
+ const versionIdx = name.indexOf("@");
197
+ if (versionIdx >= 0) {
198
+ return name.slice(0, versionIdx);
199
+ }
200
+ return name;
201
+ }
202
+
203
+ function isLocalPathSpec(source: string): boolean {
204
+ return (
205
+ source.startsWith(".") ||
206
+ source.startsWith("/") ||
207
+ source.startsWith("~") ||
208
+ source.startsWith("file:")
209
+ );
210
+ }
211
+
212
+ function buildExtensionShortName(ext: {
213
+ resolvedPath: string;
214
+ sourceInfo: { source: string; origin: string };
215
+ }): string {
216
+ if (
217
+ ext.sourceInfo.origin === "package" &&
218
+ !isLocalPathSpec(ext.sourceInfo.source)
219
+ ) {
220
+ return getTerminalPackageName(ext.sourceInfo.source);
221
+ }
222
+ const fileName = path.basename(ext.resolvedPath);
223
+ if (/^index\.(?:ts|js)$/.test(fileName)) {
224
+ const dirName = path.dirname(ext.resolvedPath);
225
+ const base = path.basename(dirName);
226
+ if (base === "src" || base === "dist") {
227
+ return path.basename(path.dirname(dirName));
228
+ }
229
+ return base;
230
+ }
231
+ const dotIdx = fileName.lastIndexOf(".");
232
+ if (dotIdx > 0) {
233
+ return fileName.slice(0, dotIdx);
234
+ }
235
+ return fileName;
236
+ }
237
+
238
+ export async function resolveAgentExtensionPaths(
239
+ cwd: string,
240
+ extensionNames: string[],
241
+ ): Promise<{ resolvedPaths: string[] } | { error: string }> {
242
+ const result = await resolveResources(cwd, extensionNames, {
243
+ cache: extensionPathsCache,
244
+ loaderOptions: {
245
+ noContextFiles: true,
246
+ noPromptTemplates: true,
247
+ noThemes: true,
248
+ noSkills: true,
249
+ },
250
+ getResources: (loader) => {
251
+ const { extensions, errors } = loader.getExtensions();
252
+ const shortNameToExtension = new Map<
253
+ string,
254
+ (typeof extensions)[number]
255
+ >();
256
+ for (const ext of extensions) {
257
+ const shortName = buildExtensionShortName(ext);
258
+ if (!shortNameToExtension.has(shortName)) {
259
+ shortNameToExtension.set(shortName, ext);
260
+ }
261
+ }
262
+ return {
263
+ items: Array.from(shortNameToExtension.values()),
264
+ errors,
265
+ };
266
+ },
267
+ buildNameToResource: (items) => {
268
+ const shortNameToPath = new Map<string, string>();
269
+ for (const item of items) {
270
+ const ext = item as {
271
+ resolvedPath: string;
272
+ sourceInfo: { source: string; origin: string };
273
+ };
274
+ const shortName = buildExtensionShortName(ext);
275
+ if (!shortNameToPath.has(shortName)) {
276
+ shortNameToPath.set(shortName, ext.resolvedPath ?? shortName);
277
+ }
278
+ }
279
+ return shortNameToPath;
280
+ },
281
+ buildResult: (requested, nameToResource) => {
282
+ return requested
283
+ .map((name) => nameToResource.get(name))
284
+ .filter((p): p is string => typeof p === "string");
285
+ },
286
+ resourceType: "extension",
287
+ });
288
+ return "error" in result ? result : { resolvedPaths: result.data };
289
+ }
@@ -1,261 +1,35 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import type { Message } from "@earendil-works/pi-ai";
5
- import {
6
- DefaultResourceLoader,
7
- getAgentDir,
8
- } from "@earendil-works/pi-coding-agent";
9
- import type { SingleResult } from "./types.js";
10
-
11
- export const DEFAULT_MAX_OUTPUT_BYTES = 50_000;
12
- export const DEFAULT_MAX_OUTPUT_LINES = 500;
13
- export const DEFAULT_AGENT_END_GRACE_MS = 250;
14
- export const DEFAULT_MAX_STDERR_BYTES = 10_000;
15
- export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
16
- const MAX_SUBAGENT_DEPTH_CEILING = 10;
17
-
18
- export interface SubagentOutputLimits {
19
- maxBytes: number;
20
- maxLines: number;
21
- }
22
-
23
- export interface SubagentRuntimeLimits {
24
- agentEndGraceMs: number;
25
- maxStderrBytes: number;
26
- maxDepth: number;
27
- }
28
-
29
- type EnvLimitConfig = Partial<Record<string, string | number | undefined>>;
30
-
31
- function parsePositiveInteger(
32
- value: string | number | undefined,
33
- ): number | undefined {
34
- const parsed = typeof value === "number" ? value : Number(value);
35
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
36
- return undefined;
37
- return parsed;
38
- }
39
-
40
- export function getSubagentOutputLimits(
41
- config: EnvLimitConfig = process.env,
42
- ): SubagentOutputLimits {
43
- return {
44
- maxBytes:
45
- parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_BYTES"]) ??
46
- DEFAULT_MAX_OUTPUT_BYTES,
47
- maxLines:
48
- parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_LINES"]) ??
49
- DEFAULT_MAX_OUTPUT_LINES,
50
- };
51
- }
52
-
53
- export function getSubagentRuntimeLimits(
54
- config: EnvLimitConfig = process.env,
55
- ): SubagentRuntimeLimits {
56
- const maxDepth =
57
- parsePositiveInteger(config["PI_SUBAGENT_MAX_DEPTH"]) ??
58
- DEFAULT_MAX_SUBAGENT_DEPTH;
59
- return {
60
- agentEndGraceMs:
61
- parsePositiveInteger(config["PI_SUBAGENT_AGENT_END_GRACE_MS"]) ??
62
- DEFAULT_AGENT_END_GRACE_MS,
63
- maxStderrBytes:
64
- parsePositiveInteger(config["PI_SUBAGENT_MAX_STDERR_BYTES"]) ??
65
- DEFAULT_MAX_STDERR_BYTES,
66
- maxDepth: Math.min(maxDepth, MAX_SUBAGENT_DEPTH_CEILING),
67
- };
68
- }
69
-
70
- export function truncateOutput(
71
- text: string,
72
- limits: SubagentOutputLimits = getSubagentOutputLimits(),
73
- ): string {
74
- const lines = text.split("\n");
75
- const maxBytes = Math.max(1, Math.floor(limits.maxBytes));
76
- const maxLines = Math.max(1, Math.floor(limits.maxLines));
77
- if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes)
78
- return text;
79
- let result = lines.slice(0, maxLines).join("\n");
80
- if (Buffer.byteLength(result, "utf-8") > maxBytes) {
81
- const buf = Buffer.from(result).subarray(0, maxBytes);
82
- result = buf.toString("utf-8").replace(/\uFFFD$/, "");
83
- }
84
- const kept = result.split("\n").length;
85
- return `[TRUNCATED: first ${kept} of ${lines.length} lines]\n${result}`;
86
- }
87
-
88
- export async function writePromptToTempFile(
89
- agentName: string,
90
- prompt: string,
91
- ): Promise<{ dir: string; filePath: string }> {
92
- const tmpDir = await fs.promises.mkdtemp(
93
- path.join(os.tmpdir(), "pi-subagent-"),
94
- );
95
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
96
- const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
97
- // mkdtemp guarantees a unique directory per call; no concurrent writer can hold this path.
98
- await fs.promises.writeFile(filePath, prompt, {
99
- encoding: "utf-8",
100
- mode: 0o600,
101
- });
102
- return { dir: tmpDir, filePath };
103
- }
104
-
105
- export function getPiInvocation(args: string[]): {
106
- command: string;
107
- args: string[];
108
- } {
109
- const currentScript = process.argv[1];
110
- if (currentScript && fs.existsSync(currentScript)) {
111
- return { command: process.execPath, args: [currentScript, ...args] };
112
- }
113
- const execName = path.basename(process.execPath).toLowerCase();
114
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
115
- if (!isGenericRuntime) {
116
- return { command: process.execPath, args };
117
- }
118
- return { command: "pi", args };
119
- }
120
-
121
- const SKILL_DISCOVERY_CACHE_TTL_MS = 300_000;
122
-
123
- type ResolvedSkillArgsCacheEntry = {
124
- skillPaths: Map<string, string>;
125
- ts: number;
126
- };
127
-
128
- const resolvedSkillArgsCache = new Map<string, ResolvedSkillArgsCacheEntry>();
129
-
130
- async function canonicalPath(filePath: string): Promise<string> {
131
- try {
132
- return await fs.promises.realpath(filePath);
133
- } catch {
134
- /* symlinks or missing paths fall back to absolute path resolution */
135
- return path.resolve(filePath);
136
- }
137
- }
138
-
139
- function buildSkillArgs(
140
- requested: string[],
141
- skillPaths: Map<string, string>,
142
- ): string[] {
143
- return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
144
- }
145
-
146
- export function resetResolvedAgentSkillArgsCache(): void {
147
- resolvedSkillArgsCache.clear();
148
- }
149
-
150
- export async function resolveAgentSkillArgs(
151
- cwd: string,
152
- skillNames: string[],
153
- ): Promise<{ args: string[] } | { error: string }> {
154
- const requested = Array.from(new Set(skillNames));
155
- if (requested.length === 0) return { args: [] };
156
- const cacheIdentitySkills = [...requested].sort();
157
- const agentDir = getAgentDir();
158
- const cacheKey = JSON.stringify({
159
- cwd: await canonicalPath(cwd),
160
- agentDir: await canonicalPath(agentDir),
161
- skills: cacheIdentitySkills,
162
- });
163
- const cached = resolvedSkillArgsCache.get(cacheKey);
164
- if (cached && Date.now() - cached.ts <= SKILL_DISCOVERY_CACHE_TTL_MS) {
165
- return { args: buildSkillArgs(requested, cached.skillPaths) };
166
- }
167
- const loader = new DefaultResourceLoader({
168
- cwd,
169
- agentDir,
170
- noContextFiles: true,
171
- noPromptTemplates: true,
172
- noThemes: true,
173
- });
174
- try {
175
- await loader.reload();
176
- } catch (error) {
177
- return {
178
- error: `Failed to discover skills: ${error instanceof Error ? error.message : String(error)}`,
179
- };
180
- }
181
- const { skills } = loader.getSkills();
182
- const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
183
- const missing = requested.filter((name) => !skillMap.has(name));
184
- if (missing.length > 0) {
185
- const available =
186
- skills
187
- .map((skill) => skill.name)
188
- .sort()
189
- .join(", ") || "none";
190
- return {
191
- error: `Unknown skill${missing.length === 1 ? "" : "s"}: ${missing
192
- .map((name) => `"${name}"`)
193
- .join(", ")}. Available skills: ${available}.`,
194
- };
195
- }
196
- const skillPaths = new Map(
197
- requested.map((name) => [name, skillMap.get(name)?.filePath ?? name]),
198
- );
199
- const args = buildSkillArgs(requested, skillPaths);
200
- resolvedSkillArgsCache.set(cacheKey, { skillPaths, ts: Date.now() });
201
- return { args };
202
- }
203
-
204
- export function getSubagentDepth(): number {
205
- const d = Number(process.env.PI_SUBAGENT_DEPTH ?? "0");
206
- if (!Number.isFinite(d) || d < 0) return 0;
207
- return Math.floor(d);
208
- }
209
-
210
- export function subagentDepthEnv(): Record<string, string> {
211
- return { PI_SUBAGENT_DEPTH: String(getSubagentDepth() + 1) };
212
- }
213
-
214
- export function findLastAssistantTextMessage(messages: Message[]): number {
215
- for (let i = messages.length - 1; i >= 0; i--) {
216
- const msg = messages[i];
217
- if (
218
- msg?.role === "assistant" &&
219
- Array.isArray(msg.content) &&
220
- msg.content.some(
221
- (c) =>
222
- c.type === "text" &&
223
- typeof c.text === "string" &&
224
- c.text.trim().length > 0,
225
- )
226
- ) {
227
- return i;
228
- }
229
- }
230
- return -1;
231
- }
232
-
233
- export function extractFinalOutputFromMessages(messages: Message[]): string {
234
- const lastAsstIdx = findLastAssistantTextMessage(messages);
235
- if (lastAsstIdx < 0) return "";
236
- const content = messages[lastAsstIdx]?.content;
237
- if (!Array.isArray(content)) return "";
238
- const lastText = content.findLast((p) => p.type === "text");
239
- return lastText?.type === "text" ? (lastText.text ?? "") : "";
240
- }
241
-
242
- export function detectMessageError(messages: Message[]): boolean {
243
- const lastAssistantIdx = findLastAssistantTextMessage(messages);
244
- const from = lastAssistantIdx >= 0 ? lastAssistantIdx + 1 : 0;
245
- for (let i = messages.length - 1; i >= from; i--) {
246
- const msg = messages[i];
247
- if (msg?.role === "toolResult" && msg.isError) return true;
248
- }
249
- return false;
250
- }
251
-
252
- export function hasSubagentFailed(result: SingleResult): boolean {
253
- if (result.outcome?.trim()) return false;
254
- return (
255
- result.exitCode !== 0 ||
256
- result.stopReason === "error" ||
257
- result.stopReason === "aborted" ||
258
- Boolean(result.errorMessage?.trim()) ||
259
- detectMessageError(result.messages ?? [])
260
- );
261
- }
1
+ /**
2
+ * Barrel export for shared utilities.
3
+ * Re-exports from focused modules.
4
+ */
5
+
6
+ export { writePromptToTempFile } from "../child/prompt-setup.js";
7
+ export {
8
+ getPiInvocation,
9
+ getSubagentDepth,
10
+ subagentDepthEnv,
11
+ } from "./invocation.js";
12
+ // Re-export all public symbols from focused modules
13
+ export {
14
+ DEFAULT_AGENT_END_GRACE_MS,
15
+ DEFAULT_MAX_OUTPUT_BYTES,
16
+ DEFAULT_MAX_OUTPUT_LINES,
17
+ DEFAULT_MAX_STDERR_BYTES,
18
+ DEFAULT_MAX_SUBAGENT_DEPTH,
19
+ getSubagentOutputLimits,
20
+ getSubagentRuntimeLimits,
21
+ truncateOutput,
22
+ } from "./limits.js";
23
+ export {
24
+ detectMessageError,
25
+ extractFinalOutputFromMessages,
26
+ findLastAssistantTextMessage,
27
+ hasSubagentFailed,
28
+ } from "./message-utils.js";
29
+ export {
30
+ EXTENSION_DISCOVERY_CACHE_TTL_MS,
31
+ resetResolvedAgentExtensionPathsCache,
32
+ resetResolvedAgentSkillArgsCache,
33
+ resolveAgentExtensionPaths,
34
+ resolveAgentSkillArgs,
35
+ } from "./resource-resolution.js";