brl-subagent 2.3.5 → 2.3.7

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.
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { join } from 'path';
3
3
  import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
4
- import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats } from './types';
5
- import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, SUBAGENT_ABORTED_MESSAGE } from './types';
4
+ import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
5
+ import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isSubagentError, isProviderError, SUBAGENT_ABORTED_MESSAGE } from './types';
6
6
  import { accumulateUsage } from './runner';
7
7
  import * as eventBus from './event-bus';
8
8
  import * as transcript from './transcript';
@@ -230,7 +230,10 @@ export function setAgentResult(id: string, result: SubagentResult): BackgroundAg
230
230
  if (!agent) return null;
231
231
 
232
232
  agent.result = result;
233
- agent.status = result.exitCode === 0 ? 'completed' : 'failed';
233
+ // Issue #179 (D1): honor the full terminal reason (stopReason error/aborted/
234
+ // length/incomplete), not exitCode alone — a mid-run provider death resolves
235
+ // with exitCode 0 and was previously recorded as 'completed'.
236
+ agent.status = isSubagentError(result) ? 'failed' : 'completed';
234
237
  agent.completedAt = Date.now();
235
238
 
236
239
  agents.set(id, agent);
@@ -238,6 +241,56 @@ export function setAgentResult(id: string, result: SubagentResult): BackgroundAg
238
241
  return agent;
239
242
  }
240
243
 
