@d3ara1n/pi-subagent 0.3.0 → 0.5.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 +63 -22
- package/package.json +1 -1
- package/src/config.ts +10 -3
- package/src/index.ts +469 -284
- package/src/spawn.ts +158 -53
- package/src/types.ts +42 -2
- package/src/utils.test.ts +278 -0
- package/src/utils.ts +286 -0
package/src/spawn.ts
CHANGED
|
@@ -6,18 +6,16 @@
|
|
|
6
6
|
* Fires onProgress on each event for streaming updates.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import type { SubagentMessage, SubagentResult } from "./types.ts";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
const
|
|
16
|
+
/** Max chars for an inline channel block (context or task) before it spills to a temp @file. */
|
|
17
|
+
const INLINE_LIMIT = 8000;
|
|
18
18
|
|
|
19
|
-
/** Maximum output characters returned to the main model. Larger outputs are truncated. */
|
|
20
|
-
const MAX_OUTPUT_CHARS = 50_000;
|
|
21
19
|
|
|
22
20
|
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
23
21
|
|
|
@@ -126,8 +124,15 @@ export async function spawnSubagent(
|
|
|
126
124
|
cwd?: string;
|
|
127
125
|
tools?: string[];
|
|
128
126
|
systemPrompt?: string;
|
|
127
|
+
/** Extra context delivered as a separate channel from the task. */
|
|
128
|
+
context?: string;
|
|
129
|
+
/** Reference file paths injected as independent @file args (child reads them directly). */
|
|
130
|
+
contextFiles?: string[];
|
|
129
131
|
subagentRoles?: string[];
|
|
130
132
|
timeoutMs?: number;
|
|
133
|
+
depth?: number;
|
|
134
|
+
maxTurns?: number;
|
|
135
|
+
maxCost?: number;
|
|
131
136
|
signal?: AbortSignal;
|
|
132
137
|
onProgress?: (update: Partial<SubagentResult>) => void;
|
|
133
138
|
},
|
|
@@ -144,7 +149,6 @@ export async function spawnSubagent(
|
|
|
144
149
|
};
|
|
145
150
|
|
|
146
151
|
let tmpDir: string | null = null;
|
|
147
|
-
let tmpFile: string | null = null;
|
|
148
152
|
|
|
149
153
|
try {
|
|
150
154
|
// Build CLI args
|
|
@@ -154,27 +158,77 @@ export async function spawnSubagent(
|
|
|
154
158
|
args.push("--tools", options.tools.join(","));
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
//
|
|
161
|
+
// Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
|
|
162
|
+
// for subagent bash work (e.g. git clone). The system prompt no longer uses it.
|
|
158
163
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
// ── System prompt channel: inline text via --append-system-prompt ──
|
|
166
|
+
// pi's resolvePromptInput treats an existing path as a file to read and any
|
|
167
|
+
// non-path string as literal text, so we pass structured blocks directly —
|
|
168
|
+
// no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
|
|
169
|
+
if (options.systemPrompt?.trim()) {
|
|
170
|
+
args.push(
|
|
171
|
+
"--append-system-prompt",
|
|
172
|
+
`<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
args.push(
|
|
176
|
+
"--append-system-prompt",
|
|
177
|
+
`<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// ── Context channel: independent size gate ──
|
|
181
|
+
// Large context spills to @ctx.md (pi auto-wraps in <file>); small context
|
|
182
|
+
// inlines as a structured <context> tag. Decoupled from the task gate so a
|
|
183
|
+
// large context never drags a short task into a spill file.
|
|
184
|
+
let contextInline = false;
|
|
185
|
+
if (options.context && options.context.trim()) {
|
|
186
|
+
if (options.context.length > INLINE_LIMIT) {
|
|
187
|
+
const ctxPath = path.join(tmpDir, "context.md");
|
|
188
|
+
await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
|
|
189
|
+
args.push(`@${ctxPath}`);
|
|
190
|
+
} else {
|
|
191
|
+
contextInline = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
166
194
|
|
|
167
|
-
|
|
195
|
+
// ── Reference files channel: each as an independent @ argument ──
|
|
196
|
+
// pi reads each and wraps in <file name="...">. Content never enters the
|
|
197
|
+
// parent model's context — the child reads it directly.
|
|
198
|
+
if (options.contextFiles) {
|
|
199
|
+
for (const f of options.contextFiles) {
|
|
200
|
+
args.push(`@${f}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Task channel: always inline, always the final block ──
|
|
205
|
+
// The task is an instruction, not reference material — it stays inline so the
|
|
206
|
+
// child sees it as the primary directive. A pathologically long task spills.
|
|
207
|
+
let taskInline = true;
|
|
208
|
+
if (task.length > INLINE_LIMIT) {
|
|
168
209
|
const taskPath = path.join(tmpDir, "task.md");
|
|
169
210
|
await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
|
|
170
211
|
args.push(`@${taskPath}`);
|
|
171
|
-
|
|
172
|
-
args.push(`Task: ${task}`);
|
|
212
|
+
taskInline = false;
|
|
173
213
|
}
|
|
174
214
|
|
|
215
|
+
// ── Compose the message body (inline context + task) ──
|
|
216
|
+
// @file args are injected by pi BEFORE this message (buildInitialMessage),
|
|
217
|
+
// so the final shape the child sees is:
|
|
218
|
+
// [<file>...spilled context / reference files...</file>]
|
|
219
|
+
// [<context>...inline context...</context>]
|
|
220
|
+
// [<task>...task...</task>]
|
|
221
|
+
const messageParts: string[] = [];
|
|
222
|
+
if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
|
|
223
|
+
if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
|
|
224
|
+
const message = messageParts.join("\n\n");
|
|
225
|
+
if (message) args.push(message);
|
|
226
|
+
|
|
175
227
|
// Spawn process
|
|
176
228
|
const invocation = getPiInvocation(args);
|
|
177
229
|
let wasAborted = false;
|
|
230
|
+
let budgetExceeded = false;
|
|
231
|
+
let wasTimeout = false;
|
|
178
232
|
let buffer = "";
|
|
179
233
|
|
|
180
234
|
const emitProgress = () => {
|
|
@@ -189,6 +243,20 @@ export async function spawnSubagent(
|
|
|
189
243
|
};
|
|
190
244
|
|
|
191
245
|
let thinkingCounter = 0;
|
|
246
|
+
// O(1) lookup from toolCallId → activityLog index (was linear find → O(n²) on busy runs)
|
|
247
|
+
const toolCallIndex = new Map<string, number>();
|
|
248
|
+
|
|
249
|
+
// Kill the child when the configured turn/cost budget is exceeded.
|
|
250
|
+
// Called after each assistant message_end (usage already accumulated).
|
|
251
|
+
const checkBudget = () => {
|
|
252
|
+
const mt = options.maxTurns ?? 0;
|
|
253
|
+
const mc = options.maxCost ?? 0;
|
|
254
|
+
if (budgetExceeded || wasTimeout) return;
|
|
255
|
+
if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
|
|
256
|
+
budgetExceeded = true;
|
|
257
|
+
killProc("budget");
|
|
258
|
+
}
|
|
259
|
+
};
|
|
192
260
|
|
|
193
261
|
const processLine = (line: string) => {
|
|
194
262
|
if (!line.trim()) return;
|
|
@@ -212,7 +280,8 @@ export async function spawnSubagent(
|
|
|
212
280
|
result.usage.cacheRead += usage.cacheRead || 0;
|
|
213
281
|
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
214
282
|
result.usage.cost += usage.cost?.total || 0;
|
|
215
|
-
|
|
283
|
+
// Peak context size, not last-turn size (accumulating is meaningless; max tells how close to the limit)
|
|
284
|
+
result.usage.contextTokens = Math.max(result.usage.contextTokens, usage.totalTokens || 0);
|
|
216
285
|
}
|
|
217
286
|
if (!result.model && msg.model) result.model = msg.model;
|
|
218
287
|
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
@@ -224,6 +293,8 @@ export async function spawnSubagent(
|
|
|
224
293
|
result.output = part.text;
|
|
225
294
|
}
|
|
226
295
|
}
|
|
296
|
+
|
|
297
|
+
checkBudget();
|
|
227
298
|
}
|
|
228
299
|
|
|
229
300
|
emitProgress();
|
|
@@ -232,6 +303,7 @@ export async function spawnSubagent(
|
|
|
232
303
|
// Activity log: track thinking blocks and tool calls in arrival order.
|
|
233
304
|
// Both update in place so the TUI reflects real-time state.
|
|
234
305
|
if (event.type === "tool_execution_start" && event.toolCallId) {
|
|
306
|
+
toolCallIndex.set(event.toolCallId, result.activityLog.length);
|
|
235
307
|
result.activityLog.push({
|
|
236
308
|
kind: "toolCall",
|
|
237
309
|
id: event.toolCallId,
|
|
@@ -241,8 +313,8 @@ export async function spawnSubagent(
|
|
|
241
313
|
});
|
|
242
314
|
emitProgress();
|
|
243
315
|
} else if (event.type === "tool_execution_end" && event.toolCallId) {
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
316
|
+
const idx = toolCallIndex.get(event.toolCallId);
|
|
317
|
+
if (idx !== undefined) result.activityLog[idx].status = event.isError ? "failed" : "done";
|
|
246
318
|
emitProgress();
|
|
247
319
|
}
|
|
248
320
|
|
|
@@ -279,73 +351,106 @@ export async function spawnSubagent(
|
|
|
279
351
|
}
|
|
280
352
|
// Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
|
|
281
353
|
childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
|
|
354
|
+
// Propagate nesting depth so child delegate calls can bound recursion
|
|
355
|
+
childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
|
|
282
356
|
|
|
283
357
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
358
|
+
let proc: ChildProcess | undefined;
|
|
359
|
+
|
|
360
|
+
// Shared kill helper used by abort, budget, and timeout paths.
|
|
361
|
+
// Centralizes reason → stopReason mapping and the SIGTERM → 5s → SIGKILL escalation.
|
|
362
|
+
const escalationTimers: ReturnType<typeof setTimeout>[] = [];
|
|
363
|
+
const killProc = (reason: "abort" | "budget" | "timeout") => {
|
|
364
|
+
if (reason === "abort") wasAborted = true;
|
|
365
|
+
else if (reason === "budget") {
|
|
366
|
+
result.stopReason = "budget_exceeded";
|
|
367
|
+
// Human-readable so the caller/TUI never falls back to raw stderr noise.
|
|
368
|
+
const mt = options.maxTurns ?? 0;
|
|
369
|
+
const mc = options.maxCost ?? 0;
|
|
370
|
+
const why = mt > 0 && result.usage.turns >= mt ? `${result.usage.turns} turns` : `$${result.usage.cost.toFixed(4)}`;
|
|
371
|
+
result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
|
|
372
|
+
}
|
|
373
|
+
else if (reason === "timeout") {
|
|
374
|
+
result.stopReason = "timeout";
|
|
375
|
+
wasTimeout = true;
|
|
376
|
+
// Human-readable message so the caller/TUI never falls back to the
|
|
377
|
+
// raw stderr (which is full of TUI teardown escape sequences).
|
|
378
|
+
const secs = Math.round((options.timeoutMs ?? 0) / 1000);
|
|
379
|
+
result.errorMessage = `Timed out after ${secs}s (completed ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"})`;
|
|
380
|
+
}
|
|
381
|
+
try { proc?.kill("SIGTERM"); } catch { /* ignore */ }
|
|
382
|
+
escalationTimers.push(setTimeout(() => {
|
|
383
|
+
try { if (proc && !proc.killed) proc.kill("SIGKILL"); } catch { /* ignore */ }
|
|
384
|
+
}, 5000));
|
|
385
|
+
};
|
|
284
386
|
|
|
285
387
|
const exitCode = await new Promise<number>((resolve) => {
|
|
286
|
-
|
|
388
|
+
// Register abort BEFORE spawning to close the (tiny) registration window
|
|
389
|
+
let onAbort: (() => void) | undefined;
|
|
390
|
+
if (options.signal) {
|
|
391
|
+
if (options.signal.aborted) { wasAborted = true; resolve(0); return; }
|
|
392
|
+
onAbort = () => killProc("abort");
|
|
393
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const p = spawn(invocation.command, invocation.args, {
|
|
287
397
|
cwd: options.cwd,
|
|
288
398
|
env: childEnv,
|
|
289
399
|
shell: false,
|
|
290
400
|
stdio: ["ignore", "pipe", "pipe"],
|
|
291
401
|
});
|
|
402
|
+
proc = p;
|
|
292
403
|
|
|
293
|
-
|
|
404
|
+
p.stdout.on("data", (data: Buffer) => {
|
|
294
405
|
buffer += data.toString();
|
|
295
406
|
const lines = buffer.split("\n");
|
|
296
407
|
buffer = lines.pop() || "";
|
|
297
408
|
for (const line of lines) processLine(line);
|
|
298
409
|
});
|
|
299
410
|
|
|
300
|
-
|
|
411
|
+
p.stderr.on("data", (data: Buffer) => {
|
|
301
412
|
result.stderr += data.toString();
|
|
302
413
|
});
|
|
303
414
|
|
|
304
|
-
|
|
415
|
+
p.on("close", (code, signal) => {
|
|
305
416
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
417
|
+
for (const t of escalationTimers) clearTimeout(t);
|
|
418
|
+
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
306
419
|
if (buffer.trim()) processLine(buffer);
|
|
307
|
-
|
|
420
|
+
|
|
421
|
+
// External signal death (OOM killer, segfault, kill -9 from elsewhere)
|
|
422
|
+
// that we didn't trigger. Distinguish from our own budget/timeout/abort kills
|
|
423
|
+
// which set the flags before we send the signal.
|
|
424
|
+
const externalKill = signal !== null && !budgetExceeded && !wasTimeout && !wasAborted;
|
|
425
|
+
if (externalKill) {
|
|
426
|
+
result.errorMessage = result.errorMessage || `Subagent killed by signal ${signal}`;
|
|
427
|
+
result.stopReason = "error";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Budget stops are intentional (success); timeouts and external kills
|
|
431
|
+
// are failures (non-zero); otherwise use the real exit code.
|
|
432
|
+
resolve(budgetExceeded ? 0 : (wasTimeout || externalKill ? (code ?? 128) : (code ?? 0)));
|
|
308
433
|
});
|
|
309
434
|
|
|
310
|
-
|
|
435
|
+
p.on("error", (err) => {
|
|
436
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
437
|
+
for (const t of escalationTimers) clearTimeout(t);
|
|
438
|
+
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
439
|
+
// Surface the real cause (e.g. ENOENT when pi is not in PATH) instead of "unknown error".
|
|
440
|
+
result.errorMessage = err?.message || String(err);
|
|
311
441
|
resolve(1);
|
|
312
442
|
});
|
|
313
443
|
|
|
314
|
-
// Handle abort signal
|
|
315
|
-
if (options.signal) {
|
|
316
|
-
const killProc = () => {
|
|
317
|
-
wasAborted = true;
|
|
318
|
-
proc.kill("SIGTERM");
|
|
319
|
-
setTimeout(() => {
|
|
320
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
321
|
-
}, 5000);
|
|
322
|
-
};
|
|
323
|
-
if (options.signal.aborted) killProc();
|
|
324
|
-
else options.signal.addEventListener("abort", killProc, { once: true });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
444
|
// Handle timeout
|
|
328
445
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
329
|
-
timeoutHandle = setTimeout(() =>
|
|
330
|
-
if (!proc.killed) {
|
|
331
|
-
proc.kill("SIGTERM");
|
|
332
|
-
setTimeout(() => {
|
|
333
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
334
|
-
}, 5000);
|
|
335
|
-
}
|
|
336
|
-
}, options.timeoutMs);
|
|
446
|
+
timeoutHandle = setTimeout(() => killProc("timeout"), options.timeoutMs);
|
|
337
447
|
}
|
|
338
448
|
});
|
|
339
449
|
|
|
340
450
|
result.exitCode = exitCode;
|
|
341
451
|
if (wasAborted) throw new Error("Subagent was aborted");
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
if (result.output.length > MAX_OUTPUT_CHARS) {
|
|
345
|
-
const head = result.output.slice(0, 30_000);
|
|
346
|
-
const tail = result.output.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
347
|
-
result.output = `[Output truncated — ${result.output.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
348
|
-
}
|
|
452
|
+
// NOTE: large outputs are kept raw here — compression/truncation happens in
|
|
453
|
+
// the extension layer (index.ts) so the summary model can compress first.
|
|
349
454
|
} finally {
|
|
350
455
|
// Cleanup temp directory and all contents
|
|
351
456
|
if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
package/src/types.ts
CHANGED
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
/** Configuration for the subagent extension. */
|
|
6
6
|
export interface SubagentConfig {
|
|
7
|
-
|
|
7
|
+
/** Per-subagent timeout in seconds. Roles that can `delegate` get 2× automatically when no per-role timeout is set. */
|
|
8
|
+
timeout: number;
|
|
9
|
+
/** Max number of subagents allowed to run concurrently. Extras queue with a TUI hint. */
|
|
10
|
+
maxConcurrency: number;
|
|
11
|
+
/** Max subagent nesting depth (the top-level session is depth 0). */
|
|
12
|
+
maxDepth: number;
|
|
13
|
+
/** Default turn budget (0 = unlimited). Per-role maxTurns overrides this. */
|
|
14
|
+
maxTurns: number;
|
|
15
|
+
/** Default cost budget in USD (0 = unlimited). Per-role maxCost overrides this. */
|
|
16
|
+
maxCost: number;
|
|
17
|
+
/** Persist each delegate run to .pi/subagent/history/{sessionId}/{id}.json for auditing. */
|
|
18
|
+
history: SubagentHistoryConfig;
|
|
8
19
|
summary: SubagentSummaryConfig;
|
|
9
20
|
/**
|
|
10
21
|
* Per-role overrides from settings.json. Keyed by role name.
|
|
@@ -14,13 +25,22 @@ export interface SubagentConfig {
|
|
|
14
25
|
agentOverrides: Record<string, Partial<SubagentRole> & { disabled?: boolean }>;
|
|
15
26
|
}
|
|
16
27
|
|
|
28
|
+
export interface SubagentHistoryConfig {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export interface SubagentSummaryConfig {
|
|
18
33
|
role: string;
|
|
19
34
|
enabled: boolean;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
export const DEFAULT_CONFIG: SubagentConfig = {
|
|
23
|
-
|
|
38
|
+
timeout: 600,
|
|
39
|
+
maxConcurrency: 4,
|
|
40
|
+
maxDepth: 3,
|
|
41
|
+
maxTurns: 0,
|
|
42
|
+
maxCost: 0,
|
|
43
|
+
history: { enabled: true },
|
|
24
44
|
summary: { role: "utility", enabled: true },
|
|
25
45
|
agentOverrides: {},
|
|
26
46
|
};
|
|
@@ -41,6 +61,12 @@ export interface SubagentRole {
|
|
|
41
61
|
tools: string[];
|
|
42
62
|
/** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
|
|
43
63
|
subagentRoles?: string[];
|
|
64
|
+
/** Per-role timeout override in seconds. Falls back to config.timeout when unset. */
|
|
65
|
+
timeout?: number;
|
|
66
|
+
/** Max assistant turns before the run is killed (0 = use config default; unset = unlimited). */
|
|
67
|
+
maxTurns?: number;
|
|
68
|
+
/** Max cumulative cost (USD) before the run is killed (0 = use config default; unset = unlimited). */
|
|
69
|
+
maxCost?: number;
|
|
44
70
|
/** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
|
|
45
71
|
fallbackRole?: string;
|
|
46
72
|
}
|
|
@@ -102,6 +128,10 @@ export interface SubagentResult {
|
|
|
102
128
|
task: string;
|
|
103
129
|
/** Process exit code (-1 = still running for streaming) */
|
|
104
130
|
exitCode: number;
|
|
131
|
+
/** True while waiting for a concurrency slot (TUI hint only). */
|
|
132
|
+
queued?: boolean;
|
|
133
|
+
/** How `output` was prepared for display: raw, compressed by summary model, or mechanically truncated. */
|
|
134
|
+
outputMethod?: "raw" | "compressed" | "truncated";
|
|
105
135
|
/** All messages from the event stream (assistant + tool results) */
|
|
106
136
|
messages: SubagentMessage[];
|
|
107
137
|
/** Last assistant text output */
|
|
@@ -120,6 +150,16 @@ export interface SubagentResult {
|
|
|
120
150
|
errorMessage?: string;
|
|
121
151
|
/** Real-time activity log: thinking blocks and tool calls in arrival order. */
|
|
122
152
|
activityLog: ActivityEntry[];
|
|
153
|
+
|
|
154
|
+
// ── TUI 渲染辅助字段(非子进程产出,由 execute 层填入)──────────
|
|
155
|
+
/** 运行开始墙钟时间;仅 queued/running 帧存在,供 TUI 实时算耗时。终态帧无此字段。 */
|
|
156
|
+
startTime?: number;
|
|
157
|
+
/** 终态总耗时(ms),由 execute 在结束时写入;覆盖整个 delegate 区间(含 fallback 重试)。 */
|
|
158
|
+
elapsedMs?: number;
|
|
159
|
+
/** delegate 传入的引用文件路径(params.files),展开视图渲染用。 */
|
|
160
|
+
files?: string[];
|
|
161
|
+
/** delegate 传入的额外上下文(params.context),展开视图渲染用。 */
|
|
162
|
+
context?: string;
|
|
123
163
|
}
|
|
124
164
|
|
|
125
165
|
/** TUI details structure passed via tool result details. */
|