@tintinweb/pi-subagents 0.2.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/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/package.json +46 -0
- package/src/agent-manager.ts +287 -0
- package/src/agent-runner.ts +341 -0
- package/src/agent-types.ts +137 -0
- package/src/context.ts +58 -0
- package/src/custom-agents.ts +94 -0
- package/src/env.ts +33 -0
- package/src/index.ts +855 -0
- package/src/prompts.ts +163 -0
- package/src/types.ts +84 -0
- package/src/ui/agent-widget.ts +326 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createAgentSession,
|
|
7
|
+
DefaultResourceLoader,
|
|
8
|
+
SessionManager,
|
|
9
|
+
SettingsManager,
|
|
10
|
+
type AgentSession,
|
|
11
|
+
type AgentSessionEvent,
|
|
12
|
+
type ExtensionAPI,
|
|
13
|
+
} from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
16
|
+
import { getToolsForType, getConfig, getCustomAgentConfig } from "./agent-types.js";
|
|
17
|
+
import { buildSystemPrompt } from "./prompts.js";
|
|
18
|
+
import { buildParentContext, extractText } from "./context.js";
|
|
19
|
+
import { detectEnv } from "./env.js";
|
|
20
|
+
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
23
|
+
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
|
|
24
|
+
|
|
25
|
+
/** Default max turns to prevent subagents from looping indefinitely. */
|
|
26
|
+
const DEFAULT_MAX_TURNS = 50;
|
|
27
|
+
|
|
28
|
+
/** Additional turns allowed after the soft limit steer message. */
|
|
29
|
+
const GRACE_TURNS = 5;
|
|
30
|
+
|
|
31
|
+
/** Haiku model IDs to try for Explore agents (in preference order). */
|
|
32
|
+
const HAIKU_MODEL_IDS = [
|
|
33
|
+
"claude-haiku-4-5-20251001",
|
|
34
|
+
"claude-3-5-haiku-20241022",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Try to find the right model for an agent type.
|
|
39
|
+
* Priority: explicit option > custom agent model > type-specific default > parent model.
|
|
40
|
+
*/
|
|
41
|
+
function resolveDefaultModel(
|
|
42
|
+
type: SubagentType,
|
|
43
|
+
parentModel: Model<any> | undefined,
|
|
44
|
+
registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
|
|
45
|
+
customModel?: string,
|
|
46
|
+
): Model<any> | undefined {
|
|
47
|
+
// Build a set of available model keys for fast lookup
|
|
48
|
+
const available = registry.getAvailable?.();
|
|
49
|
+
const availableKeys = available
|
|
50
|
+
? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
|
|
51
|
+
: undefined;
|
|
52
|
+
const isAvailable = (provider: string, modelId: string) =>
|
|
53
|
+
!availableKeys || availableKeys.has(`${provider}/${modelId}`);
|
|
54
|
+
|
|
55
|
+
// Custom agent model from frontmatter
|
|
56
|
+
if (customModel) {
|
|
57
|
+
const slashIdx = customModel.indexOf("/");
|
|
58
|
+
if (slashIdx !== -1) {
|
|
59
|
+
const provider = customModel.slice(0, slashIdx);
|
|
60
|
+
const modelId = customModel.slice(slashIdx + 1);
|
|
61
|
+
const found = registry.find(provider, modelId);
|
|
62
|
+
if (found && isAvailable(provider, modelId)) return found;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (type !== "Explore") return parentModel;
|
|
67
|
+
|
|
68
|
+
for (const modelId of HAIKU_MODEL_IDS) {
|
|
69
|
+
const found = registry.find("anthropic", modelId);
|
|
70
|
+
if (found && isAvailable("anthropic", modelId)) return found;
|
|
71
|
+
}
|
|
72
|
+
return parentModel;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Info about a tool event in the subagent. */
|
|
76
|
+
export interface ToolActivity {
|
|
77
|
+
type: "start" | "end";
|
|
78
|
+
toolName: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RunOptions {
|
|
82
|
+
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
|
|
83
|
+
pi: ExtensionAPI;
|
|
84
|
+
model?: Model<any>;
|
|
85
|
+
maxTurns?: number;
|
|
86
|
+
signal?: AbortSignal;
|
|
87
|
+
isolated?: boolean;
|
|
88
|
+
inheritContext?: boolean;
|
|
89
|
+
thinkingLevel?: ThinkingLevel;
|
|
90
|
+
/** Override system prompt entirely (for custom agents with promptMode: "replace"). */
|
|
91
|
+
systemPromptOverride?: string;
|
|
92
|
+
/** Append to default system prompt (for custom agents with promptMode: "append"). */
|
|
93
|
+
systemPromptAppend?: string;
|
|
94
|
+
/** Called on tool start/end with activity info. */
|
|
95
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
96
|
+
/** Called on streaming text deltas from the assistant response. */
|
|
97
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
98
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RunResult {
|
|
102
|
+
responseText: string;
|
|
103
|
+
session: AgentSession;
|
|
104
|
+
/** True if the agent was hard-aborted (max_turns + grace exceeded). */
|
|
105
|
+
aborted: boolean;
|
|
106
|
+
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
|
|
107
|
+
steered: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Subscribe to a session and collect the last assistant message text.
|
|
112
|
+
* Returns an object with a `getText()` getter and an `unsubscribe` function.
|
|
113
|
+
*/
|
|
114
|
+
function collectResponseText(session: AgentSession) {
|
|
115
|
+
let text = "";
|
|
116
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
117
|
+
if (event.type === "message_start") {
|
|
118
|
+
text = "";
|
|
119
|
+
}
|
|
120
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
121
|
+
text += event.assistantMessageEvent.delta;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return { getText: () => text, unsubscribe };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wire an AbortSignal to abort a session.
|
|
129
|
+
* Returns a cleanup function to remove the listener.
|
|
130
|
+
*/
|
|
131
|
+
function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
|
|
132
|
+
if (!signal) return () => {};
|
|
133
|
+
const onAbort = () => session.abort();
|
|
134
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runAgent(
|
|
139
|
+
ctx: ExtensionContext,
|
|
140
|
+
type: SubagentType,
|
|
141
|
+
prompt: string,
|
|
142
|
+
options: RunOptions,
|
|
143
|
+
): Promise<RunResult> {
|
|
144
|
+
const config = getConfig(type);
|
|
145
|
+
const customConfig = getCustomAgentConfig(type);
|
|
146
|
+
const env = await detectEnv(options.pi, ctx.cwd);
|
|
147
|
+
|
|
148
|
+
// Build system prompt: custom override > custom append > built-in
|
|
149
|
+
let systemPrompt: string;
|
|
150
|
+
if (options.systemPromptOverride) {
|
|
151
|
+
systemPrompt = options.systemPromptOverride;
|
|
152
|
+
} else if (options.systemPromptAppend) {
|
|
153
|
+
systemPrompt = buildSystemPrompt(type, ctx.cwd, env) + "\n\n" + options.systemPromptAppend;
|
|
154
|
+
} else {
|
|
155
|
+
systemPrompt = buildSystemPrompt(type, ctx.cwd, env);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tools = getToolsForType(type, ctx.cwd);
|
|
159
|
+
|
|
160
|
+
// Resolve extensions/skills: isolated overrides to false
|
|
161
|
+
const extensions = options.isolated ? false : config.extensions;
|
|
162
|
+
const skills = options.isolated ? false : config.skills;
|
|
163
|
+
|
|
164
|
+
// Load extensions/skills: true or string[] → load; false → don't
|
|
165
|
+
const loader = new DefaultResourceLoader({
|
|
166
|
+
cwd: ctx.cwd,
|
|
167
|
+
noExtensions: extensions === false,
|
|
168
|
+
noSkills: skills === false,
|
|
169
|
+
noPromptTemplates: true,
|
|
170
|
+
noThemes: true,
|
|
171
|
+
systemPromptOverride: () => systemPrompt,
|
|
172
|
+
});
|
|
173
|
+
await loader.reload();
|
|
174
|
+
|
|
175
|
+
// Resolve model: explicit option > custom agent config > type-specific default > parent model
|
|
176
|
+
const model = options.model ?? resolveDefaultModel(
|
|
177
|
+
type, ctx.model, ctx.modelRegistry, customConfig?.model,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Resolve thinking level: explicit option > custom agent config > undefined (inherit)
|
|
181
|
+
const thinkingLevel = options.thinkingLevel ?? customConfig?.thinking;
|
|
182
|
+
|
|
183
|
+
const sessionOpts: Record<string, unknown> = {
|
|
184
|
+
cwd: ctx.cwd,
|
|
185
|
+
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
186
|
+
settingsManager: SettingsManager.create(),
|
|
187
|
+
modelRegistry: ctx.modelRegistry,
|
|
188
|
+
model,
|
|
189
|
+
tools,
|
|
190
|
+
resourceLoader: loader,
|
|
191
|
+
};
|
|
192
|
+
if (thinkingLevel) {
|
|
193
|
+
sessionOpts.thinkingLevel = thinkingLevel;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// createAgentSession's type signature may not include thinkingLevel yet
|
|
197
|
+
const { session } = await createAgentSession(sessionOpts as Parameters<typeof createAgentSession>[0]);
|
|
198
|
+
|
|
199
|
+
// Filter active tools: remove our own tools to prevent nesting,
|
|
200
|
+
// and apply extension allowlist if specified
|
|
201
|
+
if (extensions !== false) {
|
|
202
|
+
const builtinToolNames = new Set(tools.map(t => t.name));
|
|
203
|
+
const activeTools = session.getActiveToolNames().filter((t) => {
|
|
204
|
+
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
|
|
205
|
+
if (builtinToolNames.has(t)) return true;
|
|
206
|
+
if (Array.isArray(extensions)) {
|
|
207
|
+
return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
});
|
|
211
|
+
session.setActiveToolsByName(activeTools);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
options.onSessionCreated?.(session);
|
|
215
|
+
|
|
216
|
+
// Track turns for graceful max_turns enforcement
|
|
217
|
+
let turnCount = 0;
|
|
218
|
+
const maxTurns = options.maxTurns ?? customConfig?.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
219
|
+
let softLimitReached = false;
|
|
220
|
+
let aborted = false;
|
|
221
|
+
|
|
222
|
+
let currentMessageText = "";
|
|
223
|
+
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
224
|
+
if (event.type === "turn_end") {
|
|
225
|
+
turnCount++;
|
|
226
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
227
|
+
softLimitReached = true;
|
|
228
|
+
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
229
|
+
} else if (softLimitReached && turnCount >= maxTurns + GRACE_TURNS) {
|
|
230
|
+
aborted = true;
|
|
231
|
+
session.abort();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (event.type === "message_start") {
|
|
235
|
+
currentMessageText = "";
|
|
236
|
+
}
|
|
237
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
238
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
239
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
240
|
+
}
|
|
241
|
+
if (event.type === "tool_execution_start") {
|
|
242
|
+
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
243
|
+
}
|
|
244
|
+
if (event.type === "tool_execution_end") {
|
|
245
|
+
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const collector = collectResponseText(session);
|
|
250
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
251
|
+
|
|
252
|
+
// Build the effective prompt: optionally prepend parent context
|
|
253
|
+
let effectivePrompt = prompt;
|
|
254
|
+
if (options.inheritContext) {
|
|
255
|
+
const parentContext = buildParentContext(ctx);
|
|
256
|
+
if (parentContext) {
|
|
257
|
+
effectivePrompt = parentContext + prompt;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await session.prompt(effectivePrompt);
|
|
263
|
+
} finally {
|
|
264
|
+
unsubTurns();
|
|
265
|
+
collector.unsubscribe();
|
|
266
|
+
cleanupAbort();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Send a new prompt to an existing session (resume).
|
|
274
|
+
*/
|
|
275
|
+
export async function resumeAgent(
|
|
276
|
+
session: AgentSession,
|
|
277
|
+
prompt: string,
|
|
278
|
+
options: { onToolActivity?: (activity: ToolActivity) => void; signal?: AbortSignal } = {},
|
|
279
|
+
): Promise<string> {
|
|
280
|
+
const collector = collectResponseText(session);
|
|
281
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
282
|
+
|
|
283
|
+
const unsubToolUse = options.onToolActivity
|
|
284
|
+
? session.subscribe((event: AgentSessionEvent) => {
|
|
285
|
+
if (event.type === "tool_execution_start") options.onToolActivity!({ type: "start", toolName: event.toolName });
|
|
286
|
+
if (event.type === "tool_execution_end") options.onToolActivity!({ type: "end", toolName: event.toolName });
|
|
287
|
+
})
|
|
288
|
+
: () => {};
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await session.prompt(prompt);
|
|
292
|
+
} finally {
|
|
293
|
+
collector.unsubscribe();
|
|
294
|
+
unsubToolUse();
|
|
295
|
+
cleanupAbort();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return collector.getText();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Send a steering message to a running subagent.
|
|
303
|
+
* The message will interrupt the agent after its current tool execution.
|
|
304
|
+
*/
|
|
305
|
+
export async function steerAgent(
|
|
306
|
+
session: AgentSession,
|
|
307
|
+
message: string,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
await session.steer(message);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get the subagent's conversation messages as formatted text.
|
|
314
|
+
*/
|
|
315
|
+
export function getAgentConversation(session: AgentSession): string {
|
|
316
|
+
const parts: string[] = [];
|
|
317
|
+
|
|
318
|
+
for (const msg of session.messages) {
|
|
319
|
+
if (msg.role === "user") {
|
|
320
|
+
const text = typeof msg.content === "string"
|
|
321
|
+
? msg.content
|
|
322
|
+
: extractText(msg.content);
|
|
323
|
+
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
324
|
+
} else if (msg.role === "assistant") {
|
|
325
|
+
const textParts: string[] = [];
|
|
326
|
+
const toolCalls: string[] = [];
|
|
327
|
+
for (const c of msg.content) {
|
|
328
|
+
if (c.type === "text" && c.text) textParts.push(c.text);
|
|
329
|
+
else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).toolName ?? "unknown"}`);
|
|
330
|
+
}
|
|
331
|
+
if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
332
|
+
if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
|
|
333
|
+
} else if (msg.role === "toolResult") {
|
|
334
|
+
const text = extractText(msg.content);
|
|
335
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
336
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return parts.join("\n\n");
|
|
341
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-types.ts — Agent type registry: tool sets and configs per subagent type.
|
|
3
|
+
*
|
|
4
|
+
* Supports both built-in types and custom agents loaded from .pi/agents/*.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createReadTool,
|
|
9
|
+
createBashTool,
|
|
10
|
+
createEditTool,
|
|
11
|
+
createWriteTool,
|
|
12
|
+
createGrepTool,
|
|
13
|
+
createFindTool,
|
|
14
|
+
createLsTool,
|
|
15
|
+
} from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
17
|
+
import type { BuiltinSubagentType, SubagentType, SubagentTypeConfig, CustomAgentConfig } from "./types.js";
|
|
18
|
+
|
|
19
|
+
type ToolFactory = (cwd: string) => AgentTool<any>;
|
|
20
|
+
|
|
21
|
+
const TOOL_FACTORIES: Record<string, ToolFactory> = {
|
|
22
|
+
read: (cwd) => createReadTool(cwd),
|
|
23
|
+
bash: (cwd) => createBashTool(cwd),
|
|
24
|
+
edit: (cwd) => createEditTool(cwd),
|
|
25
|
+
write: (cwd) => createWriteTool(cwd),
|
|
26
|
+
grep: (cwd) => createGrepTool(cwd),
|
|
27
|
+
find: (cwd) => createFindTool(cwd),
|
|
28
|
+
ls: (cwd) => createLsTool(cwd),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** All known built-in tool names, derived from the factory registry. */
|
|
32
|
+
export const BUILTIN_TOOL_NAMES = Object.keys(TOOL_FACTORIES);
|
|
33
|
+
|
|
34
|
+
const BUILTIN_CONFIGS: Record<BuiltinSubagentType, SubagentTypeConfig> = {
|
|
35
|
+
"general-purpose": {
|
|
36
|
+
displayName: "Agent",
|
|
37
|
+
description: "General-purpose agent for complex, multi-step tasks",
|
|
38
|
+
builtinToolNames: BUILTIN_TOOL_NAMES,
|
|
39
|
+
extensions: true,
|
|
40
|
+
skills: true,
|
|
41
|
+
},
|
|
42
|
+
"Explore": {
|
|
43
|
+
displayName: "Explore",
|
|
44
|
+
description: "Fast codebase exploration agent (read-only)",
|
|
45
|
+
builtinToolNames: ["read", "bash", "grep", "find", "ls"],
|
|
46
|
+
extensions: true,
|
|
47
|
+
skills: true,
|
|
48
|
+
},
|
|
49
|
+
"Plan": {
|
|
50
|
+
displayName: "Plan",
|
|
51
|
+
description: "Software architect for implementation planning (read-only)",
|
|
52
|
+
builtinToolNames: ["read", "bash", "grep", "find", "ls"],
|
|
53
|
+
extensions: true,
|
|
54
|
+
skills: true,
|
|
55
|
+
},
|
|
56
|
+
"statusline-setup": {
|
|
57
|
+
displayName: "Config",
|
|
58
|
+
description: "Configuration editor (read + edit only)",
|
|
59
|
+
builtinToolNames: ["read", "edit"],
|
|
60
|
+
extensions: false,
|
|
61
|
+
skills: false,
|
|
62
|
+
},
|
|
63
|
+
"claude-code-guide": {
|
|
64
|
+
displayName: "Guide",
|
|
65
|
+
description: "Documentation and help queries",
|
|
66
|
+
builtinToolNames: ["read", "grep", "find"],
|
|
67
|
+
extensions: false,
|
|
68
|
+
skills: false,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Runtime registry of custom agent configs. */
|
|
73
|
+
const customAgents = new Map<string, CustomAgentConfig>();
|
|
74
|
+
|
|
75
|
+
/** Register custom agents into the runtime registry. */
|
|
76
|
+
export function registerCustomAgents(agents: Map<string, CustomAgentConfig>): void {
|
|
77
|
+
customAgents.clear();
|
|
78
|
+
for (const [name, config] of agents) {
|
|
79
|
+
customAgents.set(name, config);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Get the custom agent config if it exists. */
|
|
84
|
+
export function getCustomAgentConfig(name: string): CustomAgentConfig | undefined {
|
|
85
|
+
return customAgents.get(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get all available type names (built-in + custom). */
|
|
89
|
+
export function getAvailableTypes(): string[] {
|
|
90
|
+
return [...Object.keys(BUILTIN_CONFIGS), ...customAgents.keys()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Get all custom agent names. */
|
|
94
|
+
export function getCustomAgentNames(): string[] {
|
|
95
|
+
return [...customAgents.keys()];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Check if a type is valid (built-in or custom). */
|
|
99
|
+
export function isValidType(type: string): boolean {
|
|
100
|
+
return type in BUILTIN_CONFIGS || customAgents.has(type);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Get built-in tools for a type. Works for both built-in and custom agents. */
|
|
104
|
+
export function getToolsForType(type: SubagentType, cwd: string): AgentTool<any>[] {
|
|
105
|
+
const config = BUILTIN_CONFIGS[type as BuiltinSubagentType];
|
|
106
|
+
if (config) {
|
|
107
|
+
return config.builtinToolNames.map((n) => TOOL_FACTORIES[n](cwd));
|
|
108
|
+
}
|
|
109
|
+
const custom = customAgents.get(type);
|
|
110
|
+
if (custom) {
|
|
111
|
+
return custom.builtinToolNames
|
|
112
|
+
.filter((n) => n in TOOL_FACTORIES)
|
|
113
|
+
.map((n) => TOOL_FACTORIES[n](cwd));
|
|
114
|
+
}
|
|
115
|
+
// Fallback: all tools
|
|
116
|
+
return BUILTIN_TOOL_NAMES.map((n) => TOOL_FACTORIES[n](cwd));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Get config for a type. Works for both built-in and custom agents. */
|
|
120
|
+
export function getConfig(type: SubagentType): SubagentTypeConfig {
|
|
121
|
+
const builtin = BUILTIN_CONFIGS[type as BuiltinSubagentType];
|
|
122
|
+
if (builtin) return builtin;
|
|
123
|
+
|
|
124
|
+
const custom = customAgents.get(type);
|
|
125
|
+
if (custom) {
|
|
126
|
+
return {
|
|
127
|
+
displayName: custom.name,
|
|
128
|
+
description: custom.description,
|
|
129
|
+
builtinToolNames: custom.builtinToolNames,
|
|
130
|
+
extensions: custom.extensions,
|
|
131
|
+
skills: custom.skills,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fallback for unknown types — general-purpose config
|
|
136
|
+
return BUILTIN_CONFIGS["general-purpose"];
|
|
137
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context.ts — Extract parent conversation context for subagent inheritance.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** Extract text from a message content block array. */
|
|
8
|
+
export function extractText(content: unknown[]): string {
|
|
9
|
+
return content
|
|
10
|
+
.filter((c: any) => c.type === "text")
|
|
11
|
+
.map((c: any) => c.text ?? "")
|
|
12
|
+
.join("\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a text representation of the parent conversation context.
|
|
17
|
+
* Used when inherit_context is true to give the subagent visibility
|
|
18
|
+
* into what has been discussed/done so far.
|
|
19
|
+
*/
|
|
20
|
+
export function buildParentContext(ctx: ExtensionContext): string {
|
|
21
|
+
const entries = ctx.sessionManager.getBranch();
|
|
22
|
+
if (!entries || entries.length === 0) return "";
|
|
23
|
+
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (entry.type === "message") {
|
|
28
|
+
const msg = entry.message;
|
|
29
|
+
if (msg.role === "user") {
|
|
30
|
+
const text = typeof msg.content === "string"
|
|
31
|
+
? msg.content
|
|
32
|
+
: extractText(msg.content);
|
|
33
|
+
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
34
|
+
} else if (msg.role === "assistant") {
|
|
35
|
+
const text = extractText(msg.content);
|
|
36
|
+
if (text.trim()) parts.push(`[Assistant]: ${text.trim()}`);
|
|
37
|
+
}
|
|
38
|
+
// Skip toolResult messages — too verbose for context
|
|
39
|
+
} else if (entry.type === "compaction") {
|
|
40
|
+
// Include compaction summaries — they're already condensed
|
|
41
|
+
if (entry.summary) {
|
|
42
|
+
parts.push(`[Summary]: ${entry.summary}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (parts.length === 0) return "";
|
|
48
|
+
|
|
49
|
+
return `# Parent Conversation Context
|
|
50
|
+
The following is the conversation history from the parent session that spawned you.
|
|
51
|
+
Use this context to understand what has been discussed and decided so far.
|
|
52
|
+
|
|
53
|
+
${parts.join("\n\n")}
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
# Your Task (below)
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* custom-agents.ts — Load user-defined agents from .pi/agents/*.md files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
7
|
+
import { join, basename } from "node:path";
|
|
8
|
+
import { SUBAGENT_TYPES, type CustomAgentConfig, type ThinkingLevel } from "./types.js";
|
|
9
|
+
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scan .pi/agents/*.md and return a map of custom agent configs.
|
|
13
|
+
* Filename (without .md) becomes the agent name.
|
|
14
|
+
*/
|
|
15
|
+
export function loadCustomAgents(cwd: string): Map<string, CustomAgentConfig> {
|
|
16
|
+
const dir = join(cwd, ".pi", "agents");
|
|
17
|
+
if (!existsSync(dir)) return new Map();
|
|
18
|
+
|
|
19
|
+
let files: string[];
|
|
20
|
+
try {
|
|
21
|
+
files = readdirSync(dir).filter(f => f.endsWith(".md"));
|
|
22
|
+
} catch {
|
|
23
|
+
return new Map();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const agents = new Map<string, CustomAgentConfig>();
|
|
27
|
+
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const name = basename(file, ".md");
|
|
30
|
+
if ((SUBAGENT_TYPES as readonly string[]).includes(name)) continue;
|
|
31
|
+
|
|
32
|
+
let content: string;
|
|
33
|
+
try {
|
|
34
|
+
content = readFileSync(join(dir, file), "utf-8");
|
|
35
|
+
} catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
|
|
40
|
+
|
|
41
|
+
agents.set(name, {
|
|
42
|
+
name,
|
|
43
|
+
description: str(fm.description) ?? name,
|
|
44
|
+
builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
|
|
45
|
+
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
46
|
+
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
47
|
+
model: str(fm.model),
|
|
48
|
+
thinking: str(fm.thinking) as ThinkingLevel | undefined,
|
|
49
|
+
maxTurns: positiveInt(fm.max_turns),
|
|
50
|
+
systemPrompt: body.trim(),
|
|
51
|
+
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
|
|
52
|
+
inheritContext: fm.inherit_context === true,
|
|
53
|
+
runInBackground: fm.run_in_background === true,
|
|
54
|
+
isolated: fm.isolated === true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return agents;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- Field parsers ----
|
|
62
|
+
// All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
|
|
63
|
+
|
|
64
|
+
/** Extract a string or undefined. */
|
|
65
|
+
function str(val: unknown): string | undefined {
|
|
66
|
+
return typeof val === "string" ? val : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract a positive integer or undefined. */
|
|
70
|
+
function positiveInt(val: unknown): number | undefined {
|
|
71
|
+
return typeof val === "number" && val >= 1 ? val : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a comma-separated list field.
|
|
76
|
+
* omitted → defaults; "none"/empty → []; csv → listed items.
|
|
77
|
+
*/
|
|
78
|
+
function csvList(val: unknown, defaults: string[]): string[] {
|
|
79
|
+
if (val === undefined || val === null) return defaults;
|
|
80
|
+
const s = String(val).trim();
|
|
81
|
+
if (!s || s === "none") return [];
|
|
82
|
+
return s.split(",").map(t => t.trim()).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse an inherit field (extensions, skills).
|
|
87
|
+
* omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
|
|
88
|
+
*/
|
|
89
|
+
function inheritField(val: unknown): true | string[] | false {
|
|
90
|
+
if (val === undefined || val === null || val === true) return true;
|
|
91
|
+
if (val === false || val === "none") return false;
|
|
92
|
+
const items = csvList(val, []);
|
|
93
|
+
return items.length > 0 ? items : false;
|
|
94
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env.ts — Detect environment info (git, platform) for subagent system prompts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { EnvInfo } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo> {
|
|
9
|
+
let isGitRepo = false;
|
|
10
|
+
let branch = "";
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
|
|
14
|
+
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
|
|
15
|
+
} catch {
|
|
16
|
+
// Not a git repo or git not installed
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (isGitRepo) {
|
|
20
|
+
try {
|
|
21
|
+
const result = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
|
|
22
|
+
branch = result.code === 0 ? result.stdout.trim() : "unknown";
|
|
23
|
+
} catch {
|
|
24
|
+
branch = "unknown";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
isGitRepo,
|
|
30
|
+
branch,
|
|
31
|
+
platform: process.platform,
|
|
32
|
+
};
|
|
33
|
+
}
|