244
+ /**
245
+ * Issue #179 (D4): when a run ends in a failure, the prose extracted by
246
+ * extractFinalOutput may come from an EARLIER turn — the failing turn carries
247
+ * no text of its own. Prefixing the failure reason makes the output honest
248
+ * instead of a plausible-looking success narrative.
249
+ */
250
+ export function buildFailureHeadline(reason: string, output: string): string {
251
+ const trimmed = output.trim();
252
+ return trimmed ? `${reason}\n\n${trimmed}` : reason;
253
+ }
254
+
255
+ /**
256
+ * Issue #179 (D3/D4): a run that ended without a terminal answer must still
257
+ * record WHY. When the provider supplied no message, synthesize one from the
258
+ * stopReason so the run entry never claims a failure with an empty reason.
259
+ */
260
+ function defaultTerminalMessage(stopReason: string | undefined): string {
261
+ switch (stopReason) {
262
+ case 'error':
263
+ return 'Run ended with a provider error (stopReason "error")';
264
+ case 'length':
265
+ return 'Run ended early: output length limit reached (stopReason "length")';
266
+ case 'toolUse':
267
+ return 'Run ended mid-turn without a final answer (stopReason "toolUse")';
268
+ case 'deferred':
269
+ return 'Run ended deferred without a final answer (stopReason "deferred")';
270
+ case 'pending':
271
+ return 'Run ended while still pending (stopReason "pending")';
272
+ default:
273
+ return 'Run ended without completing';
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Issue #179 (C1 fix): a failed run entry/result must carry a terminal reason
279
+ * that AGREES with its status — never undefined, never "stop". Any reason the
280
+ * classifier already treats as a failure (error / length / toolUse / deferred
281
+ * / pending / aborted) is coherent and kept; a missing or success reason is
282
+ * coerced to the honest failure reason derived from the classified category.
283
+ */
284
+ function coherentFailureReason(
285
+ stopReason: string | undefined,
286
+ errorCategory: ErrorCategory | undefined,
287
+ ): string {
288
+ if (stopReason && classifyTerminalOutcome(stopReason).status !== 'completed') {
289
+ return stopReason;
290
+ }
291
+ return errorCategory === 'aborted' ? 'aborted' : 'error';
292
+ }
293
+
241
294
  /**
242
295
  * Extract the final assistant text from a session's messages.
243
296
  * Falls back to the LAST assistant message with non-empty text content — the
@@ -531,29 +584,51 @@ export async function spawnBackgroundSession(
531
584
  // ("done"/"failed"). The guard keeps the entry single-finalized even when a
532
585
  // settle handler throws after finalizing (markTerminalBestEffort catch-all).
533
586
  let runFinalized = false;
534
- const finalizeRunEntry = (status: "done" | "failed", error?: string): void => {
587
+ const finalizeRunEntry = (
588
+ status: "done" | "failed",
589
+ error?: string,
590
+ terminal?: { stopReason?: string; errorCategory?: ErrorCategory },
591
+ ): void => {
535
592
  if (runFinalized) return;
536
593
  runFinalized = true;
537
594
  const usage = agent.result?.usage ?? EMPTY_USAGE;
538
595
  const finalOutput = agent.finalOutput ?? "";
539
- // Issue #120 (Track 1): stamp the honest errorCategory on the background run
540
- // record the same way the foreground path does (index.ts sets
541
- // run.originalParams.errorCategory = classifyError(result)). Previously the
542
- // background entry never carried a category, so background failures were
543
- // invisible to the SLA errorCategoryBreakdown and to failure diagnostics.
596
+ // Issue #179 (D1/D2): the classifier's explicit category wins (it knows the
597
+ // terminal stopReason); otherwise fall back to the legacy message-based
598
+ // classification (refused spawns, sync throws, preflight rejections, the
599
+ // catch-all) so no failure is ever left uncategorized.
544
600
  const errorCategory =
545
- status === "failed" && error
546
- ? classifyError({
547
- messages: [],
548
- usage: { ...EMPTY_USAGE },
549
- exitCode: 1,
550
- stderr: "",
551
- errorMessage: error,
552
- })
601
+ status === "failed"
602
+ ? terminal?.errorCategory ??
603
+ (error
604
+ ? classifyError({
605
+ messages: [],
606
+ usage: { ...EMPTY_USAGE },
607
+ exitCode: 1,
608
+ stderr: "",
609
+ errorMessage: error,
610
+ })
611
+ : undefined)
553
612
  : undefined;
613
+ const stopReason =
614
+ status === "failed"
615
+ ? coherentFailureReason(
616
+ terminal?.stopReason ?? agent.result?.stopReason,
617
+ errorCategory,
618
+ )
619
+ : terminal?.stopReason ?? agent.result?.stopReason;
554
620
  const entry: SubagentRun = {
555
621
  ...run,
556
622
  status,
623
+ // Issue #179 (D2): the run entry carries the CLASSIFIED reason, not just
624
+ // an opaque error string.
625
+ stopReason,
626
+ errorCategory,
627
+ // Issue #179 (D3): work volume + terminal-error signal. `turns`/`tokensOut`
628
+ // are already in the usage data; recording them here makes "did nothing"
629
+ // (turns 0) explicit versus "did work then died" (turns > 0, error).
630
+ turns: usage.turns,
631
+ finalTurnError: isProviderError(stopReason),
557
632
  errorMessage: error,
558
633
  finishedAt: new Date().toISOString(),
559
634
  durationMs: Date.now() - startedAtMs,
@@ -570,6 +645,17 @@ export async function spawnBackgroundSession(
570
645
  : run.originalParams,
571
646
  };
572
647
  pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
648
+ // Issue #179 (D6): the explicit settle line — the log's job is to record the
649
+ // CLASSIFIED OUTCOME, which cwd alone can never convey.
650
+ log.info("Background run settled", {
651
+ agentId: id,
652
+ status,
653
+ stopReason,
654
+ turns: usage.turns,
655
+ tokensOut: usage.output,
656
+ errorCategory,
657
+ error: error ?? null,
658
+ });
573
659
  };
574
660
 
575
661
  // Start transcript for this agent
@@ -582,8 +668,16 @@ export async function spawnBackgroundSession(
582
668
  // final output while the session is still live, THEN release the ref — the
583
669
  // capture must always precede the release or the output dies with the
584
670
  // session graph. Single helper for all four settlement paths.
585
- const captureAndReleaseSession = (): void => {
586
- setAgentFinalOutput(id, extractFinalOutput(session));
671
+ //
672
+ // Issue #179 (D4): an optional failureHeadline is prepended when the run did
673
+ // NOT complete — the extracted prose may come from an earlier turn, so it
674
+ // must not be presented as the run's output unqualified.
675
+ const captureAndReleaseSession = (failureHeadline?: string): void => {
676
+ const output = extractFinalOutput(session);
677
+ setAgentFinalOutput(
678
+ id,
679
+ failureHeadline ? buildFailureHeadline(failureHeadline, output) : output,
680
+ );
587
681
  agent._sessionRef = undefined;
588
682
  };
589
683
 
@@ -895,7 +989,8 @@ export async function spawnBackgroundSession(
895
989
  // 1. stopAgent pre-set status to 'stopped' before calling abort();
896
990
  // 2. the last assistant message carries stopReason "aborted".
897
991
  const lastAssistant = [...(session.messages ?? [])].reverse().find(m => m.role === 'assistant');
898
- const aborted = agent.status === 'stopped' || lastAssistant?.stopReason === 'aborted';
992
+ const terminalStopReason = lastAssistant?.stopReason;
993
+ const aborted = agent.status === 'stopped' || terminalStopReason === 'aborted';
899
994
  if (aborted) {
900
995
  // Session was aborted — mark stopped. If stopAgent already flipped the
901
996
  // status (pre-set 'stopped' + completedAt + event + transcript), only
@@ -931,13 +1026,70 @@ export async function spawnBackgroundSession(
931
1026
  // (stop_subagent) sets no error, so fall back to the honest user-abort
932
1027
  // stamp (→ 'aborted') instead of the old ambiguous 'Aborted or timed out'.
933
1028
  recordSessionUsage();
934
- finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
1029
+ finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE, {
1030
+ stopReason:
1031
+ terminalStopReason && terminalStopReason !== 'stop'
1032
+ ? terminalStopReason
1033
+ : 'aborted',
1034
+ });
935
1035
  // Issue #31: capture the final output while the session is still
936
1036
  // live, then release the ref on the terminal path (memory retention;
937
1037
  // the poller treats a nulled ref on a terminal agent as expected).
938
1038
  captureAndReleaseSession();
939
1039
  return;
940
1040
  }
1041
+ // Issue #179 (D1/D2): prompt() RESOLVED — but a resolved prompt is NOT
1042
+ // proof of success. The SDK's runWithLifecycle catches a mid-run provider
1043
+ // death internally and resolves with an assistant message carrying
1044
+ // stopReason "error"; the completion branch below would record it as a
1045
+ // false success (exitCode 0, no category). Classify the terminal outcome
1046
+ // from the last assistant message + the session error, reusing
1047
+ // classifyError for the provider-error case.
1048
+ const providerErrorMessage =
1049
+ lastAssistant?.errorMessage ?? session.state?.errorMessage;
1050
+ const outcome = classifyTerminalOutcome(terminalStopReason, providerErrorMessage);
1051
+
1052
+ if (outcome.status === 'failed') {
1053
+ // Mid-run provider death / truncation / incomplete turn.
1054
+ agent.status = 'failed';
1055
+ agent.completedAt = Date.now();
1056
+ const sanitizedError = sanitizeErrorMessage(
1057
+ providerErrorMessage ?? defaultTerminalMessage(terminalStopReason),
1058
+ effectiveCwd,
1059
+ );
1060
+ agent.error = sanitizedError;
1061
+ const gitInfo = cleanupWorkBranch();
1062
+ agent.result = {
1063
+ ...(agent.result ?? {}),
1064
+ exitCode: 1,
1065
+ messages: agent.result?.messages ?? [],
1066
+ stderr: agent.result?.stderr ?? '',
1067
+ usage: agent.result?.usage ?? EMPTY_USAGE,
1068
+ stopReason: terminalStopReason,
1069
+ errorMessage: sanitizedError,
1070
+ errorCategory: outcome.errorCategory,
1071
+ ...(gitInfo.gitBranch
1072
+ ? { gitBranch: gitInfo.gitBranch, gitDiff: gitInfo.gitDiff }
1073
+ : {}),
1074
+ } as SubagentResult;
1075
+ // Issue #31: capture the final output while the session is still live.
1076
+ // Issue #179 (D4): surface the failure as the HEADLINE — the extracted
1077
+ // prose may come from an earlier turn, so it must not be presented as an
1078
+ // unqualified success narrative.
1079
+ captureAndReleaseSession(sanitizedError);
1080
+ // Issue #122: fold the real usage (turns/tokens) the run burned.
1081
+ recordSessionUsage();
1082
+ agents.set(id, agent);
1083
+ persistAgent(agent);
1084
+ finalizeRunEntry('failed', sanitizedError, {
1085
+ stopReason: terminalStopReason,
1086
+ errorCategory: outcome.errorCategory,
1087
+ });
1088
+ transcript.completeTranscript(id, 'failed');
1089
+ eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
1090
+ return;
1091
+ }
1092
+
941
1093
  // Session completed
942
1094
  agent.status = 'completed';
943
1095
  agent.completedAt = Date.now();
@@ -951,6 +1103,7 @@ export async function spawnBackgroundSession(
951
1103
  messages: [],
952
1104
  stderr: '',
953
1105
  usage: agent.result?.usage ?? EMPTY_USAGE,
1106
+ stopReason: terminalStopReason ?? 'stop',
954
1107
  gitBranch: gitInfo.gitBranch,
955
1108
  gitDiff: gitInfo.gitDiff,
956
1109
  } as SubagentResult;
@@ -965,7 +1118,7 @@ export async function spawnBackgroundSession(
965
1118
  agents.set(id, agent);
966
1119
  persistAgent(agent);
967
1120
  // Issue #98: mirror the agent-record flip in the session run entry.
968
- finalizeRunEntry('done');
1121
+ finalizeRunEntry('done', undefined, { stopReason: terminalStopReason ?? 'stop' });
969
1122
  transcript.completeTranscript(id, 'completed');
970
1123
  eventBus.emit(eventBus.createEvent('subagent:completed', id, {}));
971
1124
  } catch (err) {
@@ -1009,7 +1162,9 @@ export async function spawnBackgroundSession(
1009
1162
  // agent.error ('Timed out...' → 'timeout'); a user cancel has no error
1010
1163
  // and stamps 'Subagent aborted by user' (→ 'aborted').
1011
1164
  recordSessionUsage();
1012
- finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
1165
+ finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE, {
1166
+ stopReason: 'aborted',
1167
+ });
1013
1168
  // Issue #31: the stopped path is terminal too — capture + release.
1014
1169
  captureAndReleaseSession();
1015
1170
  transcript.completeTranscript(id, 'stopped');
@@ -1022,29 +1177,36 @@ export async function spawnBackgroundSession(
1022
1177
  // result errorMessage, and the subagent:failed event (DRY, PR #63 review).
1023
1178
  const sanitizedError = sanitizeErrorMessage(err.message, effectiveCwd);
1024
1179
  agent.error = sanitizedError;
1025
- if (gitInfo.gitBranch) {
1026
- agent.result = {
1027
- ...(agent.result ?? {}),
1028
- exitCode: 1,
1029
- messages: [],
1030
- stderr: '',
1031
- usage: agent.result?.usage ?? EMPTY_USAGE,
1032
- errorMessage: sanitizedError,
1033
- gitBranch: gitInfo.gitBranch,
1034
- gitDiff: gitInfo.gitDiff,
1035
- } as SubagentResult;
1036
- }
1180
+ // Issue #179: record the failure on the result UNCONDITIONALLY (not only
1181
+ // on the git-diff path) so a failed run never persists exitCode 0.
1182
+ agent.result = {
1183
+ ...(agent.result ?? {}),
1184
+ exitCode: 1,
1185
+ messages: agent.result?.messages ?? [],
1186
+ stderr: agent.result?.stderr ?? '',
1187
+ usage: agent.result?.usage ?? EMPTY_USAGE,
1188
+ // Issue #179 (C1 fix): a genuine preflight/run failure always carries
1189
+ // the failure reason — never leave the result with an undefined
1190
+ // stopReason that downstream display/aggregate paths read as success.
1191
+ stopReason: 'error',
1192
+ errorMessage: sanitizedError,
1193
+ ...(gitInfo.gitBranch
1194
+ ? { gitBranch: gitInfo.gitBranch, gitDiff: gitInfo.gitDiff }
1195
+ : {}),
1196
+ } as SubagentResult;
1037
1197
  // Issue #31: capture the final output while the session is still live,
1038
1198
  // then release the ref before the branch's persist — the persisted
1039
1199
  // record is consistent and the live session graph is freed.
1040
- captureAndReleaseSession();
1200
+ // Issue #179 (D4): lead with the failure reason (the prose may be from an
1201
+ // earlier turn).
1202
+ captureAndReleaseSession(sanitizedError);
1041
1203
  // Issue #122: fold any assistant turns the preflight/run did produce —
1042
1204
  // usually none (auth/model rejection) → honest zeros, never a crash.
1043
1205
  recordSessionUsage();
1044
1206
  agents.set(id, agent);
1045
1207
  persistAgent(agent);
1046
1208
  // Issue #98: mirror the agent-record flip in the session run entry.
1047
- finalizeRunEntry('failed', sanitizedError);
1209
+ finalizeRunEntry('failed', sanitizedError, { stopReason: 'error' });
1048
1210
  transcript.completeTranscript(id, 'failed');
1049
1211
  eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
1050
1212
  } catch (err) {
package/src/state.ts CHANGED
@@ -142,8 +142,6 @@ export class SessionState {
142
142
  circuitBreaker: this.defaultCircuitBreaker(),
143
143
  slaTrackingEnabled: false,
144
144
  slaWindowSize: 50,
145
- updateCheckEnabled: true,
146
- lastUpdateCheck: 0,
147
145
  };
148
146
  }
149
147
 
@@ -168,8 +166,6 @@ export class SessionState {
168
166
  slaTrackingEnabled: this.config.slaTrackingEnabled,
169
167
  slaWindowSize: this.config.slaWindowSize,
170
168
  lastSLAMetrics: this.config.lastSLAMetrics,
171
- updateCheckEnabled: this.config.updateCheckEnabled,
172
- lastUpdateCheck: this.config.lastUpdateCheck,
173
169
  });
174
170
  }
175
171
 
@@ -258,10 +254,6 @@ export class SessionState {
258
254
  this.config.lastSLAMetrics = data.lastSLAMetrics as import("./types").SLAMetrics;
259
255
  }
260
256
 
261
- // Restore update check fields
262
- if (typeof data.updateCheckEnabled === "boolean") this.config.updateCheckEnabled = data.updateCheckEnabled;
263
- if (typeof data.lastUpdateCheck === "number" && data.lastUpdateCheck >= 0) this.config.lastUpdateCheck = data.lastUpdateCheck;
264
-
265
257
  this.log?.info("State restored from session", {
266
258
  model: data.model ? `${data.model.provider}/${data.model.id}` : "none",
267
259
  thinkingLevel: data.maxThinkingLevel,
@@ -508,8 +500,6 @@ export class SessionState {
508
500
  this.config.slaTrackingEnabled = false;
509
501
  this.config.slaWindowSize = 50;
510
502
  this.config.lastSLAMetrics = undefined;
511
- this.config.updateCheckEnabled = true;
512
- this.config.lastUpdateCheck = 0;
513
503
  }
514
504
  }
515
505
 
package/src/templates.ts CHANGED
@@ -407,7 +407,7 @@ export function validateTemplatePresetRefs(
407
407
  dangling++;
408
408
  log?.warn(
409
409
  `Template "${template.name}" references preset "${template.preset}" which does not exist ` +
410
- "— delegation will run preset-less with auto-route suppressed (issue #81)",
410
+ "— delegation will run preset-less with auto-route suppressed",
411
411
  { template: template.name, preset: template.preset },
412
412
  );
413
413
  }
package/src/transcript.ts CHANGED
@@ -19,8 +19,9 @@ export function __setOutputDir(dir: string): void {
19
19
  * Ensure output directory exists
20
20
  */
21
21
  function ensureOutputDir(): void {
22
- // F6 (issue #29): transcripts contain the full subagent conversation — the
23
- // dir must be owner-only (0o700) so other local users cannot list the files.
22
+ // F6 (issue #29): transcripts may hold task/steering text — the dir must be
23
+ // owner-only (0o700) so other local users cannot list the files. NOTE: the
24
+ // transcript is NOT the full conversation (see appendEntry below).
24
25
  mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 });
25
26
  }
