brl-subagent 2.3.6 → 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.
package/AGENT.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  > The `delegate_task` prompt guidelines are a derived summary of this file; if they disagree, this file wins.
4
4
 
5
+ > **Naming:** deliberately `AGENT.md`, **not** `AGENTS.md`. This is *not* a pi
6
+ > context file — it is read on demand via the extension's `delegate_task`
7
+ > guidance, not auto-loaded into the system prompt. Renaming it would change
8
+ > nothing for users (a package directory is not in pi's context-file lookup
9
+ > chain) while adding its full contents to every session run inside this repo.
10
+
5
11
  This file is the authoritative reference for how the brl-subagent extension behaves
6
12
  and how you (the conductor) should judge a delegation. Read it when planning
7
13
  delegation-heavy work.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Multi-agent orchestration for [pi](https://github.com/earendil-works/pi) — chain, parallel, and dependency-graph delegation to isolated subagents with per-step model routing, preset-driven tool scoping, thinking-level control, and background execution with live monitoring, real abort, and per-agent timeouts.
4
4
 
5
- **Version:** 2.3.6 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
5
+ **Version:** 2.3.7 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
6
6
 
7
7
  ---
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brl-subagent",
3
- "version": "2.3.6",
3
+ "version": "2.3.7",
4
4
  "description": "Subagent extension for pi with configurable model and thinking mode via selection UI",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -35,10 +35,10 @@
35
35
  "typebox": "*"
36
36
  },
