brl-subagent 2.3.6 → 2.3.8

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/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",
@@ -1884,6 +1878,740 @@ export default function (pi: ExtensionAPI) {
1884
1878
  }
1885
1879
  }
1886
1880
 
1881
+ // -------------------------------------------------------------------
1882
+ // runBackgroundFanOut — background fan-out for tasks mode.
1883
+ // Resolves + validates the WHOLE batch up front (no spawn on any
1884
+ // failure), then spawns the tasks sequentially, in task order, through the
1885
+ // shared startBackgroundAgent tail, and returns immediately with their ids.
1886
+ // Foreground-only gates (preflight, circuit breaker, recursion depth) are
1887
+ // deliberately skipped — the single-background path skips them too.
1888
+ // -------------------------------------------------------------------
1889
+
1890
+ async function runBackgroundFanOut(
1891
+ pi: ExtensionAPI,
1892
+ ctx: ExtensionContext,
1893
+ params: Record<string, unknown>,
1894
+ signal: AbortSignal | undefined,
1895
+ ): Promise<ToolResult<SubagentResult | undefined>> {
1896
+ const taskList = params.tasks as SubTaskParams[];
1897
+
1898
+ // Session cost limit gates the WHOLE batch before any spawn — same
1899
+ // estimate/limit logic as runParallelMode, scaled by task count.
1900
+ const perTaskEstimate =
1901
+ state.config.perTaskCostEstimate > 0
1902
+ ? state.config.perTaskCostEstimate
1903
+ : 0.05;
1904
+ if (state.checkCostLimit(perTaskEstimate * taskList.length, ctx)) {
1905
+ const currentTotal = state.getSessionTotalCost(ctx);
1906
+ const limit = state.config.sessionCostLimit;
1907
+ log.warn("Background fan-out rejected: session cost limit reached", {
1908
+ currentTotal,
1909
+ estimatedCost: perTaskEstimate * taskList.length,
1910
+ limit,
1911
+ });
1912
+ return {
1913
+ content: [
1914
+ {
1915
+ type: "text" as const,
1916
+ text:
1917
+ `Cannot delegate: session cost limit reached ` +
1918
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
1919
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
1920
+ },
1921
+ ],
1922
+ details: undefined,
1923
+ isError: true,
1924
+ };
1925
+ }
1926
+
1927
+ // Resolve global params once — same call shape as runParallelMode.
1928
+ const globalParams = resolveSubagentParams(
1929
+ params as {
1930
+ task: string;
1931
+ label?: string;
1932
+ preset?: string;
1933
+ systemPrompt?: string;
1934
+ inheritSystemPrompt?: boolean;
1935
+ thinkingLevel?: string;
1936
+ outputFile?: string;
1937
+ timeout?: number;
1938
+ cwd?: string;
1939
+ tools?: string[];
1940
+ excludeTools?: string[];
1941
+ noBuiltinTools?: boolean;
1942
+ gitMode?: string;
1943
+ },
1944
+ state,
1945
+ ctx,
1946
+ log,
1947
+ );
1948
+
1949
+ // Resolve the global model once (per-call model beats preset/config).
1950
+ const globalModelResult = resolveSubagentModel(
1951
+ ctx,
1952
+ globalParams.resolvedPreset,
1953
+ params.model as string | undefined,
1954
+ );
1955
+ if (!globalModelResult.ok) return globalModelResult.error;
1956
+ const globalModel = globalModelResult.model;
1957
+
1958
+ // Same rejection as the single-background path: approvalMode 'always'
1959
+ // cannot work in background — there is no interactive dialog to approve
1960
+ // the diff, for any of the N agents.
1961
+ if (globalParams.resolvedApprovalMode === 'always') {
1962
+ return {
1963
+ content: [{ type: "text" as const, text:
1964
+ `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
1965
+ `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
1966
+ }],
1967
+ details: undefined,
1968
+ isError: true,
1969
+ };
1970
+ }
1971
+ // W5 parity with the single-background path: 'writes' silently
1972
+ // auto-approves in background — warn once so the caller knows none of
1973
+ // the N agents' diffs will be gated on approval.
1974
+ if (globalParams.resolvedApprovalMode === 'writes') {
1975
+ log.warn("Background fan-out spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
1976
+ tasks: taskList.length,
1977
+ });
1978
+ }
1979
+
1980
+ // gitMode 'branch' is rejected for the whole batch: the per-repository
1981
+ // git lock is awaited inside the first spawn and held until that agent
1982
+ // settles, so same-repository branch-mode spawns would serialize and
1983
+ // block this call for the whole chain. The rule is blanket (not
1984
+ // same-cwd-only) because the lock is keyed by the cwd string, not the
1985
+ // repository root — different subdirectories of one repo share a working
1986
+ // tree without sharing a lock.
1987
+ if (globalParams.resolvedGitMode === 'branch') {
1988
+ return {
1989
+ content: [{ type: "text" as const, text:
1990
+ `Cannot start ${taskList.length} background agents with gitMode 'branch': the per-repository git lock ` +
1991
+ `is awaited by the first spawn and held until that agent settles, so same-repository branch-mode ` +
1992
+ `spawns would serialize and block this call for the whole chain. Background fan-out therefore ` +
1993
+ `rejects gitMode 'branch' for all tasks in this version. Use gitMode 'none' for the batch, or ` +
1994
+ `dispatch each unit as a separate single-task background call.`
1995
+ }],
1996
+ details: undefined,
1997
+ isError: true,
1998
+ };
1999
+ }
2000
+
2001
+ // Per-task pre-pass: resolve + validate EVERYTHING before any spawn, so a
2002
+ // single bad task rejects the whole batch with nothing started.
2003
+ type FanOutTask = {
2004
+ merged: ReturnType<typeof mergeSubTaskParams>;
2005
+ model: { provider: string; id: string };
2006
+ cwd: string;
2007
+ outputFile: string | undefined;
2008
+ };
2009
+ const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
2010
+ merged.label ??
2011
+ (merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
2012
+
2013
+ const fanOutTasks: FanOutTask[] = [];
2014
+ for (let i = 0; i < taskList.length; i++) {
2015
+ const merged = mergeSubTaskParams(globalParams, taskList[i]);
2016
+ const name = displayTaskName(merged);
2017
+ const taskPrefix = `Task ${i + 1} ("${name}")`;
2018
+ // Per-task model override wins; the global model is the fallback.
2019
+ const stepModel = resolveStepModel(ctx, merged.model, globalModel);
2020
+
2021
+ const cwdResult = validateCwd(merged.effectiveCwd, ctx.cwd);
2022
+ if (!cwdResult.ok) {
2023
+ return {
2024
+ content: [{ type: "text" as const, text: `${taskPrefix}: Invalid cwd: ${cwdResult.error}` }],
2025
+ details: undefined,
2026
+ isError: true,
2027
+ };
2028
+ }
2029
+ let resolvedOutputFile: string | undefined;
2030
+ if (merged.outputFile) {
2031
+ const ofResult = validateOutputFile(merged.outputFile, cwdResult.value);
2032
+ if (!ofResult.ok) {
2033
+ return {
2034
+ content: [{ type: "text" as const, text: `${taskPrefix}: Invalid outputFile: ${ofResult.error}` }],
2035
+ details: undefined,
2036
+ isError: true,
2037
+ };
2038
+ }
2039
+ resolvedOutputFile = ofResult.value;
2040
+ }
2041
+
2042
+ const validation = validatePreTask({
2043
+ task: merged.task,
2044
+ toolOptions: merged.toolOptions,
2045
+ thinkingLevel: merged.thinkingLevel,
2046
+ gitMode: globalParams.resolvedGitMode,
2047
+ outputFile: merged.outputFile,
2048
+ });
2049
+ if (validation.warnings.length > 0) {
2050
+ log.warn("Background fan-out pre-task validation warnings", { task: i + 1, warnings: validation.warnings });
2051
+ }
2052
+ if (!validation.valid) {
2053
+ const errText = validation.errors.join("; ");
2054
+ log.warn("Background fan-out pre-task validation failed", { task: i + 1, errors: validation.errors });
2055
+ return {
2056
+ content: [{ type: "text" as const, text: `${taskPrefix}: ${errText}` }],
2057
+ details: undefined,
2058
+ isError: true,
2059
+ };
2060
+ }
2061
+
2062
+ fanOutTasks.push({ merged, model: stepModel, cwd: cwdResult.value, outputFile: resolvedOutputFile });
2063
+ }
2064
+
2065
+ // Spawn pass — sequential, in task order, through the shared tail.
2066
+ const started: Array<{ id: string; label?: string }> = [];
2067
+ const startedIdsText = (): string => started.map((s) => s.id).join(", ");
2068
+
2069
+ for (let i = 0; i < fanOutTasks.length; i++) {
2070
+ if (signal?.aborted) {
2071
+ if (started.length === 0) {
2072
+ return {
2073
+ content: [{ type: "text" as const, text: `Background fan-out cancelled before any agent started.` }],
2074
+ details: undefined,
2075
+ isError: true,
2076
+ };
2077
+ }
2078
+ return {
2079
+ content: [{ type: "text" as const, text:
2080
+ `Background fan-out cancelled after starting ${started.length} of ${fanOutTasks.length} agents ` +
2081
+ `(${startedIdsText()}) — they are detached and will still wake you.`
2082
+ }],
2083
+ details: undefined,
2084
+ isError: true,
2085
+ };
2086
+ }
2087
+
2088
+ const { merged, model, cwd, outputFile } = fanOutTasks[i];
2089
+ const name = displayTaskName(merged);
2090
+ const subagentPrompt = buildSubagentPrompt(
2091
+ ctx.getSystemPrompt(),
2092
+ merged.inheritSP,
2093
+ merged.customSP,
2094
+ outputFile,
2095
+ merged.toolOptions?.tools,
2096
+ globalParams.resolvedPreset?.promptGuideline,
2097
+ );
2098
+ const unitPriority = merged.priority ?? (params.priority as string | undefined);
2099
+
2100
+ let agent: BackgroundAgent;
2101
+ try {
2102
+ agent = await startBackgroundAgent(pi, ctx, {
2103
+ task: merged.task,
2104
+ type: (params.preset as string | undefined) || 'general-purpose',
2105
+ description: merged.label,
2106
+ model: `${model.provider}/${model.id}`,
2107
+ thinkingLevel: merged.thinkingLevel,
2108
+ priority: unitPriority,
2109
+ systemPrompt: subagentPrompt,
2110
+ cwd,
2111
+ toolOptions: merged.toolOptions,
2112
+ timeout: merged.timeout,
2113
+ gitMode: globalParams.resolvedGitMode,
2114
+ // Retry parity with createUnitRun: snapshot this unit's resolved
2115
+ // values so a retry restores them.
2116
+ originalParams: snapshotOriginalParams({
2117
+ systemPrompt: merged.customSP,
2118
+ inheritSystemPrompt: merged.inheritSP,
2119
+ model: merged.model,
2120
+ thinkingLevel: merged.thinkingLevel,
2121
+ priority: unitPriority,
2122
+ outputFile: merged.outputFile,
2123
+ timeout: merged.timeout,
2124
+ cwd: merged.effectiveCwd,
2125
+ tools: merged.toolOptions?.tools,
2126
+ excludeTools: merged.toolOptions?.excludeTools,
2127
+ noBuiltinTools: merged.toolOptions?.noBuiltinTools,
2128
+ preset: globalParams.resolvedPreset?.name,
2129
+ }),
2130
+ sanitizeCwd: merged.effectiveCwd,
2131
+ });
2132
+ } catch (err) {
2133
+ const message = sanitizeErrorMessage(
2134
+ err instanceof Error ? err.message : String(err),
2135
+ merged.effectiveCwd,
2136
+ );
2137
+ log.error("Background fan-out spawn failed", { task: i + 1, error: message });
2138
+ const alreadyStarted = started.length > 0
2139
+ ? `\n\nAlready started: ${startedIdsText()} — they are detached and will still wake you.`
2140
+ : "";
2141
+ return {
2142
+ content: [{ type: "text" as const, text:
2143
+ `Failed to spawn background agent for Task ${i + 1} ("${name}"): ${message}` + alreadyStarted
2144
+ }],
2145
+ details: undefined,
2146
+ isError: true,
2147
+ };
2148
+ }
2149
+ started.push({ id: agent.id, label: merged.label });
2150
+ }
2151
+
2152
+ // Text-only result: ids in task order + the wake hint.
2153
+ const lines = started
2154
+ .map((s, i) => `${i + 1}. ${s.label ? `"${s.label}" ` : ""}— ${s.id}`)
2155
+ .join("\n");
2156
+ return {
2157
+ content: [{ type: "text" as const, text:
2158
+ `Background agents started: ${started.length}\n\n` +
2159
+ `${lines}\n\n` +
2160
+ `You'll be woken with a completion message as each finishes; use get_subagent_result({ agent_id }) for retrieval and stall checks.`
2161
+ }],
2162
+ details: undefined,
2163
+ };
2164
+ }
2165
+
2166
+ // -------------------------------------------------------------------
2167
+ // startBackgroundAgent — the shared background-spawn tail.
2168
+ // Used by BOTH spawnBackgroundRun (single task) and runBackgroundFanOut
2169
+ // (tasks fan-out): the session-manager import, the spawn call, live-monitor
2170
+ // registration, footer counters, the 2s progress poller and the hard-cap
2171
+ // timer live here exactly once. Callers keep their own prelude, checks,
2172
+ // validations and result formatting. THROWS on spawn failure — callers
2173
+ // translate the error into their own result shape.
2174
+ // -------------------------------------------------------------------
2175
+
2176
+ async function startBackgroundAgent(
2177
+ pi: ExtensionAPI,
2178
+ ctx: ExtensionContext,
2179
+ spawn: {
2180
+ task: string;
2181
+ type?: string;
2182
+ description?: string;
2183
+ model?: string;
2184
+ thinkingLevel: ThinkingLevel;
2185
+ priority?: string;
2186
+ systemPrompt: string;
2187
+ cwd: string;
2188
+ toolOptions?: SubagentToolOptions;
2189
+ timeout?: number;
2190
+ gitMode: GitMode;
2191
+ originalParams?: SubagentRun["originalParams"];
2192
+ /** cwd used to redact paths from crash notifications. */
2193
+ sanitizeCwd: string;
2194
+ },
2195
+ ): Promise<BackgroundAgent> {
2196
+ const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
2197
+ // Issue #31 (PR #76 review, DRY): extract final output from a
2198
+ // possibly-released ref — the poller may observe a terminal agent
2199
+ // whose ref was already nulled by the settlement path.
2200
+ const extractAgentFinalOutput = (a: BackgroundAgent): string =>
2201
+ extractFinalOutput(a._sessionRef ?? { messages: [] });
2202
+
2203
+ const agent = await spawnBackgroundSession(pi, ctx, {
2204
+ task: spawn.task,
2205
+ type: spawn.type,
2206
+ description: spawn.description,
2207
+ model: spawn.model,
2208
+ thinkingLevel: spawn.thinkingLevel,
2209
+ // Issue #114: per-unit priority rides the spawn so the background
2210
+ // run entry + drill-in header carry it like foreground runs.
2211
+ priority: spawn.priority,
2212
+ systemPrompt: spawn.systemPrompt,
2213
+ cwd: spawn.cwd,
2214
+ toolOptions: spawn.toolOptions,
2215
+ timeout: spawn.timeout,
2216
+ gitMode: spawn.gitMode,
2217
+ // Issue #98/#108: snapshotOriginalParams is the single source of truth
2218
+ // for the caller params a later retry restores.
2219
+ originalParams: spawn.originalParams,
2220
+ });
2221
+
2222
+ // Register for live monitor
2223
+ state.registerLiveSubagent(agent.id, {
2224
+ id: agent.id,
2225
+ label: agent.description,
2226
+ task: agent.task,
2227
+ model: agent.model,
2228
+ thinkingLevel: agent.thinkingLevel,
2229
+ priority: agent.priority,
2230
+ startedAt: agent.startedAt,
2231
+ ctx,
2232
+ });
2233
+
2234
+ // Update footer counters
2235
+ state.activeSubagents++;
2236
+ updateProgressStatus(state, ctx);
2237
+
2238
+ let completed = false;
2239
+
2240
+ // Poll for live progress
2241
+ const pollInterval = setInterval(() => {
2242
+ try {
2243
+ if (completed) return;
2244
+
2245
+ const session = agent._sessionRef;
2246
+ // Issue #31: a nulled ref on a TERMINAL agent is expected (the
2247
+ // settlement path releases the ref) — fall through to finalize
2248
+ // below instead of treating it as a crash. A nulled ref on a
2249
+ // live agent is still a crash.
2250
+ if (!session && !agent.completedAt) {
2251
+ // Session ref not available — session may have crashed.
2252
+ // Defensive: the ref is assigned synchronously before the poller starts
2253
+ // and is never nulled, so this is mostly unreachable — but keep the
2254
+ // bookkeeping safe regardless.
2255
+ completed = true;
2256
+ clearInterval(pollInterval);
2257
+ clearTimeout(hardCapHandle);
2258
+ // Capture whatever output exists (may be empty — fine).
2259
+ setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
2260
+ // Gate the counter on the finalize claim: if the
2261
+ // stale sweep already finalized this entry, the
2262
+ // decrement happened there (PR #71 review).
2263
+ if (state.finalizeLiveSubagent(agent.id)) {
2264
+ state.activeSubagents--;
2265
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2266
+ }
2267
+ state.failedSubagents++;
2268
+ updateProgressStatus(state, ctx);
2269
+ pi.sendMessage({
2270
+ customType: "subagent-notification",
2271
+ content: `Background agent "${agent.description}" crashed.`,
2272
+ display: true,
2273
+ details: { agentId: agent.id }
2274
+ }, { deliverAs: "followUp" });
2275
+ return;
2276
+ }
2277
+
2278
+ // While running, update the live monitor.
2279
+ // Once completedAt is set, skip straight to finalize below.
2280
+ // Issue #31: past the guard above, the only nulled-ref path left is a
2281
+ // TERMINAL agent (settlement releases the ref), so a live agent always
2282
+ // has its session — the guard already returned on the live-nulled-ref
2283
+ // crash path. The compiler can't correlate the two checks, so assert it.
2284
+ if (!agent.completedAt) {
2285
+ const finalOutput = extractFinalOutput(session!);
2286
+
2287
+ try {
2288
+ const stats = session!.getSessionStats();
2289
+ state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
2290
+ } catch {
2291
+ state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
2292
+ }
2293
+ }
2294
+
2295
+ // Authoritative completion: the prompt promise settled — status and
2296
+ // completedAt are set by .then()/.catch() in spawnBackgroundSession.
2297
+ // Do NOT rely on !session.isStreaming: it is false during prompt()
2298
+ // preflight (auth check, model resolution) and for failed starts.
2299
+ if (agent.completedAt) {
2300
+ completed = true;
2301
+ clearInterval(pollInterval);
2302
+ clearTimeout(hardCapHandle);
2303
+
2304
+ // Issue #31: prefer the output already captured at settlement;
2305
+ // the ref may already be nulled on terminal agents, so never
2306
+ // overwrite with empty and stay null-safe.
2307
+ const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
2308
+ setAgentFinalOutput(agent.id, finalOutput);
2309
+
2310
+ // Gate the counter on the finalize claim: if the
2311
+ // stale sweep already finalized this entry, the
2312
+ // decrement happened there (PR #71 review).
2313
+ if (state.finalizeLiveSubagent(agent.id)) {
2314
+ state.activeSubagents--;
2315
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2316
+ }
2317
+
2318
+ if (agent.status === 'failed') {
2319
+ state.failedSubagents++;
2320
+ updateProgressStatus(state, ctx);
2321
+ pi.sendMessage({
2322
+ customType: "subagent-notification",
2323
+ content: `Background agent "${agent.description}" failed.`,
2324
+ display: true,
2325
+ details: { agentId: agent.id }
2326
+ }, { deliverAs: "followUp" });
2327
+ } else if (agent.status === 'stopped') {
2328
+ // User-initiated stop (stop_subagent) or deadline abort
2329
+ // (timeout/hard cap) — not a failure.
2330
+ updateProgressStatus(state, ctx);
2331
+ } else {
2332
+ state.completedSubagents++;
2333
+ state.unseenSubagents++;
2334
+ updateProgressStatus(state, ctx);
2335
+ }
2336
+ }
2337
+ } catch (err) {
2338
+ if (!completed) {
2339
+ completed = true;
2340
+ clearInterval(pollInterval);
2341
+ clearTimeout(hardCapHandle);
2342
+ try {
2343
+ setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
2344
+ } catch { /* ignore */ }
2345
+ // Gate the counter on the finalize claim: if the
2346
+ // stale sweep already finalized this entry, the
2347
+ // decrement happened there (PR #71 review).
2348
+ if (state.finalizeLiveSubagent(agent.id)) {
2349
+ state.activeSubagents--;
2350
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2351
+ }
2352
+ state.failedSubagents++;
2353
+ updateProgressStatus(state, ctx);
2354
+ pi.sendMessage({
2355
+ customType: "subagent-notification",
2356
+ content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, spawn.sanitizeCwd)}`,
2357
+ display: true,
2358
+ details: { agentId: agent.id }
2359
+ }, { deliverAs: "followUp" });
2360
+ }
2361
+ }
2362
+ }, 2000);
2363
+
2364
+ // Hard cap: stop polling AND abort the session after the deadline.
2365
+ // W2 (issue #28): previously this only stopped the poller — the pi
2366
+ // session kept running forever (orphaned). Now it pre-sets
2367
+ // 'stopped' (so the .then keeps it, per W1) and aborts the
2368
+ // session. The cap honors a shorter per-agent timeout.
2369
+ const hardCapMs = Math.min(spawn.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
2370
+ const hardCapHandle = setTimeout(() => {
2371
+ if (!completed && !agent.completedAt) {
2372
+ try {
2373
+ completed = true;
2374
+ clearInterval(pollInterval);
2375
+ // Pre-set stopped BEFORE aborting so the .then in
2376
+ // spawnBackgroundSession keeps the stopped state.
2377
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2378
+ agent._sessionRef?.abort().catch(() => {
2379
+ // Abort may reject if the session is mid-dispose;
2380
+ // the status flip above is already recorded.
2381
+ });
2382
+ // Capture whatever final output exists in the session
2383
+ const hardCapSession = agent._sessionRef;
2384
+ const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
2385
+ setAgentFinalOutput(agent.id, hardCapFinalOutput);
2386
+ // Gate the counter on the finalize claim: if the
2387
+ // stale sweep already finalized this entry, the
2388
+ // decrement happened there (PR #71 review).
2389
+ if (state.finalizeLiveSubagent(agent.id)) {
2390
+ state.activeSubagents--;
2391
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2392
+ }
2393
+ // m6: deadline abort is a stop, not a completion — mirror the
2394
+ // W3/poller stopped path (no completedSubagents increment).
2395
+ updateProgressStatus(state, ctx);
2396
+ } catch (err) {
2397
+ // Defensive: never let the hard-cap timer throw uncaught — that would
2398
+ // skip finalizeLiveSubagent, the counter decrement, and the notification.
2399
+ // Do all fallible work first (output capture), then mutate counters,
2400
+ // then notify — a throw mid-path can't double-fire mutations.
2401
+ completed = true;
2402
+ clearInterval(pollInterval);
2403
+ // m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
2404
+ // persistAgent fs failure) — leave the record terminal so
2405
+ // get_subagent_result doesn't report 'running' and the W3 timer
2406
+ // guard (!agent.completedAt) can't re-fire later.
2407
+ try {
2408
+ updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2409
+ } catch { /* ignore */ }
2410
+ try {
2411
+ setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
2412
+ } catch { /* ignore */ }
2413
+ // Gate the counter on the finalize claim: if the
2414
+ // stale sweep already finalized this entry, the
2415
+ // decrement happened there (PR #71 review).
2416
+ if (state.finalizeLiveSubagent(agent.id)) {
2417
+ state.activeSubagents--;
2418
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
2419
+ }
2420
+ state.failedSubagents++;
2421
+ updateProgressStatus(state, ctx);
2422
+ }
2423
+ }
2424
+ }, hardCapMs);
2425
+
2426
+ log.info("Background agent spawned", { agentId: agent.id, task: spawn.task, model: agent.model });
2427
+
2428
+ return agent;
2429
+ }
2430
+
2431
+ // -------------------------------------------------------------------
2432
+ // spawnBackgroundRun — Phase 6.5 background spawn, extracted verbatim
2433
+ // from execute() (#198 phase 1) so a later phase can fan out N runs.
2434
+ // -------------------------------------------------------------------
2435
+
2436
+ async function spawnBackgroundRun(
2437
+ pi: ExtensionAPI,
2438
+ ctx: ExtensionContext,
2439
+ params: Omit<Parameters<typeof resolveSubagentParams>[0], "task"> & {
2440
+ task?: string;
2441
+ model?: string;
2442
+ priority?: string;
2443
+ },
2444
+ singleTask: string,
2445
+ ): Promise<ToolResult<undefined>> {
2446
+ // Phase 6.5: Background execution — spawn session and return ID immediately.
2447
+ // Resolve the preset and its model BEFORE the background branch so the
2448
+ // preset's model (and system prompt) are honored in background mode.
2449
+ const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
2450
+ const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
2451
+ const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
2452
+ const bgModel = bgModelResult.ok
2453
+ ? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
2454
+ : undefined;
2455
+
2456
+ try {
2457
+ // W5 (issue #28): approvalMode 'always' cannot work in background —
2458
+ // there is no interactive dialog to approve the diff. Reject loudly
2459
+ // instead of silently running unattended with write access.
2460
+ if (bgResolved.resolvedApprovalMode === 'always') {
2461
+ return {
2462
+ content: [{ type: "text" as const, text:
2463
+ `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
2464
+ `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
2465
+ }],
2466
+ details: undefined,
2467
+ isError: true,
2468
+ };
2469
+ }
2470
+ // W5: 'writes' in background silently auto-approves — warn once so the
2471
+ // caller knows the diff will NOT be gated on approval.
2472
+ if (bgResolved.resolvedApprovalMode === 'writes') {
2473
+ log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
2474
+ task: params.task,
2475
+ });
2476
+ }
2477
+
2478
+ // W6 (issue #28): session cost limit must gate background spawns too —
2479
+ // the R5 check after the background branch never runs for them.
2480
+ // Same estimate/limit logic as single mode.
2481
+ const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
2482
+ ? state.config.perTaskCostEstimate
2483
+ : 0.05;
2484
+ if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
2485
+ const bgLimit = state.config.sessionCostLimit;
2486
+ const bgCurrentTotal = state.getSessionTotalCost(ctx);
2487
+ log.warn("Background delegation rejected: session cost limit reached", {
2488
+ currentTotal: bgCurrentTotal,
2489
+ estimatedCost: bgPerTaskEstimate,
2490
+ limit: bgLimit,
2491
+ });
2492
+ return {
2493
+ content: [
2494
+ {
2495
+ type: "text" as const,
2496
+ text:
2497
+ `Cannot delegate: session cost limit reached ` +
2498
+ `($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
2499
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
2500
+ },
2501
+ ],
2502
+ details: undefined,
2503
+ isError: true,
2504
+ };
2505
+ }
2506
+
2507
+ // F1: Validate cwd + outputFile the same way foreground single mode does —
2508
+ // an unvalidated outputFile would reach the prompt and could steer the
2509
+ // background agent's write tool outside the project root.
2510
+ const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
2511
+ if (!bgCwdResult.ok) {
2512
+ return {
2513
+ content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
2514
+ details: undefined,
2515
+ isError: true,
2516
+ };
2517
+ }
2518
+ let bgResolvedOutputFile: string | undefined;
2519
+ if (bgResolved.outputFile) {
2520
+ const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
2521
+ if (!ofResult.ok) {
2522
+ return {
2523
+ content: [
2524
+ { type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
2525
+ ],
2526
+ details: undefined,
2527
+ isError: true,
2528
+ };
2529
+ }
2530
+ bgResolvedOutputFile = ofResult.value;
2531
+ }
2532
+
2533
+ // C: H1 validation for background mode — reject outputFile-vs-write
2534
+ // conflicts before spawning (loud failure, same as single mode).
2535
+ const bgValidation = validatePreTask({
2536
+ task: singleTask,
2537
+ toolOptions: bgResolved.toolOptions,
2538
+ thinkingLevel: bgResolved.thinkingLevel,
2539
+ gitMode: bgResolved.resolvedGitMode,
2540
+ outputFile: bgResolvedOutputFile,
2541
+ });
2542
+ if (bgValidation.warnings.length > 0) {
2543
+ log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
2544
+ }
2545
+ if (!bgValidation.valid) {
2546
+ const errText = bgValidation.errors.join("; ");
2547
+ log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
2548
+ return {
2549
+ content: [{ type: "text" as const, text: errText }],
2550
+ details: undefined,
2551
+ isError: true,
2552
+ };
2553
+ }
2554
+
2555
+ // Build the full prompt the same way foreground single mode does:
2556
+ // base prompt (optionally inherited) + custom prompt + preset guidance.
2557
+ const bgPrompt = buildSubagentPrompt(
2558
+ ctx.getSystemPrompt(),
2559
+ bgResolved.inheritSP,
2560
+ bgResolved.customSP,
2561
+ bgResolvedOutputFile,
2562
+ bgResolved.toolOptions?.tools,
2563
+ bgResolvedPreset?.promptGuideline,
2564
+ );
2565
+
2566
+ const agent = await startBackgroundAgent(pi, ctx, {
2567
+ task: singleTask,
2568
+ type: params.preset || 'general-purpose',
2569
+ description: params.label,
2570
+ model: bgModel,
2571
+ thinkingLevel: bgResolved.thinkingLevel,
2572
+ priority: params.priority,
2573
+ systemPrompt: bgPrompt,
2574
+ cwd: bgCwdResult.value,
2575
+ toolOptions: bgResolved.toolOptions,
2576
+ timeout: bgResolved.timeout,
2577
+ gitMode: bgResolved.resolvedGitMode,
2578
+ originalParams: snapshotOriginalParams(params),
2579
+ sanitizeCwd: bgResolved.effectiveCwd,
2580
+ });
2581
+
2582
+ // B2: Surface auto-route decisions in background spawn result too.
2583
+ // Derive the restriction from the RESOLVED toolOptions (which reflects
2584
+ // per-call overrides) rather than the preset's declared values.
2585
+ const bgAutoRouteNote = bgAutoRoutedPreset
2586
+ ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2587
+ : "";
2588
+
2589
+ return {
2590
+ content: [{
2591
+ type: "text" as const,
2592
+ text: `Background agent started: ${agent.id}\n\n` +
2593
+ `Description: ${agent.description}\n` +
2594
+ `Task: ${agent.task}\n` +
2595
+ `Status: ${agent.status}\n\n` +
2596
+ `You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
2597
+ bgAutoRouteNote,
2598
+ }],
2599
+ details: undefined,
2600
+ };
2601
+ } catch (err) {
2602
+ const message = sanitizeErrorMessage(
2603
+ err instanceof Error ? err.message : String(err),
2604
+ bgResolved.effectiveCwd,
2605
+ );
2606
+ log.error("Failed to spawn background agent", { error: message });
2607
+ return {
2608
+ content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
2609
+ details: undefined,
2610
+ isError: true,
2611
+ };
2612
+ }
2613
+ }
2614
+
1887
2615
  // -------------------------------------------------------------------
1888
2616
  // /brl-subagent command
1889
2617
  // -------------------------------------------------------------------
@@ -1986,7 +2714,7 @@ export default function (pi: ExtensionAPI) {
1986
2714
  buildTemplateGuideline(templateSummary),
1987
2715
  "To retry a failed subagent, pass its run ID as retryRunId. The retried run uses the same task and parameters as the original. Parallel-origin entries retry as a single-subtask run carrying that subtask's task, label, and priority. Explicit parameters on this call override the original's. Use /brl-subagent retry to browse failed runs and get their IDs.",
1988
2716
  "Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
1989
- "Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
2717
+ "Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. With tasks, background fans out: every task starts as its own background agent, the call returns one ID per task in task order, and the conductor is woken once per agent as each finishes; chain and graph cannot be combined with background (rejected). Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
1990
2718
  "",
1991
2719
  "## Conductor Guardrails",
1992
2720
  "",
@@ -2141,6 +2869,7 @@ export default function (pi: ExtensionAPI) {
2141
2869
  "Run the subagent in the background without blocking the conductor. " +
2142
2870
  "When true, the tool returns immediately with an agent ID. " +
2143
2871
  "The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
2872
+ "Supports a single task or the tasks array (a fan-out that starts one background agent per task; with tasks the call returns one ID per task and the conductor is woken once per agent as each finishes); combining background with chain or graph is rejected. " +
2144
2873
  "Default: false (blocking mode).",
2145
2874
  }),
2146
2875
  ),
@@ -2320,48 +3049,10 @@ export default function (pi: ExtensionAPI) {
2320
3049
  }, { deliverAs: "followUp" });
2321
3050
  }
2322
3051
 
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
3052
+ // Handle template resolution — MUST run before the single-mode sanitize
3053
+ // block below: a template ASSIGNS params.task, so resolving first lets the
3054
+ // sanitizer validate the real resolved body (a template-only call has no
3055
+ // task yet) and lets mode detection see a non-empty task (issue #175).
2365
3056
  if (params.template) {
2366
3057
  const templateEntry = state.config.templates.find((t) => t.name === params.template);
2367
3058
  if (!templateEntry) {
@@ -2393,7 +3084,34 @@ export default function (pi: ExtensionAPI) {
2393
3084
  }
2394
3085
 
2395
3086
  const tv = resolved.value;
2396
- // Use resolved template fields as defaults, explicitly provided params override
3087
+ // Issue #175 review (fix 4): template-wins silently replaces a
3088
+ // supplied `task`. If that task would itself have failed sanitization
3089
+ // (e.g. over-length), the caller gets no signal — pre-#175 `dev`
3090
+ // rejected it. Warn (never reject — #99/#110 precedent, same
3091
+ // pi.sendMessage channel as the unknown-param warning) so both the LLM
3092
+ // and the human see the ignored task. Emitted ONLY when the supplied
3093
+ // task actually fails sanitization: a valid task alongside a template
3094
+ // is the intended template-wins override (issue #57), not noise.
3095
+ if (params.task !== undefined) {
3096
+ const suppliedTask = sanitizeTask(params.task);
3097
+ if (!suppliedTask.ok) {
3098
+ log.warn("delegate_task: supplied task ignored because a template was given", {
3099
+ template: params.template,
3100
+ error: suppliedTask.error,
3101
+ });
3102
+ pi.sendMessage({
3103
+ customType: "delegate-notification",
3104
+ content: `delegate_task: the supplied 'task' was ignored because a template ('${params.template}') was given. The task would otherwise have been rejected: ${suppliedTask.error}`,
3105
+ display: true,
3106
+ details: { template: params.template, rejectedTask: suppliedTask.error },
3107
+ }, { deliverAs: "followUp" });
3108
+ }
3109
+ }
3110
+ // Issue #175 review (fix direction item 4): this assignment is
3111
+ // UNCONDITIONAL — the resolved template task replaces any supplied
3112
+ // `task` (template-wins, issue #57); it is NOT a default the caller can
3113
+ // override. The fields below, by contrast, ARE defaults: an explicitly
3114
+ // provided param wins over the template's value.
2397
3115
  params.task = tv.task;
2398
3116
  if (tv.preset && !params.preset) params.preset = tv.preset;
2399
3117
  if (tv.thinkingLevel && !params.thinkingLevel) params.thinkingLevel = tv.thinkingLevel;
@@ -2405,6 +3123,53 @@ export default function (pi: ExtensionAPI) {
2405
3123
  if (tv.inheritSystemPrompt !== undefined && params.inheritSystemPrompt === undefined) params.inheritSystemPrompt = tv.inheritSystemPrompt;
2406
3124
  }
2407
3125
 
3126
+ // Handle retryRunId — MUST run after template resolution and before the
3127
+ // single-mode sanitize block. resolveRetryParams assigns params.task from
3128
+ // the recorded run, so the sanitizer must see the retried body (a
3129
+ // retry-only call has no task yet). It MUST run AFTER template resolution:
3130
+ // resolveRetryParams returns a fresh object that DROPS `template`, so
3131
+ // resolving templates first keeps a combined template + retryRunId call
3132
+ // from silently disabling template resolution (issue #175 review).
3133
+ if (params.retryRunId) {
3134
+ const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
3135
+ if (runEntry) {
3136
+ params = resolveRetryParams(params, runEntry);
3137
+ } else {
3138
+ // Issue #98: a silent no-op here made retries of background runs
3139
+ // (which previously never wrote run entries) start as FRESH runs
3140
+ // with no signal to the caller. Fail loudly instead.
3141
+ log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
3142
+ return {
3143
+ content: [{
3144
+ type: "text" as const,
3145
+ 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.`,
3146
+ }],
3147
+ details: undefined,
3148
+ isError: true,
3149
+ };
3150
+ }
3151
+ }
3152
+
3153
+ // F1: Sanitize task input — skip for chain/parallel/graph modes
3154
+ const hasChain = params.chain && params.chain.length > 0;
3155
+ const hasParallel = params.tasks && params.tasks.length > 0;
3156
+ const hasGraph = params.graph && params.graph.length > 0;
3157
+ if (!hasChain && !hasParallel && !hasGraph) {
3158
+ // task is optional per the schema; single mode requires it. When absent,
3159
+ // feed "" to the sanitizer — same rejection path ("Task must not be
3160
+ // empty.") as when task was a required field.
3161
+ const taskResult = sanitizeTask(params.task ?? "");
3162
+ if (!taskResult.ok) {
3163
+ log.warn("Task rejected by sanitizer", { error: taskResult.error });
3164
+ return {
3165
+ content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
3166
+ details: undefined,
3167
+ isError: true,
3168
+ };
3169
+ }
3170
+ params.task = taskResult.value;
3171
+ }
3172
+
2408
3173
  // P1+P2+P10: Mode detection — graph > chain > parallel > single
2409
3174
  const isChain = hasChain;
2410
3175
  const isParallel = hasParallel;
@@ -2426,6 +3191,24 @@ export default function (pi: ExtensionAPI) {
2426
3191
  };
2427
3192
  }
