pi-agenticoding 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,166 @@
1
+ /**
2
+ * Ledger tool definitions for the agenticoding extension.
3
+ *
4
+ * Three tools: ledger_add (sequential, serialized write), ledger_get, ledger_list.
5
+ * All read from the in-memory state.ledger Map and always return the current
6
+ * list of entry names in both result text and details.
7
+ */
8
+
9
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import type { AgenticodingState } from "../state.js";
12
+ import { formatEntryList, getEntryNames, saveLedgerEntry } from "./store.js";
13
+
14
+ // ── Factory ───────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Creates ledger tool definitions (ledger_add, ledger_get, ledger_list).
18
+ *
19
+ * Shared by parent registration (withPromptHints=true) and child spawn
20
+ * sessions (withPromptHints=false). The prompt hints (snippet, guidelines)
21
+ * are only included for the parent — child agents don't need them.
22
+ */
23
+ export function createLedgerToolDefinitions(
24
+ pi: ExtensionAPI,
25
+ state: AgenticodingState,
26
+ options?: { withPromptHints?: boolean; isStale?: () => boolean },
27
+ ): ToolDefinition[] {
28
+ const withHints = options?.withPromptHints ?? false;
29
+ const assertFresh = () => {
30
+ if (options?.isStale?.()) {
31
+ throw new Error("Spawn invalidated by reset.");
32
+ }
33
+ };
34
+
35
+ const ledgerAdd: ToolDefinition = {
36
+ name: "ledger_add",
37
+ label: "Ledger Add",
38
+ description:
39
+ "Save or refine a compact continuity entry. " +
40
+ "Same name overwrites the previous entry (refinement). " +
41
+ "Writes are serialized via a process-local lock; same-name writes overwrite in completion order. " +
42
+ "Always returns the current list of up to date entries.",
43
+ ...(withHints
44
+ ? {
45
+ promptSnippet: "Save or refine a compact continuity entry",
46
+ promptGuidelines: [
47
+ "Continuously maintain the ledger while you work. After meaningful reads, research, analysis, decisions, or milestones, either refine an existing entry, create a compact reusable entry, or consciously skip because nothing reusable was learned.",
48
+ "Prefer refining existing entries over creating many tiny ones. Do not try to make the ledger complete.",
49
+ ],
50
+ }
51
+ : {}),
52
+ executionMode: "sequential",
53
+ parameters: Type.Object({
54
+ name: Type.String({
55
+ description:
56
+ "Kebab-case entry identifier. Using an existing name overwrites that entry (refinement).",
57
+ }),
58
+ content: Type.String({
59
+ description:
60
+ "Compact markdown. Capture only reusable facts, decisions, " +
61
+ "constraints, progress, and expensive discoveries. " +
62
+ "Truncated at 50KB / 2000 lines.",
63
+ }),
64
+ }),
65
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
66
+ assertFresh();
67
+ const names = await saveLedgerEntry(pi, state, params.name, params.content, assertFresh);
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: `Saved ledger entry "${params.name}".` +
73
+ `\n\nEntries:\n${formatEntryList(state) || "(empty)"}`,
74
+ },
75
+ ],
76
+ details: { entries: names },
77
+ };
78
+ },
79
+ };
80
+
81
+ const ledgerGet: ToolDefinition = {
82
+ name: "ledger_get",
83
+ label: "Ledger Get",
84
+ description:
85
+ "Retrieve a ledger entry's full body by name. " +
86
+ "Always returns the current list of entry names.",
87
+ ...(withHints
88
+ ? { promptSnippet: "Fetch a ledger entry by name" }
89
+ : {}),
90
+ parameters: Type.Object({
91
+ name: Type.String({
92
+ description: "Entry name to retrieve.",
93
+ }),
94
+ }),
95
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
96
+ assertFresh();
97
+ const content = state.ledger.get(params.name);
98
+ const names = getEntryNames(state);
99
+
100
+ if (content === undefined) {
101
+ return {
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text:
106
+ `Entry "${params.name}" not found.` +
107
+ `\n\nEntries:\n${formatEntryList(state) || "(empty)"}`,
108
+ },
109
+ ],
110
+ details: { entries: names, found: false },
111
+ };
112
+ }
113
+
114
+ return {
115
+ content: [
116
+ {
117
+ type: "text",
118
+ text:
119
+ `--- ${params.name} ---\n${content}\n` +
120
+ `---\nEntries:\n${formatEntryList(state) || "(empty)"}`,
121
+ },
122
+ ],
123
+ details: { entries: names, found: true },
124
+ };
125
+ },
126
+ };
127
+
128
+ const ledgerList: ToolDefinition = {
129
+ name: "ledger_list",
130
+ label: "Ledger List",
131
+ description:
132
+ "List all ledger entries as name + first-line preview. " +
133
+ "Always returns the current list of entry names.",
134
+ ...(withHints
135
+ ? { promptSnippet: "List all ledger entries" }
136
+ : {}),
137
+ parameters: Type.Object({}),
138
+ async execute() {
139
+ assertFresh();
140
+ const names = getEntryNames(state);
141
+ return {
142
+ content: [
143
+ {
144
+ type: "text",
145
+ text: `Entries:\n${formatEntryList(state) || "(empty)"}`,
146
+ },
147
+ ],
148
+ details: { entries: names },
149
+ };
150
+ },
151
+ };
152
+
153
+ return [ledgerAdd, ledgerGet, ledgerList];
154
+ }
155
+
156
+ // ── Registration ──────────────────────────────────────────────────────
157
+
158
+ export function registerLedgerTools(
159
+ pi: ExtensionAPI,
160
+ state: AgenticodingState,
161
+ ): void {
162
+ const tools = createLedgerToolDefinitions(pi, state, { withPromptHints: true });
163
+ for (const tool of tools) {
164
+ pi.registerTool(tool);
165
+ }
166
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "pi-agenticoding",
3
+ "version": "0.1.0",
4
+ "description": "Context management primitives for the pi coding agent — spawn, ledger, handoff",
5
+ "license": "MIT",
6
+ "keywords": ["pi-package"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/earendil-works/pi-agenticoding.git"
10
+ },
11
+ "peerDependencies": {
12
+ "@earendil-works/pi-ai": "*",
13
+ "@earendil-works/pi-agent-core": "*",
14
+ "@earendil-works/pi-coding-agent": "*",
15
+ "@earendil-works/pi-tui": "*",
16
+ "typebox": "*"
17
+ },
18
+ "pi": {
19
+ "extensions": ["./index.ts"]
20
+ }
21
+ }
package/spawn/index.ts ADDED
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Spawn tool for the agenticoding extension.
3
+ *
4
+ * Creates an isolated in-memory child AgentSession for focused subtask execution.
5
+ * Children inherit the parent's model, thinking level, cwd, and ledger access.
6
+ * Max nesting depth: 1 edge (parent → child only).
7
+ *
8
+ * Spawn is context isolation, not a security boundary. Child agents are trusted
9
+ * extensions of the parent and inherit parent authority by design.
10
+ */
11
+
12
+ import type {
13
+ ExtensionAPI,
14
+ ExtensionContext,
15
+ ToolDefinition,
16
+ ToolInfo,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ AuthStorage,
20
+ createAgentSession,
21
+ ModelRegistry,
22
+ SessionManager,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { StringEnum } from "@earendil-works/pi-ai";
25
+ import { Type } from "typebox";
26
+ import type { AgenticodingState } from "../state.js";
27
+ import { formatEntryList } from "../ledger/store.js";
28
+ import { createLedgerToolDefinitions } from "../ledger/tools.js";
29
+ import {
30
+ renderSpawnCall,
31
+ renderSpawnResult,
32
+ } from "./renderer.js";
33
+ import {
34
+ getLastAssistantText,
35
+ type SpawnOutcome,
36
+ type SpawnResultDetails,
37
+ type ThinkingValue,
38
+ } from "./shared.js";
39
+
40
+ // ── Constants ─────────────────────────────────────────────────────────
41
+
42
+ const MAX_SPAWN_DEPTH = 1;
43
+ const CHILD_MAX_LINES = 2000;
44
+ const CHILD_MAX_BYTES = 50 * 1024;
45
+
46
+ // ── Helpers ───────────────────────────────────────────────────────────
47
+
48
+ type AssistantMessageLike = {
49
+ role: string;
50
+ content?: { type: string; text?: string }[];
51
+ stopReason?: unknown;
52
+ };
53
+
54
+ function getLastAssistantMessage(messages: AssistantMessageLike[]): AssistantMessageLike | undefined {
55
+ for (let i = messages.length - 1; i >= 0; i--) {
56
+ const msg = messages[i];
57
+ if (msg.role === "assistant") return msg;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ function getLastAssistantOutcome(messages: AssistantMessageLike[]): SpawnOutcome {
63
+ const stopReason = getLastAssistantMessage(messages)?.stopReason;
64
+ if (stopReason === "aborted") return "aborted";
65
+ if (stopReason === "error") return "error";
66
+ return "success";
67
+ }
68
+
69
+ /**
70
+ * Truncates text to stay within maxLines/maxBytes.
71
+ * Line-count limit is applied first, then byte limit.
72
+ * May end mid-line if the byte limit is the tighter constraint.
73
+ */
74
+ function truncateText(text: string, maxLines: number, maxBytes: number): string {
75
+ const lines = text.split("\n");
76
+ let truncated = lines.slice(0, maxLines).join("\n");
77
+ if (new TextEncoder().encode(truncated).length > maxBytes) {
78
+ truncated = new TextDecoder().decode(
79
+ new TextEncoder().encode(truncated).slice(0, maxBytes),
80
+ );
81
+ }
82
+ return truncated;
83
+ }
84
+
85
+ /**
86
+ * Truncates child agent output to CHILD_MAX_LINES lines / CHILD_MAX_BYTES bytes.
87
+ * Appends a "[Result truncated...]" advisory when truncation occurs.
88
+ * Returns { text, truncated }.
89
+ */
90
+ function truncateResult(text: string): { text: string; truncated: boolean } {
91
+ const lines = text.split("\n");
92
+ const bytes = new TextEncoder().encode(text).length;
93
+
94
+ if (lines.length <= CHILD_MAX_LINES && bytes <= CHILD_MAX_BYTES) {
95
+ return { text, truncated: false };
96
+ }
97
+
98
+ const truncated = truncateText(text, CHILD_MAX_LINES, CHILD_MAX_BYTES);
99
+ return {
100
+ text:
101
+ truncated +
102
+ `\n\n[Result truncated to ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB. ` +
103
+ `Ask the child to summarize further if needed.]`,
104
+ truncated: true,
105
+ };
106
+ }
107
+
108
+
109
+ /**
110
+ * Build the final list of tool names for a child session.
111
+ *
112
+ * Child sessions inherit the parent's active built-in tools plus the local
113
+ * child custom tools defined here. Parent-only custom tools are intentionally
114
+ * excluded so the child never advertises a tool it cannot execute.
115
+ *
116
+ * handoff never carries into children, and spawn is only re-added from
117
+ * childTools when the current depth still allows nesting.
118
+ */
119
+ function getInheritableParentToolNames(parentToolNames: string[], availableTools: Pick<ToolInfo, "name" | "sourceInfo">[]): string[] {
120
+ const activeToolNames = new Set(parentToolNames);
121
+ return availableTools
122
+ .filter((tool) => activeToolNames.has(tool.name) && tool.sourceInfo?.source === "builtin")
123
+ .map((tool) => tool.name);
124
+ }
125
+
126
+ export function buildChildToolNames(
127
+ parentToolNames: string[],
128
+ childTools: ToolDefinition[],
129
+ availableTools?: Pick<ToolInfo, "name" | "sourceInfo">[],
130
+ ): string[] {
131
+ const inheritableParentToolNames = availableTools
132
+ ? getInheritableParentToolNames(parentToolNames, availableTools)
133
+ : parentToolNames;
134
+ const inheritedTools = inheritableParentToolNames.filter((name) => name !== "spawn" && name !== "handoff");
135
+ return [...new Set([...inheritedTools, ...childTools.map((tool) => tool.name)])];
136
+ }
137
+
138
+ // ── Shared spawn tool metadata (used by both parent and child tool definitions) ──
139
+
140
+ const SPAWN_DESCRIPTION =
141
+ "Spawn an isolated child agent for a focused subtask. " +
142
+ "Child inherits parent model, thinking level, cwd, supported built-in tools, and shared ledger tools; spawn is only exposed when depth allows. " +
143
+ "Reference ledger entries by name — child will ledger_get them on demand.";
144
+
145
+ const SPAWN_PROMPT_SNIPPET = "Spawn a focused subtask agent";
146
+
147
+ const SPAWN_PROMPT_GUIDELINES = [
148
+ "Use spawn to delegate isolated work to child agents. They are trusted extensions of you with their own context and the same authority. Only condensed results are returned.",
149
+ ];
150
+
151
+ const SPAWN_PARAMETERS = Type.Object({
152
+ prompt: Type.String({
153
+ description:
154
+ "Self-contained task description. Reference ledger entries by name — " +
155
+ "child will ledger_get them on demand.",
156
+ }),
157
+ thinking: StringEnum(
158
+ ["off", "minimal", "low", "medium", "high", "xhigh"] as const,
159
+ {
160
+ description:
161
+ "Override child thinking level. Inherits parent by default.",
162
+ },
163
+ ),
164
+ });
165
+
166
+
167
+
168
+ /**
169
+ * Build the custom tool set for child agent sessions.
170
+ *
171
+ * Produces ledger tools (add/get/list) and conditionally includes the spawn
172
+ * tool when currentDepth is below MAX_SPAWN_DEPTH. The spawn tool is omitted
173
+ * at max depth to prevent the LLM from attempting illegal recursion.
174
+ *
175
+ * All tools read/write the shared parent state so ledger entries are visible
176
+ * across parent and child contexts.
177
+ *
178
+ * @param sessionFactory - Test seam for dependency-injecting createAgentSession.
179
+ */
180
+ export function createChildTools(
181
+ pi: ExtensionAPI,
182
+ state: AgenticodingState,
183
+ defaultThinking: ThinkingValue,
184
+ currentDepth: number,
185
+ sessionFactory: typeof createAgentSession = createAgentSession,
186
+ options?: { isStale?: () => boolean },
187
+ ): ToolDefinition[] {
188
+ // Child sessions inherit only executable parent tools via
189
+ // buildChildToolNames(). Only built-in parent tools are carried through.
190
+ // handoff never carries into children, and spawn is only re-added here
191
+ // while depth allows it.
192
+
193
+ const childSpawnTool: ToolDefinition = {
194
+ name: "spawn",
195
+ label: "Spawn",
196
+ description: SPAWN_DESCRIPTION,
197
+ promptSnippet: SPAWN_PROMPT_SNIPPET,
198
+ promptGuidelines: SPAWN_PROMPT_GUIDELINES,
199
+ parameters: SPAWN_PARAMETERS,
200
+ async execute(
201
+ toolCallId: string,
202
+ params: { prompt: string; thinking?: ThinkingValue },
203
+ signal: AbortSignal | undefined,
204
+ onUpdate:
205
+ | ((result: {
206
+ content: { type: string; text: string }[];
207
+ details?: unknown;
208
+ }) => void)
209
+ | undefined,
210
+ ctx: ExtensionContext,
211
+ ) {
212
+ return executeSpawn(toolCallId, pi, ctx, state, params, signal, onUpdate, defaultThinking, currentDepth, sessionFactory);
213
+ },
214
+ renderCall: renderSpawnCall,
215
+ renderResult(result, { expanded }, theme, context) {
216
+ return renderSpawnResult(result, expanded, theme, context, state);
217
+ },
218
+ };
219
+
220
+ const childLedgerTools = createLedgerToolDefinitions(pi, state, { isStale: options?.isStale });
221
+
222
+ return [
223
+ ...(currentDepth < MAX_SPAWN_DEPTH ? [childSpawnTool] : []),
224
+ ...childLedgerTools,
225
+ ];
226
+ }
227
+
228
+
229
+
230
+ // ── Shared spawn execution logic ──────────────────────────────────────
231
+ // Used by both the parent-registered spawn tool and child custom spawn tools.
232
+
233
+ /**
234
+ * Creates an isolated child agent session, runs the given prompt, and returns
235
+ * the result with usage stats.
236
+ *
237
+ * Errors (all thrown, not returned):
238
+ * - "Max spawn depth reached" → currentDepth >= MAX_SPAWN_DEPTH
239
+ * - "No model configured..." → ctx.model is undefined
240
+ * - "Child agent produced no output." → no assistant text after prompt
241
+ *
242
+ * Side effects on state:
243
+ * - state.childSessions.set(toolCallId, session) on creation
244
+ * - state.liveChildSessions.set(toolCallId, session) on creation
245
+ * - both registries delete(toolCallId) on error and completion paths
246
+ *
247
+ * @param onUpdate - Callback that fires once after session creation with
248
+ * empty content + initial details (depth, model, thinking). Pi uses this
249
+ * to render the component before the child produces output.
250
+ * @param sessionFactory - Test seam for mocking createAgentSession.
251
+ */
252
+ export async function executeSpawn(
253
+ toolCallId: string,
254
+ pi: ExtensionAPI,
255
+ ctx: ExtensionContext,
256
+ state: AgenticodingState,
257
+ params: { prompt: string; thinking?: ThinkingValue },
258
+ signal: AbortSignal | undefined,
259
+ onUpdate:
260
+ | ((result: {
261
+ content: { type: string; text: string }[];
262
+ details?: unknown;
263
+ }) => void)
264
+ | undefined,
265
+ defaultThinking: ThinkingValue,
266
+ currentDepth: number,
267
+ sessionFactory: typeof createAgentSession = createAgentSession,
268
+ ) {
269
+ if (currentDepth >= MAX_SPAWN_DEPTH) {
270
+ throw new Error(`Max spawn depth (${MAX_SPAWN_DEPTH}) reached. Cannot spawn further children.`);
271
+ }
272
+
273
+ const childModel = ctx.model;
274
+ if (!childModel) {
275
+ throw new Error("No model configured. Cannot spawn child agent.");
276
+ }
277
+
278
+ const childThinking: ThinkingValue = params.thinking ?? defaultThinking;
279
+ const depth = currentDepth + 1;
280
+
281
+ const listing = formatEntryList(state);
282
+ const ledgerListing = listing
283
+ ? "Available ledger entries:\n" + listing
284
+ : "No ledger entries.";
285
+ const fullPrompt =
286
+ `You are a focused child agent spawned by a parent agent. ` +
287
+ `You have the same authority as the parent. ` +
288
+ `You inherit the parent's supported built-in tools plus shared ledger tools, and spawn is only exposed when depth allows it. ` +
289
+ `Your result will be read by the parent, so be concise and complete.\n\n` +
290
+ `${ledgerListing}\n\n` +
291
+ `## Task\n\n${params.prompt}\n\n` +
292
+ `When complete, provide a concise summary of findings. ` +
293
+ `Keep the result under ${CHILD_MAX_LINES} lines / ${(CHILD_MAX_BYTES / 1024).toFixed(0)}KB.`;
294
+
295
+ const authStorage = AuthStorage.create();
296
+ const modelRegistry = ModelRegistry.create(authStorage);
297
+ const childSessionEpoch = state.childSessionEpoch;
298
+ const isStale = () => state.childSessionEpoch !== childSessionEpoch;
299
+ const childTools = createChildTools(pi, state, childThinking, depth, sessionFactory, { isStale });
300
+ const parentToolNames = pi.getActiveTools();
301
+ const childToolNames = buildChildToolNames(parentToolNames, childTools, pi.getAllTools());
302
+
303
+ const { session } = await sessionFactory({
304
+ sessionManager: SessionManager.inMemory(),
305
+ model: childModel,
306
+ thinkingLevel: childThinking,
307
+ cwd: ctx.cwd,
308
+ tools: childToolNames,
309
+ customTools: childTools,
310
+ authStorage,
311
+ modelRegistry,
312
+ });
313
+
314
+ const invalidatedError = new Error("Spawn invalidated by reset.");
315
+ let wasAborted = false;
316
+ const abortChild = () => {
317
+ wasAborted = true;
318
+ session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e));
319
+ };
320
+ const clearChildSession = () => {
321
+ if (state.childSessions.get(toolCallId) === session) {
322
+ state.childSessions.delete(toolCallId);
323
+ }
324
+ if (state.liveChildSessions.get(toolCallId) === session) {
325
+ state.liveChildSessions.delete(toolCallId);
326
+ }
327
+ };
328
+ const abortAndInvalidate = async () => {
329
+ clearChildSession();
330
+ await session.abort().catch(e => console.error("[spawn] abort failed:", toolCallId, e));
331
+ throw invalidatedError;
332
+ };
333
+
334
+ if (isStale()) {
335
+ await abortAndInvalidate();
336
+ }
337
+
338
+ // liveChildSessions must be set first — renderSpawnResult checks it to decide
339
+ // whether to pass the live registry to attachSession for stale detection.
340
+ state.liveChildSessions.set(toolCallId, session);
341
+ state.childSessions.set(toolCallId, session);
342
+
343
+ try {
344
+ if (signal?.aborted) {
345
+ wasAborted = true;
346
+ await session.abort();
347
+ throw signal.reason instanceof Error
348
+ ? signal.reason
349
+ : new Error("Spawn aborted before child session started.");
350
+ }
351
+
352
+ if (isStale()) {
353
+ await abortAndInvalidate();
354
+ }
355
+
356
+ onUpdate?.({
357
+ content: [],
358
+ details: {
359
+ depth,
360
+ model: childModel.id,
361
+ thinking: childThinking,
362
+ truncated: false,
363
+ outcome: "running",
364
+ } satisfies SpawnResultDetails,
365
+ });
366
+
367
+ signal?.addEventListener("abort", abortChild, { once: true });
368
+ await session.prompt(fullPrompt);
369
+ } catch (error) {
370
+ clearChildSession();
371
+ if (isStale()) {
372
+ throw invalidatedError;
373
+ }
374
+ throw error;
375
+ } finally {
376
+ signal?.removeEventListener("abort", abortChild);
377
+ }
378
+
379
+ if (isStale()) {
380
+ clearChildSession();
381
+ throw invalidatedError;
382
+ }
383
+
384
+ const resultText = getLastAssistantText(session.messages);
385
+ if (!resultText) {
386
+ clearChildSession();
387
+ throw new Error("Child agent produced no output.");
388
+ }
389
+ const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages);
390
+ const { text: finalText, truncated } = truncateResult(resultText);
391
+
392
+ // Execution should not retain live children after completion. If the TUI
393
+ // already rendered the child, it still owns the session object itself.
394
+ // Clearing here intentionally makes the component's dispose() a no-op for
395
+ // liveChildSessions — the child already completed so there's nothing to abort.
396
+ clearChildSession();
397
+
398
+ let stats: Record<string, number> | undefined;
399
+ let statsUnavailable = false;
400
+ try {
401
+ const sessionStats = session.getSessionStats();
402
+ if (sessionStats) {
403
+ stats = {
404
+ inputTokens: sessionStats.tokens?.input ?? 0,
405
+ outputTokens: sessionStats.tokens?.output ?? 0,
406
+ cacheReadTokens: sessionStats.tokens?.cacheRead ?? 0,
407
+ cacheWriteTokens: sessionStats.tokens?.cacheWrite ?? 0,
408
+ totalTokens: sessionStats.tokens?.total ?? 0,
409
+ cost: sessionStats.cost ?? 0,
410
+ turns: sessionStats.assistantMessages ?? 0,
411
+ };
412
+ }
413
+ } catch (error: unknown) {
414
+ statsUnavailable = true;
415
+ console.warn("[spawn] Failed to collect child session stats:", error, toolCallId);
416
+ }
417
+
418
+ if (isStale()) {
419
+ throw invalidatedError;
420
+ }
421
+
422
+ const details: SpawnResultDetails = {
423
+ depth,
424
+ model: childModel.id,
425
+ thinking: childThinking,
426
+ truncated,
427
+ outcome,
428
+ };
429
+ if (stats) {
430
+ details.stats = stats;
431
+ } else if (statsUnavailable) {
432
+ details.statsUnavailable = true;
433
+ }
434
+
435
+ return {
436
+ content: [{ type: "text" as const, text: finalText }],
437
+ details,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Register the spawn tool with pi's tool system.
443
+ *
444
+ * Creates a ToolDefinition that spawns an isolated child AgentSession
445
+ * for focused subtasks. Children inherit the parent model, thinking
446
+ * level, cwd, and ledger access.
447
+ *
448
+ * @param pi - Extension API instance for tool registration
449
+ * @param state - Shared session state (child sessions, epoch, ledger)
450
+ * @param sessionFactory - Optional test seam for mocking createAgentSession
451
+ */
452
+ export function registerSpawnTool(
453
+ pi: ExtensionAPI,
454
+ state: AgenticodingState,
455
+ sessionFactory: typeof createAgentSession = createAgentSession,
456
+ ): void {
457
+ pi.registerTool({
458
+ name: "spawn",
459
+ label: "Spawn",
460
+ description: SPAWN_DESCRIPTION,
461
+ promptSnippet: SPAWN_PROMPT_SNIPPET,
462
+ promptGuidelines: SPAWN_PROMPT_GUIDELINES,
463
+ parameters: SPAWN_PARAMETERS,
464
+
465
+ async execute(
466
+ _toolCallId: string,
467
+ params: { prompt: string; thinking?: ThinkingValue },
468
+ signal: AbortSignal | undefined,
469
+ onUpdate:
470
+ | ((result: {
471
+ content: { type: string; text: string }[];
472
+ details?: unknown;
473
+ }) => void)
474
+ | undefined,
475
+ ctx: ExtensionContext,
476
+ ) {
477
+ const parentThinking: ThinkingValue = pi.getThinkingLevel();
478
+ return executeSpawn(_toolCallId, pi, ctx, state, params, signal, onUpdate, parentThinking, 0, sessionFactory);
479
+ },
480
+
481
+ renderCall: renderSpawnCall,
482
+
483
+ renderResult(result, { expanded }, theme, context) {
484
+ return renderSpawnResult(result, expanded, theme, context, state);
485
+ },
486
+ });
487
+ }