@mystilleef/pi-subagent 0.6.0 → 0.8.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 CHANGED
@@ -151,7 +151,7 @@ executable automation.
151
151
  **Environment variables:**
152
152
 
153
153
  - `PI_SUBAGENT_DEPTH`: nested subagent depth guard. Nested calls stop at
154
- depth `1`.
154
+ depth `3`.
155
155
  - `PI_SUBAGENT_MAX_OUTPUT_BYTES`: max returned output bytes. Default:
156
156
  `50000`.
157
157
  - `PI_SUBAGENT_MAX_OUTPUT_LINES`: max returned output lines. Default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi subagent for the SPAE Framework",
5
5
  "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
6
  "license": "MIT",
@@ -48,12 +48,11 @@
48
48
  },
49
49
  "scripts": {
50
50
  "typecheck": "tsc --noEmit",
51
- "lint": "biome check --error-on-warnings .",
52
- "fix": "biome check --write --unsafe .",
51
+ "lint": "biome check --write --unsafe --error-on-warnings .",
53
52
  "migrate": "biome migrate --write",
54
53
  "coverage": "bun test --coverage",
55
54
  "check": "bun lint && bun typecheck",
56
- "verify": "bun migrate && bun fix && bun typecheck && bun coverage",
55
+ "verify": "bun migrate && bun check && bun coverage",
57
56
  "pack:smoke": "bun scripts/pack-smoke.ts",
58
57
  "release": "sh -c 'npm version \"$1\" -m \"chore(release): %s\" && git push --follow-tags' --"
59
58
  },
@@ -66,13 +65,13 @@
66
65
  },
67
66
  "devDependencies": {
68
67
  "@biomejs/biome": "^2.4.16",
69
- "@earendil-works/pi-agent-core": "^0.78.0",
70
- "@earendil-works/pi-ai": "^0.78.0",
71
- "@earendil-works/pi-coding-agent": "^0.78.0",
72
- "@earendil-works/pi-tui": "^0.78.0",
68
+ "@earendil-works/pi-agent-core": "^0.78.1",
69
+ "@earendil-works/pi-ai": "^0.78.1",
70
+ "@earendil-works/pi-coding-agent": "^0.78.1",
71
+ "@earendil-works/pi-tui": "^0.78.1",
73
72
  "@types/bun": "^1.3.14",
74
73
  "@types/node": "^25.9.1",
75
- "typebox": "^1.1.39",
74
+ "typebox": "^1.2.1",
76
75
  "typescript": "^6.0.3"
77
76
  }
78
77
  }
@@ -1,32 +1,277 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fsPromises from "node:fs/promises";
1
3
  import path from "node:path";
2
4
  import {
3
5
  type AgentDiscoveryResult,
6
+ type AgentDiscoveryScopeResult,
4
7
  type AgentScope,
8
+ type AgentSource,
5
9
  discoverAgentsAsync,
10
+ emptyScopeResult,
11
+ getUserAgentsDir,
12
+ readMarkdownDirWithStatusAsync,
6
13
  } from "./agents.js";
7
14
 
8
- export type AgentDiscoveryCacheEntry = AgentDiscoveryResult & { ts: number };
15
+ interface AgentDiscoveryScopeSnapshot {
16
+ markdownFiles: string[];
17
+ fileHashes: Record<string, string | null>;
18
+ listingTrusted: boolean;
19
+ directory: string | null;
20
+ }
21
+
22
+ type AgentDiscoverySnapshots = Record<AgentSource, AgentDiscoveryScopeSnapshot>;
23
+
24
+ export type AgentDiscoveryCacheEntry = AgentDiscoveryResult & {
25
+ ts: number;
26
+ snapshots?: AgentDiscoverySnapshots;
27
+ };
9
28
  export type AgentDiscoveryCache = Map<string, AgentDiscoveryCacheEntry>;
10
29
  export const AGENT_DISCOVERY_CACHE_TTL_MS = 300_000;
11
30
  const sharedAgentDiscoveryCache: AgentDiscoveryCache = new Map();