2428
3193
 
3194
+ // background: true with chain/graph was silently ignored — the batch ran
3195
+ // foreground (blocking until every unit finished) while the caller believed
3196
+ // it was background. Both remain rejected loudly at dispatch validation;
3197
+ // tasks (parallel) fans out into independent background agents instead.
3198
+ if (params.background && (isChain || isGraph)) {
3199
+ const batchMode = isChain ? "chain" : "graph";
3200
+ return {
3201
+ content: [
3202
+ {
3203
+ type: "text" as const,
3204
+ text: `background: true is not supported with ${batchMode} — batch modes run foreground and would block until every unit finishes. Remove background to run it foreground, or start each unit as its own single-task background call and sequence the dependencies yourself.`,
3205
+ },
3206
+ ],
3207
+ details: undefined,
3208
+ isError: true,
3209
+ };
3210
+ }
3211
+
2429
3212
  if (isChain) {
2430
3213
  if (params.chain!.length > MAX_CHAIN_STEPS) {
2431
3214
  return {
@@ -2455,6 +3238,7 @@ export default function (pi: ExtensionAPI) {
2455
3238
  isError: true,
2456
3239
  };
2457
3240
  }
3241
+ if (params.background) return runBackgroundFanOut(pi, ctx, params, signal);
2458
3242
  return runParallelMode(params, signal, onUpdate, ctx);
2459
3243
  }
2460
3244
 
@@ -2467,391 +3251,8 @@ export default function (pi: ExtensionAPI) {
2467
3251
  // boolean-array filter — assert, mirroring params.chain!/params.tasks! above.
2468
3252
  const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
2469
3253
 
2470
- // Phase 6.5: Background execution — spawn session and return ID immediately.
2471
- // Resolve the preset and its model BEFORE the background branch so the
2472
- // preset's model (and system prompt) are honored in background mode.
2473
- const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
2474
- const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
2475
- const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
2476
- const bgModel = bgModelResult.ok
2477
- ? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
2478
- : undefined;
2479
-
2480
3254
  if (params.background) {
2481
- const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
2482
- // Issue #31 (PR #76 review, DRY): extract final output from a
2483
- // possibly-released ref — the poller may observe a terminal agent
2484
- // whose ref was already nulled by the settlement path.
2485
- const extractAgentFinalOutput = (a: BackgroundAgent): string =>
2486
- extractFinalOutput(a._sessionRef ?? { messages: [] });
2487
-
2488
- try {
2489
- // W5 (issue #28): approvalMode 'always' cannot work in background —
2490
- // there is no interactive dialog to approve the diff. Reject loudly
2491
- // instead of silently running unattended with write access.
2492
- if (bgResolved.resolvedApprovalMode === 'always') {
2493
- return {
2494
- content: [{ type: "text" as const, text:
2495
- `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
2496
- `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
2497
- }],
2498
- details: undefined,
2499
- isError: true,
2500
- };
2501
- }
2502
- // W5: 'writes' in background silently auto-approves — warn once so the
2503
- // caller knows the diff will NOT be gated on approval.
2504
- if (bgResolved.resolvedApprovalMode === 'writes') {
2505
- log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
2506
- task: params.task,
2507
- });
2508
- }
2509
-
2510
- // W6 (issue #28): session cost limit must gate background spawns too —
2511
- // the R5 check after the background branch never runs for them.
2512
- // Same estimate/limit logic as single mode.
2513
- const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
2514
- ? state.config.perTaskCostEstimate
2515
- : 0.05;
2516
- if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
2517
- const bgLimit = state.config.sessionCostLimit;
2518
- const bgCurrentTotal = state.getSessionTotalCost(ctx);
2519
- log.warn("Background delegation rejected: session cost limit reached", {
2520
- currentTotal: bgCurrentTotal,
2521
- estimatedCost: bgPerTaskEstimate,
2522
- limit: bgLimit,
2523
- });
2524
- return {
2525
- content: [
2526
- {
2527
- type: "text" as const,
2528
- text:
2529
- `Cannot delegate: session cost limit reached ` +
2530
- `($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
2531
- `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
2532
- },
2533
- ],
2534
- details: undefined,
2535
- isError: true,
2536
- };
2537
- }
2538
-
2539
- // F1: Validate cwd + outputFile the same way foreground single mode does —
2540
- // an unvalidated outputFile would reach the prompt and could steer the
2541
- // background agent's write tool outside the project root.
2542
- const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
2543
- if (!bgCwdResult.ok) {
2544
- return {
2545
- content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
2546
- details: undefined,
2547
- isError: true,
2548
- };
2549
- }
2550
- let bgResolvedOutputFile: string | undefined;
2551
- if (bgResolved.outputFile) {
2552
- const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
2553
- if (!ofResult.ok) {
2554
- return {
2555
- content: [
2556
- { type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
2557
- ],
2558
- details: undefined,
2559
- isError: true,
2560
- };
2561
- }
2562
- bgResolvedOutputFile = ofResult.value;
2563
- }
2564
-
2565
- // C: H1 validation for background mode — reject outputFile-vs-write
2566
- // conflicts before spawning (loud failure, same as single mode).
2567
- const bgValidation = validatePreTask({
2568
- task: singleTask,
2569
- toolOptions: bgResolved.toolOptions,
2570
- thinkingLevel: bgResolved.thinkingLevel,
2571
- gitMode: bgResolved.resolvedGitMode,
2572
- outputFile: bgResolvedOutputFile,
2573
- });
2574
- if (bgValidation.warnings.length > 0) {
2575
- log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
2576
- }
2577
- if (!bgValidation.valid) {
2578
- const errText = bgValidation.errors.join("; ");
2579
- log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
2580
- return {
2581
- content: [{ type: "text" as const, text: errText }],
2582
- details: undefined,
2583
- isError: true,
2584
- };
2585
- }
2586
-
2587
- // Build the full prompt the same way foreground single mode does:
2588
- // base prompt (optionally inherited) + custom prompt + preset guidance.
2589
- const bgPrompt = buildSubagentPrompt(
2590
- ctx.getSystemPrompt(),
2591
- bgResolved.inheritSP,
2592
- bgResolved.customSP,
2593
- bgResolvedOutputFile,
2594
- bgResolved.toolOptions?.tools,
2595
- bgResolvedPreset?.promptGuideline,
2596
- );
2597
-
2598
- const agent = await spawnBackgroundSession(pi, ctx, {
2599
- task: singleTask,
2600
- type: params.preset || 'general-purpose',
2601
- description: params.label,
2602
- model: bgModel,
2603
- thinkingLevel: bgResolved.thinkingLevel,
2604
- // Issue #114: per-unit priority rides the spawn so the background
2605
- // run entry + drill-in header carry it like foreground runs.
2606
- priority: params.priority,
2607
- systemPrompt: bgPrompt,
2608
- cwd: bgCwdResult.value,
2609
- toolOptions: bgResolved.toolOptions,
2610
- timeout: bgResolved.timeout,
2611
- gitMode: bgResolved.resolvedGitMode,
2612
- // Issue #98: snapshot the caller's raw params on the run entry so a
2613
- // retry of this background run restores them — single source of truth
2614
- // snapshotOriginalParams (issue #108: no more duplicated 11-field literal).
2615
- originalParams: snapshotOriginalParams(params),
2616
- });
2617
-
2618
- // Register for live monitor
2619
- state.registerLiveSubagent(agent.id, {
2620
- id: agent.id,
2621
- label: agent.description,
2622
- task: agent.task,
2623
- model: agent.model,
2624
- thinkingLevel: agent.thinkingLevel,
2625
- priority: agent.priority,
2626
- startedAt: agent.startedAt,
2627
- ctx,
2628
- });
2629
-
2630
- // Update footer counters
2631
- state.activeSubagents++;
2632
- updateProgressStatus(state, ctx);
2633
-
2634
- let completed = false;
2635
-
2636
- // Poll for live progress
2637
- const pollInterval = setInterval(() => {
2638
- try {
2639
- if (completed) return;
2640
-
2641
- const session = agent._sessionRef;
2642
- // Issue #31: a nulled ref on a TERMINAL agent is expected (the
2643
- // settlement path releases the ref) — fall through to finalize
2644
- // below instead of treating it as a crash. A nulled ref on a
2645
- // live agent is still a crash.
2646
- if (!session && !agent.completedAt) {
2647
- // Session ref not available — session may have crashed.
2648
- // Defensive: the ref is assigned synchronously before the poller starts
2649
- // and is never nulled, so this is mostly unreachable — but keep the
2650
- // bookkeeping safe regardless.
2651
- completed = true;
2652
- clearInterval(pollInterval);
2653
- clearTimeout(hardCapHandle);
2654
- // Capture whatever output exists (may be empty — fine).
2655
- setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
2656
- // Gate the counter on the finalize claim: if the
2657
- // stale sweep already finalized this entry, the
2658
- // decrement happened there (PR #71 review).
2659
- if (state.finalizeLiveSubagent(agent.id)) {
2660
- state.activeSubagents--;
2661
- if (state.activeSubagents < 0) state.activeSubagents = 0;
2662
- }
2663
- state.failedSubagents++;
2664
- updateProgressStatus(state, ctx);
2665
- pi.sendMessage({
2666
- customType: "subagent-notification",
2667
- content: `Background agent "${agent.description}" crashed.`,
2668
- display: true,
2669
- details: { agentId: agent.id }
2670
- }, { deliverAs: "followUp" });
2671
- return;
2672
- }
2673
-
2674
- // While running, update the live monitor.
2675
- // Once completedAt is set, skip straight to finalize below.
2676
- // Issue #31: past the guard above, the only nulled-ref path left is a
2677
- // TERMINAL agent (settlement releases the ref), so a live agent always
2678
- // has its session — the guard already returned on the live-nulled-ref
2679
- // crash path. The compiler can't correlate the two checks, so assert it.
2680
- if (!agent.completedAt) {
2681
- const finalOutput = extractFinalOutput(session!);
2682
-
2683
- try {
2684
- const stats = session!.getSessionStats();
2685
- state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
2686
- } catch {
2687
- state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
2688
- }
2689
- }
2690
-
2691
- // Authoritative completion: the prompt promise settled — status and
2692
- // completedAt are set by .then()/.catch() in spawnBackgroundSession.
2693
- // Do NOT rely on !session.isStreaming: it is false during prompt()
2694
- // preflight (auth check, model resolution) and for failed starts.
2695
- if (agent.completedAt) {
2696
- completed = true;
2697
- clearInterval(pollInterval);
2698
- clearTimeout(hardCapHandle);
2699
-
2700
- // Issue #31: prefer the output already captured at settlement;
2701
- // the ref may already be nulled on terminal agents, so never
2702
- // overwrite with empty and stay null-safe.
2703
- const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
2704
- setAgentFinalOutput(agent.id, finalOutput);
2705
-
2706
- // Gate the counter on the finalize claim: if the
2707
- // stale sweep already finalized this entry, the
2708
- // decrement happened there (PR #71 review).
2709
- if (state.finalizeLiveSubagent(agent.id)) {
2710
- state.activeSubagents--;
2711
- if (state.activeSubagents < 0) state.activeSubagents = 0;
2712
- }
2713
-
2714
- if (agent.status === 'failed') {
2715
- state.failedSubagents++;
2716
- updateProgressStatus(state, ctx);
2717
- pi.sendMessage({
2718
- customType: "subagent-notification",
2719
- content: `Background agent "${agent.description}" failed.`,
2720
- display: true,
2721
- details: { agentId: agent.id }
2722
- }, { deliverAs: "followUp" });
2723
- } else if (agent.status === 'stopped') {
2724
- // User-initiated stop (stop_subagent) or deadline abort
2725
- // (timeout/hard cap) — not a failure.
2726
- updateProgressStatus(state, ctx);
2727
- } else {
2728
- state.completedSubagents++;
2729
- state.unseenSubagents++;
2730
- updateProgressStatus(state, ctx);
2731
- }
2732
- }
2733
- } catch (err) {
2734
- if (!completed) {
2735
- completed = true;
2736
- clearInterval(pollInterval);
2737
- clearTimeout(hardCapHandle);
2738
- try {
2739
- setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
2740
- } catch { /* ignore */ }
2741
- // Gate the counter on the finalize claim: if the
2742
- // stale sweep already finalized this entry, the
2743
- // decrement happened there (PR #71 review).
2744
- if (state.finalizeLiveSubagent(agent.id)) {
2745
- state.activeSubagents--;
2746
- if (state.activeSubagents < 0) state.activeSubagents = 0;
2747
- }
2748
- state.failedSubagents++;
2749
- updateProgressStatus(state, ctx);
2750
- pi.sendMessage({
2751
- customType: "subagent-notification",
2752
- content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, bgResolved.effectiveCwd)}`,
2753
- display: true,
2754
- details: { agentId: agent.id }
2755
- }, { deliverAs: "followUp" });
2756
- }
2757
- }
2758
- }, 2000);
2759
-
2760
- // Hard cap: stop polling AND abort the session after the deadline.
2761
- // W2 (issue #28): previously this only stopped the poller — the pi
2762
- // session kept running forever (orphaned). Now it pre-sets
2763
- // 'stopped' (so the .then keeps it, per W1) and aborts the
2764
- // session. The cap honors a shorter per-agent timeout.
2765
- const hardCapMs = Math.min(bgResolved.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
2766
- const hardCapHandle = setTimeout(() => {
2767
- if (!completed && !agent.completedAt) {
2768
- try {
2769
- completed = true;
2770
- clearInterval(pollInterval);
2771
- // Pre-set stopped BEFORE aborting so the .then in
2772
- // spawnBackgroundSession keeps the stopped state.
2773
- updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2774
- agent._sessionRef?.abort().catch(() => {
2775
- // Abort may reject if the session is mid-dispose;
2776
- // the status flip above is already recorded.
2777
- });
2778
- // Capture whatever final output exists in the session
2779
- const hardCapSession = agent._sessionRef;
2780
- const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
2781
- setAgentFinalOutput(agent.id, hardCapFinalOutput);
2782
- // Gate the counter on the finalize claim: if the
2783
- // stale sweep already finalized this entry, the
2784
- // decrement happened there (PR #71 review).
2785
- if (state.finalizeLiveSubagent(agent.id)) {
2786
- state.activeSubagents--;
2787
- if (state.activeSubagents < 0) state.activeSubagents = 0;
2788
- }
2789
- // m6: deadline abort is a stop, not a completion — mirror the
2790
- // W3/poller stopped path (no completedSubagents increment).
2791
- updateProgressStatus(state, ctx);
2792
- } catch (err) {
2793
- // Defensive: never let the hard-cap timer throw uncaught — that would
2794
- // skip finalizeLiveSubagent, the counter decrement, and the notification.
2795
- // Do all fallible work first (output capture), then mutate counters,
2796
- // then notify — a throw mid-path can't double-fire mutations.
2797
- completed = true;
2798
- clearInterval(pollInterval);
2799
- // m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
2800
- // persistAgent fs failure) — leave the record terminal so
2801
- // get_subagent_result doesn't report 'running' and the W3 timer
2802
- // guard (!agent.completedAt) can't re-fire later.
2803
- try {
2804
- updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
2805
- } catch { /* ignore */ }
2806
- try {
2807
- setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
2808
- } catch { /* ignore */ }
2809
- // Gate the counter on the finalize claim: if the
2810
- // stale sweep already finalized this entry, the
2811
- // decrement happened there (PR #71 review).
2812
- if (state.finalizeLiveSubagent(agent.id)) {
2813
- state.activeSubagents--;
2814
- if (state.activeSubagents < 0) state.activeSubagents = 0;
2815
- }
2816
- state.failedSubagents++;
2817
- updateProgressStatus(state, ctx);
2818
- }
2819
- }
2820
- }, hardCapMs);
2821
-
2822
- log.info("Background agent spawned", { agentId: agent.id, task: params.task, model: agent.model });
2823
-
2824
- // B2: Surface auto-route decisions in background spawn result too.
2825
- // Derive the restriction from the RESOLVED toolOptions (which reflects
2826
- // per-call overrides) rather than the preset's declared values.
2827
- const bgAutoRouteNote = bgAutoRoutedPreset
2828
- ? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
2829
- : "";
2830
-
2831
- return {
2832
- content: [{
2833
- type: "text" as const,
2834
- text: `Background agent started: ${agent.id}\n\n` +
2835
- `Description: ${agent.description}\n` +
2836
- `Task: ${agent.task}\n` +
2837
- `Status: ${agent.status}\n\n` +
2838
- `You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
2839
- bgAutoRouteNote,
2840
- }],
2841
- details: undefined,
2842
- };
2843
- } catch (err) {
2844
- const message = sanitizeErrorMessage(
2845
- err instanceof Error ? err.message : String(err),
2846
- bgResolved.effectiveCwd,
2847
- );
2848
- log.error("Failed to spawn background agent", { error: message });
2849
- return {
2850
- content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
2851
- details: undefined,
2852
- isError: true,
2853
- };
2854
- }
3255
+ return spawnBackgroundRun(pi, ctx, params, singleTask);
2855
3256
  }
2856
3257
 
2857
3258
  // R5: Check session cost limit before spawning
@@ -3681,6 +4082,9 @@ export default function (pi: ExtensionAPI) {
3681
4082
  pi.on("session_start", async (_event, ctx) => {
3682
4083
  // Issue #147: capture the session context for the completion-push subscriber.
3683
4084
  sessionCtx = ctx;
4085
+ // Issue #179 (D6): createLogger ran at module load with no cwd — point
4086
+ // the shared file sink at this session's cwd now that it exists.
4087
+ setLogCwd(ctx.cwd);
3684
4088
  // Load built-in presets
3685
4089
  const presetsDir = pkgPath("presets");
3686
4090
  state.builtinPresets = loadBuiltinPresets(presetsDir, log);
@@ -3787,7 +4191,10 @@ export default function (pi: ExtensionAPI) {
3787
4191
  const knob = state.config.completionNotify ?? "all";
3788
4192
  const run = resolveRunEntry(state.getRunEntries(ctx), id);
3789
4193
  const message = buildCompletionMessage(agent, run);
3790
- const delivery = resolveDelivery(normalizeCompletionStatus(agent.status), knob);
4194
+ // Issue #179 (D2): resolve delivery from the CLASSIFIED notification
4195
+ // status, not the raw agent status — otherwise a run whose completion
4196
+ // the builder downgraded to failed would still be delivered as a success.
4197
+ const delivery = resolveDelivery(message.details.status, knob);
3791
4198
  sendCompletionNotification(pi, message, delivery);
3792
4199
  } catch (err) {
3793
4200
  log.warn(`completion-push handler failed for ${id}`, {