@shanepadgett/tau-agent 0.15.0 → 0.17.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.
Files changed (57) hide show
  1. package/docs/subagents.md +25 -6
  2. package/extensions/attention/README.md +1 -0
  3. package/extensions/attention/index.ts +38 -0
  4. package/extensions/context/README.md +13 -3
  5. package/extensions/context/definitions.ts +3 -15
  6. package/extensions/context/evidence.ts +516 -0
  7. package/extensions/context/index.ts +114 -116
  8. package/extensions/context/panel.ts +60 -0
  9. package/extensions/context/settings.ts +30 -1
  10. package/extensions/context/sync.ts +171 -686
  11. package/extensions/context/validation.ts +4 -1
  12. package/extensions/context/write-scope.ts +109 -0
  13. package/extensions/context-pruning/README.md +22 -0
  14. package/extensions/context-pruning/file-evidence.ts +265 -0
  15. package/extensions/context-pruning/index.ts +379 -0
  16. package/extensions/context-pruning/projection.ts +108 -0
  17. package/extensions/context-pruning/prune.ts +346 -0
  18. package/extensions/context-pruning/render.ts +179 -0
  19. package/extensions/context-pruning/settings.ts +41 -0
  20. package/extensions/explore/README.md +3 -1
  21. package/extensions/explore/autoread.ts +85 -22
  22. package/extensions/explore/full-file-knowledge.ts +234 -0
  23. package/extensions/explore/index.ts +2 -3
  24. package/extensions/explore/read-cache.ts +150 -86
  25. package/extensions/explore/read-snapshots.ts +17 -4
  26. package/extensions/explore/read.ts +57 -38
  27. package/extensions/footer/index.ts +62 -60
  28. package/extensions/patch/README.md +1 -1
  29. package/extensions/patch/index.ts +19 -5
  30. package/extensions/run-summary/index.ts +5 -5
  31. package/extensions/silent-command-runner/README.md +1 -1
  32. package/extensions/silent-command-runner/index.ts +45 -28
  33. package/extensions/soul/prompt.ts +3 -1
  34. package/extensions/subagent/README.md +21 -5
  35. package/extensions/subagent/agents/context-sync.md +90 -0
  36. package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
  37. package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
  38. package/extensions/subagent/agents/review.md +48 -0
  39. package/extensions/subagent/agents/web-research.md +6 -0
  40. package/extensions/subagent/agents.ts +15 -2
  41. package/extensions/subagent/cmux-dashboard.ts +454 -0
  42. package/extensions/subagent/index.ts +181 -234
  43. package/extensions/subagent/render.ts +1 -1
  44. package/extensions/subagent/resume.ts +78 -0
  45. package/extensions/subagent/run.ts +213 -118
  46. package/extensions/subagent/runtime.ts +856 -0
  47. package/extensions/subagent/session-resource.ts +169 -0
  48. package/extensions/tau-help/help.md +7 -3
  49. package/extensions/turn-budget/index.ts +8 -36
  50. package/package.json +2 -2
  51. package/schemas/tau.schema.json +48 -1
  52. package/shared/context-pruning-state.ts +364 -0
  53. package/shared/events.ts +18 -0
  54. package/shared/model-fallback/index.ts +21 -10
  55. package/shared/model-fallback/types.ts +5 -3
  56. package/shared/settings/load.ts +78 -1
  57. package/shared/tool-row-state.ts +21 -1
@@ -1,44 +1,30 @@
1
- import { mkdtemp, writeFile } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import type { AssistantMessage } from "@earendil-works/pi-ai";
4
+ import { join, resolve } from "node:path";
5
+ import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
5
6
  import {
6
- createAgentSession,
7
7
  DEFAULT_MAX_BYTES,
8
8
  DEFAULT_MAX_LINES,
9
- DefaultResourceLoader,
10
9
  formatSize,
11
- getAgentDir,
12
- SessionManager,
13
10
  truncateHead,
14
- type AgentSession,
15
11
  type AgentSessionEvent,
16
12
  type ExtensionAPI,
17
13
  type ExtensionContext,
18
14
  } from "@earendil-works/pi-coding-agent";