26
27
 
@@ -52,8 +53,9 @@ export function startTranscript(agentId: string, task: string): string {
52
53
  metadata: { task },
53
54
  };
54
55
 
55
- // F6 (issue #29): transcripts hold the full conversation — append
56
- // owner-only (0o600) on file CREATE (mode is not retroactive).
56
+ // F6 (issue #29): append owner-only (0o600) on file CREATE (mode is not
57
+ // retroactive). The file holds the system start entry plus any steering
58
+ // entries appended below — NOT the full conversation.
57
59
  appendFileSync(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
58
60
  return path;
59
61
  }
@@ -79,8 +81,11 @@ export function appendEntry(
79
81
  metadata,
80
82
  };
81
83
 
82
- // F6 (issue #29): transcripts hold the full conversation — append
83
- // owner-only (0o600) on file CREATE (mode is not retroactive).
84
+ // F6 (issue #29): append owner-only (0o600) on file CREATE (mode is not
85
+ // retroactive). NOTE (D5 / issue #179): this is NOT a conversation recorder —
86
+ // the only caller is steering (steerAgent), so the transcript holds the
87
+ // system start entry and steering lines. Do NOT describe it as the full
88
+ // conversation without actually recording one.
84
89
  appendFileSync(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
85
90
  }
86
91
 
