pi-subagent-in-memory 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.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # pi-subagent-in-memory
2
+
3
+ In-process subagent tool for [pi](https://github.com/nicholasgasior/pi-coding-agent) with live TUI card widgets, JSONL session logging, and **zero system-prompt overhead**.
4
+
5
+ ![Parallel subagents running with live TUI card widgets](media/parallel-subagents.png)
6
+
7
+ ## Key Design Principle
8
+
9
+ **This extension adds nothing to your LLM context beyond tool parameter definitions.** No system prompt injection, no hidden instructions, no pre-determined behavior โ€” the LLM only sees the `subagent_create` tool schema and decides how to use it naturally.
10
+
11
+ ## Features
12
+
13
+ ### ๐Ÿค– `subagent_create` Tool
14
+
15
+ Spawns an in-process subagent session using pi's `createAgentSession` SDK. The subagent runs in the same process (not a subprocess), with its own session, tools, and model. Multiple subagents can run in parallel.
16
+
17
+ **Tool parameters:**
18
+
19
+ | Parameter | Type | Required | Description |
20
+ |-----------|------|----------|-------------|
21
+ | `task` | string | โœ… | The task for the subagent to perform |
22
+ | `title` | string | | Display title for the card widget |
23
+ | `provider` | string | | LLM provider (e.g. `anthropic`, `google`, `openai`) |
24
+ | `model` | string | | Model ID. Supports `provider/model` format (e.g. `openai/gpt-4o-mini`) |
25
+ | `cwd` | string | | Working directory for the subagent |
26
+ | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded |
27
+ | `columnWidthPercent` | number | | Card width as % of terminal (33โ€“100). Controls card grid layout |
28
+
29
+ If `provider` and `model` are omitted, the subagent inherits the main agent's model.
30
+
31
+ ### ๐Ÿ“Š Live TUI Card Widgets
32
+
33
+ Each running subagent is displayed as a colored card widget above the editor:
34
+
35
+ - Cards show **title**, **model**, **live output preview**, **elapsed time**, and **status icon** (โ— running, โœ“ completed, โœ— error)
36
+ - Cards auto-layout into a responsive grid (1โ€“3 columns based on `columnWidthPercent`)
37
+ - Output preview shows the last 4 lines of subagent activity, including tool execution indicators (โณ running, โœ… done, โŒ failed)
38
+ - Six rotating color themes for visual distinction between cards
39
+
40
+ ### ๐Ÿ“ JSONL Session Logging
41
+
42
+ Every subagent session is logged to disk for debugging and auditing:
43
+
44
+ ```
45
+ .pi/subagent-in-memory/<mainSessionId>/
46
+ โ”œโ”€โ”€ subagent_1/
47
+ โ”‚ โ”œโ”€โ”€ events.jsonl # Full event stream (text, tool calls, results)
48
+ โ”‚ โ””โ”€โ”€ result.md # Final subagent output (or error.md on failure)
49
+ โ”œโ”€โ”€ subagent_2/
50
+ โ”‚ โ”œโ”€โ”€ events.jsonl
51
+ โ”‚ โ””โ”€โ”€ result.md
52
+ โ””โ”€โ”€ ...
53
+ ```
54
+
55
+ The JSONL log includes:
56
+ - Session metadata (model, provider, task, cwd)
57
+ - Aggregated text output (deltas combined into single entries)
58
+ - Tool call arguments and results
59
+ - Timestamps and parent event IDs for tracing
60
+
61
+ ### ๐Ÿ”„ Nested Subagent Support
62
+
63
+ Subagents can spawn their own subagents. All nested cards render in the main agent's widget โ€” they share the same module-level state regardless of nesting depth. This is achieved by passing the `subagent_create` tool directly as an `AgentTool` to child sessions.
64
+
65
+ ### ๐Ÿงน `/in-memory-clear-widgets` Slash Command
66
+
67
+ Type `/in-memory-clear-widgets` in the pi prompt to clear all subagent card widgets from the TUI. Useful after a batch of subagent runs when you want a clean view.
68
+
69
+ ## Install
70
+
71
+ ### From local path (recommended for development)
72
+
73
+ ```bash
74
+ pi install /path/to/pi-subagent-in-memory
75
+ ```
76
+
77
+ ### From a git repository
78
+
79
+ ```bash
80
+ pi install git:github.com/your-org/pi-subagent-in-memory
81
+ ```
82
+
83
+ ### Project-local install (shared with team via `.pi/settings.json`)
84
+
85
+ ```bash
86
+ pi install -l /path/to/pi-subagent-in-memory
87
+ ```
88
+
89
+ ### One-time use without installing
90
+
91
+ ```bash
92
+ pi -e /path/to/pi-subagent-in-memory/extensions/index.ts
93
+ ```
94
+
95
+ ## Remove
96
+
97
+ ```bash
98
+ pi remove pi-subagent-in-memory
99
+ ```
100
+
101
+ For project-local installs:
102
+
103
+ ```bash
104
+ pi remove -l pi-subagent-in-memory
105
+ ```
106
+
107
+ ## Verify Installation
108
+
109
+ After installing, start pi and check:
110
+
111
+ 1. The `subagent_create` tool should appear in the tool list
112
+ 2. The `/in-memory-clear-widgets` command should be available (type `/` to see commands)
113
+ 3. Ask the agent to "run a subagent to list files" โ€” you should see a card widget appear
114
+
115
+ ## Usage Examples
116
+
117
+ Once installed, the LLM will discover the `subagent_create` tool from its schema and use it when appropriate. Some natural prompts:
118
+
119
+ ```
120
+ # Single subagent
121
+ "Spawn a subagent to analyze the test coverage in this repo"
122
+
123
+ # Parallel subagents
124
+ "Run 2 subagents in parallel: one to summarize src/ and another to summarize tests/"
125
+
126
+ # Different models
127
+ "Use a subagent with openai/gpt-4o-mini to review the README"
128
+
129
+ # With timeout
130
+ "Spawn a subagent with a 60-second timeout to count lines of code"
131
+
132
+ # Custom working directory
133
+ "Run a subagent in /tmp to check disk space"
134
+ ```
135
+
136
+ ## How It Works
137
+
138
+ 1. **Tool registration** โ€” On load, registers `subagent_create` as a tool and `/in-memory-clear-widgets` as a command. No system prompt modifications.
139
+ 2. **Session creation** โ€” When the LLM calls `subagent_create`, a new `createAgentSession` is created in-process with its own model, auth, and coding tools (read, write, edit, bash, grep, find, ls).
140
+ 3. **Event streaming** โ€” All subagent events (text deltas, tool calls, completions) are forwarded as `tool_execution_update` events to the parent agent and logged to JSONL.
141
+ 4. **Widget rendering** โ€” A TUI widget renders card(s) above the editor, updated on every event.
142
+ 5. **Result handoff** โ€” The final text output is written to `result.md`. The parent agent receives a short pointer path, not the full content, keeping context lean.
143
+
144
+ ## Requirements
145
+
146
+ - [pi](https://github.com/nicholasgasior/pi-coding-agent) (peer dependency)
147
+ - API keys configured for any providers you want subagents to use (via `pi login` or environment variables)
148
+
149
+ ## License
150
+
151
+ MIT
@@ -0,0 +1,552 @@
1
+ /**
2
+ * pi-subagent-in-memory โ€” In-process subagent tool for pi.
3
+ *
4
+ * Registers a `subagent_create` tool that spawns subagent sessions in the same
5
+ * process via the pi SDK's createAgentSession. Live progress is streamed back
6
+ * as tool_execution_update events and rendered as TUI card widgets.
7
+ *
8
+ * Key design principle: apart from tool parameter definitions, this extension
9
+ * adds NOTHING to your LLM context. No system prompt injection, no hidden
10
+ * instructions โ€” the LLM only sees the tool schema.
11
+ *
12
+ * Features:
13
+ * - Live TUI card widgets showing subagent status and output
14
+ * - JSONL event logging to ~/.pi/subagent-in-memory/<sessionId>/
15
+ * - Nested subagent support (subagents can spawn subagents)
16
+ * - /in-memory-clear-widgets slash command to remove widget cards
17
+ * - Multi-provider support (Anthropic, OpenAI, Google, etc.)
18
+ *
19
+ * Results are written to ./.pi/subagent-in-memory/<mainSessionId>/subagent_<N>/result.md
20
+ * (or error.md on failure) so the calling agent gets a short pointer instead of
21
+ * the full output.
22
+ */
23
+
24
+ import { Type, type Static } from "@sinclair/typebox";
25
+ import {
26
+ createAgentSession,
27
+ SessionManager,
28
+ AuthStorage,
29
+ DefaultResourceLoader,
30
+ getAgentDir,
31
+ createCodingTools,
32
+ createGrepTool,
33
+ createFindTool,
34
+ createLsTool,
35
+ } from "@mariozechner/pi-coding-agent";
36
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
37
+ import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
38
+ import { resolveModel } from "./model.ts";
39
+ import { renderCard, type CardTheme } from "./tui-draw.ts";
40
+
41
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
42
+ import { mkdirSync, writeFileSync, appendFileSync } from "node:fs";
43
+ import { join } from "node:path";
44
+ import { randomUUID } from "node:crypto";
45
+
46
+ // โ”€โ”€ JSONL event logger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
47
+ function jsonlAppend(filePath: string, data: Record<string, any>) {
48
+ appendFileSync(filePath, JSON.stringify(data) + "\n", "utf-8");
49
+ }
50
+
51
+ // โ”€โ”€ Subagent card state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
52
+ interface SubagentCard {
53
+ sessionId: string;
54
+ title: string;
55
+ modelLabel: string;
56
+ status: "created" | "running" | "completed" | "error";
57
+ textPreview: string;
58
+ columnWidthPercent: number;
59
+ startedAt: number;
60
+ endedAt?: number;
61
+ }
62
+
63
+ const CARD_THEMES: CardTheme[] = [
64
+ { bg: "\x1b[48;2;20;30;75m", br: "\x1b[38;2;70;110;210m" },
65
+ { bg: "\x1b[48;2;80;18;28m", br: "\x1b[38;2;210;65;85m" },
66
+ { bg: "\x1b[48;2;50;22;85m", br: "\x1b[38;2;145;80;220m" },
67
+ { bg: "\x1b[48;2;12;65;75m", br: "\x1b[38;2;40;175;195m" },
68
+ { bg: "\x1b[48;2;55;50;10m", br: "\x1b[38;2;190;170;50m" },
69
+ { bg: "\x1b[48;2;15;55;30m", br: "\x1b[38;2;50;185;100m" },
70
+ ];
71
+
72
+ function formatElapsed(startedAt: number, endedAt?: number): string {
73
+ const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
74
+ const m = Math.floor(elapsed / 60);
75
+ const s = elapsed % 60;
76
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
77
+ }
78
+
79
+ // โ”€โ”€ Shared state โ€” single instance across all nesting levels โ”€โ”€โ”€โ”€
80
+ const subagents: SubagentCard[] = [];
81
+ let currentCtx: { ui: any } | null = null;
82
+ let mainSessionId = "unknown";
83
+ let subagentCount = 0;
84
+
85
+ function updateSubagentWidget() {
86
+ if (!currentCtx) return;
87
+ const ctx = currentCtx;
88
+
89
+ ctx.ui.setWidget(
90
+ "in-memory-subagent-cards",
91
+ (_tui: any, theme: any) => ({
92
+ render(width: number): string[] {
93
+ if (subagents.length === 0) return [];
94
+
95
+ // Derive cols from columnWidthPercent (all cards share the same value).
96
+ const pct = subagents[subagents.length - 1].columnWidthPercent;
97
+ const cols = Math.min(3, Math.max(1, Math.round(100 / pct)));
98
+ const gap = 1;
99
+ const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
100
+ const maxContentLines = 4;
101
+ const lines: string[] = [""];
102
+
103
+ for (let i = 0; i < subagents.length; i += cols) {
104
+ const rowCards = subagents.slice(i, i + cols).map((sa, idx) => {
105
+ const cardTheme = CARD_THEMES[(i + idx) % CARD_THEMES.length];
106
+
107
+ const titleText = `${sa.title} [${sa.modelLabel}]`;
108
+ const innerW = colWidth - 4;
109
+
110
+ const allText = sa.textPreview || "โ€ฆ";
111
+ const contentLines = allText.split("\n");
112
+ const trimmedLines = contentLines.map((l) =>
113
+ visibleWidth(l) > innerW ? "โ€ฆ" + truncateToWidth(l, innerW - 1) : l
114
+ );
115
+ const visible = trimmedLines.slice(-maxContentLines);
116
+ const content = (contentLines.length > maxContentLines ? "โ€ฆ\n" : "") + visible.join("\n");
117
+
118
+ const statusIcon = sa.status === "completed" ? "โœ“" : sa.status === "error" ? "โœ—" : "โ—";
119
+ const footer = `${statusIcon} ${formatElapsed(sa.startedAt, sa.endedAt)}`;
120
+
121
+ return renderCard({
122
+ title: titleText,
123
+ content,
124
+ footer,
125
+ colWidth,
126
+ theme,
127
+ cardTheme,
128
+ });
129
+ });
130
+
131
+ // Pad incomplete rows
132
+ while (rowCards.length < cols) {
133
+ rowCards.push(Array(rowCards[0].length).fill(" ".repeat(colWidth)));
134
+ }
135
+
136
+ const cardHeight = Math.max(...rowCards.map((c) => c.length));
137
+ for (const card of rowCards) {
138
+ while (card.length < cardHeight) {
139
+ card.push(" ".repeat(colWidth));
140
+ }
141
+ }
142
+
143
+ for (let row = 0; row < cardHeight; row++) {
144
+ lines.push(rowCards.map((card) => card[row]).join(" ".repeat(gap)));
145
+ }
146
+ }
147
+
148
+ return lines;
149
+ },
150
+ invalidate() {},
151
+ }),
152
+ { placement: "aboveEditor" }
153
+ );
154
+ }
155
+
156
+ // โ”€โ”€ Parameter schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
157
+ const SubagentParams = Type.Object({
158
+ task: Type.String({ description: "The task for the subagent to perform" }),
159
+ title: Type.Optional(
160
+ Type.String({ description: "Display title for the subagent card. Defaults to a truncated version of the task." })
161
+ ),
162
+ provider: Type.Optional(
163
+ Type.String({ description: "LLM provider (e.g. 'anthropic', 'google'). Defaults to the main agent's provider." })
164
+ ),
165
+ model: Type.Optional(
166
+ Type.String({ description: "Model ID (e.g. 'claude-sonnet-4-5'). Defaults to the main agent's model." })
167
+ ),
168
+ cwd: Type.Optional(
169
+ Type.String({ description: "Working directory for the subagent. Defaults to the main agent's cwd." })
170
+ ),
171
+ timeout: Type.Optional(
172
+ Type.Number({
173
+ description:
174
+ "Timeout in seconds for the subagent execution. If exceeded, the subagent is aborted. " +
175
+ "Defaults to unlimited (no timeout).",
176
+ minimum: 1,
177
+ })
178
+ ),
179
+ columnWidthPercent: Type.Optional(
180
+ Type.Number({
181
+ description:
182
+ "Width of this subagent's card as a percentage of terminal width (e.g. 50 for 2 parallel agents, 33 for 3). " +
183
+ "Max 3 cards per row. Defaults to 50.",
184
+ minimum: 33,
185
+ maximum: 100,
186
+ })
187
+ ),
188
+ });
189
+
190
+ type SubagentParamsType = Static<typeof SubagentParams>;
191
+
192
+ // โ”€โ”€ Core execution logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
193
+ async function executeSubagent(
194
+ toolCallId: string,
195
+ params: SubagentParamsType,
196
+ signal: AbortSignal | undefined,
197
+ onUpdate: AgentToolUpdateCallback | undefined,
198
+ fallbackProvider?: string,
199
+ fallbackModel?: string,
200
+ fallbackCwd?: string,
201
+ ): Promise<AgentToolResult<any>> {
202
+ subagentCount++;
203
+ const subagentNum = subagentCount;
204
+ const outDir = join(".pi", "subagent-in-memory", mainSessionId, `subagent_${subagentNum}`);
205
+ mkdirSync(outDir, { recursive: true });
206
+
207
+ // Parse "provider/model" format (e.g. "openai/gpt-4o-mini")
208
+ let providerName = params.provider ?? fallbackProvider;
209
+ let modelId = params.model ?? fallbackModel;
210
+ if (modelId && !params.provider && modelId.includes("/")) {
211
+ const slashIdx = modelId.indexOf("/");
212
+ providerName = modelId.slice(0, slashIdx);
213
+ modelId = modelId.slice(slashIdx + 1);
214
+ }
215
+
216
+ if (!providerName || !modelId) {
217
+ throw new Error("Could not determine model. Provide provider and model parameters.");
218
+ }
219
+
220
+ const { model: resolvedModel, apiKey } = await resolveModel(providerName, modelId);
221
+
222
+ const cwd = params.cwd ?? fallbackCwd ?? process.cwd();
223
+
224
+ const authStorage = AuthStorage.create();
225
+ authStorage.setRuntimeApiKey(providerName, apiKey);
226
+
227
+ // Create tools for the subagent, including nested subagent support
228
+ const tools = [
229
+ ...createCodingTools(cwd),
230
+ createGrepTool(cwd),
231
+ createFindTool(cwd),
232
+ createLsTool(cwd),
233
+ createSubagentAgentTool(providerName, modelId, cwd),
234
+ ];
235
+
236
+ const resourceLoader = new DefaultResourceLoader({
237
+ cwd,
238
+ agentDir: getAgentDir(),
239
+ });
240
+ await resourceLoader.reload();
241
+
242
+ const { session } = await createAgentSession({
243
+ model: resolvedModel,
244
+ authStorage,
245
+ tools,
246
+ thinkingLevel: "off",
247
+ cwd,
248
+ sessionManager: SessionManager.inMemory(),
249
+ resourceLoader,
250
+ });
251
+
252
+ // Set up JSONL event log
253
+ const jsonlPath = join(outDir, "events.jsonl");
254
+ const sessionTs = new Date().toISOString();
255
+ jsonlAppend(jsonlPath, {
256
+ type: "session",
257
+ version: 1,
258
+ id: session.sessionId,
259
+ timestamp: sessionTs,
260
+ cwd,
261
+ provider: providerName,
262
+ model: modelId,
263
+ task: params.task,
264
+ title: params.title,
265
+ });
266
+ let lastEventId = session.sessionId;
267
+
268
+ // Track card
269
+ const card: SubagentCard = {
270
+ sessionId: session.sessionId,
271
+ title: params.title ?? params.task.slice(0, 30),
272
+ modelLabel: modelId,
273
+ status: "created",
274
+ textPreview: "",
275
+ columnWidthPercent: params.columnWidthPercent ?? 50,
276
+ startedAt: Date.now(),
277
+ };
278
+ subagents.push(card);
279
+ updateSubagentWidget();
280
+
281
+ onUpdate?.({
282
+ content: [{ type: "text", text: `Subagent session created: ${session.sessionId}` }],
283
+ details: { sessionId: session.sessionId, status: "created" },
284
+ });
285
+
286
+ // Timeout handling
287
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
288
+ const timeoutController = new AbortController();
289
+ if (params.timeout) {
290
+ timeoutTimer = setTimeout(() => {
291
+ timeoutController.abort();
292
+ }, params.timeout * 1000);
293
+ }
294
+
295
+ const combinedAbort = () => {
296
+ session.abort();
297
+ card.status = "error";
298
+ card.endedAt = Date.now();
299
+ updateSubagentWidget();
300
+ };
301
+
302
+ try {
303
+ const result = await new Promise<string>((resolve, reject) => {
304
+ let finalText = "";
305
+ let textDeltaBuffer = "";
306
+ let toolcallDeltaBuffer = "";
307
+
308
+ session.subscribe((event) => {
309
+ const updateData: Record<string, any> = {
310
+ type: event.type,
311
+ sessionId: session.sessionId,
312
+ };
313
+
314
+ const eventId = randomUUID().slice(0, 8);
315
+ const eventTs = new Date().toISOString();
316
+ const baseLog = { type: event.type, id: eventId, parentId: lastEventId, timestamp: eventTs };
317
+
318
+ switch (event.type) {
319
+ case "agent_start":
320
+ card.status = "running";
321
+ updateSubagentWidget();
322
+ jsonlAppend(jsonlPath, baseLog);
323
+ lastEventId = eventId;
324
+ onUpdate?.({
325
+ content: [{ type: "text", text: "Subagent started..." }],
326
+ details: updateData,
327
+ });
328
+ break;
329
+
330
+ case "message_update": {
331
+ const ame = event.assistantMessageEvent;
332
+ if (ame.type === "text_delta") {
333
+ textDeltaBuffer += ame.delta;
334
+ finalText += ame.delta;
335
+ card.textPreview = finalText;
336
+ updateSubagentWidget();
337
+ onUpdate?.({
338
+ content: [{ type: "text", text: finalText }],
339
+ details: {
340
+ ...updateData,
341
+ data: { assistantMessageEventType: ame.type, delta: ame.delta },
342
+ },
343
+ });
344
+ } else if (ame.type === "text_end") {
345
+ if (textDeltaBuffer) {
346
+ jsonlAppend(jsonlPath, { ...baseLog, data: { assistantMessageEventType: "text", text: textDeltaBuffer } });
347
+ textDeltaBuffer = "";
348
+ } else {
349
+ jsonlAppend(jsonlPath, { ...baseLog, data: { assistantMessageEventType: ame.type } });
350
+ }
351
+ lastEventId = eventId;
352
+ } else if (ame.type === "toolcall_delta") {
353
+ if ("delta" in ame) toolcallDeltaBuffer += (ame as any).delta ?? "";
354
+ } else if (ame.type === "toolcall_end") {
355
+ if (toolcallDeltaBuffer) {
356
+ jsonlAppend(jsonlPath, { ...baseLog, data: { assistantMessageEventType: "toolcall", content: toolcallDeltaBuffer } });
357
+ toolcallDeltaBuffer = "";
358
+ } else {
359
+ jsonlAppend(jsonlPath, { ...baseLog, data: { assistantMessageEventType: ame.type } });
360
+ }
361
+ lastEventId = eventId;
362
+ } else if (ame.type === "text_start" || ame.type === "toolcall_start") {
363
+ // Skip start markers
364
+ } else {
365
+ jsonlAppend(jsonlPath, { ...baseLog, data: { assistantMessageEventType: ame.type } });
366
+ lastEventId = eventId;
367
+ }
368
+ break;
369
+ }
370
+
371
+ case "tool_execution_start":
372
+ card.textPreview = finalText + `\n[${event.toolName} โณ]`;
373
+ updateSubagentWidget();
374
+ jsonlAppend(jsonlPath, { ...baseLog, toolName: event.toolName, args: event.args });
375
+ lastEventId = eventId;
376
+ onUpdate?.({
377
+ content: [
378
+ { type: "text", text: finalText + `\n[Tool: ${event.toolName}]` },
379
+ ],
380
+ details: {
381
+ ...updateData,
382
+ data: { toolName: event.toolName, args: event.args },
383
+ },
384
+ });
385
+ break;
386
+
387
+ case "tool_execution_end":
388
+ card.textPreview = finalText + `\n[${event.toolName} ${event.isError ? "โŒ" : "โœ…"}]`;
389
+ updateSubagentWidget();
390
+ jsonlAppend(jsonlPath, { ...baseLog, toolName: event.toolName, isError: event.isError, result: event.result });
391
+ lastEventId = eventId;
392
+ onUpdate?.({
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: finalText + `\n[Tool: ${event.toolName} ${event.isError ? "โŒ" : "โœ…"}]`,
397
+ },
398
+ ],
399
+ details: {
400
+ ...updateData,
401
+ data: { toolName: event.toolName, isError: event.isError },
402
+ },
403
+ });
404
+ break;
405
+
406
+ case "agent_end":
407
+ card.status = "completed";
408
+ card.endedAt = Date.now();
409
+ updateSubagentWidget();
410
+ jsonlAppend(jsonlPath, { ...baseLog, finalTextLength: finalText.length });
411
+ resolve(finalText || "Subagent completed with no text output.");
412
+ break;
413
+
414
+ case "turn_start":
415
+ case "turn_end":
416
+ case "message_start":
417
+ case "message_end":
418
+ jsonlAppend(jsonlPath, baseLog);
419
+ lastEventId = eventId;
420
+ onUpdate?.({
421
+ content: [{ type: "text", text: finalText || "..." }],
422
+ details: updateData,
423
+ });
424
+ break;
425
+
426
+ default:
427
+ jsonlAppend(jsonlPath, { ...baseLog, raw: event });
428
+ lastEventId = eventId;
429
+ break;
430
+ }
431
+ });
432
+
433
+ if (signal) {
434
+ signal.addEventListener("abort", () => {
435
+ combinedAbort();
436
+ reject(new Error("Subagent was aborted"));
437
+ });
438
+ }
439
+ timeoutController.signal.addEventListener("abort", () => {
440
+ combinedAbort();
441
+ reject(new Error(`Subagent timed out after ${params.timeout}s`));
442
+ });
443
+
444
+ session.prompt(params.task).catch((err) => {
445
+ card.status = "error";
446
+ card.endedAt = Date.now();
447
+ updateSubagentWidget();
448
+ reject(err);
449
+ });
450
+ });
451
+
452
+ if (timeoutTimer) clearTimeout(timeoutTimer);
453
+ session.dispose();
454
+
455
+ const resultPath = join(outDir, "result.md");
456
+ writeFileSync(resultPath, result, "utf-8");
457
+
458
+ return {
459
+ content: [{ type: "text", text: `Execution succeeded. Result is in \`${resultPath}\`` }],
460
+ details: { sessionId: session.sessionId, status: "completed", outputDir: outDir },
461
+ };
462
+ } catch (err: any) {
463
+ if (timeoutTimer) clearTimeout(timeoutTimer);
464
+ session.dispose();
465
+
466
+ const errorMsg = err?.message ?? String(err);
467
+ const errorPath = join(outDir, "error.md");
468
+ writeFileSync(errorPath, `# Subagent Error\n\n${errorMsg}\n`, "utf-8");
469
+
470
+ return {
471
+ content: [{ type: "text", text: `Execution failed. Detail is in \`${errorPath}\`` }],
472
+ details: { sessionId: session.sessionId, status: "error", outputDir: outDir },
473
+ };
474
+ }
475
+ }
476
+
477
+ // โ”€โ”€ AgentTool factory for nested subagent sessions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
478
+ function createSubagentAgentTool(
479
+ parentProvider: string,
480
+ parentModel: string,
481
+ parentCwd: string,
482
+ ) {
483
+ return {
484
+ name: "subagent_create",
485
+ label: "Subagent",
486
+ description:
487
+ "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
488
+ "Progress is streamed back as execution updates. Returns the final result when the subagent finishes.",
489
+ parameters: SubagentParams,
490
+ async execute(
491
+ toolCallId: string,
492
+ params: SubagentParamsType,
493
+ signal?: AbortSignal,
494
+ onUpdate?: AgentToolUpdateCallback,
495
+ ) {
496
+ return executeSubagent(
497
+ toolCallId,
498
+ params,
499
+ signal,
500
+ onUpdate,
501
+ parentProvider,
502
+ parentModel,
503
+ parentCwd,
504
+ );
505
+ },
506
+ };
507
+ }
508
+
509
+ // โ”€โ”€ Extension entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
510
+ export default function (pi: ExtensionAPI) {
511
+ pi.on("session_start", async (_event, ctx) => {
512
+ currentCtx = ctx;
513
+ mainSessionId = ctx.sessionManager.getSessionId?.() ?? `session-${Date.now()}`;
514
+ subagentCount = 0;
515
+ updateSubagentWidget();
516
+ });
517
+
518
+ pi.registerCommand("in-memory-clear-widgets", {
519
+ description: "Clear all in-memory subagent card widgets",
520
+ handler: async (_args, ctx) => {
521
+ subagents.length = 0;
522
+ ctx.ui.setWidget("in-memory-subagent-cards", undefined);
523
+ ctx.ui.notify("In-memory subagent widgets cleared", "info");
524
+ },
525
+ });
526
+
527
+ pi.registerTool<typeof SubagentParams>({
528
+ name: "subagent_create",
529
+ label: "Subagent",
530
+ description:
531
+ "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
532
+ "Progress is streamed back as execution updates. Returns the final result when the subagent finishes.",
533
+ parameters: SubagentParams,
534
+
535
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
536
+ const mainModel = ctx.model;
537
+ const providerName = params.provider ?? mainModel?.provider;
538
+ const modelId = params.model ?? mainModel?.id;
539
+ const cwd = params.cwd ?? ctx.cwd;
540
+
541
+ return executeSubagent(
542
+ toolCallId,
543
+ params,
544
+ signal,
545
+ onUpdate,
546
+ providerName,
547
+ modelId,
548
+ cwd,
549
+ );
550
+ },
551
+ });
552
+ }
@@ -0,0 +1,50 @@
1
+ import { getModel, getModels } from "@mariozechner/pi-ai";
2
+ import type { Api, Model } from "@mariozechner/pi-ai";
3
+ import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
4
+
5
+ export interface ResolvedModel {
6
+ model: Model<Api>;
7
+ apiKey: string;
8
+ }
9
+
10
+ /**
11
+ * Resolve a model and its API key using ModelRegistry + AuthStorage.
12
+ * Supports subscription/OAuth-based API keys without requiring env vars.
13
+ */
14
+ export async function resolveModel(provider: string, modelId: string): Promise<ResolvedModel> {
15
+ const authStorage = AuthStorage.create();
16
+ const modelRegistry = new ModelRegistry(authStorage);
17
+
18
+ // Try ModelRegistry first (includes custom models from models.json),
19
+ // fall back to pi-ai's built-in getModel.
20
+ let model: Model<Api> | undefined;
21
+ try {
22
+ model = modelRegistry.find(provider, modelId);
23
+ } catch {
24
+ // ModelRegistry.find may throw if registry failed to load
25
+ }
26
+ if (!model) {
27
+ try {
28
+ model = getModel(provider as any, modelId as any);
29
+ } catch {
30
+ // getModel throws if provider/model combo is unknown
31
+ }
32
+ }
33
+ if (!model) {
34
+ throw new Error(`Could not find model ${provider}/${modelId}. Check provider and model parameters.`);
35
+ }
36
+
37
+ // Resolve API key: try ModelRegistry, then AuthStorage directly
38
+ let apiKey: string | undefined;
39
+ try {
40
+ apiKey = await modelRegistry.getApiKey(model);
41
+ } catch {
42
+ // Fall back to AuthStorage if ModelRegistry method fails
43
+ apiKey = await authStorage.getApiKey(provider);
44
+ }
45
+ if (!apiKey) {
46
+ throw new Error(`No API key for ${provider}. Login or set the appropriate API key env var.`);
47
+ }
48
+
49
+ return { model, apiKey };
50
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * tui-draw โ€” Reusable TUI drawing primitives for pi extensions.
3
+ */
4
+
5
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
6
+
7
+ const FG_RESET = "\x1b[39m";
8
+ const BG_RESET = "\x1b[49m";
9
+
10
+ export interface CardTheme {
11
+ bg: string; // ANSI bg escape
12
+ br: string; // ANSI fg escape for borders
13
+ }
14
+
15
+ export interface RenderCardOptions {
16
+ title: string;
17
+ content?: string;
18
+ footer?: string;
19
+ colWidth: number;
20
+ theme: {
21
+ fg: (style: string, text: string) => string;
22
+ bold: (text: string) => string;
23
+ [key: string]: any;
24
+ };
25
+ cardTheme: CardTheme;
26
+ }
27
+
28
+ /**
29
+ * Render a bordered card as an array of ANSI-styled lines.
30
+ *
31
+ * Each line is exactly `colWidth` visible characters wide (including borders).
32
+ */
33
+ export function renderCard(opts: RenderCardOptions): string[] {
34
+ const { title, content, footer, colWidth, theme, cardTheme } = opts;
35
+ const w = colWidth - 2; // inner width (minus left+right border)
36
+ const { bg, br } = cardTheme;
37
+
38
+ const bord = (s: string) => bg + br + s + BG_RESET + FG_RESET;
39
+
40
+ const borderLine = (text: string) => {
41
+ const visLen = visibleWidth(text);
42
+ const pad = " ".repeat(Math.max(0, w - visLen));
43
+ return bord("โ”‚") + bg + text + bg + pad + BG_RESET + bord("โ”‚");
44
+ };
45
+
46
+ const top = "โ”Œ" + "โ”€".repeat(w) + "โ”";
47
+ const bot = "โ””" + "โ”€".repeat(w) + "โ”˜";
48
+
49
+ const lines: string[] = [bord(top)];
50
+
51
+ // Title โ€” truncate raw text, then style
52
+ const truncTitle = truncateToWidth(title, w - 1);
53
+ const styledTitle = theme.fg("accent", theme.bold(truncTitle));
54
+ lines.push(borderLine(" " + styledTitle));
55
+
56
+ // Content (defaults to "ready" muted) โ€” supports multi-line
57
+ const contentText = content ?? "ready";
58
+ for (const cLine of contentText.split("\n")) {
59
+ const truncContent = truncateToWidth(cLine, w - 1);
60
+ const styledContent = theme.fg("muted", truncContent);
61
+ lines.push(borderLine(" " + styledContent));
62
+ }
63
+
64
+ // Optional footer โ€” truncate then style
65
+ if (footer !== undefined) {
66
+ const truncFooter = truncateToWidth(footer, w - 1);
67
+ const styledFooter = theme.fg("muted", truncFooter);
68
+ lines.push(borderLine(" " + styledFooter));
69
+ }
70
+
71
+ lines.push(bord(bot));
72
+ return lines;
73
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "pi-subagent-in-memory",
3
+ "version": "0.1.0",
4
+ "description": "In-process subagent tool for pi with live TUI card widgets, JSONL session logging, and zero system-prompt overhead.",
5
+ "author": "Ross Z",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "subagent",
10
+ "in-memory",
11
+ "agent",
12
+ "widget"
13
+ ],
14
+ "files": [
15
+ "extensions",
16
+ "package.json",
17
+ "README.md"
18
+ ],
19
+ "peerDependencies": {
20
+ "@mariozechner/pi-coding-agent": "*",
21
+ "@mariozechner/pi-tui": "*",
22
+ "@mariozechner/pi-ai": "*",
23
+ "@sinclair/typebox": "*"
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "extensions/index.ts"
28
+ ]
29
+ }
30
+ }