15
+ import { createCompleteFileMeta } from "../explore/full-file-knowledge.ts";
19
16
  import type { AgentDefinition } from "./agents.ts";
17
+ import { emptySubagentResumeState, type RetainedTurnOutcome, type SubagentResumeState } from "./resume.ts";
18
+ import {
19
+ createSubagentSessionResource,
20
+ resolveSubagentSessionInputs,
21
+ type SubagentSessionInputs,
22
+ type SubagentSessionResource,
23
+ } from "./session-resource.ts";
20
24
 
21
25
  const PREVIEW_LIMIT = 600;
22
26
  const VALUE_LIMIT = 180;
23
-
24
- const CHILD_UI_BLOCKED_METHODS = new Set([
25
- "setEditorComponent",
26
- "setFooter",
27
- "setStatus",
28
- "setTitle",
29
- "setWidget",
30
- "setWorkingIndicator",
31
- ]);
32
-
33
- function childUiContext(ui: ExtensionContext["ui"]): ExtensionContext["ui"] {
34
- return new Proxy(ui, {
35
- get(target, property, receiver) {
36
- if (typeof property === "string" && CHILD_UI_BLOCKED_METHODS.has(property)) return () => undefined;
37
- const value = Reflect.get(target, property, receiver) as unknown;
38
- return typeof value === "function" ? value.bind(target) : value;
39
- },
40
- });
41
- }
27
+ const AUTOREAD_MESSAGE_TYPE = "tau.autoread";
42
28
 