package/src/tui.ts CHANGED
@@ -337,28 +337,6 @@ export async function showCompletionNotifySelector(
337
337
  onConfigChanged(ctx, `Completion notify mode set to ${result}`);
338
338
  }
339
339
 
340
- // ---------------------------------------------------------------------------
341
- // Update check toggle
342
- // ---------------------------------------------------------------------------
343
-
344
- export async function showUpdateCheckToggle(
345
- ctx: ExtensionContext,
346
- state: SessionState,
347
- onConfigChanged: (ctx: ExtensionContext, msg: string) => void,
348
- ): Promise<void> {
349
- const items: SelectItem[] = [
350
- { value: "enabled", label: "Enabled", description: "Check for updates on session start" },
351
- { value: "disabled", label: "Disabled", description: "Skip update check" },
352
- ];
353
-
354
- const result = await showSelectList(ctx, "Update Check", items, 5);
355
- if (!result) return;
356
-
357
- state.config.updateCheckEnabled = result === "enabled";
358
- onConfigChanged(ctx, `Update check ${result}`);
359
- }
360
-
361
- // ---------------------------------------------------------------------------
362
340
  // ---------------------------------------------------------------------------
363
341
  // E4: SLA tracking configuration
364
342
  // ---------------------------------------------------------------------------