31
+ const AGENT_SOURCES = ["user", "project"] as const;
32
+
33
+ interface CacheOperationContext {
34
+ cwd: string;
35
+ cache: AgentDiscoveryCache;
36
+ ts: number;
37
+ cacheTtlMs: number;
38
+ }
12
39
 
13
40
  export function resetAgentDiscoveryCache(): void {
14
41
  sharedAgentDiscoveryCache.clear();
15
42
  }
16
43
 
44
+ function cacheKey(cwd: string, scope: AgentScope): string {
45
+ return `${path.resolve(cwd)}\0${scope}`;
46
+ }
47
+
48
+ function getFreshCacheEntry(
49
+ ctx: CacheOperationContext,
50
+ key: string,
51
+ ): AgentDiscoveryCacheEntry | undefined {
52
+ const entry = ctx.cache.get(key);
53
+ return entry && ctx.ts - entry.ts <= ctx.cacheTtlMs ? entry : undefined;
54
+ }
55
+
56
+ function emptySnapshot(): AgentDiscoveryScopeSnapshot {
57
+ return {
58
+ markdownFiles: [],
59
+ fileHashes: {},
60
+ listingTrusted: false,
61
+ directory: null,
62
+ };
63
+ }
64
+
65
+ function cloneScopeSnapshot(
66
+ snapshot: AgentDiscoveryScopeSnapshot | undefined,
67
+ ): AgentDiscoveryScopeSnapshot {
68
+ if (!snapshot) return emptySnapshot();
69
+ return {
70
+ markdownFiles: [...snapshot.markdownFiles],
71
+ fileHashes: { ...snapshot.fileHashes },
72
+ listingTrusted: snapshot.listingTrusted,
73
+ directory: snapshot.directory,
74
+ };
75
+ }
76
+
77
+ function equalStringSets(a: string[], b: string[]): boolean {
78
+ if (a.length !== b.length) return false;
79
+ const set = new Set(a);
80
+ return b.every((value) => set.has(value));
81
+ }
82
+
83
+ function snapshotHasRequiredMetadata(
84
+ snapshot: AgentDiscoveryScopeSnapshot,
85
+ ): boolean {
86
+ if (typeof snapshot.listingTrusted !== "boolean") return false;
87
+ if (snapshot.directory !== null && typeof snapshot.directory !== "string")
88
+ return false;
89
+ if (
90
+ !equalStringSets(snapshot.markdownFiles, Object.keys(snapshot.fileHashes))
91
+ )
92
+ return false;
93
+ return true;
94
+ }
95
+
96
+ function scopeAgentsMatchListing(
97
+ scopeResult: AgentDiscoveryScopeResult,
98
+ source: AgentSource,
99
+ dir: string | null,
100
+ ): boolean {
101
+ if (!dir)
102
+ return (
103
+ scopeResult.agents.length === 0 && scopeResult.markdownFiles.length === 0
104
+ );
105
+ const listedFiles = new Set(scopeResult.markdownFiles);
106
+ const resolvedDir = path.resolve(dir);
107
+ return scopeResult.agents.every((agent) => {
108
+ const fileName = path.basename(agent.filePath);
109
+ return (
110
+ agent.source === source &&
111
+ listedFiles.has(fileName) &&
112
+ path.resolve(path.dirname(agent.filePath)) === resolvedDir
113
+ );
114
+ });
115
+ }
116
+
117
+ async function hashMarkdownFileAsync(
118
+ dir: string,
119
+ fileName: string,
120
+ ): Promise<string | null> {
121
+ try {
122
+ return createHash("sha256")
123
+ .update(await fsPromises.readFile(path.join(dir, fileName)))
124
+ .digest("hex");
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ async function buildScopeSnapshotAsync(
131
+ dir: string | null,
132
+ ): Promise<AgentDiscoveryScopeSnapshot> {
133
+ const listing = await readMarkdownDirWithStatusAsync(dir);
134
+ const markdownFiles = listing.entries.map((entry) => entry.name);
135
+ const hashPairs = await Promise.all(
136
+ markdownFiles.map(
137
+ async (fileName) =>
138
+ [fileName, await hashMarkdownFileAsync(dir ?? "", fileName)] as const,
139
+ ),
140
+ );
141
+ return {
142
+ markdownFiles,
143
+ fileHashes: Object.fromEntries(hashPairs),
144
+ listingTrusted: listing.ok,
145
+ directory: dir ? path.resolve(dir) : null,
146
+ };
147
+ }
148
+
149
+ async function buildCacheSnapshotsAsync(
150
+ discovery: AgentDiscoveryResult,
151
+ ): Promise<AgentDiscoverySnapshots> {
152
+ const [user, project] = await Promise.all([
153
+ buildScopeSnapshotAsync(getUserAgentsDir()),
154
+ buildScopeSnapshotAsync(discovery.projectAgentsDir),
155
+ ]);
156
+ return { user, project };
157
+ }
158
+
159
+ async function canTrustDerivedScopeAsync(
160
+ source: AgentSource,
161
+ bothEntry: AgentDiscoveryCacheEntry,
162
+ ): Promise<boolean> {
163
+ const dir =
164
+ source === "user" ? getUserAgentsDir() : bothEntry.projectAgentsDir;
165
+ const resolvedDir = dir ? path.resolve(dir) : null;
166
+ const scopeResult = bothEntry.scopes[source];
167
+ const cachedSnapshot = bothEntry.snapshots?.[source];
168
+ if (!cachedSnapshot) return false;
169
+ if (!snapshotHasRequiredMetadata(cachedSnapshot)) return false;
170
+ if (!cachedSnapshot.listingTrusted) return false;
171
+ if (cachedSnapshot.directory !== resolvedDir) return false;
172
+ if (!scopeAgentsMatchListing(scopeResult, source, dir)) return false;
173
+ if (!equalStringSets(scopeResult.markdownFiles, cachedSnapshot.markdownFiles))
174
+ return false;
175
+ const listing = await readMarkdownDirWithStatusAsync(dir);
176
+ if (!listing.ok) return false;
177
+ return equalStringSets(
178
+ cachedSnapshot.markdownFiles,
179
+ listing.entries.map((entry) => entry.name),
180
+ );
181
+ }
182
+
183
+ function buildSourceRecord<T>(
184
+ source: AgentSource,
185
+ value: T,
186
+ empty: () => T,
187
+ ): Record<AgentSource, T> {
188
+ const record: Record<AgentSource, T> = { user: empty(), project: empty() };
189
+ record[source] = value;
190
+ return record;
191
+ }
192
+ function createDerivedCacheEntry(
193
+ bothEntry: AgentDiscoveryCacheEntry,
194
+ source: AgentSource,
195
+ ): AgentDiscoveryCacheEntry {
196
+ const { agents, markdownFiles } = bothEntry.scopes[source];
197
+ const clonedScope: AgentDiscoveryScopeResult = {
198
+ agents: [...agents],
199
+ markdownFiles: [...markdownFiles],
200
+ };
201
+ return {
202
+ agents: clonedScope.agents,
203
+ projectAgentsDir: bothEntry.projectAgentsDir,
204
+ scopes: buildSourceRecord(source, clonedScope, emptyScopeResult),
205
+ ts: bothEntry.ts,
206
+ snapshots: buildSourceRecord(
207
+ source,
208
+ cloneScopeSnapshot(bothEntry.snapshots?.[source]),
209
+ emptySnapshot,
210
+ ),
211
+ };
212
+ }
213
+
214
+ async function discoverAndCacheAsync(
215
+ ctx: CacheOperationContext,
216
+ scope: AgentScope,
217
+ ): Promise<AgentDiscoveryCacheEntry> {
218
+ const discovery = await discoverAgentsAsync(ctx.cwd, scope);
219
+ const entry: AgentDiscoveryCacheEntry = {
220
+ ...discovery,
221
+ ts: ctx.ts,
222
+ snapshots: await buildCacheSnapshotsAsync(discovery),
223
+ };
224
+ ctx.cache.set(cacheKey(ctx.cwd, scope), entry);
225
+ if (scope === "both") {
226
+ await primeScopedCacheEntriesAsync(ctx, entry);
227
+ }
228
+ return entry;
229
+ }
230
+
231
+ async function deriveOrDiscoverScopedEntryAsync(
232
+ ctx: CacheOperationContext,
233
+ source: AgentSource,
234
+ bothEntry: AgentDiscoveryCacheEntry,
235
+ ): Promise<AgentDiscoveryCacheEntry> {
236
+ if (await canTrustDerivedScopeAsync(source, bothEntry)) {
237
+ const entry = createDerivedCacheEntry(bothEntry, source);
238
+ ctx.cache.set(cacheKey(ctx.cwd, source), entry);
239
+ return entry;
240
+ }
241
+ return discoverAndCacheAsync(ctx, source);
242
+ }
243
+
244
+ async function primeScopedCacheEntriesAsync(
245
+ ctx: CacheOperationContext,
246
+ bothEntry: AgentDiscoveryCacheEntry,
247
+ ): Promise<void> {
248
+ const missingSources = AGENT_SOURCES.filter(
249
+ (source) => !getFreshCacheEntry(ctx, cacheKey(ctx.cwd, source)),
250
+ );
251
+ await Promise.all(
252
+ missingSources.map((source) =>
253
+ deriveOrDiscoverScopedEntryAsync(ctx, source, bothEntry),
254
+ ),
255
+ );
256
+ }
257
+
17
258
  export async function getCachedAgentDiscovery(
18
259
  cwd: string,
19
260
  scope: AgentScope,
20
261
  cache: AgentDiscoveryCache = sharedAgentDiscoveryCache,
21
262
  cacheTtlMs = AGENT_DISCOVERY_CACHE_TTL_MS,
22
263
  ): Promise<AgentDiscoveryCacheEntry> {
23
- const key = `${path.resolve(cwd)}\0${scope}`;
24
- const now = Date.now();
25
- const entry = cache.get(key);
26
- if (entry && now - entry.ts <= cacheTtlMs) return entry;
27
- const nextEntry = { ...(await discoverAgentsAsync(cwd, scope)), ts: now };
28
- cache.set(key, nextEntry);
29
- return nextEntry;
264
+ const ctx: CacheOperationContext = { cwd, cache, ts: Date.now(), cacheTtlMs };
265
+ const key = cacheKey(cwd, scope);
266
+ const entry = getFreshCacheEntry(ctx, key);
267
+ if (entry) return entry;
268
+ if (scope !== "both") {
269
+ const bothEntry = getFreshCacheEntry(ctx, cacheKey(cwd, "both"));
270
+ if (bothEntry) {
271
+ return deriveOrDiscoverScopedEntryAsync(ctx, scope, bothEntry);
272
+ }
273
+ }
274
+ return discoverAndCacheAsync(ctx, scope);
30
275
  }
31
276
 
32
277
  export async function getCachedAgentCompletions(
@@ -1,21 +1,10 @@
1
- /**
2
- * Agent discovery and configuration
3
- */
4
-
5
1
  import type { Dirent } from "node:fs";
6
2
  import * as fsPromises from "node:fs/promises";
7
3
  import * as path from "node:path";
8
4
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
9
5
 
10
- export type AgentScope = "user" | "project" | "both";
11
-
12
- export type ThinkingLevel =
13
- | "off"
14
- | "minimal"
15
- | "low"
16
- | "medium"
17
- | "high"
18
- | "xhigh";
6
+ export type AgentSource = "user" | "project";
7
+ export type AgentScope = AgentSource | "both";
19
8
 
20
9
  const THINKING_LEVELS = [
21
10
  "off",
@@ -26,6 +15,8 @@ const THINKING_LEVELS = [
26
15
  "xhigh",
27
16
  ] as const;
28
17
 
18
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
19
+
29
20
  export interface AgentConfig {
30
21
  name: string;
31
22
  description: string;
@@ -33,24 +24,37 @@ export interface AgentConfig {
33
24
  skills?: string[];
34
25
  thinking?: ThinkingLevel;
35
26
  systemPrompt: string;
36
- source: "user" | "project";
27
+ source: AgentSource;
37
28
  filePath: string;
38
29
  }
39
30
 
31
+ export interface AgentDiscoveryScopeResult {
32
+ agents: AgentConfig[];
33
+ markdownFiles: string[];
34
+ }
35
+
40
36
  export interface AgentDiscoveryResult {
41
37
  agents: AgentConfig[];
42
38
  projectAgentsDir: string | null;
39
+ scopes: Record<AgentSource, AgentDiscoveryScopeResult>;
43
40
  }
44
41
 
45
42
  function mergeAgentLists(
46
- userAgents: AgentConfig[],
47
- projectAgents: AgentConfig[],
43
+ userResult: AgentDiscoveryScopeResult,
44
+ projectResult: AgentDiscoveryScopeResult,
48
45
  projectAgentsDir: string | null,
49
46
  ): AgentDiscoveryResult {
50
47
  const agentMap = new Map<string, AgentConfig>();
51
- for (const agent of userAgents) agentMap.set(agent.name, agent);
52
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
53
- return { agents: Array.from(agentMap.values()), projectAgentsDir };
48
+ for (const agent of userResult.agents) agentMap.set(agent.name, agent);
49
+ for (const agent of projectResult.agents) agentMap.set(agent.name, agent);
50
+ return {
51
+ agents: Array.from(agentMap.values()),
52
+ projectAgentsDir,
53
+ scopes: {
54
+ user: userResult,
55
+ project: projectResult,
56
+ },
57
+ };
54
58
  }
55
59
 
56
60
  function parseCommaList(raw: unknown): string[] | undefined {
@@ -72,7 +76,7 @@ function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
72
76
 
73
77
  function parseAgentConfig(
74
78
  content: string,
75
- source: "user" | "project",
79
+ source: AgentSource,
76
80
  filePath: string,
77
81
  ): AgentConfig | null {
78
82
  let parsed: ReturnType<typeof parseFrontmatter<Record<string, unknown>>>;
@@ -116,35 +120,70 @@ function parseAgentConfig(
116
120
  };
117
121
  }
118
122
 
119
- async function loadAgentsFromDirAsync(
123
+ async function loadAgentEntryAsync(
120
124
  dir: string,
121
- source: "user" | "project",
122
- ): Promise<AgentConfig[]> {
123
- const agents: AgentConfig[] = [];
124
- if (!(await isDirectoryAsync(dir))) return agents;
125
- let entries: Dirent[];
125
+ entryName: string,
126
+ source: AgentSource,
127
+ ): Promise<AgentConfig | null> {
128
+ const filePath = path.join(dir, entryName);
129
+ let content: string;
126
130
  try {
127
- entries = await fsPromises.readdir(dir, { withFileTypes: true });
131
+ content = await fsPromises.readFile(filePath, "utf-8");
128
132
  } catch {
129
- return agents;
133
+ return null;
130
134
  }
131
- for (const entry of entries) {
132
- if (!entry.name.endsWith(".md")) continue;
133
- if (!entry.isFile() && !entry.isSymbolicLink()) continue;
134
- const filePath = path.join(dir, entry.name);
135
- let content: string;
136
- try {
137
- content = await fsPromises.readFile(filePath, "utf-8");
138
- } catch {
139
- continue;
140
- }
141
- const agent = parseAgentConfig(content, source, filePath);
142
- if (agent) agents.push(agent);
135
+ return parseAgentConfig(content, source, filePath);
136
+ }
137
+
138
+ export function emptyScopeResult(): AgentDiscoveryScopeResult {
139
+ return { agents: [], markdownFiles: [] };
140
+ }
141
+
142
+ async function loadAgentsFromDirAsync(
143
+ dir: string,
144
+ source: AgentSource,
145
+ ): Promise<AgentDiscoveryScopeResult> {
146
+ const markdownEntries = (await readMarkdownDirWithStatusAsync(dir)).entries;
147
+ const markdownFiles = markdownEntries.map((entry) => entry.name);
148
+ const parsedAgents = await Promise.all(
149
+ markdownEntries.map((entry) =>
150
+ loadAgentEntryAsync(dir, entry.name, source),
151
+ ),
152
+ );
153
+ const agents = parsedAgents.filter(
154
+ (agent): agent is AgentConfig => agent !== null,
155
+ );
156
+ return { agents, markdownFiles };
157
+ }
158
+
159
+ export function isMarkdownDirent(entry: Dirent): boolean {
160
+ return (
161
+ entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink())
162
+ );
163
+ }
164
+
165
+ export interface MarkdownDirListing {
166
+ entries: Dirent[];
167
+ ok: boolean;
168
+ }
169
+
170
+ export async function readMarkdownDirWithStatusAsync(
171
+ dir: string | null,
172
+ ): Promise<MarkdownDirListing> {
173
+ if (!dir) return { entries: [], ok: true };
174
+ try {
175
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
176
+ return { entries: entries.filter(isMarkdownDirent), ok: true };
177
+ } catch {
178
+ return { entries: [], ok: false };
143
179
  }
144
- return agents;
145
180
  }
146
181
 
147
- async function isDirectoryAsync(p: string): Promise<boolean> {
182
+ export function getUserAgentsDir(): string {
183
+ return path.join(getAgentDir(), "agents");
184
+ }
185
+
186
+ export async function isDirectoryAsync(p: string): Promise<boolean> {
148
187
  try {
149
188
  return (await fsPromises.stat(p)).isDirectory();
150
189
  } catch {
@@ -169,15 +208,17 @@ export async function discoverAgentsAsync(
169
208
  cwd: string,
170
209
  scope: AgentScope,
171
210
  ): Promise<AgentDiscoveryResult> {
172
- const userDir = path.join(getAgentDir(), "agents");
211
+ const userDir = getUserAgentsDir();
173
212
  const projectAgentsDir = await findNearestProjectAgentsDirAsync(cwd);
174
- const [userAgents, projectAgents] = await Promise.all([
175
- scope === "project" ? [] : loadAgentsFromDirAsync(userDir, "user"),
213
+ const [userDiscovery, projectDiscovery] = await Promise.all([
214
+ scope === "project"
215
+ ? emptyScopeResult()
216
+ : loadAgentsFromDirAsync(userDir, "user"),
176
217
  scope === "user" || !projectAgentsDir
177
- ? []
218
+ ? emptyScopeResult()
178
219
  : loadAgentsFromDirAsync(projectAgentsDir, "project"),
179
220
  ]);
180
- return mergeAgentLists(userAgents, projectAgents, projectAgentsDir);
221
+ return mergeAgentLists(userDiscovery, projectDiscovery, projectAgentsDir);
181
222
  }
182
223
 
183
224
  export function formatAgentList(
@@ -1,14 +1,87 @@
1
- type ChildKnownEvent =
1
+ import { makeToolPreview } from "../output/normalize.js";
2
+ import type { ToolActivity } from "../shared/types.js";
3
+
4
+ // Extracts results[0] from details; null-safe for malformed input.
5
+ function tryFirstResult(details: unknown): Record<string, unknown> | null {
6
+ try {
7
+ if (typeof details !== "object" || details === null) return null;
8
+ const results = (details as Record<string, unknown>).results;
9
+ if (!Array.isArray(results) || results.length === 0) return null;
10
+ const nested = results[0];
11
+ if (typeof nested !== "object" || nested === null) return null;
12
+ return nested as Record<string, unknown>;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ // Malformed details (null result) falls back to { toolName }.
19
+ function parseToolActivity(
20
+ toolName: string,
21
+ partialResult: { content?: unknown; details?: unknown },
22
+ ): ToolActivity {
23
+ const nestedRecord = tryFirstResult(partialResult.details);
24
+ if (!nestedRecord) return { toolName, inputSummary: toolName };
25
+ const isSubagent = toolName === "subagent";
26
+ const activity: ToolActivity = { toolName };
27
+ const agent = typeof nestedRecord.agent === "string" && nestedRecord.agent;
28
+ activity.inputSummary =
29
+ isSubagent && agent ? makeToolPreview(toolName, nestedRecord) : toolName;
30
+ if (
31
+ typeof nestedRecord.instanceName === "string" &&
32
+ nestedRecord.instanceName
33
+ ) {
34
+ activity.instanceName = nestedRecord.instanceName;
35
+ }
36
+ const progress = nestedRecord.progress;
37
+ if (typeof progress === "object" && progress !== null) {
38
+ const activeToolActivity = (progress as Record<string, unknown>)
39
+ .activeToolActivity;
40
+ if (
41
+ typeof activeToolActivity === "object" &&
42
+ activeToolActivity !== null &&
43
+ typeof (activeToolActivity as Record<string, unknown>).toolName ===
44
+ "string"
45
+ ) {
46
+ const childActivity = activeToolActivity as ToolActivity;
47
+ activity.child = childActivity;
48
+ // Subagent delegates inputSummary to its own agent name, not child
49
+ if (
50
+ !isSubagent &&
51
+ typeof childActivity.inputSummary === "string" &&
52
+ childActivity.inputSummary
53
+ ) {
54
+ activity.inputSummary = childActivity.inputSummary;
55
+ }
56
+ }
57
+ }
58
+ return activity;
59
+ }
60
+
61
+ export type ChildKnownEvent =
2
62
  | { type: "message_end"; message: unknown }
3
63
  | { type: "tool_result_end"; message: unknown }
4
- | { type: "agent_end"; messages?: unknown; stopReason?: string };
64
+ | { type: "agent_end"; messages?: unknown; stopReason?: string }
65
+ | {
66
+ type: "tool_execution_update";
67
+ toolName: string;
68
+ partialResult: { content?: unknown; details?: unknown };
69
+ toolActivity: ToolActivity;
70
+ };
5
71
 
6
72
  export type ChildEventParseResult =
7
73
  | { kind: "known"; event: ChildKnownEvent }
8
74
  | { kind: "unknown"; event: unknown }
9
75
  | { kind: "invalid"; line: string };
10
76
 
11
- const KNOWN_TYPES = new Set(["message_end", "tool_result_end", "agent_end"]);
77
+ export const TOOL_EXECUTION_UPDATE_EVENT = "tool_execution_update" as const;
78
+
79
+ const KNOWN_TYPES = new Set([
80
+ "message_end",
81
+ "tool_result_end",
82
+ "agent_end",
83
+ TOOL_EXECUTION_UPDATE_EVENT,
84
+ ]);
12
85
 
13
86
  export function parseChildEventLine(line: string): ChildEventParseResult {
14
87
  if (typeof line !== "string" || !line.trim())
@@ -26,6 +99,20 @@ export function parseChildEventLine(line: string): ChildEventParseResult {
26
99
  typeof (event as Record<string, unknown>).type === "string" &&
27
100
  KNOWN_TYPES.has((event as Record<string, unknown>).type as string)
28
101
  ) {
102
+ const record = event as Record<string, unknown>;
103
+ if (record.type === TOOL_EXECUTION_UPDATE_EVENT) {
104
+ if (
105
+ typeof record.toolName !== "string" ||
106
+ typeof record.partialResult !== "object" ||
107
+ record.partialResult === null
108
+ ) {
109
+ return { kind: "unknown", event };
110
+ }
111
+ record.toolActivity = parseToolActivity(
112
+ record.toolName as string,
113
+ record.partialResult as { content?: unknown; details?: unknown },
114
+ );
115
+ }
29
116
  return { kind: "known", event: event as ChildKnownEvent };
30
117
  }
31
118
  return { kind: "unknown", event };