43
29
  export interface SubagentAction {
44
30
  tool: string;
@@ -53,11 +39,17 @@ export interface SubagentUsage {
53
39
  cost: number;
54
40
  turns: number;
55
41
  }
42
+
43
+ export type SubagentLifecycle = "waiting" | "starting" | "running" | "completed" | "failed" | "aborted";
44
+ export type SubagentPhase = "discovery" | "queue" | "startup" | "run" | "output";
45
+
56
46
  export interface SubagentDetails {
57
47
  agent: string;
48
+ displayName: string;
58
49
  threadId?: string;
59
- status: "waiting" | "running" | "completed" | "failed" | "aborted";
60
- phase: "discovery" | "queue" | "startup" | "run" | "output";
50
+ invocationId?: string;
51
+ status: SubagentLifecycle;
52
+ phase: SubagentPhase;
61
53
  completionState?: string;
62
54
  task: string;
63
55
  model: string;
@@ -81,14 +73,24 @@ export interface SubagentDetails {
81
73
  };
82
74
  }
83
75
 
76
+ /** Immutable observer payload. Distinct invocation IDs for queued turns of one thread. */
77
+ export interface SubagentInvocationSnapshot extends SubagentDetails {
78
+ invocationId: string;
79
+ startedAt: number;
80
+ }
81
+
84
82
  export interface SubagentThread {
85
83
  id: string;
84
+ displayName: string;
86
85
  definition: AgentDefinition;
87
- session: AgentSession;
86
+ sessionInputs: SubagentSessionInputs;
87
+ resource: SubagentSessionResource;
88
88
  cwd: string;
89
89
  model: string;
90
90
  thinkingLevel: string;
91
91
  initialTask: string;
92
+ resumeState: SubagentResumeState;
93
+ lastAssistantMessageAt: number | undefined;
92
94
  turns: number;
93
95
  turnGate: FifoGate;
94
96
  pendingTurns: number;
@@ -162,6 +164,18 @@ function capped(value: unknown, limit = VALUE_LIMIT): string {
162
164
  return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
163
165
  }
164
166
 
167
+ /** Rolling tail so live previews keep changing after the limit. */
168
+ export function cappedTail(value: unknown, limit = PREVIEW_LIMIT): string {
169
+ let text: string;
170
+ try {
171
+ text = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
172
+ } catch {
173
+ text = "[unavailable]";
174
+ }
175
+ if (text.length <= limit) return text;
176
+ return `…${text.slice(-(limit - 1))}`;
177
+ }
178
+
165
179
  function textOf(message: AssistantMessage): string {
166
180
  return message.content
167
181
  .filter((part) => part.type === "text")
@@ -169,8 +183,17 @@ function textOf(message: AssistantMessage): string {
169
183
  .join("");
170
184
  }
171
185
 
186
+ function cloneSnapshot(details: SubagentDetails): SubagentDetails {
187
+ return structuredClone(details);
188
+ }
189
+
190
+ export function cloneInvocationSnapshot(snapshot: SubagentInvocationSnapshot): SubagentInvocationSnapshot {
191
+ return structuredClone(snapshot);
192
+ }
193
+
172
194
  export async function createSubagentThread(options: {
173
195
  id: string;
196
+ displayName: string;
174
197
  definition: AgentDefinition;
175
198
  extensionPaths: readonly string[];
176
199
  initialTask: string;
@@ -181,6 +204,7 @@ export async function createSubagentThread(options: {
181
204
  }): Promise<SubagentThread> {
182
205
  const {
183
206
  id,
207
+ displayName,
184
208
  definition,
185
209
  extensionPaths,
186
210
  initialTask,
@@ -189,104 +213,76 @@ export async function createSubagentThread(options: {
189
213
  signal,
190
214
  onWarning,
191
215
  } = options;
192
- let model = ctx.model;
193
- let thinkingLevel = parentThinkingLevel;
194
- if (definition.model) {
195
- const separator = definition.model.indexOf("/");
196
- const configured = ctx.modelRegistry.find(
197
- definition.model.slice(0, separator),
198
- definition.model.slice(separator + 1),
199
- );
200
- if (!configured) onWarning?.(`model ${definition.model} is unavailable; using parent model`);
201
- else {
202
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
203
- if (!auth.ok) onWarning?.(`model ${definition.model} is unavailable: ${auth.error}; using parent model`);
204
- else model = configured;
205
- }
206
- }
207
- if (definition.thinking) {
208
- const mapped = model?.thinkingLevelMap?.[definition.thinking];
209
- const unsupported =
210
- !model?.reasoning ||
211
- mapped === null ||
212
- ((definition.thinking === "xhigh" || definition.thinking === "max") && mapped === undefined);
213
- if (unsupported)
214
- onWarning?.(`thinking ${definition.thinking} is unavailable for the selected model; using parent thinking`);
215
- else thinkingLevel = definition.thinking;
216
- }
217
- const modelName = model ? `${model.provider}/${model.id}` : "unavailable";
218
- let session: AgentSession | undefined;
216
+ const sessionInputs = await resolveSubagentSessionInputs({
217
+ definition,
218
+ extensionPaths,
219
+ ctx,
220
+ parentThinkingLevel,
221
+ signal,
222
+ ...(onWarning === undefined ? {} : { onWarning }),
223
+ });
224
+ const resource = await createSubagentSessionResource(sessionInputs, signal);
219
225
  try {
220
- if (!model) throw new Error(`Agent ${definition.name} startup failed: parent has no model`);
221
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
222
- if (!auth.ok) throw new Error(`Agent ${definition.name} startup failed: ${auth.error}`);
223
- if (signal.aborted) throw new Error(`Agent ${definition.name} startup aborted`);
224
- const resourceLoader = new DefaultResourceLoader({
225
- cwd: ctx.cwd,
226
- agentDir: getAgentDir(),
227
- noExtensions: true,
228
- additionalExtensionPaths: [...extensionPaths],
229
- });
230
- await resourceLoader.reload();
231
- const created = await createAgentSession({
232
- cwd: ctx.cwd,
233
- model,
234
- thinkingLevel: thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
235
- tools: definition.tools,
236
- excludeTools: ["subagent"],
237
- resourceLoader,
238
- sessionManager: SessionManager.inMemory(ctx.cwd),
239
- });
240
- session = created.session;
241
- await session.bindExtensions(
242
- ctx.mode === "tui" && ctx.hasUI ? { mode: "tui", uiContext: childUiContext(ctx.ui) } : { mode: "print" },
243
- );
244
- const active = session.getActiveToolNames().sort();
245
- const expected = [...definition.tools].sort();
246
- if (active.join("\0") !== expected.join("\0") || active.includes("subagent")) {
247
- const missing = expected.filter((tool) => !active.includes(tool));
248
- throw new Error(
249
- `Agent ${definition.name} startup failed: unavailable tools: ${missing.join(", ") || "active tool mismatch"}`,
250
- );
251
- }
252
226
  return {
253
227
  id,
228
+ displayName,
254
229
  definition,
255
- session,
230
+ sessionInputs,
231
+ resource,
256
232
  cwd: ctx.cwd,
257
- model: modelName,
258
- thinkingLevel,
233
+ model: sessionInputs.modelName,
234
+ thinkingLevel: sessionInputs.thinkingLevel,
259
235
  initialTask,
236
+ resumeState: emptySubagentResumeState(),
237
+ lastAssistantMessageAt: undefined,
260
238
  turns: 0,
261
239
  turnGate: new FifoGate(1),
262
240
  pendingTurns: 0,
263
241
  lastUsedAt: Date.now(),
264
242
  };
265
243
  } catch (error) {
266
- if (session?.isStreaming) await session.abort().catch(() => undefined);
267
- session?.dispose();
244
+ await resource.dispose();
268
245
  throw error;
269
246
  }
270
247
  }
271
248
 
272
249
  export async function disposeSubagentThread(thread: SubagentThread): Promise<void> {
273
- if (thread.session.isStreaming) await thread.session.abort().catch(() => undefined);
274
- thread.session.dispose();
250
+ await thread.resource.dispose();
275
251
  }
276
252
 
277
253
  export async function runSubagentTurn(options: {
278
254
  thread: SubagentThread;
279
255
  task: string;
256
+ files?: readonly string[];
257
+ invocationId?: string;
280
258
  initial: boolean;
259
+ resumePrompt?: string;
281
260
  signal: AbortSignal;
282
261
  onUpdate?: (details: SubagentDetails) => void | Promise<void>;
283
- }): Promise<{ content: string; details: SubagentDetails }> {
284
- const { thread, task, initial, signal, onUpdate } = options;
285
- const { definition, session } = thread;
262
+ }): Promise<{
263
+ content: string;
264
+ details: SubagentDetails;
265
+ retainable: boolean;
266
+ terminalOutcome: RetainedTurnOutcome;
267
+ assistantMessageEndAt: readonly number[];
268
+ usage?: Usage;
269
+ }> {
270
+ const { thread, task, files = [], invocationId = thread.id, initial, resumePrompt, signal, onUpdate } = options;
271
+ const { definition } = thread;
272
+ const { session } = thread.resource;
286
273
  const started = Date.now();
287
274
  const usage: SubagentUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
275
+ const toolUsage: Usage = {
276
+ input: 0,
277
+ output: 0,
278
+ cacheRead: 0,
279
+ cacheWrite: 0,
280
+ totalTokens: 0,
281
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
282
+ };
288
283
  const details: SubagentDetails = {
289
284
  agent: definition.name,
285
+ displayName: thread.displayName,
290
286
  threadId: thread.id,
291
287
  status: "running",
292
288
  phase: "run",
@@ -301,26 +297,67 @@ export async function runSubagentTurn(options: {
301
297
  durationMs: 0,
302
298
  };
303
299
  let unsubscribe: (() => void) | undefined;
304
- let updateChain = Promise.resolve();
305
300
  let lastTextUpdate = 0;
306
301
  const turnMessages: AssistantMessage[] = [];
302
+ const assistantMessageEndAt: number[] = [];
303
+ let retainable = false;
307
304
  const publish = (force = false) => {
308
305
  const now = Date.now();
309
306
  if (!force && now - lastTextUpdate < 100) return;
310
307
  lastTextUpdate = now;
311
308
  details.durationMs = now - started;
312
- const snapshot = structuredClone(details);
309
+ const snapshot = cloneSnapshot(details);
313
310
  snapshot.actions = snapshot.actions.slice(-5);
314
- updateChain = updateChain.then(() => onUpdate?.(snapshot)).catch(() => undefined);
311
+ // Detached observer chain — must not delay prompt completion.
312
+ void Promise.resolve()
313
+ .then(() => onUpdate?.(snapshot))
314
+ .catch(() => undefined);
315
315
  };
316
316
  try {
317
+ if (signal.aborted) {
318
+ details.status = "aborted";
319
+ details.error = "Subagent call aborted before prompt";
320
+ details.durationMs = Date.now() - started;
321
+ publish(true);
322
+ return {
323
+ content: details.error,
324
+ details,
325
+ retainable: false,
326
+ terminalOutcome: "aborted",
327
+ assistantMessageEndAt,
328
+ };
329
+ }
317
330
  const actionById = new Map<string, string>();
318
331
  unsubscribe = session.subscribe((event: AgentSessionEvent) => {
319
332
  if (event.type === "message_update" && event.message.role === "assistant") {
320
- details.response = capped(textOf(event.message), PREVIEW_LIMIT);
333
+ details.response = cappedTail(textOf(event.message), PREVIEW_LIMIT);
321
334
  publish();
322
335
  } else if (event.type === "message_end" && event.message.role === "assistant") {
323
336
  turnMessages.push(event.message);
337
+ assistantMessageEndAt.push(Date.now());
338
+ usage.input += event.message.usage.input;
339
+ usage.output += event.message.usage.output;
340
+ usage.cacheRead += event.message.usage.cacheRead;
341
+ usage.cacheWrite += event.message.usage.cacheWrite;
342
+ usage.cost += event.message.usage.cost.total;
343
+ usage.turns += 1;
344
+ toolUsage.input += event.message.usage.input;
345
+ toolUsage.output += event.message.usage.output;
346
+ toolUsage.cacheRead += event.message.usage.cacheRead;
347
+ toolUsage.cacheWrite += event.message.usage.cacheWrite;
348
+ toolUsage.totalTokens += event.message.usage.totalTokens;
349
+ toolUsage.cost.input += event.message.usage.cost.input;
350
+ toolUsage.cost.output += event.message.usage.cost.output;
351
+ toolUsage.cost.cacheRead += event.message.usage.cost.cacheRead;
352
+ toolUsage.cost.cacheWrite += event.message.usage.cost.cacheWrite;
353
+ toolUsage.cost.total += event.message.usage.cost.total;
354
+ if (event.message.usage.cacheWrite1h !== undefined)
355
+ toolUsage.cacheWrite1h = (toolUsage.cacheWrite1h ?? 0) + event.message.usage.cacheWrite1h;
356
+ if (event.message.usage.reasoning !== undefined)
357
+ toolUsage.reasoning = (toolUsage.reasoning ?? 0) + event.message.usage.reasoning;
358
+ details.response = cappedTail(textOf(event.message), PREVIEW_LIMIT);
359
+ details.currentActivity = undefined;
360
+ publish(true);
324
361
  } else if (event.type === "tool_execution_start") {
325
362
  details.toolCalls += 1;
326
363
  const summary = `${event.toolName} ${capped(event.args)}`.trim();
@@ -345,27 +382,73 @@ export async function runSubagentTurn(options: {
345
382
  }
346
383
  });
347
384
  const abort = () => {
348
- void session?.abort();
385
+ void session.abort().catch(() => undefined);
349
386
  };
350
387
  signal.addEventListener("abort", abort, { once: true });
351
388
  try {
389
+ const autoreadMessages = await Promise.all(
390
+ files.map(async (file, index) => {
391
+ const pathKey = resolve(thread.cwd, file);
392
+ const common = {
393
+ rowId: `${invocationId}:${index}`,
394
+ path: file,
395
+ cwd: thread.cwd,
396
+ source: "subagent",
397
+ batchId: invocationId,
398
+ };
399
+ try {
400
+ const bytes = await readFile(pathKey);
401
+ const content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
402
+ const lines = content.split("\n");
403
+ const numbered = lines.map((line, lineIndex) => `${lineIndex + 1}: ${line}`).join("\n");
404
+ return {
405
+ customType: AUTOREAD_MESSAGE_TYPE,
406
+ content: `${file}\n${numbered}`,
407
+ display: false,
408
+ details: {
409
+ ...common,
410
+ status: "read",
411
+ readCache: createCompleteFileMeta({
412
+ pathKey,
413
+ presentation: "line-numbered",
414
+ servedHash: createHash("sha256").update(bytes).digest("hex"),
415
+ mode: "baseline",
416
+ sourceText: content,
417
+ returnedText: `${file}\n${numbered}`,
418
+ totalLines: lines.length,
419
+ summary: `${lines.length} lines`,
420
+ }),
421
+ },
422
+ };
423
+ } catch (error) {
424
+ const message = error instanceof Error ? error.message : String(error);
425
+ return {
426
+ customType: AUTOREAD_MESSAGE_TYPE,
427
+ content: `${file}\nAutoread failed: ${message}`,
428
+ display: false,
429
+ details: { ...common, status: "failed", error: message },
430
+ };
431
+ }
432
+ }),
433
+ );
434
+ if (signal.aborted) throw new Error("Subagent call aborted before prompt");
435
+ for (const message of autoreadMessages) {
436
+ if (signal.aborted) throw new Error("Subagent call aborted during autoread delivery");
437
+ await session.sendCustomMessage<unknown>(message, { deliverAs: "nextTurn" });
438
+ }
439
+ if (signal.aborted) throw new Error("Subagent call aborted before prompt");
352
440
  await session.prompt(
353
- initial
354
- ? `You are an isolated delegated child agent. Stay within the delegated task and return only the requested result.\n\n## Agent instructions\n${definition.prompt}\n\n## Delegated task\n${task}`
355
- : `Continue the existing delegated work using the context already in this thread. Return only the requested result.\n\n## Parent follow-up\n${task}`,
441
+ resumePrompt ??
442
+ (initial
443
+ ? `You are an isolated delegated child agent. Stay within the delegated task and return only the requested result.${files.length ? " Parent-supplied autoread files are included as line-numbered context for this turn." : ""}\n\n## Agent instructions\n${definition.prompt}\n\n## Delegated task\n${task}`
444
+ : `Continue the existing delegated work using the context already in this thread. Return only the requested result.${files.length ? " Parent-supplied autoread files are included as line-numbered context for this turn." : ""}\n\n## Parent follow-up\n${task}`),
356
445
  { expandPromptTemplates: false },
357
446
  );
358
447
  } finally {
359
448
  signal.removeEventListener("abort", abort);
360
449
  }
361
- for (const message of turnMessages) {
362
- usage.input += message.usage.input;
363
- usage.output += message.usage.output;
364
- usage.cacheRead += message.usage.cacheRead;
365
- usage.cacheWrite += message.usage.cacheWrite;
366
- usage.cost += message.usage.cost.total;
367
- usage.turns += 1;
368
- }
450
+ // Prompt returned without throw — session remains usable for retention decisions.
451
+ retainable = true;
369
452
  const terminal = turnMessages.at(-1);
370
453
  if (!terminal) throw new Error(`Agent ${definition.name} run failed: no terminal assistant response`);
371
454
  const response = textOf(terminal);
@@ -406,21 +489,33 @@ export async function runSubagentTurn(options: {
406
489
  }
407
490
  details.durationMs = Date.now() - started;
408
491
  publish(true);
409
- await updateChain;
410
492
  return {
411
493
  content:
412
494
  details.status === "completed"
413
495
  ? (details.response ?? "")
414
496
  : (details.error ?? `Agent ${definition.name} failed`),
415
497
  details,
498
+ retainable,
499
+ terminalOutcome:
500
+ details.status === "completed" ? "completed" : details.status === "aborted" ? "aborted" : "failed",
501
+ assistantMessageEndAt,
502
+ ...(usage.turns === 0 ? {} : { usage: toolUsage }),
416
503
  };
417
504
  } catch (error) {
418
505
  details.status = signal.aborted ? "aborted" : "failed";
419
506
  details.error = capped(error instanceof Error ? error.message : "Subagent failed");
420
507
  details.durationMs = Date.now() - started;
508
+ // Prompt/session failures leave the child unsafe for reuse.
509
+ retainable = false;
421
510
  publish(true);
422
- await updateChain;
423
- return { content: details.error, details };
511
+ return {
512
+ content: details.error,
513
+ details,
514
+ retainable,
515
+ terminalOutcome: details.status === "aborted" ? "aborted" : "failed",
516
+ assistantMessageEndAt,
517
+ ...(usage.turns === 0 ? {} : { usage: toolUsage }),
518
+ };
424
519
  } finally {
425
520
  unsubscribe?.();
426
521
  thread.turns += 1;