@harms-haus/pi-subagents 0.1.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.
@@ -0,0 +1,151 @@
1
+ import type { SubagentSessionData, ToolCallPart } from "./types";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
+ import { extractTextParts } from "./utils";
4
+
5
+ const TOOL_CALL_PREVIEW_LENGTH = 120;
6
+ const TOOL_RESULT_TRUNCATION_LENGTH = 500;
7
+
8
+ /**
9
+ * Options controlling how transcript runs are formatted.
10
+ */
11
+ export interface TranscriptOptions {
12
+ /** Whether to include user messages in the transcript */
13
+ includeUserMessages: boolean;
14
+ /** Prefix for user messages */
15
+ userPrefix: string;
16
+ /** Prefix for assistant text messages */
17
+ assistantPrefix: string;
18
+ /** Prefix format for tool calls. Use `{name}` and `{args}` placeholders */
19
+ toolCallPrefix: string;
20
+ /** Prefix for tool result messages */
21
+ toolResultPrefix: string;
22
+ /** Maximum characters for tool result text before truncation */
23
+ toolResultTruncation: number;
24
+ /** Maximum characters for tool call arguments preview */
25
+ toolCallPreviewLength: number;
26
+ /** Separator used between formatted parts/lines */
27
+ partSeparator: string;
28
+ /**
29
+ * Function to format the run header when there are multiple runs.
30
+ * Receives (runIndex, totalRuns, run). Return undefined to skip header.
31
+ */
32
+ runHeader: (runIndex: number, totalRuns: number, run: SubagentSessionData) => string | undefined;
33
+ }
34
+
35
+ /** Format tool calls from an assistant message's content. */
36
+ function formatToolCalls(
37
+ content: Message["content"],
38
+ prefix: string,
39
+ previewLength: number,
40
+ ): string[] {
41
+ if (typeof content === "string" || !Array.isArray(content)) return [];
42
+ const results: string[] = [];
43
+ for (const part of content) {
44
+ if (part.type === "toolCall") {
45
+ const toolCall = part as ToolCallPart;
46
+ const args = JSON.stringify(toolCall.arguments || {}).slice(0, previewLength);
47
+ results.push(prefix.replace("{name}", toolCall.name).replace("{args}", args));
48
+ }
49
+ }
50
+ return results;
51
+ }
52
+
53
+ /** Format a single message into transcript lines. */
54
+ function formatMessage(msg: Message, options: TranscriptOptions): string[] {
55
+ const parts: string[] = [];
56
+
57
+ if (msg.role === "user" && options.includeUserMessages) {
58
+ const text = getTextContent(msg);
59
+ if (text) parts.push(`${options.userPrefix}${text}`);
60
+ } else if (msg.role === "assistant") {
61
+ const text = getTextContent(msg);
62
+ if (text) parts.push(`${options.assistantPrefix}${text}`);
63
+ parts.push(
64
+ ...formatToolCalls(msg.content, options.toolCallPrefix, options.toolCallPreviewLength),
65
+ );
66
+ } else if (msg.role === "toolResult") {
67
+ const text = getTextContent(msg);
68
+ if (text) {
69
+ const truncated =
70
+ text.length > options.toolResultTruncation
71
+ ? `${text.slice(0, options.toolResultTruncation)}...`
72
+ : text;
73
+ parts.push(`${options.toolResultPrefix}${truncated}`);
74
+ }
75
+ }
76
+
77
+ return parts;
78
+ }
79
+
80
+ /**
81
+ * Format a complete transcript from an array of runs.
82
+ * Iterates over runs, extracts text/tool calls/tool results from messages,
83
+ * applies role prefixes, truncates tool call args and tool results, and
84
+ * adds run separators.
85
+ */
86
+ export function formatTranscript(runs: SubagentSessionData[], options: TranscriptOptions): string {
87
+ const parts: string[] = [];
88
+
89
+ for (let i = 0; i < runs.length; i++) {
90
+ const run = runs[i];
91
+ if (!run) continue;
92
+ const header = options.runHeader(i, runs.length, run);
93
+ if (header) parts.push(header);
94
+
95
+ for (const msg of run.messages) {
96
+ parts.push(...formatMessage(msg, options));
97
+ }
98
+
99
+ if (run.errorMessage) {
100
+ parts.push(`[Error: ${run.errorMessage}]`);
101
+ }
102
+ }
103
+
104
+ return parts.join(options.partSeparator);
105
+ }
106
+
107
+ /**
108
+ * Options for formatting runs when resuming a sub-agent session.
109
+ */
110
+ export const RESUME_OPTIONS: TranscriptOptions = {
111
+ includeUserMessages: true,
112
+ userPrefix: "User: ",
113
+ assistantPrefix: "Assistant: ",
114
+ toolCallPrefix: "Tool Call: {name}({args})",
115
+ toolResultPrefix: "Tool Result: ",
116
+ toolResultTruncation: TOOL_RESULT_TRUNCATION_LENGTH,
117
+ toolCallPreviewLength: TOOL_CALL_PREVIEW_LENGTH,
118
+ partSeparator: "\n\n",
119
+ runHeader: (i, total, run) =>
120
+ total > 1 ? `--- Run ${i + 1} (${run.status}, ${run.messages.length} messages) ---` : undefined,
121
+ };
122
+
123
+ /**
124
+ * Options for formatting runs when retrieving a session transcript via tool.
125
+ */
126
+ export const RETRIEVAL_OPTIONS: TranscriptOptions = {
127
+ includeUserMessages: false,
128
+ userPrefix: "",
129
+ assistantPrefix: "",
130
+ toolCallPrefix: "→ {name}: {args}",
131
+ toolResultPrefix: "[tool result]: ",
132
+ toolResultTruncation: TOOL_RESULT_TRUNCATION_LENGTH,
133
+ toolCallPreviewLength: TOOL_CALL_PREVIEW_LENGTH,
134
+ partSeparator: "\n---\n",
135
+ runHeader: (i, total, run) =>
136
+ total > 1 ? `=== Run ${i + 1}/${total} (${run.status}) ===` : undefined,
137
+ };
138
+
139
+ /**
140
+ * Format previous runs' session data for inclusion in a resume prompt.
141
+ * Produces a human-readable transcript of all previous runs.
142
+ */
143
+ export function formatRunsForResume(runs: SubagentSessionData[]): string {
144
+ return formatTranscript(runs, RESUME_OPTIONS);
145
+ }
146
+
147
+ /** Extract text content from a Message */
148
+ export function getTextContent(msg: { content?: unknown }): string | undefined {
149
+ const parts = extractTextParts(msg);
150
+ return parts.length > 0 ? parts.join("\n") : undefined;
151
+ }
package/src/index.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * pi-subagents Extension
3
+ *
4
+ * Allows the main agent to spawn parallel sub-agents, with each sub-agent's
5
+ * latest output rendered in a rolling TUI window inline with the main agent's
6
+ * conversation history.
7
+ *
8
+ * Supports named profiles defined as markdown files in the
9
+ * agent-profiles/ directory, each pre-configuring provider/model, system
10
+ * prompts, thinking levels, and other model settings per profile.
11
+ *
12
+ * Tools provided:
13
+ * delegate_to_subagents — spawn parallel sub-agents
14
+ * get_subagent_output — retrieve last assistant text from a sub-agent session
15
+ * get_subagent_session — retrieve full session transcript from a sub-agent
16
+ * list_subagent_profiles — list available named profiles
17
+ *
18
+ * Usage (from the LLM):
19
+ * delegate_to_subagents({ tasks: [{ name: "test", prompt: "...", profile: "code-reviewer" }] })
20
+ * get_subagent_output({ sessionId: "abc12345def45678" })
21
+ * get_subagent_session({ sessionId: "abc12345def45678" })
22
+ * list_subagent_profiles({})
23
+ */
24
+
25
+ import { registerProfileCommand } from "./commands/profile";
26
+ import { registerDelegateTool } from "./tools/delegate";
27
+ import { registerRetrievalTools } from "./tools/retrieval";
28
+ import { CUSTOM_ENTRY_TYPE, deserializeSessionData } from "./types";
29
+ import type { SessionRecord, SubagentSessionData } from "./types";
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+
32
+ export default function (pi: ExtensionAPI) {
33
+ // ── In-memory session store ──────────────────────────────────────
34
+ const sessionStore = new Map<string, SessionRecord>();
35
+ const MAX_STORED_SESSIONS = 32;
36
+ const MAX_RUNS_PER_SESSION = 10;
37
+
38
+ function registerSession(session: SubagentSessionData): void {
39
+ const existing = sessionStore.get(session.sessionId);
40
+ if (existing) {
41
+ // Resume: append to existing record
42
+ existing.runs.push(session);
43
+ // Cap runs per session to prevent unbounded memory growth
44
+ while (existing.runs.length > MAX_RUNS_PER_SESSION) {
45
+ existing.runs.shift();
46
+ }
47
+ } else {
48
+ // New session: create a new record
49
+ if (sessionStore.size >= MAX_STORED_SESSIONS) {
50
+ let oldestKey: string | null = null;
51
+ let oldestTime = Infinity;
52
+ for (const [key, val] of sessionStore) {
53
+ // Skip sessions with running tasks — don't evict active sessions
54
+ if (val.runs.some((r) => r.status === "running")) continue;
55
+ const firstRun = val.runs[0];
56
+ if (firstRun && firstRun.startedAt < oldestTime) {
57
+ oldestTime = firstRun.startedAt;
58
+ oldestKey = key;
59
+ }
60
+ }
61
+ if (oldestKey) {
62
+ sessionStore.delete(oldestKey);
63
+ }
64
+ }
65
+ sessionStore.set(session.sessionId, { runs: [session] });
66
+ }
67
+ }
68
+
69
+ function getActiveSessionIds(): Set<string> {
70
+ const active = new Set<string>();
71
+ for (const [, record] of sessionStore) {
72
+ if (record.runs.some((r) => r.status === "running")) {
73
+ const firstRun = record.runs[0];
74
+ if (firstRun) {
75
+ active.add(firstRun.sessionId);
76
+ }
77
+ }
78
+ }
79
+ return active;
80
+ }
81
+
82
+ // Reconstruct session store from persisted custom entries on session load
83
+ pi.on("session_start", (event, ctx) => {
84
+ // New sessions have no prior data to reconstruct
85
+ if (event.reason === "new") {
86
+ return;
87
+ }
88
+
89
+ try {
90
+ const entries = ctx.sessionManager.getEntries();
91
+ for (const entry of entries) {
92
+ if (
93
+ entry.type === "custom" &&
94
+ "customType" in entry &&
95
+ entry.customType === CUSTOM_ENTRY_TYPE
96
+ ) {
97
+ const sessionData = deserializeSessionData(entry.data);
98
+ if (sessionData) {
99
+ registerSession(sessionData);
100
+ }
101
+ }
102
+ }
103
+ } catch (err) {
104
+ console.warn("[pi-subagents] Failed to reconstruct session data:", err);
105
+ }
106
+ });
107
+
108
+ pi.on("session_shutdown", () => {
109
+ sessionStore.clear();
110
+ });
111
+
112
+ // ── Register tools and commands ──────────────────────────────────
113
+
114
+ registerDelegateTool(pi, sessionStore, registerSession, getActiveSessionIds);
115
+ registerRetrievalTools(pi, sessionStore);
116
+ registerProfileCommand(pi);
117
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Interactive Profile Editor
3
+ *
4
+ * Interactive wizard for creating and editing subagent profiles.
5
+ * Uses the extension API's UI methods to prompt for profile settings.
6
+ */
7
+
8
+ import {
9
+ formatProfileDetail,
10
+ saveProfile,
11
+ type ProfileScope,
12
+ type SubagentProfile,
13
+ type ThinkingLevel,
14
+ } from "./profiles";
15
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
16
+
17
+ /** Subset of ExtensionContext used by the profile editor. */
18
+ type ProfileEditorUIContext = ExtensionUIContext;
19
+
20
+ type ProfileEditorContext = {
21
+ ui: ProfileEditorUIContext;
22
+ cwd: string;
23
+ };
24
+
25
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
26
+
27
+ /** Sentinel returned by step helpers when the user cancels. */
28
+ const CANCELLED = Symbol("CANCELLED");
29
+ type StepResult = typeof CANCELLED | true;
30
+
31
+ // ── Individual Editor Steps ──────────────────────────────────────────
32
+
33
+ async function promptScope(
34
+ ui: ProfileEditorUIContext,
35
+ name: string,
36
+ ): Promise<ProfileScope | typeof CANCELLED> {
37
+ const scope = await ui.select("Save to which scope?", [
38
+ `Global (~/.pi/agent-profiles/${name}.md)`,
39
+ `Project (.pi/agent-profiles/${name}.md)`,
40
+ ]);
41
+ if (!scope) return CANCELLED;
42
+ return scope.startsWith("Global") ? "global" : "project";
43
+ }
44
+
45
+ async function promptProvider(
46
+ ui: ProfileEditorUIContext,
47
+ profile: SubagentProfile,
48
+ ): Promise<StepResult> {
49
+ const value = await ui.input(
50
+ "Provider (e.g. anthropic, openai, dashscope):",
51
+ profile.provider ?? "",
52
+ );
53
+ if (value === undefined) return CANCELLED;
54
+ if (value) {
55
+ profile.provider = value;
56
+ } else {
57
+ delete profile.provider;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ async function promptModel(
63
+ ui: ProfileEditorUIContext,
64
+ profile: SubagentProfile,
65
+ ): Promise<StepResult> {
66
+ const value = await ui.input(
67
+ "Model (supports provider/id and :thinking shorthand):",
68
+ profile.model ?? "",
69
+ );
70
+ if (value === undefined) return CANCELLED;
71
+ if (value) {
72
+ profile.model = value;
73
+ } else {
74
+ delete profile.model;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ async function promptSystemPrompt(
80
+ ui: ProfileEditorUIContext,
81
+ profile: SubagentProfile,
82
+ ): Promise<StepResult> {
83
+ const hasSystem = await ui.confirm(
84
+ "System prompt?",
85
+ profile.systemPrompt
86
+ ? "A custom system prompt is set. Keep it?"
87
+ : "Set a custom system prompt?",
88
+ );
89
+ if (hasSystem) {
90
+ const sp = await ui.editor(
91
+ "System prompt:",
92
+ profile.systemPrompt ?? "You are a helpful coding assistant.",
93
+ );
94
+ if (sp === undefined) return CANCELLED;
95
+ profile.systemPrompt = sp;
96
+ } else {
97
+ delete profile.systemPrompt;
98
+ }
99
+ return true;
100
+ }
101
+
102
+ async function promptAppendSystemPrompt(
103
+ ui: ProfileEditorUIContext,
104
+ profile: SubagentProfile,
105
+ ): Promise<StepResult> {
106
+ const hasAppend = await ui.confirm(
107
+ "Append to system prompt?",
108
+ profile.appendSystemPrompt
109
+ ? "An appended system prompt is set. Keep it?"
110
+ : "Append text to the default system prompt?",
111
+ );
112
+ if (hasAppend) {
113
+ const ap = await ui.input("Append text:", profile.appendSystemPrompt ?? "");
114
+ if (ap === undefined) return CANCELLED;
115
+ if (ap) {
116
+ profile.appendSystemPrompt = ap;
117
+ }
118
+ } else {
119
+ delete profile.appendSystemPrompt;
120
+ }
121
+ return true;
122
+ }
123
+
124
+ async function promptThinkingLevel(
125
+ ui: ProfileEditorUIContext,
126
+ profile: SubagentProfile,
127
+ ): Promise<StepResult> {
128
+ const hasThinking = await ui.confirm(
129
+ "Thinking level?",
130
+ profile.thinkingLevel
131
+ ? `Thinking level is ${profile.thinkingLevel}. Set one?`
132
+ : "Set a thinking level?",
133
+ );
134
+ if (hasThinking) {
135
+ const tl = await ui.select("Thinking level:", THINKING_LEVELS);
136
+ if (tl) {
137
+ profile.thinkingLevel = tl as ThinkingLevel;
138
+ }
139
+ } else {
140
+ delete profile.thinkingLevel;
141
+ }
142
+ return true;
143
+ }
144
+
145
+ async function promptToolListInput(
146
+ ui: ProfileEditorUIContext,
147
+ label: string,
148
+ current: string | undefined,
149
+ ): Promise<string[] | undefined> {
150
+ const str = await ui.input(label, current ?? "");
151
+ if (str === undefined) return undefined;
152
+ if (str.trim()) {
153
+ return str
154
+ .split(",")
155
+ .map((t: string) => t.trim())
156
+ .filter(Boolean);
157
+ }
158
+ return [];
159
+ }
160
+
161
+ async function promptToolsConfig(
162
+ ui: ProfileEditorUIContext,
163
+ profile: SubagentProfile,
164
+ ): Promise<StepResult> {
165
+ const hasTools = await ui.confirm(
166
+ "Configure tools?",
167
+ profile.tools || profile.excludeTools || profile.noTools
168
+ ? "Tool config is set. Change it?"
169
+ : "Restrict which tools the subagent can use?",
170
+ );
171
+ if (!hasTools) return true;
172
+
173
+ const noTools = await ui.confirm("Disable all tools?", "");
174
+ if (noTools) {
175
+ profile.noTools = true;
176
+ delete profile.tools;
177
+ delete profile.excludeTools;
178
+ return true;
179
+ }
180
+
181
+ delete profile.noTools;
182
+ const toolMode = await ui.select("Select tool mode:", [
183
+ "Allowlist (only these tools)",
184
+ "Blacklist (all tools except these)",
185
+ ]);
186
+ if (!toolMode) return CANCELLED;
187
+
188
+ if (toolMode.startsWith("Blacklist")) {
189
+ delete profile.tools;
190
+ const list = await promptToolListInput(
191
+ ui,
192
+ "Tool blacklist (comma-separated, e.g. read,bash,grep):",
193
+ profile.excludeTools?.join(","),
194
+ );
195
+ if (list === undefined) return CANCELLED;
196
+ if (list.length > 0) {
197
+ profile.excludeTools = list;
198
+ } else {
199
+ delete profile.excludeTools;
200
+ }
201
+ } else {
202
+ delete profile.excludeTools;
203
+ const list = await promptToolListInput(
204
+ ui,
205
+ "Tool allowlist (comma-separated, e.g. read,bash,grep):",
206
+ profile.tools?.join(","),
207
+ );
208
+ if (list === undefined) return CANCELLED;
209
+ if (list.length > 0) {
210
+ profile.tools = list;
211
+ } else {
212
+ delete profile.tools;
213
+ }
214
+ }
215
+ return true;
216
+ }
217
+
218
+ async function promptExtensionsConfig(
219
+ ui: ProfileEditorUIContext,
220
+ profile: SubagentProfile,
221
+ ): Promise<StepResult> {
222
+ const hasExts = await ui.confirm(
223
+ "Configure extensions?",
224
+ profile.noExtensions || profile.extensions
225
+ ? "Extension config is set. Change it?"
226
+ : "Configure extension loading?",
227
+ );
228
+ if (!hasExts) return true;
229
+
230
+ const noExt = await ui.confirm("Disable all extensions?", "");
231
+ if (noExt) {
232
+ profile.noExtensions = true;
233
+ delete profile.extensions;
234
+ return true;
235
+ }
236
+
237
+ delete profile.noExtensions;
238
+ const extStr = await ui.input(
239
+ "Extension paths (comma-separated):",
240
+ profile.extensions?.join(",") ?? "",
241
+ );
242
+ if (extStr === undefined) return CANCELLED;
243
+ if (extStr.trim()) {
244
+ profile.extensions = extStr
245
+ .split(",")
246
+ .map((e: string) => e.trim())
247
+ .filter(Boolean);
248
+ } else {
249
+ delete profile.extensions;
250
+ }
251
+ return true;
252
+ }
253
+
254
+ async function promptSkillsConfig(
255
+ ui: ProfileEditorUIContext,
256
+ profile: SubagentProfile,
257
+ ): Promise<StepResult> {
258
+ if (profile.suggestedSkills || profile.loadSkills) {
259
+ const remove = await ui.confirm(
260
+ "Remove skills?",
261
+ "Skill config is set. Remove existing skill configuration?",
262
+ );
263
+ if (remove) {
264
+ delete profile.suggestedSkills;
265
+ delete profile.loadSkills;
266
+ }
267
+ return true;
268
+ }
269
+
270
+ const add = await ui.confirm("Configure skills?", "Configure skill loading for the subagent?");
271
+ if (!add) return true;
272
+
273
+ const suggestedStr = await ui.input(
274
+ "Suggested skills (comma-separated skill names, model chooses to load):",
275
+ "",
276
+ );
277
+ if (suggestedStr === undefined) return CANCELLED;
278
+ if (suggestedStr.trim()) {
279
+ profile.suggestedSkills = suggestedStr
280
+ .split(",")
281
+ .map((s: string) => s.trim())
282
+ .filter(Boolean);
283
+ }
284
+
285
+ const loadStr = await ui.input(
286
+ "Pre-loaded skills (comma-separated skill names, content injected into system prompt):",
287
+ "",
288
+ );
289
+ if (loadStr === undefined) return CANCELLED;
290
+ if (loadStr.trim()) {
291
+ profile.loadSkills = loadStr
292
+ .split(",")
293
+ .map((s: string) => s.trim())
294
+ .filter(Boolean);
295
+ }
296
+ return true;
297
+ }
298
+
299
+ // ── Main Wizard ──────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Interactive profile editor wizard.
303
+ *
304
+ * Prompts the user through a series of UI dialogs to configure or edit a profile.
305
+ * Returns undefined if the user cancels at any point.
306
+ */
307
+ export async function editProfileInteractive(
308
+ name: string,
309
+ initial: SubagentProfile,
310
+ ctx: ProfileEditorContext,
311
+ ): Promise<void> {
312
+ const profile = { ...initial };
313
+
314
+ const scopeValue = await promptScope(ctx.ui, name);
315
+ if (scopeValue === CANCELLED) return;
316
+
317
+ let result: StepResult;
318
+
319
+ result = await promptProvider(ctx.ui, profile);
320
+ if (result === CANCELLED) return;
321
+
322
+ result = await promptModel(ctx.ui, profile);
323
+ if (result === CANCELLED) return;
324
+
325
+ result = await promptSystemPrompt(ctx.ui, profile);
326
+ if (result === CANCELLED) return;
327
+
328
+ result = await promptAppendSystemPrompt(ctx.ui, profile);
329
+ if (result === CANCELLED) return;
330
+
331
+ result = await promptThinkingLevel(ctx.ui, profile);
332
+ if (result === CANCELLED) return;
333
+
334
+ result = await promptToolsConfig(ctx.ui, profile);
335
+ if (result === CANCELLED) return;
336
+
337
+ result = await promptExtensionsConfig(ctx.ui, profile);
338
+ if (result === CANCELLED) return;
339
+
340
+ result = await promptSkillsConfig(ctx.ui, profile);
341
+ if (result === CANCELLED) return;
342
+
343
+ // Review and save
344
+ const summary = formatProfileDetail(name, profile);
345
+ const confirmed = await ctx.ui.confirm(
346
+ `Save profile "${name}"?`,
347
+ `${summary}\n\nSave this profile?`,
348
+ );
349
+ if (!confirmed) {
350
+ ctx.ui.notify("Cancelled.", "info");
351
+ return;
352
+ }
353
+
354
+ await saveProfile(name, profile, scopeValue, ctx.cwd);
355
+ ctx.ui.notify(`Profile "${name}" saved to ${scopeValue} agent-profiles.`, "info");
356
+ }