@@ -1199,11 +1177,6 @@ export function getConfigMenuItems(state: SessionState): SelectItem[] {
1199
1177
  ? `Enabled (window: ${state.config.slaWindowSize})`
1200
1178
  : "Disabled",
1201
1179
  },
1202
- {
1203
- value: "update-check",
1204
- label: "Update Check",
1205
- description: state.config.updateCheckEnabled ? "Enabled" : "Disabled",
1206
- },
1207
1180
  ];
1208
1181
  }
1209
1182
 
@@ -2217,10 +2190,7 @@ function renderSubTaskSummary(
2217
2190
  },
2218
2191
  maxLines: number,
2219
2192
  ): string {
2220
- const isError =
2221
- result.exitCode !== 0 ||
2222
- result.stopReason === "error" ||
2223
- result.stopReason === "aborted";
2193
+ const isError = isSubagentError(result);
2224
2194
  const icon = isError
2225
2195
  ? theme.fg("error", "\u2717")
2226
2196
  : theme.fg("success", "\u2713");
@@ -2240,12 +2210,7 @@ function renderCollapsedChain(
2240
2210
  details: ChainDetails,
2241
2211
  theme: Theme,
2242
2212
  ): string {
2243
- const allSucceeded = details.results.every(
2244
- (r) =>
2245
- r.exitCode === 0 &&
2246
- r.stopReason !== "error" &&
2247
- r.stopReason !== "aborted",
2248
- );
2213
+ const allSucceeded = details.results.every((r) => !isSubagentError(r));
2249
2214
  const icon = allSucceeded
2250
2215
  ? theme.fg("success", "\u2713")
2251
2216
  : theme.fg("error", "\u2717");
@@ -2253,10 +2218,7 @@ function renderCollapsedChain(
2253
2218
  let text = `${icon} ${theme.fg("toolTitle", theme.bold("chain"))} ${theme.fg("muted", `${details.completedSteps}/${details.totalSteps} steps${stoppedEarly}`)}`;
2254
2219
 
2255
2220
  for (const r of details.results) {
2256
- const isErr =
2257
- r.exitCode !== 0 ||
2258
- r.stopReason === "error" ||
2259
- r.stopReason === "aborted";
2221
+ const isErr = isSubagentError(r);
2260
2222
  const statusIcon = isErr
2261
2223
  ? theme.fg("error", "\u2717")
2262
2224
  : theme.fg("success", "\u2713");
@@ -2290,10 +2252,7 @@ function renderCollapsedParallel(
2290
2252
  let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel"))} ${theme.fg("muted", `${details.succeeded}/${details.failed}/${details.results.length} total`)}`;
2291
2253
 
2292
2254
  for (const r of details.results) {
2293
- const isErr =
2294
- r.exitCode !== 0 ||
2295
- r.stopReason === "error" ||
2296
- r.stopReason === "aborted";
2255
+ const isErr = isSubagentError(r);
2297
2256
  const statusIcon = isErr
2298
2257
  ? theme.fg("error", "\u2717")
2299
2258
  : theme.fg("success", "\u2713");
@@ -2339,10 +2298,7 @@ function renderExpandedChain(
2339
2298
  container.addChild(new Spacer(1));
2340
2299
 
2341
2300
  for (const r of details.results) {
2342
- const isErr =
2343
- r.exitCode !== 0 ||
2344
- r.stopReason === "error" ||
2345
- r.stopReason === "aborted";
2301
+ const isErr = isSubagentError(r);
2346
2302
  const statusIcon = isErr
2347
2303
  ? theme.fg("error", "\u2717")
2348
2304
  : theme.fg("success", "\u2713");
@@ -2424,10 +2380,7 @@ function renderExpandedParallel(
2424
2380
  container.addChild(new Spacer(1));
2425
2381
 
2426
2382
  for (const r of details.results) {
2427
- const isErr =
2428
- r.exitCode !== 0 ||
2429
- r.stopReason === "error" ||
2430
- r.stopReason === "aborted";
2383
+ const isErr = isSubagentError(r);
2431
2384
  const statusIcon = isErr
2432
2385
  ? theme.fg("error", "\u2717")
2433
2386
  : theme.fg("success", "\u2713");
@@ -2801,12 +2754,7 @@ function renderCollapsedGraph(
2801
2754
  ): string {
2802
2755
  const totalTasks = details.waves.reduce((s, w) => s + w.tasks.length, 0);
2803
2756
  const allSucceeded = details.waves.every((w) =>
2804
- w.tasks.every(
2805
- (r) =>
2806
- r.exitCode === 0 &&
2807
- r.stopReason !== "error" &&
2808
- r.stopReason !== "aborted",
2809
- ),
2757
+ w.tasks.every((r) => !isSubagentError(r)),
2810
2758
  );
2811
2759
  const icon = allSucceeded
2812
2760
  ? theme.fg("success", "\u2713")
@@ -2817,22 +2765,16 @@ function renderCollapsedGraph(
2817
2765
  const wavesToRender = details.waves.slice(0, maxWavesToShow);
2818
2766
 
2819
2767
  for (const wave of wavesToRender) {
2820
- const waveOk = wave.tasks.every(
2821
- (r) =>
2822
- r.exitCode === 0 &&
2823
- r.stopReason !== "error" &&
2824
- r.stopReason !== "aborted",
2825
- );
2768
+ const waveOk = wave.tasks.every((r) => !isSubagentError(r));
2826
2769
  const waveIcon = waveOk
2827
2770
  ? theme.fg("success", "\u2713")
2828
2771
  : theme.fg("error", "\u2717");
2829
2772
  const modeLabel = wave.parallel ? "parallel" : "serial";
2830
2773
  const taskLabels = wave.tasks.map((r) => {
2831
2774
  const label = r.label || r.task.slice(0, 30);
2832
- const taskIcon =
2833
- r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted"
2834
- ? theme.fg("success", "\u2713")
2835
- : theme.fg("error", "\u2717");
2775
+ const taskIcon = !isSubagentError(r)
2776
+ ? theme.fg("success", "\u2713")
2777
+ : theme.fg("error", "\u2717");
2836
2778
  return `${taskIcon} ${theme.fg("dim", label)}`;
2837
2779
  });
2838
2780
  text += `
@@ -2870,12 +2812,7 @@ function renderExpandedGraph(
2870
2812
  container.addChild(new Spacer(1));
2871
2813
 
2872
2814
  for (const wave of details.waves) {
2873
- const waveOk = wave.tasks.every(
2874
- (r) =>
2875
- r.exitCode === 0 &&
2876
- r.stopReason !== "error" &&
2877
- r.stopReason !== "aborted",
2878
- );
2815
+ const waveOk = wave.tasks.every((r) => !isSubagentError(r));
2879
2816
  const waveIcon = waveOk
2880
2817
  ? theme.fg("success", "\u2713")
2881
2818
  : theme.fg("error", "\u2717");
@@ -2889,10 +2826,7 @@ function renderExpandedGraph(
2889
2826
  );
2890
2827
 
2891
2828
  for (const r of wave.tasks) {
2892
- const isErr =
2893
- r.exitCode !== 0 ||
2894
- r.stopReason === "error" ||
2895
- r.stopReason === "aborted";
2829
+ const isErr = isSubagentError(r);
2896
2830
  const statusIcon = isErr
2897
2831
  ? theme.fg("error", "\u2717")
2898
2832
  : theme.fg("success", "\u2713");