37
37
  "devDependencies": {
38
- "@earendil-works/pi-agent-core": "^0.85.1",
39
- "@earendil-works/pi-ai": "^0.85.1",
40
- "@earendil-works/pi-coding-agent": "^0.85.1",
41
- "@earendil-works/pi-tui": "^0.85.1",
38
+ "@earendil-works/pi-agent-core": "^0.86.0",
39
+ "@earendil-works/pi-ai": "^0.86.0",
40
+ "@earendil-works/pi-coding-agent": "^0.86.0",
41
+ "@earendil-works/pi-tui": "^0.86.0",
42
42
  "jiti": "^2.7.0",
43
43
  "typebox": "^1.3.25",
44
44
  "typescript": "^5.9.3",
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  EMPTY_USAGE,
40
40
  getFinalOutput,
41
41
  isSubagentError,
42
+ countSubagentOutcomes,
42
43
  classifyError,
43
44
  MAX_CHAIN_STEPS,
44
45
  MAX_PARALLEL_TASKS,
@@ -102,7 +103,7 @@ import {
102
103
  renderDelegateCall,
103
104
  renderDelegateResult,
104
105
  } from "./tui";
105
- import { createLogger, type Logger } from "./logging";
106
+ import { createLogger, setLogCwd, type Logger } from "./logging";
106
107
  import { Intercom } from "./messaging";
107
108
  import * as eventBus from "./event-bus";
108
109
  import {
@@ -110,7 +111,6 @@ import {
110
111
  resolveDelivery,
111
112
  sendCompletionNotification,
112
113
  markTerminalSeen,
113
- normalizeCompletionStatus,
114
114
  resolveRunEntry,
115
115
  } from "./notify-completion";
116
116
 
@@ -796,7 +796,7 @@ export default function (pi: ExtensionAPI) {
796
796
  previousOutput = getFinalOutput(result.messages);
797
797
  }
798
798
 
799
- chainSuccess = chainResults.every((r) => r.exitCode === 0);
799
+ chainSuccess = countSubagentOutcomes(chainResults).failed === 0;
800
800
 
801
801
  // Compute aggregated totals
802
802
  const totalInput = chainResults.reduce(
@@ -1203,11 +1203,8 @@ export default function (pi: ExtensionAPI) {
1203
1203
  }
1204
1204
 
1205
1205
  // Emit progress update
1206
- const completed = results.filter(Boolean).length;
1207
- const succeeded = results.filter(
1208
- (r) => r && r.exitCode === 0,
1209
- ).length;
1210
- const failed = completed - succeeded;
1206
+ const { succeeded, failed } = countSubagentOutcomes(results);
1207
+ const completed = succeeded + failed;
1211
1208
 
1212
1209
  const partialDetails: ParallelDetails = {
1213
1210
  mode: "parallel",
@@ -1306,8 +1303,7 @@ export default function (pi: ExtensionAPI) {
1306
1303
  (s, r) => s + r.usage.turns,
1307
1304
  0,
1308
1305
  );
1309
- const succeeded = finalResults.filter((r) => r.exitCode === 0).length;
1310
- const failed = finalResults.length - succeeded;
1306
+ const { succeeded, failed } = countSubagentOutcomes(finalResults);
1311
1307
 
1312
1308
  const parallelDetails: ParallelDetails = {
1313
1309
  mode: "parallel",
@@ -1810,9 +1806,7 @@ export default function (pi: ExtensionAPI) {
1810
1806
  const totalCost = allResults.reduce((s, r) => s + r.usage.cost, 0);
1811
1807
  const totalTurns = allResults.reduce((s, r) => s + r.usage.turns, 0);
1812
1808
 
1813
- chainSuccess = allResults.every(
1814
- (r) => r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted",
1815
- );
1809
+ chainSuccess = countSubagentOutcomes(allResults).failed === 0;
1816
1810
 
1817
1811
  const graphDetails: GraphDetails = {
1818
1812
  mode: "graph",
@@ -2320,48 +2314,10 @@ export default function (pi: ExtensionAPI) {
2320
2314
  }, { deliverAs: "followUp" });
2321
2315
  }
2322
2316
 
2323
- // F1: Sanitize task input — skip for chain/parallel modes
2324
- const hasChain = params.chain && params.chain.length > 0;
2325
- const hasParallel = params.tasks && params.tasks.length > 0;
2326
- const hasGraph = params.graph && params.graph.length > 0;
2327
- if (!hasChain && !hasParallel && !hasGraph) {
2328
- // task is optional per the schema; single mode requires it. When absent,
2329
- // feed "" to the sanitizer — same rejection path ("Task must not be
2330
- // empty.") as when task was a required field.
2331
- const taskResult = sanitizeTask(params.task ?? "");
2332
- if (!taskResult.ok) {
2333
- log.warn("Task rejected by sanitizer", { error: taskResult.error });
2334
- return {
2335
- content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
2336
- details: undefined,
2337
- isError: true,
2338
- };
2339
- }
2340
- params.task = taskResult.value;
2341
- }
2342
-
2343
- // Handle retryRunId
2344
- if (params.retryRunId) {
2345
- const runEntry = state.findRunById(ctx, params.retryRunId);
2346
- if (runEntry) {
2347
- params = resolveRetryParams(params, runEntry);
2348
- } else {
2349
- // Issue #98: a silent no-op here made retries of background runs
2350
- // (which previously never wrote run entries) start as FRESH runs
2351
- // with no signal to the caller. Fail loudly instead.
2352
- log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
2353
- return {
2354
- content: [{
2355
- type: "text" as const,
2356
- text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix (issue #98). Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
2357
- }],
2358
- details: undefined,
2359
- isError: true,
2360
- };
2361
- }
2362
- }
2363
-
2364
- // Handle template resolution
2317
+ // Handle template resolution — MUST run before the single-mode sanitize
2318
+ // block below: a template ASSIGNS params.task, so resolving first lets the
2319
+ // sanitizer validate the real resolved body (a template-only call has no
2320
+ // task yet) and lets mode detection see a non-empty task (issue #175).
2365
2321
  if (params.template) {
2366
2322
  const templateEntry = state.config.templates.find((t) => t.name === params.template);
2367
2323
  if (!templateEntry) {
@@ -2393,7 +2349,34 @@ export default function (pi: ExtensionAPI) {
2393
2349
  }
2394
2350
 
2395
2351
  const tv = resolved.value;
2396
- // Use resolved template fields as defaults, explicitly provided params override
2352
+ // Issue #175 review (fix 4): template-wins silently replaces a
2353
+ // supplied `task`. If that task would itself have failed sanitization
2354
+ // (e.g. over-length), the caller gets no signal — pre-#175 `dev`
2355
+ // rejected it. Warn (never reject — #99/#110 precedent, same
2356
+ // pi.sendMessage channel as the unknown-param warning) so both the LLM
2357
+ // and the human see the ignored task. Emitted ONLY when the supplied
2358
+ // task actually fails sanitization: a valid task alongside a template
2359
+ // is the intended template-wins override (issue #57), not noise.
2360
+ if (params.task !== undefined) {
2361
+ const suppliedTask = sanitizeTask(params.task);
2362
+ if (!suppliedTask.ok) {
2363
+ log.warn("delegate_task: supplied task ignored because a template was given", {
2364
+ template: params.template,
2365
+ error: suppliedTask.error,
2366
+ });
2367
+ pi.sendMessage({
2368
+ customType: "delegate-notification",
2369
+ content: `delegate_task: the supplied 'task' was ignored because a template ('${params.template}') was given. The task would otherwise have been rejected: ${suppliedTask.error}`,
2370
+ display: true,
2371
+ details: { template: params.template, rejectedTask: suppliedTask.error },
2372
+ }, { deliverAs: "followUp" });
2373
+ }
2374
+ }
2375
+ // Issue #175 review (fix direction item 4): this assignment is
2376
+ // UNCONDITIONAL — the resolved template task replaces any supplied
2377
+ // `task` (template-wins, issue #57); it is NOT a default the caller can
2378
+ // override. The fields below, by contrast, ARE defaults: an explicitly
2379
+ // provided param wins over the template's value.
2397
2380
  params.task = tv.task;
2398
2381
  if (tv.preset && !params.preset) params.preset = tv.preset;
2399
2382
  if (tv.thinkingLevel && !params.thinkingLevel) params.thinkingLevel = tv.thinkingLevel;
@@ -2405,6 +2388,53 @@ export default function (pi: ExtensionAPI) {
2405
2388
  if (tv.inheritSystemPrompt !== undefined && params.inheritSystemPrompt === undefined) params.inheritSystemPrompt = tv.inheritSystemPrompt;
2406
2389
  }
2407
2390
 
2391
+ // Handle retryRunId — MUST run after template resolution and before the
2392
+ // single-mode sanitize block. resolveRetryParams assigns params.task from
2393
+ // the recorded run, so the sanitizer must see the retried body (a
2394
+ // retry-only call has no task yet). It MUST run AFTER template resolution:
2395
+ // resolveRetryParams returns a fresh object that DROPS `template`, so
2396
+ // resolving templates first keeps a combined template + retryRunId call
2397
+ // from silently disabling template resolution (issue #175 review).
2398
+ if (params.retryRunId) {
2399
+ const runEntry = state.findRunById(ctx, params.retryRunId);
2400
+ if (runEntry) {
2401
+ params = resolveRetryParams(params, runEntry);
2402
+ } else {
2403
+ // Issue #98: a silent no-op here made retries of background runs
2404
+ // (which previously never wrote run entries) start as FRESH runs
2405
+ // with no signal to the caller. Fail loudly instead.
2406
+ log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
2407
+ return {
2408
+ content: [{
2409
+ type: "text" as const,
2410
+ text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix. Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
2411
+ }],
2412
+ details: undefined,
2413
+ isError: true,
2414
+ };
2415
+ }
2416
+ }
2417
+
2418
+ // F1: Sanitize task input — skip for chain/parallel/graph modes
2419
+ const hasChain = params.chain && params.chain.length > 0;
2420
+ const hasParallel = params.tasks && params.tasks.length > 0;
2421
+ const hasGraph = params.graph && params.graph.length > 0;
2422
+ if (!hasChain && !hasParallel && !hasGraph) {
2423
+ // task is optional per the schema; single mode requires it. When absent,
2424
+ // feed "" to the sanitizer — same rejection path ("Task must not be
2425
+ // empty.") as when task was a required field.
2426
+ const taskResult = sanitizeTask(params.task ?? "");
2427
+ if (!taskResult.ok) {
2428
+ log.warn("Task rejected by sanitizer", { error: taskResult.error });
2429
+ return {
2430
+ content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
2431
+ details: undefined,
2432
+ isError: true,
2433
+ };
2434
+ }
2435
+ params.task = taskResult.value;
2436
+ }
2437
+
2408
2438
  // P1+P2+P10: Mode detection — graph > chain > parallel > single
2409
2439
  const isChain = hasChain;
2410
2440
  const isParallel = hasParallel;
@@ -3681,6 +3711,9 @@ export default function (pi: ExtensionAPI) {
3681
3711
  pi.on("session_start", async (_event, ctx) => {
3682
3712
  // Issue #147: capture the session context for the completion-push subscriber.
3683
3713
  sessionCtx = ctx;
3714
+ // Issue #179 (D6): createLogger ran at module load with no cwd — point
3715
+ // the shared file sink at this session's cwd now that it exists.
3716
+ setLogCwd(ctx.cwd);
3684
3717
  // Load built-in presets
3685
3718
  const presetsDir = pkgPath("presets");
3686
3719
  state.builtinPresets = loadBuiltinPresets(presetsDir, log);
@@ -3787,7 +3820,10 @@ export default function (pi: ExtensionAPI) {
3787
3820
  const knob = state.config.completionNotify ?? "all";
3788
3821
  const run = resolveRunEntry(state.getRunEntries(ctx), id);
3789
3822
  const message = buildCompletionMessage(agent, run);
3790
- const delivery = resolveDelivery(normalizeCompletionStatus(agent.status), knob);
3823
+ // Issue #179 (D2): resolve delivery from the CLASSIFIED notification
3824
+ // status, not the raw agent status — otherwise a run whose completion
3825
+ // the builder downgraded to failed would still be delivered as a success.
3826
+ const delivery = resolveDelivery(message.details.status, knob);
3791
3827
  sendCompletionNotification(pi, message, delivery);
3792
3828
  } catch (err) {
3793
3829
  log.warn(`completion-push handler failed for ${id}`, {
package/src/logging.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  * const log = createLogger("runner", cwd);
9
9
  * log.info("Subagent started", { model, thinkingLevel });
10
10
  * log.error("Subagent failed", { error: err.message });
11
+ *
12
+ * Module-load callers pass no cwd; they call setLogCwd(ctx.cwd) once the
13
+ * session starts and all loggers share <cwd>/.pi/subagent-logs/brl-subagent.log.
11
14
  */
12
15
 
13
16
  import * as fs from "node:fs";
@@ -24,6 +27,12 @@ const MAX_LOG_SIZE = 5 * 1024 * 1024;
24
27
  /** Maximum number of rotated log files to keep */
25
28
  const MAX_LOG_FILES = 5;
26
29
 
30
+ /**
31
+ * The single log file every logger writes to (issue #179). The JSON entry body
32
+ * already carries its `prefix`, so one shared file stays readable.
33
+ */
34
+ const LOG_FILE_NAME = "brl-subagent.log";
35
+
27
36
  /** Log levels in order of verbosity */
28
37
  const LOG_LEVELS: Record<LogLevel, number> = {
29
38
  debug: 0,
@@ -50,23 +59,33 @@ export function setLogLevel(level: LogLevel): void {
50
59
  minLevel = level;
51
60
  }
52
61
 
62
+ // Issue #179 (D6): createLogger() runs at MODULE LOAD (index.ts,
63
+ // session-manager.ts) where the session cwd does not exist yet. The cwd is set
64
+ // later from the session_start hook (ctx.cwd). All logger instances share this
65
+ // module-level value so every entry lands in ONE
66
+ // `<cwd>/.pi/subagent-logs/brl-subagent.log`.
67
+ let logCwd: string | undefined;
68
+
69
+ /**
70
+ * Point file logging at a session cwd (or clear it with undefined). Called from
71
+ * the session-start hook so module-load loggers can write once a cwd exists.
72
+ */
73
+ export function setLogCwd(cwd: string | undefined): void {
74
+ logCwd = cwd;
75
+ }
76
+
77
+ function resolveLogDir(): string | undefined {
78
+ return logCwd ? path.join(logCwd, ".pi", "subagent-logs") : undefined;
79
+ }
80
+
53
81
  /**
54
82
  * Create a logger for a specific module.
55
- * Logs are written to `.pi/subagent-logs/<prefix>.log` relative to `cwd`.
83
+ * Logs are written to `<cwd>/.pi/subagent-logs/brl-subagent.log` relative to the
84
+ * session cwd (set via setLogCwd or the optional `cwd` arg).
56
85
  */
57
86
  export function createLogger(prefix: string, cwd?: string): Logger {
58
- const logDir = cwd ? path.join(cwd, ".pi", "subagent-logs") : undefined;
59
-
60
- // Ensure log directory exists — owner-only (0o700) so other local users
61
- // cannot list log files (F6 / issue #29). File writes below already use
62
- // 0o600.
63
- if (logDir) {
64
- try {
65
- fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
66
- } catch {
67
- // Can't create log dir — fall back to console-only logging
68
- }
69
- }
87
+ // Compat: an explicit cwd argument still sets the shared location eagerly.
88
+ if (cwd) logCwd = cwd;
70
89
 
71
90
  function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
72
91
  if (LOG_LEVELS[level] < LOG_LEVELS[minLevel]) return;
@@ -86,10 +105,15 @@ export function createLogger(prefix: string, cwd?: string): Logger {
86
105
  const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
87
106
  consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
88
107
 
89
- // File output
108
+ // File output — resolved on each call because the cwd may be set after
109
+ // createLogger ran (module load) but before the first entry.
110
+ const logDir = resolveLogDir();
90
111
  if (logDir) {
91
112
  try {
92
- const logFile = path.join(logDir, `${prefix}.log`);
113
+ // Owner-only (0o700) so other local users cannot list log files
114
+ // (F6 / issue #29); the file writes below already use 0o600.
115
+ fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
116
+ const logFile = path.join(logDir, LOG_FILE_NAME);
93
117
  rotateIfNeeded(logFile);
94
118
  fs.appendFileSync(logFile, line + "\n", { encoding: "utf-8", mode: 0o600 });
95
119
  } catch {
@@ -25,6 +25,7 @@ import type {
25
25
  } from "./types";
26
26
  import { truncateTail } from "./transcript-tail";
27
27
  import { formatRunDuration } from "./history";
28
+ import { classifyTerminalOutcome } from "./types";
28
29
 
29
30
  /** Terminal statuses the completion message can carry. */
30
31
  export type CompletionStatus = "completed" | "failed" | "stopped";
@@ -35,6 +36,8 @@ export interface CompletionMessageDetails {
35
36
  status: CompletionStatus;
36
37
  errorCategory: string;
37
38
  errorMessage?: string;
39
+ /** Issue #179 (D2): the terminal stopReason behind the classified status. */
40
+ stopReason?: string;
38
41
  costUsd?: number;
39
42
  tokensIn?: number;
40
43
  tokensOut?: number;
@@ -59,12 +62,12 @@ const DEDUPE_CAP = 200;
59
62
 
60
63
  /**
61
64
  * Soft directive appended to the content (D3): the conductor should process
62
- * the completion silently unless action is needed; on failure apply the retry
63
- * taxonomy (re-dispatch ≠ retry; Rule 18 governs terminations — ask the user
64
- * before terminating anything).
65
+ * the completion silently unless action is needed. If the run failed, it
66
+ * should investigate the cause before re-dispatching rather than re-issuing an
67
+ * identical task — any re-issue is confirmed with the user first.
65
68
  */
66
69
  const COMPLETION_DIRECTIVE =
67
- "Process this completion silently unless action is needed. If the run failed, apply the retry taxonomy: a re-dispatch is not a retry, and Rule 18 governs terminations — ask the user before terminating anything.";
70
+ "Process this completion silently unless action is needed. If the run failed, investigate before re-dispatching — do not re-issue an identical task without confirming the cause with the user.";
68
71
 
69
72
  /**
70
73
  * Normalize a BackgroundAgent status to a terminal status word. The subscriber
@@ -128,23 +131,50 @@ export function buildCompletionMessage(
128
131
  agent: BackgroundAgent,
129
132
  run: SubagentRun | undefined,
130
133
  ): CompletionMessagePayload {
131
- const status = normalizeCompletionStatus(agent.status);
134
+ let status = normalizeCompletionStatus(agent.status);
135
+ // Issue #179 (D2): the notification category must NEVER default to success
136
+ // merely because `agent.status === "completed"`. The SDK resolves a mid-run
137
+ // provider death, so the run entry is the authoritative classified reason —
138
+ // downgrade a stale "completed" when the run says otherwise.
139
+ if (status === "completed" && run) {
140
+ const outcome = classifyTerminalOutcome(run.stopReason, run.errorMessage);
141
+ if (outcome.status !== "completed") {
142
+ status = outcome.status;
143
+ } else if (run.status === "failed") {
144
+ status = "failed";
145
+ }
146
+ }
132
147
  // BackgroundAgent has no `label` field — the caller's label is `description`
133
148
  // (set from params.description at spawn). Fall back to the id.
134
149
  const label = agent.description || agent.id;
135
150
  // completed → "success"; failed/stopped → classified category from the run
136
151
  // record (finalizeRunEntry stamps it), falling back to "unknown".
137
152
  const errorCategory =
138
- status === "completed" ? "success" : (run?.originalParams?.errorCategory ?? "unknown");
153
+ status === "completed"
154
+ ? "success"
155
+ : (run?.errorCategory ?? run?.originalParams?.errorCategory ?? "unknown");
139
156
  const durationMs = run?.durationMs;
140
157
  const costUsd = run?.cost;
141
158
  const tokensIn = run?.tokensIn;
142
159
  const tokensOut = run?.tokensOut;
160
+ const stopReason = run?.stopReason;
143
161
  const errorMessage = status === "completed" ? undefined : agent.error ?? run?.errorMessage;
144
162
 
145
163
  const summary = buildSummaryLine(label, agent.id, status, durationMs, costUsd, errorCategory);
146
- const tail = truncateTail(agent.finalOutput ?? run?.fullOutput ?? "", 15);
164
+ // Issue #179 (C1 fix): truncateTail keeps the LAST 15 lines, but the failure
165
+ // headline is PREPENDED to the stored output — long earlier-turn prose would
166
+ // otherwise push the failure reason out of the notified body. Peel it off
167
+ // before tailing and re-add it as its own part so the reason is never
168
+ // truncated away.
169
+ const stored = agent.finalOutput ?? run?.fullOutput ?? "";
170
+ const headline = status === "completed" ? undefined : errorMessage;
171
+ const body =
172
+ headline && stored.startsWith(headline)
173
+ ? stored.slice(headline.length).replace(/^\n+/, "")
174
+ : stored;
175
+ const tail = truncateTail(body, 15);
147
176
  const parts = [summary];
177
+ if (headline) parts.push(headline);
148
178
  if (tail) parts.push(tail);
149
179
  parts.push(COMPLETION_DIRECTIVE);
150
180
  const content = parts.join("\n\n");
@@ -154,6 +184,7 @@ export function buildCompletionMessage(
154
184
  status,
155
185
  errorCategory,
156
186
  ...(errorMessage ? { errorMessage } : {}),
187
+ ...(stopReason ? { stopReason } : {}),
157
188
  ...(costUsd !== undefined ? { costUsd } : {}),
158
189
  ...(tokensIn !== undefined ? { tokensIn } : {}),
159
190
  ...(tokensOut !== undefined ? { tokensOut } : {}),
package/src/runner.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  SIGKILL_GRACE_MS,
28
28
  TEMP_FILE_MODE,
29
29
  MAX_TEMP_DIR_AGE_MS,
30
+ isProviderError,
30
31
  classifyError,
31
32
  SUBAGENT_ABORTED_MESSAGE,
32
33
  } from "./types";
@@ -666,7 +667,7 @@ export function parseSubagentLine(
666
667
  function countModelErrorTurns(messages: Array<Record<string, unknown>>): number {
667
668
  let count = 0;
668
669
  for (const msg of messages) {
669
- if (msg.role === "assistant" && msg.stopReason === "error") count++;
670
+ if (msg.role === "assistant" && isProviderError(msg.stopReason as string | undefined)) count++;
670
671
  }
671
672
  return count;
672
673
  }
@@ -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/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
@@ -2190,10 +2190,7 @@ function renderSubTaskSummary(
2190
2190
  },
2191
2191
  maxLines: number,
2192
2192
  ): string {
2193
- const isError =
2194
- result.exitCode !== 0 ||
2195
- result.stopReason === "error" ||
2196
- result.stopReason === "aborted";
2193
+ const isError = isSubagentError(result);
2197
2194
  const icon = isError
2198
2195
  ? theme.fg("error", "\u2717")
2199
2196
  : theme.fg("success", "\u2713");
@@ -2213,12 +2210,7 @@ function renderCollapsedChain(
2213
2210
  details: ChainDetails,
2214
2211
  theme: Theme,
2215
2212
  ): string {
2216
- const allSucceeded = details.results.every(
2217
- (r) =>
2218
- r.exitCode === 0 &&
2219
- r.stopReason !== "error" &&
2220
- r.stopReason !== "aborted",
2221
- );
2213
+ const allSucceeded = details.results.every((r) => !isSubagentError(r));
2222
2214
  const icon = allSucceeded
2223
2215
  ? theme.fg("success", "\u2713")
2224
2216
  : theme.fg("error", "\u2717");
@@ -2226,10 +2218,7 @@ function renderCollapsedChain(
2226
2218
  let text = `${icon} ${theme.fg("toolTitle", theme.bold("chain"))} ${theme.fg("muted", `${details.completedSteps}/${details.totalSteps} steps${stoppedEarly}`)}`;
2227
2219
 
2228
2220
  for (const r of details.results) {
2229
- const isErr =
2230
- r.exitCode !== 0 ||
2231
- r.stopReason === "error" ||
2232
- r.stopReason === "aborted";
2221
+ const isErr = isSubagentError(r);
2233
2222
  const statusIcon = isErr
2234
2223
  ? theme.fg("error", "\u2717")
2235
2224
  : theme.fg("success", "\u2713");
@@ -2263,10 +2252,7 @@ function renderCollapsedParallel(
2263
2252
  let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel"))} ${theme.fg("muted", `${details.succeeded}/${details.failed}/${details.results.length} total`)}`;
2264
2253
 
2265
2254
  for (const r of details.results) {
2266
- const isErr =
2267
- r.exitCode !== 0 ||
2268
- r.stopReason === "error" ||
2269
- r.stopReason === "aborted";
2255
+ const isErr = isSubagentError(r);
2270
2256
  const statusIcon = isErr
2271
2257
  ? theme.fg("error", "\u2717")
2272
2258
  : theme.fg("success", "\u2713");
@@ -2312,10 +2298,7 @@ function renderExpandedChain(
2312
2298
  container.addChild(new Spacer(1));
2313
2299
 
2314
2300
  for (const r of details.results) {
2315
- const isErr =
2316
- r.exitCode !== 0 ||
2317
- r.stopReason === "error" ||
2318
- r.stopReason === "aborted";
2301
+ const isErr = isSubagentError(r);
2319
2302
  const statusIcon = isErr
2320
2303
  ? theme.fg("error", "\u2717")
2321
2304
  : theme.fg("success", "\u2713");
@@ -2397,10 +2380,7 @@ function renderExpandedParallel(
2397
2380
  container.addChild(new Spacer(1));
2398
2381
 
2399
2382
  for (const r of details.results) {
2400
- const isErr =
2401
- r.exitCode !== 0 ||
2402
- r.stopReason === "error" ||
2403
- r.stopReason === "aborted";
2383
+ const isErr = isSubagentError(r);
2404
2384
  const statusIcon = isErr
2405
2385
  ? theme.fg("error", "\u2717")
2406
2386
  : theme.fg("success", "\u2713");
@@ -2774,12 +2754,7 @@ function renderCollapsedGraph(
2774
2754
  ): string {
2775
2755
  const totalTasks = details.waves.reduce((s, w) => s + w.tasks.length, 0);
2776
2756
  const allSucceeded = details.waves.every((w) =>
2777
- w.tasks.every(
2778
- (r) =>
2779
- r.exitCode === 0 &&
2780
- r.stopReason !== "error" &&
2781
- r.stopReason !== "aborted",
2782
- ),
2757
+ w.tasks.every((r) => !isSubagentError(r)),
2783
2758
  );
2784
2759
  const icon = allSucceeded
2785
2760
  ? theme.fg("success", "\u2713")
@@ -2790,22 +2765,16 @@ function renderCollapsedGraph(
2790
2765
  const wavesToRender = details.waves.slice(0, maxWavesToShow);
2791
2766
 
2792
2767
  for (const wave of wavesToRender) {
2793
- const waveOk = wave.tasks.every(
2794
- (r) =>
2795
- r.exitCode === 0 &&
2796
- r.stopReason !== "error" &&
2797
- r.stopReason !== "aborted",
2798
- );
2768
+ const waveOk = wave.tasks.every((r) => !isSubagentError(r));
2799
2769
  const waveIcon = waveOk
2800
2770
  ? theme.fg("success", "\u2713")
2801
2771
  : theme.fg("error", "\u2717");
2802
2772
  const modeLabel = wave.parallel ? "parallel" : "serial";
2803
2773
  const taskLabels = wave.tasks.map((r) => {
2804
2774
  const label = r.label || r.task.slice(0, 30);
2805
- const taskIcon =
2806
- r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted"
2807
- ? theme.fg("success", "\u2713")
2808
- : theme.fg("error", "\u2717");
2775
+ const taskIcon = !isSubagentError(r)
2776
+ ? theme.fg("success", "\u2713")
2777
+ : theme.fg("error", "\u2717");
2809
2778
  return `${taskIcon} ${theme.fg("dim", label)}`;
2810
2779
  });
2811
2780
  text += `
@@ -2843,12 +2812,7 @@ function renderExpandedGraph(
2843
2812
  container.addChild(new Spacer(1));
2844
2813
 
2845
2814
  for (const wave of details.waves) {
2846
- const waveOk = wave.tasks.every(
2847
- (r) =>
2848
- r.exitCode === 0 &&
2849
- r.stopReason !== "error" &&
2850
- r.stopReason !== "aborted",
2851
- );
2815
+ const waveOk = wave.tasks.every((r) => !isSubagentError(r));
2852
2816
  const waveIcon = waveOk
2853
2817
  ? theme.fg("success", "\u2713")
2854
2818
  : theme.fg("error", "\u2717");
@@ -2862,10 +2826,7 @@ function renderExpandedGraph(
2862
2826
  );
2863
2827
 
2864
2828
  for (const r of wave.tasks) {
2865
- const isErr =
2866
- r.exitCode !== 0 ||
2867
- r.stopReason === "error" ||
2868
- r.stopReason === "aborted";
2829
+ const isErr = isSubagentError(r);
2869
2830
  const statusIcon = isErr
2870
2831
  ? theme.fg("error", "\u2717")
2871
2832
  : theme.fg("success", "\u2713");
package/src/types.ts CHANGED
@@ -43,7 +43,14 @@ export type ErrorCategory =
43
43
  | "aborted"
44
44
  | "exit_error"
45
45
  | "crash"
46
- | "unknown";
46
+ | "unknown"
47
+ // Issue #179: additive terminal-reason categories. "truncated" = the model
48
+ // hit its output/context limit (stopReason "length"); "incomplete" = the
49
+ // agent stopped mid-turn without a terminal answer (stopReason
50
+ // "toolUse"/"deferred"/"pending"). Existing consumers treat categories as
51
+ // string keys (metrics/SLA breakdown), so both are additive.
52
+ | "truncated"
53
+ | "incomplete";
47
54
 
48
55
  // Issue #120 (Review round): single source of truth for the user-abort stamp.
49
56
  // Used by the abort handler (runner.ts), the stopped/aborted settlement paths
@@ -58,6 +65,17 @@ export const SUBAGENT_ABORTED_MESSAGE = "Subagent aborted by user";
58
65
  */
59
66
  export function classifyError(result: SubagentResult): ErrorCategory {
60
67
  if (result.stopReason === "aborted") return "aborted";
68
+ // Issue #179: a terminal stopReason with no terminal answer is a failure in
69
+ // its own right — classify it before the message/exitCode fallbacks so the
70
+ // honest category wins (length → "truncated"; mid-turn → "incomplete").
71
+ if (result.stopReason === "length") return "truncated";
72
+ if (
73
+ result.stopReason === "toolUse" ||
74
+ result.stopReason === "deferred" ||
75
+ result.stopReason === "pending"
76
+ ) {
77
+ return "incomplete";
78
+ }
61
79
 
62
80
  const msg = (result.errorMessage ?? "").toLowerCase();
63
81
  const err = (result.stderr ?? "").toLowerCase();
@@ -226,6 +244,27 @@ export interface SubagentRun {
226
244
  id: string;
227
245
  task: string;
228
246
  label?: string;
247
+ /**
248
+ * Issue #179 (D1/D2): terminal stopReason of the final assistant turn
249
+ * ("stop" | "error" | "aborted" | "length" | "toolUse" | "deferred" |
250
+ * "pending"). Recorded on the finalized entry so consumers can tell a real
251
+ * success from a resolved-but-failed run.
252
+ */
253
+ stopReason?: string;
254
+ /**
255
+ * Issue #179 (D1/D2): classified error category at the TOP level. The legacy
256
+ * `originalParams.errorCategory` is kept populated for back-compat (metrics,
257
+ * retry snapshot), but new consumers should prefer this field.
258
+ */
259
+ errorCategory?: ErrorCategory;
260
+ /**
261
+ * Issue #179 (D3): assistant turns folded from the session usage. 0 means the
262
+ * run burned nothing ("did nothing"); > 0 with `finalTurnError` means it did
263
+ * work and then died.
264
+ */
265
+ turns?: number;
266
+ /** Issue #179 (D3): whether the final assistant turn was a provider error. */
267
+ finalTurnError?: boolean;
229
268
  // Issue #98: background run entries carry the agent description (the label
230
269
  // the caller passed at spawn) — kept separate from `label` for symmetry
231
270
  // with the background agent record.
@@ -665,9 +704,108 @@ export function getFinalOutput(messages: Array<Record<string, unknown>>): string
665
704
  }
666
705
 
667
706
  export function isSubagentError(result: SubagentResult): boolean {
668
- return result.exitCode !== 0
669
- || result.stopReason === "error"
670
- || result.stopReason === "aborted";
707
+ if (result.exitCode !== 0) return true;
708
+ // Issue #179: a resolved-but-failed terminal reason is still an error. This
709
+ // is the foreground terminal-status derivation (finalizeRunRecord) — the
710
+ // background path classifies via classifyTerminalOutcome below.
711
+ return result.stopReason === "error"
712
+ || result.stopReason === "aborted"
713
+ || result.stopReason === "length"
714
+ || result.stopReason === "toolUse"
715
+ || result.stopReason === "deferred"
716
+ || result.stopReason === "pending";
717
+ }
718
+
719
+ /**
720
+ * Issue #179 (review ratchet): the NARROWER question — was the terminal turn a
721
+ * provider/model error specifically?
722
+ *
723
+ * This is distinct from `isSubagentError` (did the run fail at all): a `length`,
724
+ * `toolUse`, `deferred` or `pending` ending is a FAILED RUN but NOT a provider
725
+ * error, and callers asking this narrower question want only `"error"`.
726
+ *
727
+ * Named so the literal has exactly one home — a consistency ratchet test fails if
728
+ * any other module compares `stopReason` against a failure reason directly, which
729
+ * is how the private copies of the wider predicate silently desynced.
730
+ */
731
+ export function isProviderError(stopReason: string | undefined): boolean {
732
+ return stopReason === "error";
733
+ }
734
+
735
+ /**
736
+ * Issue #179 (C1 fix): tally subtask outcomes by the SHARED failure predicate.
737
+ * Deriving success from `exitCode === 0` alone mislabels a truncated or
738
+ * mid-turn run (a resolved prompt with stopReason length/toolUse/deferred/
739
+ * pending, exitCode 0) as a success — the exact "failure displayed as a
740
+ * success" defect. Every aggregate (parallel succeeded/failed, chain/graph
741
+ * success, the TUI verdicts) must route through isSubagentError so a future
742
+ * widening of the predicate cannot silently desync a private copy.
743
+ */
744
+ export function countSubagentOutcomes(
745
+ results: ReadonlyArray<SubagentResult | undefined>,
746
+ ): { succeeded: number; failed: number } {
747
+ let succeeded = 0;
748
+ let failed = 0;
749
+ for (const r of results) {
750
+ if (!r) continue;
751
+ if (isSubagentError(r)) failed++;
752
+ else succeeded++;
753
+ }
754
+ return { succeeded, failed };
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // Issue #179: terminal outcome classification
759
+ // ---------------------------------------------------------------------------
760
+
761
+ /** Terminal status a settled run can take. */
762
+ export type TerminalStatus = "completed" | "failed" | "stopped";
763
+
764
+ export interface TerminalOutcome {
765
+ status: TerminalStatus;
766
+ errorCategory?: ErrorCategory;
767
+ }
768
+
769
+ /**
770
+ * Issue #179 (D1): classify a run's terminal outcome from its final assistant
771
+ * stopReason. This is the settle-time counterpart to classifyError/isSubagentError
772
+ * and REUSES classifyError for every failure reason (single taxonomy).
773
+ *
774
+ * | stopReason | status | errorCategory |
775
+ * |--------------------------|-----------|----------------------------|
776
+ * | stop / undefined | completed | — |
777
+ * | aborted | stopped | aborted |
778
+ * | error | failed | from classifyError |
779
+ * | length | failed | truncated |
780
+ * | toolUse/deferred/pending | failed | incomplete |
781
+ */
782
+ export function classifyTerminalOutcome(
783
+ stopReason: string | undefined,
784
+ errorMessage?: string,
785
+ extra?: { stderr?: string; exitCode?: number },
786
+ ): TerminalOutcome {
787
+ switch (stopReason) {
788
+ case "aborted":
789
+ return { status: "stopped", errorCategory: "aborted" };
790
+ case "error":
791
+ case "length":
792
+ case "toolUse":
793
+ case "deferred":
794
+ case "pending":
795
+ return {
796
+ status: "failed",
797
+ errorCategory: classifyError({
798
+ messages: [],
799
+ usage: { ...EMPTY_USAGE },
800
+ exitCode: extra?.exitCode ?? 0,
801
+ stderr: extra?.stderr ?? "",
802
+ errorMessage,
803
+ stopReason,
804
+ }),
805
+ };
806
+ default:
807
+ return { status: "completed" };
808
+ }
671
809
  }
672
810
 
673
811
  export function formatModel(m: { provider: string; id: string } | undefined): string {