brl-subagent 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
@@ -0,0 +1,1065 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { join } from 'path';
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';
6
+ import { accumulateUsage } from './runner';
7
+ import * as eventBus from './event-bus';
8
+ import * as transcript from './transcript';
9
+ import { createEvent } from './event-bus';
10
+ import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
11
+ import { wrapTask } from './prompt';
12
+ import { createLogger } from './logging';
13
+ import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, commitAll, captureWorkingDiff } from './git';
14
+
15
+ const log = createLogger('brl-subagent');
16
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
17
+
18
+ // Serialize concurrent spawn attempts — pi's API modules aren't safe for
19
+ // concurrent access from extensions.
20
+ var spawnQueue: Promise<void> = Promise.resolve();
21
+
22
+ // C2: per-repo locks serializing the FULL gitMode=branch lifecycle (setup →
23
+ // settle → teardown). Keyed by cwd because different repos don't conflict.
24
+ const gitBranchLocks = new Map<string, Promise<void>>();
25
+
26
+ /**
27
+ * TEST-ONLY: release every held git branch lock. The lock is normally held
28
+ * for the agent's lifetime (released at settle), but tests that never settle
29
+ * (hanging prompt mocks) would leak it into the next test.
30
+ */
31
+ export function __testResetGitBranchLocks(): void {
32
+ gitBranchLocks.clear();
33
+ }
34
+
35
+ // Robust UUID generation with fallback
36
+ function generateUUID(): string {
37
+ try {
38
+ return randomUUID();
39
+ } catch {
40
+ // Fallback for contexts where crypto is not available
41
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
42
+ const r = (Math.random() * 16) | 0;
43
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
44
+ return v.toString(16);
45
+ });
46
+ }
47
+ }
48
+
49
+ // In-memory store of background agents
50
+ const agents = new Map<string, BackgroundAgent>();
51
+
52
+ // Persistence directory — overridable for tests (issue #52): unit tests must
53
+ // NOT write into the real repo .pi/ dir; the setter is test-only.
54
+ let STORAGE_DIR = '.pi/subagents';
55
+
56
+ /**
57
+ * TEST-ONLY: point the agent-record storage at an alternate directory.
58
+ * The tests call this in beforeEach to isolate from the real repo .pi/.
59
+ */
60
+ export function __setStorageDir(dir: string): void {
61
+ STORAGE_DIR = dir;
62
+ }
63
+
64
+ /**
65
+ * Ensure storage directory exists
66
+ */
67
+ function ensureStorageDir(): void {
68
+ // F6 (issue #29): records hold the full task/result conversation — the dir
69
+ // must be owner-only (0o700) so other local users cannot list the files.
70
+ mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 });
71
+ }
72
+
73
+ /**
74
+ * Persist agent record to disk
75
+ */
76
+ function persistAgent(agent: BackgroundAgent): void {
77
+ // F24: agent.id may come from a disk-loaded record (loadAgent JSON.parse's
78
+ // the id field unvalidated). A planted record with a traversal id would
79
+ // make join() escape STORAGE_DIR — validate BEFORE building the write path.
80
+ try {
81
+ assertSafeAgentId(agent.id);
82
+ } catch {
83
+ console.error(`[brl-subagent] Refusing to persist agent with invalid id: ${agent.id}`);
84
+ return;
85
+ }
86
+ ensureStorageDir();
87
+ const filePath = join(STORAGE_DIR, `${agent.id}.json`);
88
+ // Strip live/non-serializable fields before stringify — `_sessionRef` holds the
89
+ // ENTIRE live pi session graph (1.4MB+ for an empty session, and uninitialized
90
+ // getters like Theme can make JSON.stringify throw).
91
+ const { _sessionRef, ...persistable } = agent;
92
+ try {
93
+ // F6 (issue #29): records contain task, error, result.messages (full
94
+ // conversation) and finalOutput — write owner-only (0o600) so other local
95
+ // users cannot read subagent tasks/output. mode applies on file CREATE;
96
+ // pre-existing files keep their old mode (not retroactive).
97
+ writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
98
+ } catch (err) {
99
+ // Log but never throw — persistence must not break execution
100
+ console.error(`[brl-subagent] Failed to persist agent ${agent.id}:`, err);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Load agent record from disk
106
+ */
107
+ function loadAgent(id: string): BackgroundAgent | null {
108
+ const filePath = join(STORAGE_DIR, `${id}.json`);
109
+ if (!existsSync(filePath)) return null;
110
+ try {
111
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Create a new background agent session
119
+ *
120
+ * NOTE: In v2.0.3, this creates a record but does NOT actually spawn a pi session.
121
+ * The actual session spawning will be implemented when pi's ExtensionAPI supports it.
122
+ * For now, this is a placeholder that creates the agent record.
123
+ */
124
+ export function createSession(params: {
125
+ task: string;
126
+ type?: string;
127
+ description?: string;
128
+ model?: string;
129
+ thinkingLevel?: ThinkingLevel;
130
+ systemPrompt?: string;
131
+ }): BackgroundAgent {
132
+ const id = generateUUID();
133
+ const agent: BackgroundAgent = {
134
+ id,
135
+ sessionId: `session-${id}`,
136
+ type: params.type || 'general-purpose',
137
+ description: params.description || params.task.slice(0, 50),
138
+ status: 'pending',
139
+ startedAt: Date.now(),
140
+ task: params.task,
141
+ model: params.model || 'unknown',
142
+ thinkingLevel: params.thinkingLevel || 'medium',
143
+ };
144
+
145
+ agents.set(id, agent);
146
+ persistAgent(agent);
147
+
148
+ // Start transcript for this agent
149
+ transcript.startTranscript(agent.id, params.task);
150
+
151
+ eventBus.emit(eventBus.createEvent('subagent:created', agent.id, {
152
+ type: agent.type,
153
+ description: agent.description,
154
+ task: agent.task,
155
+ }));
156
+
157
+ return agent;
158
+ }
159
+
160
+ /**
161
+ * Get agent record by ID
162
+ */
163
+ export function getAgent(id: string): BackgroundAgent | null {
164
+ // F24: id may arrive from LLM tool params (get_agent_status, steer_subagent,
165
+ // get_agent_result). Validate BEFORE any map/fs access — path.join does not
166
+ // sanitize "../" or absolute segments, so a traversal id like "../../etc/foo"
167
+ // would otherwise read arbitrary .json files via loadAgent(). Invalid ids
168
+ // surface as "agent not found" to tool callers.
169
+ try {
170
+ assertSafeAgentId(id);
171
+ } catch {
172
+ return null;
173
+ }
174
+ return agents.get(id) || loadAgent(id);
175
+ }
176
+
177
+ /**
178
+ * List all background agents
179
+ */
180
+ export function listAgents(): BackgroundAgent[] {
181
+ ensureStorageDir();
182
+ const files = readdirSync(STORAGE_DIR).filter(f => f.endsWith('.json'));
183
+ const result: BackgroundAgent[] = [];
184
+
185
+ for (const file of files) {
186
+ const id = file.replace('.json', '');
187
+ const agent = agents.get(id) || loadAgent(id);
188
+ if (agent) result.push(agent);
189
+ }
190
+
191
+ return result.sort((a, b) => b.startedAt - a.startedAt);
192
+ }
193
+
194
+ /**
195
+ * Update agent status
196
+ */
197
+ export function updateAgentStatus(id: string, status: AgentStatus, error?: string): BackgroundAgent | null {
198
+ const agent = getAgent(id);
199
+ if (!agent) return null;
200
+
201
+ agent.status = status;
202
+ if (status === 'completed' || status === 'failed' || status === 'stopped') {
203
+ agent.completedAt = Date.now();
204
+ }
205
+ if (error) agent.error = error;
206
+
207
+ agents.set(id, agent);
208
+ persistAgent(agent);
209
+
210
+ if (status === 'running') {
211
+ eventBus.emit(eventBus.createEvent('subagent:started', id, {}));
212
+ } else if (status === 'completed') {
213
+ eventBus.emit(eventBus.createEvent('subagent:completed', id, { error }));
214
+ } else if (status === 'failed') {
215
+ eventBus.emit(eventBus.createEvent('subagent:failed', id, { error }));
216
+ } else if (status === 'stopped') {
217
+ eventBus.emit(eventBus.createEvent('subagent:stopped', id, {}));
218
+ } else if (status === 'steered') {
219
+ eventBus.emit(eventBus.createEvent('subagent:steered', id, {}));
220
+ }
221
+
222
+ return agent;
223
+ }
224
+
225
+ /**
226
+ * Set agent result
227
+ */
228
+ export function setAgentResult(id: string, result: SubagentResult): BackgroundAgent | null {
229
+ const agent = getAgent(id);
230
+ if (!agent) return null;
231
+
232
+ agent.result = result;
233
+ agent.status = result.exitCode === 0 ? 'completed' : 'failed';
234
+ agent.completedAt = Date.now();
235
+
236
+ agents.set(id, agent);
237
+ persistAgent(agent);
238
+ return agent;
239
+ }
240
+
241
+ /**
242
+ * Extract the final assistant text from a session's messages.
243
+ * Falls back to the LAST assistant message with non-empty text content — the
244
+ * final message is often a tool-call-only turn (agent stopped mid-turn, failed,
245
+ * or hit the hard cap), and that turn carries no text of its own.
246
+ */
247
+ export function extractFinalOutput(session: { messages: Array<{ role: string; content?: Array<{ type: string; text?: string }> | string | null }> }): string {
248
+ const assistants = [...session.messages].reverse().filter(m => m.role === 'assistant');
249
+ for (const msg of assistants) {
250
+ const content = msg.content;
251
+ let text = '';
252
+ if (Array.isArray(content)) {
253
+ text = content.filter(c => c.type === 'text').map(c => c.text ?? '').join('');
254
+ } else if (typeof content === 'string') {
255
+ text = content;
256
+ }
257
+ if (text.trim()) return text;
258
+ }
259
+ return '';
260
+ }
261
+
262
+ /**
263
+ * Set the final assistant output captured from the agent's session
264
+ */
265
+ export function setAgentFinalOutput(id: string, output: string): BackgroundAgent | null {
266
+ const agent = getAgent(id);
267
+ if (!agent) return null;
268
+
269
+ agent.finalOutput = output;
270
+
271
+ agents.set(id, agent);
272
+ persistAgent(agent);
273
+ return agent;
274
+ }
275
+
276
+ /**
277
+ * Stop a running agent — REAL abort (PR #28 W1).
278
+ *
279
+ * Probe contract (sdk-abort-contract.test.ts): session.prompt() RESOLVES on
280
+ * abort — the SDK's runWithLifecycle catches the AbortError and converts it
281
+ * into a failure message with stopReason "aborted". So:
282
+ * 1. set status 'stopped' FIRST (the .then in spawnBackgroundSession will
283
+ * fire after abort settles and must not overwrite it), then
284
+ * 2. await session.abort() (waits for the agent to become idle), then
285
+ * 3. complete the transcript.
286
+ */
287
+ export async function stopAgent(id: string): Promise<BackgroundAgent | null> {
288
+ const agent = getAgent(id);
289
+ if (!agent) return null;
290
+ // Nothing to stop — already terminal.
291
+ if (agent.status === 'completed' || agent.status === 'failed' || agent.status === 'stopped') {
292
+ return agent;
293
+ }
294
+
295
+ // Mark stopped BEFORE aborting so the spawnBackgroundSession .then handler
296
+ // (which fires after the run settles) keeps the stopped state.
297
+ updateAgentStatus(id, 'stopped');
298
+
299
+ const session = agent._sessionRef;
300
+ if (session) {
301
+ try {
302
+ await session.abort();
303
+ } catch (err) {
304
+ // Abort can throw if the session is mid-dispose; the status flip above
305
+ // is already recorded, so this is non-fatal.
306
+ log.warn(`stopAgent: abort failed for ${id}`, { error: (err as Error).message });
307
+ }
308
+ }
309
+
310
+ transcript.completeTranscript(id, 'stopped');
311
+ return agent;
312
+ }
313
+
314
+ /**
315
+ * Steer a running agent by injecting a message
316
+ *
317
+ * NOTE: In v2.0.3, this is a placeholder.
318
+ * Actual message injection will be implemented when pi's ExtensionAPI supports it.
319
+ */
320
+ export function steerAgent(id: string, message: string): BackgroundAgent | null {
321
+ const agent = getAgent(id);
322
+ if (!agent) return null;
323
+ if (agent.status !== 'running') {
324
+ throw new Error(`Cannot steer agent ${id}: status is ${agent.status}, not running`);
325
+ }
326
+
327
+ // Record steering in transcript
328
+ transcript.appendEntry(id, 'user', `Steering: ${message}`);
329
+
330
+ agent.status = 'steered';
331
+ agents.set(id, agent);
332
+ persistAgent(agent);
333
+
334
+ eventBus.emit(eventBus.createEvent('subagent:steered', agent.id, { message }));
335
+
336
+ return agent;
337
+ }
338
+
339
+ /**
340
+ * Get transcript path for an agent
341
+ */
342
+ export function getTranscriptPath(id: string): string {
343
+ // F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
344
+ // ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
345
+ // already validated before this is reached with a live agent.)
346
+ assertSafeAgentId(id);
347
+ return join('.pi', 'output', `agent-${id}.jsonl`);
348
+ }
349
+
350
+ /**
351
+ * Spawn a background session using pi's session API
352
+ *
353
+ * This creates a real pi session that runs independently.
354
+ * The session can be polled later with getAgent() or steered with steerAgent().
355
+ */
356
+ export async function spawnBackgroundSession(
357
+ pi: ExtensionAPI,
358
+ ctx: ExtensionContext,
359
+ params: {
360
+ task: string;
361
+ type?: string;
362
+ description?: string;
363
+ model?: string;
364
+ thinkingLevel?: ThinkingLevel;
365
+ systemPrompt?: string;
366
+ cwd?: string;
367
+ toolOptions?: SubagentToolOptions;
368
+ /** Per-agent deadline in ms — the session is aborted when exceeded (issue #28 W3). */
369
+ timeout?: number;
370
+ /** Git isolation for the background run — "branch" creates a work branch (issue #28 W4). */
371
+ gitMode?: GitMode;
372
+ /** Issue #98: snapshot of the caller's raw params — lets retryRunId restore
373
+ * the original model/thinkingLevel/preset for background runs (mirrors the
374
+ * foreground run-record creation in index.ts). */
375
+ originalParams?: SubagentRun["originalParams"];
376
+ /** Issue #114: per-unit priority — recorded on the run entry + agent record
377
+ * so the drill-in header and history carry it (absent → no segment). */
378
+ priority?: string;
379
+ }
380
+ ): Promise<BackgroundAgent> {
381
+ // Serialize access to pi API to prevent concurrent import races
382
+ const prev = spawnQueue;
383
+ let resolveNext!: () => void;
384
+ spawnQueue = new Promise<void>(r => { resolveNext = r; });
385
+ await prev;
386
+
387
+ try {
388
+ const id = generateUUID();
389
+ const effectiveCwd = params.cwd ?? ctx.cwd;
390
+ // Dynamic import — static import fails under concurrent jiti loads
391
+ const { getAgentDir, createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader } = await import('@earendil-works/pi-coding-agent');
392
+ const agentDir = getAgentDir();
393
+
394
+ // Resolve the model STRING to a real Model object — createAgentSession expects
395
+ // Model<any>, not "provider/id". Passing a string makes provider resolution
396
+ // yield undefined ("No API key found for undefined") and clamps thinking to "off".
397
+ // Falls back to undefined → SDK's findInitialModel picks the settings default.
398
+ let resolvedModel;
399
+ if (params.model) {
400
+ const slashIdx = params.model.indexOf('/');
401
+ if (slashIdx > 0) {
402
+ const provider = params.model.slice(0, slashIdx);
403
+ const modelId = params.model.slice(slashIdx + 1);
404
+ resolvedModel = ctx.modelRegistry.find(provider, modelId);
405
+ }
406
+ }
407
+
408
+ // Create session manager for this background agent
409
+ const sessionManager = SessionManager.inMemory(effectiveCwd);
410
+ const settingsManager = SettingsManager.create(effectiveCwd);
411
+
412
+ // E20: Inject the subagent prompt into the session's system prompt.
413
+ // The SDK has no systemPrompt option on createAgentSession — the sanctioned
414
+ // injection point is the resource loader's appendSystemPrompt (the same
415
+ // mechanism pi uses for --append-system-prompt).
416
+ //
417
+ // F26: resolvePromptInput (resource-loader.js:16) checks existsSync(input)
418
+ // and readFileSync's the string if it matches a real path — a raw prompt
419
+ // value like ".env" or "package.json" would be silently REPLACED by that
420
+ // file's contents (arbitrary-file-read-into-prompt).
421
+ //
422
+ // The defense is NOT "newlines are invalid in filenames" (false on POSIX —
423
+ // filenames can contain \n). The actual mechanism:
424
+ // 1. The "/" in "</system-prompt>" forces a multi-component RELATIVE path
425
+ // (never absolute — leading \n guarantees no "/" prefix), so the
426
+ // wrapped value can never equal a single-component name like ".env".
427
+ // 2. buildSubagentPrompt ALWAYS appends SUBAGENT_INSTRUCTIONS (measured
428
+ // 1644 bytes, no "/"), so one contiguous path component exceeds
429
+ // NAME_MAX=255 on mainstream filesystems → the path can never be
430
+ // created → existsSync is always false.
431
+ // 3. Windows: \n, <, > are illegal in filenames — impossible outright.
432
+ //
433
+ // Residual (theoretical): a filesystem with component limit > ~1663 bytes,
434
+ // cwd write access, AND byte-exact prediction of the full prompt could
435
+ // plant a newline-named dir + symlink leaf. Not exploitable realistically;
436
+ // do not "simplify" the frame (e.g. dropping the "/" marker) without
437
+ // re-verifying this property.
438
+ const literalPrompt = params.systemPrompt?.trim()
439
+ ? `\n<system-prompt>\n${params.systemPrompt}\n</system-prompt>\n`
440
+ : undefined;
441
+ //
442
+ // F25: ALWAYS build our own loader (even without a systemPrompt). If
443
+ // resourceLoader is undefined, createAgentSession constructs its own
444
+ // DefaultResourceLoader({ cwd, agentDir, settingsManager }) and reload()s it
445
+ // — importing extension/skill code from the LLM-controlled target cwd into
446
+ // THIS process (RCE; background mode has no trust prompt).
447
+ //
448
+ // noExtensions + noSkills are BLANKET (they also disable user-global
449
+ // extensions/skills from agentDir, e.g. ~/.pi/agent/skills). This is a
450
+ // deliberate security choice: background sessions have no trust prompt, so
451
+ // nothing is imported from anywhere — the prompt is fully specified by the
452
+ // caller. Users needing skills/extensions should use foreground delegation.
453
+ const loader = new DefaultResourceLoader({
454
+ cwd: effectiveCwd,
455
+ agentDir,
456
+ settingsManager,
457
+ ...(literalPrompt ? { appendSystemPrompt: [literalPrompt] } : {}),
458
+ noExtensions: true,
459
+ noSkills: true,
460
+ });
461
+ await loader.reload();
462
+ const resourceLoader: InstanceType<typeof DefaultResourceLoader> = loader;
463
+
464
+ // Create the session — honor the resolved tool restrictions so the
465
+ // background agent's ACTUAL toolset matches what the prompt tells it
466
+ // (and what H1 validation checked). Pre-PR this was a hardcoded full
467
+ // toolset, so an agent told "read-only" by the prompt still had write.
468
+ const bgTools = params.toolOptions?.tools;
469
+ const bgExcludeTools = params.toolOptions?.excludeTools;
470
+ const { session } = await createAgentSession({
471
+ cwd: effectiveCwd,
472
+ agentDir,
473
+ sessionManager,
474
+ settingsManager,
475
+ // NOTE: modelRegistry is NOT part of CreateAgentSessionOptions (pi 0.84
476
+ // SDK surface) and the runtime never reads it — the model is passed
477
+ // explicitly via `model: resolvedModel` above, so this option was dead.
478
+ resourceLoader,
479
+ model: resolvedModel,
480
+ thinkingLevel: params.thinkingLevel,
481
+ tools: bgTools ?? ['read', 'bash', 'grep', 'find', 'ls', 'write', 'edit'],
482
+ ...(bgExcludeTools ? { excludeTools: bgExcludeTools } : {}),
483
+ ...(params.toolOptions?.noBuiltinTools ? { noTools: 'builtin' as const } : {}),
484
+ });
485
+
486
+ // Set session name
487
+ session.setSessionName(`background-${id.slice(0, 8)}`);
488
+
489
+ // Create agent record
490
+ const agent: BackgroundAgent = {
491
+ id,
492
+ sessionId: session.sessionId ?? id,
493
+ type: params.type || 'general-purpose',
494
+ description: params.description || params.task.slice(0, 50),
495
+ status: 'running',
496
+ startedAt: Date.now(),
497
+ task: params.task,
498
+ model: params.model || 'unknown',
499
+ thinkingLevel: params.thinkingLevel || 'medium',
500
+ priority: params.priority,
501
+ };
502
+
503
+ agents.set(id, agent);
504
+ persistAgent(agent);
505
+
506
+ // Issue #98: background runs must be retry-able — persist a session run
507
+ // entry at spawn (id == agent id, so retryRunId: <agent-id> resolves via
508
+ // state.findRunById → getRunEntries). The entry lives in the same session
509
+ // custom-entry store the foreground path writes to (state.persistRun); the
510
+ // background path previously never wrote there, so retries silently no-oped.
511
+ const startedAtMs = agent.startedAt; // reuse the agent record's start clock
512
+ const run: SubagentRun = {
513
+ id, // SAME as agent.id — the caller holds this id
514
+ task: params.task,
515
+ description: params.description,
516
+ // Issue #98 (review F3): history/reports consumers read run.label —
517
+ // the background label (params.description) must land there too, not
518
+ // only in description.
519
+ label: params.description,
520
+ status: "running",
521
+ model: params.model || "unknown",
522
+ thinkingLevel: params.thinkingLevel || "medium",
523
+ priority: params.priority,
524
+ startedAt: new Date().toISOString(),
525
+ originalParams: params.originalParams,
526
+ };
527
+ pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
528
+
529
+ // Issue #98: keep the session run entry in lockstep with the agent record —
530
+ // every terminal branch that flips agent.status also finalizes the run entry
531
+ // ("done"/"failed"). The guard keeps the entry single-finalized even when a
532
+ // settle handler throws after finalizing (markTerminalBestEffort catch-all).
533
+ let runFinalized = false;
534
+ const finalizeRunEntry = (status: "done" | "failed", error?: string): void => {
535
+ if (runFinalized) return;
536
+ runFinalized = true;
537
+ const usage = agent.result?.usage ?? EMPTY_USAGE;
538
+ 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.
544
+ const errorCategory =
545
+ status === "failed" && error
546
+ ? classifyError({
547
+ messages: [],
548
+ usage: { ...EMPTY_USAGE },
549
+ exitCode: 1,
550
+ stderr: "",
551
+ errorMessage: error,
552
+ })
553
+ : undefined;
554
+ const entry: SubagentRun = {
555
+ ...run,
556
+ status,
557
+ errorMessage: error,
558
+ finishedAt: new Date().toISOString(),
559
+ durationMs: Date.now() - startedAtMs,
560
+ // Issue #122: audit fields — usage and final output are already
561
+ // captured on the agent record before the terminal finalize runs.
562
+ cost: usage.cost,
563
+ tokensIn: usage.input,
564
+ tokensOut: usage.output,
565
+ outputSummary: finalOutput.slice(0, 200),
566
+ fullOutput: finalOutput || undefined,
567
+ originalParams:
568
+ errorCategory
569
+ ? { ...(run.originalParams ?? {}), errorCategory }
570
+ : run.originalParams,
571
+ };
572
+ pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
573
+ };
574
+
575
+ // Start transcript for this agent
576
+ transcript.startTranscript(agent.id, params.task);
577
+
578
+ // Store session ref for live monitor polling
579
+ agent._sessionRef = session;
580
+
581
+ // Issue #31 (PR #76 review, DRY): every terminal branch must capture the
582
+ // final output while the session is still live, THEN release the ref — the
583
+ // capture must always precede the release or the output dies with the
584
+ // session graph. Single helper for all four settlement paths.
585
+ const captureAndReleaseSession = (): void => {
586
+ setAgentFinalOutput(id, extractFinalOutput(session));
587
+ agent._sessionRef = undefined;
588
+ };
589
+
590
+ // Issue #122: the session's REAL usage lives on the assistant messages in
591
+ // session.messages — each turn carries the same usage shape accumulateUsage
592
+ // folds (the runner's single source of truth for usage semantics: turns++,
593
+ // sum input/output/cache, cost.total, contextTokens from totalTokens). Fold
594
+ // the session's assistant turns into a fresh stats object and MERGE it onto
595
+ // the agent's result record — never replace: the git-diff branches may have
596
+ // already set exitCode/messages/stderr/gitBranch/gitDiff, and the aborted/
597
+ // failed branches previously OVERWROTE usage with the empty fallback. Runs
598
+ // on every terminal path right before the finalize; must never throw
599
+ // (best-effort, same discipline as markTerminalBestEffort).
600
+ const recordSessionUsage = (): void => {
601
+ try {
602
+ const usage: UsageStats = { ...EMPTY_USAGE };
603
+ for (const msg of session.messages ?? []) {
604
+ if (msg.role === 'assistant') {
605
+ accumulateUsage(usage, msg.usage as Parameters<typeof accumulateUsage>[1]);
606
+ }
607
+ }
608
+ agent.result = {
609
+ // Issue #122: seed the FULL required SubagentResult shape (messages /
610
+ // exitCode / stderr) BEFORE the existing record is spread — the seed
611
+ // only fills fields the record doesn't already have, so real values
612
+ // from the git-diff branches (exitCode 1 on aborted/failed, 0 on
613
+ // completed, plus messages/stderr) always win. A seed AFTER the spread
614
+ // would clobber a real exitCode: 1 back to 0. usage is folded below
615
+ // and merged last so the REAL session usage replaces the branches'
616
+ // EMPTY_USAGE placeholder.
617
+ messages: [],
618
+ exitCode: 0,
619
+ stderr: '',
620
+ ...(agent.result ?? {}),
621
+ usage,
622
+ } as SubagentResult;
623
+ } catch {
624
+ // Usage extraction is best-effort — it must never block the terminal
625
+ // flip, the finalize, or the ref release.
626
+ }
627
+ };
628
+
629
+ // W4 (issue #28): git isolation — create a work branch BEFORE the prompt so
630
+ // the background agent's file writes land on the branch, never on the
631
+ // working tree's base branch. There is no interactive approval in background
632
+ // mode, so on completion the diff is captured and the branch is DISCARDED
633
+ // (the diff is recorded on the agent for review); on failure/abort the
634
+ // branch is discarded the same way. A branch that cannot be created is a
635
+ // hard failure — never spawn an unisolated background agent that was asked
636
+ // for isolation (fail-loud, same principle as foreground's fallback but
637
+ // background cannot warn-and-continue safely).
638
+ let originalBranch: string | undefined;
639
+ let workBranchName: string | undefined;
640
+ let releaseGitLock: (() => void) | undefined;
641
+ const gitCwd = params.cwd ?? ctx.cwd;
642
+ if (params.gitMode === 'branch') {
643
+ // C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
644
+ // covers setup; without this lock, a second concurrent branch-mode spawn
645
+ // would read the first's work branch as its base and both teardowns would
646
+ // fight over the shared working tree (stranding the repo on an orphan
647
+ // branch). The lock is released in cleanupWorkBranch (or on setup throw).
648
+ const prev = gitBranchLocks.get(gitCwd) ?? Promise.resolve();
649
+ let release!: () => void;
650
+ const gate = new Promise<void>(r => { release = r; });
651
+ const entry = prev.then(() => gate);
652
+ gitBranchLocks.set(gitCwd, entry);
653
+ await prev;
654
+ releaseGitLock = () => {
655
+ // Delete the map entry ONLY if we are still the head of the chain — a
656
+ // queued spawn behind us replaced the entry with its own.
657
+ if (gitBranchLocks.get(gitCwd) === entry) gitBranchLocks.delete(gitCwd);
658
+ release();
659
+ };
660
+
661
+ try {
662
+ // C1: isolation is only real when the base tree is clean. A dirty tree
663
+ // would ride onto the work branch, get clobbered by the agent's writes,
664
+ // and leak back into the base working tree on switch — refuse loudly.
665
+ if (hasUncommittedChanges(gitCwd)) {
666
+ throw new Error(
667
+ 'working tree has uncommitted changes; commit or stash before ' +
668
+ 'requesting background gitMode=branch isolation'
669
+ );
670
+ }
671
+ originalBranch = getCurrentBranch(gitCwd);
672
+ const branchResult = createWorkBranch(gitCwd, originalBranch);
673
+ if (branchResult.ok) {
674
+ workBranchName = branchResult.branch;
675
+ log.info('Created work branch for background agent', {
676
+ branch: workBranchName,
677
+ base: originalBranch,
678
+ agentId: id,
679
+ });
680
+ } else {
681
+ throw new Error(`createWorkBranch failed: ${branchResult.error}`);
682
+ }
683
+ } catch (err) {
684
+ // The agent record was already persisted as 'running' above — flip it
685
+ // to 'failed' so no zombie 'running' record survives the refused spawn
686
+ // (get_subagent_result would otherwise report 'running' forever).
687
+ releaseGitLock?.();
688
+ releaseGitLock = undefined;
689
+ // Sanitize ONCE and reuse for the status update and the thrown error.
690
+ const sanitizedError = sanitizeErrorMessage((err as Error).message, effectiveCwd);
691
+ try {
692
+ updateAgentStatus(id, 'failed',
693
+ `gitMode 'branch' requested but work branch setup failed: ${sanitizedError}`);
694
+ // Issue #98: the refused spawn is a failure — finalize the run entry so
695
+ // no zombie 'running' entry survives (lockstep with the record flip).
696
+ finalizeRunEntry('failed',
697
+ `gitMode 'branch' requested but work branch setup failed: ${sanitizedError}`);
698
+ transcript.completeTranscript(id, 'failed');
699
+ } catch { /* ignore */ }
700
+ throw new Error(
701
+ `gitMode 'branch' requested but work branch setup failed: ${sanitizedError}. ` +
702
+ `Refusing to spawn the background agent unisolated.`,
703
+ { cause: err }
704
+ );
705
+ }
706
+ }
707
+
708
+ // Branch teardown — commit the agent's work (so the diff is real), capture
709
+ // the diff, switch back to the original branch, delete the work branch, and
710
+ // release the per-repo git lock. Runs on EVERY settle path. C1: agents
711
+ // rarely commit, so captureDiff(base...HEAD) alone would be empty and the
712
+ // uncommitted edits would leak into the base working tree on switch —
713
+ // commitAll first makes the diff real and the switch clean.
714
+ const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
715
+ if (!workBranchName || !originalBranch) return {};
716
+ const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
717
+ // The lock MUST be released on every exit path — early returns included.
718
+ try {
719
+ try {
720
+ const stillOnOurBranch = getCurrentBranch(gitCwd) === workBranchName;
721
+ if (!stillOnOurBranch) {
722
+ log.warn(`cleanupWorkBranch: tree moved off ${workBranchName} — skipping switch/delete`, {
723
+ agentId: id,
724
+ });
725
+ return info;
726
+ }
727
+ } catch (err) {
728
+ log.warn(`getCurrentBranch failed during cleanup for background agent ${id}`, {
729
+ error: (err as Error).message,
730
+ });
731
+ return info;
732
+ }
733
+ try {
734
+ // C1: commit the agent's work so captureDiff sees it. If no identity is
735
+ // configured, fall back to capturing the working-tree diff directly.
736
+ const dirty = hasUncommittedChanges(gitCwd);
737
+ if (dirty) {
738
+ const commitResult = commitAll(gitCwd, `brl-subagent ${id}: background work`);
739
+ if (commitResult.ok) {
740
+ log.info('Committed background agent work on work branch', {
741
+ sha: commitResult.sha,
742
+ branch: workBranchName,
743
+ agentId: id,
744
+ });
745
+ } else {
746
+ log.warn(`commitAll failed for background agent ${id} — capturing working diff`, {
747
+ error: commitResult.error,
748
+ });
749
+ const workingDiff = captureWorkingDiff(gitCwd);
750
+ if (workingDiff) info.gitDiff = workingDiff;
751
+ }
752
+ }
753
+ const diffResult = captureDiff(gitCwd, originalBranch);
754
+ if (diffResult.ok && diffResult.diff.trim()) {
755
+ // Merge committed + working diffs — either can exist alone.
756
+ info.gitDiff = info.gitDiff
757
+ ? `${info.gitDiff}\n${diffResult.diff}`
758
+ : diffResult.diff;
759
+ }
760
+ } catch (err) {
761
+ log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
762
+ }
763
+ try {
764
+ switchToBranch(gitCwd, originalBranch);
765
+ } catch (err) {
766
+ log.warn(`switchToBranch failed for background agent ${id}`, { error: (err as Error).message });
767
+ }
768
+ try {
769
+ deleteBranch(gitCwd, workBranchName);
770
+ } catch (err) {
771
+ log.warn(`deleteBranch failed for background agent ${id}`, { error: (err as Error).message });
772
+ }
773
+ } finally {
774
+ releaseGitLock?.();
775
+ releaseGitLock = undefined;
776
+ }
777
+ return info;
778
+ };
779
+
780
+ // Issue #53: the settle handlers (.then/.catch below) run in promise
781
+ // callbacks — a throw from any step (fs failure, deleted transcript, emit
782
+ // error) would escape as an uncaughtException and kill the whole pi
783
+ // process. If a handler step throws, log it and best-effort flip the record
784
+ // terminal so no zombie 'running' record survives; never rethrow.
785
+ const markTerminalBestEffort = (fallback: AgentStatus): void => {
786
+ try {
787
+ // Issue #31 (PR #76 review): this catch-all fires when a settle handler
788
+ // threw — either BEFORE the branch's own capture (ref still live: capture
789
+ // now, it is the last chance to record the session's output) or AFTER it
790
+ // (finalOutput already recorded: never clobber it with a re-capture of a
791
+ // released ref, which would yield ''). Guard on finalOutput to cover both.
792
+ if (agent.finalOutput === undefined) {
793
+ try {
794
+ setAgentFinalOutput(id, extractFinalOutput(agent._sessionRef ?? { messages: [] }));
795
+ } catch {
796
+ // Output capture is best-effort — it must never block the terminal
797
+ // flip or the ref release below.
798
+ }
799
+ }
800
+ if (agent.status !== 'completed' && agent.status !== 'failed' && agent.status !== 'stopped') {
801
+ agent.status = fallback;
802
+ }
803
+ if (!agent.completedAt) agent.completedAt = Date.now();
804
+ // Issue #122: merge the session's real usage even on the catch-all — it
805
+ // fires when a settle handler threw BEFORE the branch's own extraction,
806
+ // so this is the last chance to record the tokens the run burned.
807
+ recordSessionUsage();
808
+ agents.set(id, agent);
809
+ persistAgent(agent);
810
+ // Issue #98 (review F2): derive the run-entry status from the agent
811
+ // record's terminal state — the catch-all fires on ANY settle-handler
812
+ // throw, so hardcoding 'failed' would mislabel a run whose completion
813
+ // branch already flipped the agent to 'completed'.
814
+ // Issue #120 (Review round): the catch-all fires when a settle handler
815
+ // threw — possibly BEFORE the branch's own stamp. A user-cancel whose
816
+ // abort branch never reached its stamped finalize would otherwise record
817
+ // an EMPTY errorMessage and NO errorCategory. Mirror the stamped paths
818
+ // with the honest user-abort fallback so no path is left without a
819
+ // category.
820
+ finalizeRunEntry(
821
+ agent.status === 'completed' ? 'done' : 'failed',
822
+ agent.error ?? SUBAGENT_ABORTED_MESSAGE,
823
+ );
824
+ // Issue #31: the live session ref must not survive terminal paths —
825
+ // including this catch-all, which fires when a settle handler throws.
826
+ agent._sessionRef = undefined;
827
+ } catch {
828
+ // The record may be beyond saving — the process must survive.
829
+ }
830
+ };
831
+
832
+ // Emit created event
833
+ eventBus.emit(eventBus.createEvent('subagent:created', id, {
834
+ type: agent.type,
835
+ description: agent.description,
836
+ task: agent.task,
837
+ }));
838
+
839
+ // Start the session in the background (don't await)
840
+ // The session will run independently
841
+ // F27: wrap in the task-as-data fence — the user message is DATA, not
842
+ // instructions (see SUBAGENT_INSTRUCTIONS Task Boundary).
843
+ // NOTE: if prompt() throws SYNCHRONOUSLY, the .then/.catch below never
844
+ // attach and cleanupWorkBranch would never run — catch here so the work
845
+ // branch is not orphaned (it is a real filesystem branch).
846
+ let runPromise: Promise<unknown>;
847
+ try {
848
+ runPromise = session.prompt(wrapTask(params.task));
849
+ } catch (err) {
850
+ cleanupWorkBranch();
851
+ // Issue #98 (review F1): a synchronous prompt() throw never reaches the
852
+ // settle handlers — finalize the run entry so no zombie 'running' entry
853
+ // survives (findRunById would otherwise resolve and retry a run that
854
+ // never started).
855
+ finalizeRunEntry('failed', sanitizeErrorMessage((err as Error).message, effectiveCwd));
856
+ throw err;
857
+ }
858
+
859
+ // W3 (issue #28): per-agent deadline — abort the session when exceeded.
860
+ // Armed immediately after the prompt call so in-prompt preflight (auth
861
+ // check, model resolution) counts toward the deadline; the abort lands in
862
+ // the .then below as an aborted run (probe contract). We pre-set the status
863
+ // so the .then keeps 'stopped' with the timeout reason recorded. The value
864
+ // is normalized upstream (normalizeTimeout) but clamp again here — a raw
865
+ // >=2^31 or Infinity would make Node fire the timer at ~1ms (instant kill).
866
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
867
+ if (params.timeout && params.timeout > 0) {
868
+ timeoutHandle = setTimeout(() => {
869
+ try {
870
+ if (agent.completedAt) return; // already settled — nothing to abort
871
+ updateAgentStatus(id, 'stopped', `Timed out after ${params.timeout}ms`);
872
+ transcript.completeTranscript(id, 'stopped');
873
+ session.abort().catch(() => {
874
+ // Abort can reject if the session is mid-dispose; the status flip
875
+ // above is already recorded, so this is non-fatal.
876
+ });
877
+ } catch (err) {
878
+ // Timer callbacks must never throw uncaught — that crashes the host
879
+ // process. updateAgentStatus/completeTranscript can throw (persist
880
+ // fs failure, transcript deleted); the abort is best-effort.
881
+ log.warn(`spawnBackgroundSession: timeout handler error for ${id}`, {
882
+ error: (err as Error).message,
883
+ });
884
+ }
885
+ }, Math.min(params.timeout, 30 * 60 * 1000));
886
+ }
887
+
888
+ runPromise.then(() => {
889
+ if (timeoutHandle) clearTimeout(timeoutHandle);
890
+ try {
891
+ // Probe contract (sdk-abort-contract.test.ts): prompt() RESOLVES on abort
892
+ // — the SDK's runWithLifecycle catches the AbortError and converts it into
893
+ // a failure message with stopReason "aborted". So an aborted run lands
894
+ // HERE, not in .catch. Two signals distinguish stop from completion:
895
+ // 1. stopAgent pre-set status to 'stopped' before calling abort();
896
+ // 2. the last assistant message carries stopReason "aborted".
897
+ const lastAssistant = [...(session.messages ?? [])].reverse().find(m => m.role === 'assistant');
898
+ const aborted = agent.status === 'stopped' || lastAssistant?.stopReason === 'aborted';
899
+ if (aborted) {
900
+ // Session was aborted — mark stopped. If stopAgent already flipped the
901
+ // status (pre-set 'stopped' + completedAt + event + transcript), only
902
+ // the transcript note is missing from this path.
903
+ if (agent.status !== 'stopped') {
904
+ updateAgentStatus(id, 'stopped');
905
+ transcript.completeTranscript(id, 'stopped');
906
+ }
907
+ // W4/M1: capture whatever the branch produced before discarding it — an
908
+ // aborted background agent's partial work is still worth reviewing, and
909
+ // must be RECORDED, not just captured-and-deleted.
910
+ const gitInfo = cleanupWorkBranch();
911
+ if (gitInfo.gitBranch) {
912
+ agent.result = {
913
+ ...(agent.result ?? {}),
914
+ exitCode: 1,
915
+ messages: [],
916
+ stderr: '',
917
+ usage: agent.result?.usage ?? EMPTY_USAGE,
918
+ stopReason: 'aborted',
919
+ gitBranch: gitInfo.gitBranch,
920
+ gitDiff: gitInfo.gitDiff,
921
+ } as SubagentResult;
922
+ agents.set(id, agent);
923
+ persistAgent(agent);
924
+ }
925
+ // Issue #98: aborts/timeouts are retryable failures (retry schema:
926
+ // timeout/abort counts as failure) — finalize the run entry.
927
+ // Issue #122: a timed-out run burned tokens — record the real usage
928
+ // (the git-diff block above must not leave the empty fallback).
929
+ // Issue #120 (Track 1): agent.error carries the reason when the stop was
930
+ // a deadline timeout ('Timed out after Xms' → 'timeout'); a user cancel
931
+ // (stop_subagent) sets no error, so fall back to the honest user-abort
932
+ // stamp (→ 'aborted') instead of the old ambiguous 'Aborted or timed out'.
933
+ recordSessionUsage();
934
+ finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
935
+ // Issue #31: capture the final output while the session is still
936
+ // live, then release the ref on the terminal path (memory retention;
937
+ // the poller treats a nulled ref on a terminal agent as expected).
938
+ captureAndReleaseSession();
939
+ return;
940
+ }
941
+ // Session completed
942
+ agent.status = 'completed';
943
+ agent.completedAt = Date.now();
944
+ // W4: capture diff + discard the work branch; record branch info on the
945
+ // agent so the caller can review what the background agent changed.
946
+ const gitInfo = cleanupWorkBranch();
947
+ if (gitInfo.gitBranch) {
948
+ agent.result = {
949
+ ...(agent.result ?? {}),
950
+ exitCode: 0,
951
+ messages: [],
952
+ stderr: '',
953
+ usage: agent.result?.usage ?? EMPTY_USAGE,
954
+ gitBranch: gitInfo.gitBranch,
955
+ gitDiff: gitInfo.gitDiff,
956
+ } as SubagentResult;
957
+ }
958
+ // Issue #31: capture the final output while the session is still live,
959
+ // then release the ref before the branch's persist — the persisted
960
+ // record is consistent and the live session graph is freed.
961
+ captureAndReleaseSession();
962
+ // Issue #122: merge the session's summed usage onto the result record
963
+ // before the persist + finalize — the entry's cost/tokens are real.
964
+ recordSessionUsage();
965
+ agents.set(id, agent);
966
+ persistAgent(agent);
967
+ // Issue #98: mirror the agent-record flip in the session run entry.
968
+ finalizeRunEntry('done');
969
+ transcript.completeTranscript(id, 'completed');
970
+ eventBus.emit(eventBus.createEvent('subagent:completed', id, {}));
971
+ } catch (err) {
972
+ // Issue #53: any throw in this handler would escape as an
973
+ // uncaughtException and kill pi. Log and mark the record terminal —
974
+ // never rethrow.
975
+ log.error(`spawnBackgroundSession: completion handler failed for ${id}`, {
976
+ error: (err as Error).message,
977
+ });
978
+ markTerminalBestEffort('completed');
979
+ }
980
+ }).catch((err: Error) => {
981
+ if (timeoutHandle) clearTimeout(timeoutHandle);
982
+ try {
983
+ // W4: capture partial work + discard the work branch on failure — a failed
984
+ // background agent may still have produced reviewable changes.
985
+ const gitInfo = cleanupWorkBranch();
986
+ // Session failed — genuine preflight/rejection errors only (auth failure,
987
+ // model resolution, invalid task). Abort never rejects (probe contract).
988
+ if (agent.status === 'stopped') {
989
+ // M1: record the partial work even on the stopped path.
990
+ if (gitInfo.gitBranch) {
991
+ agent.result = {
992
+ ...(agent.result ?? {}),
993
+ exitCode: 1,
994
+ messages: [],
995
+ stderr: '',
996
+ usage: agent.result?.usage ?? EMPTY_USAGE,
997
+ stopReason: 'aborted',
998
+ gitBranch: gitInfo.gitBranch,
999
+ gitDiff: gitInfo.gitDiff,
1000
+ } as SubagentResult;
1001
+ agents.set(id, agent);
1002
+ persistAgent(agent);
1003
+ }
1004
+ // Issue #98: the stopped path is a retryable failure too — finalize
1005
+ // the run entry alongside the agent record flip.
1006
+ // Issue #122: a stopped/timed-out run burned tokens too — record the
1007
+ // real usage (same aborted path semantics as the .then branch).
1008
+ // Issue #120 (Track 1): same honest fallback — a deadline timeout keeps
1009
+ // agent.error ('Timed out...' → 'timeout'); a user cancel has no error
1010
+ // and stamps 'Subagent aborted by user' (→ 'aborted').
1011
+ recordSessionUsage();
1012
+ finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
1013
+ // Issue #31: the stopped path is terminal too — capture + release.
1014
+ captureAndReleaseSession();
1015
+ transcript.completeTranscript(id, 'stopped');
1016
+ return;
1017
+ }
1018
+ agent.status = 'failed';
1019
+ agent.completedAt = Date.now();
1020
+ // F7 (issue #30): err.message may contain absolute paths (CWD prefixes,
1021
+ // spawn commands) — sanitize ONCE here and reuse it for the record, the
1022
+ // result errorMessage, and the subagent:failed event (DRY, PR #63 review).
1023
+ const sanitizedError = sanitizeErrorMessage(err.message, effectiveCwd);
1024
+ 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
+ }
1037
+ // Issue #31: capture the final output while the session is still live,
1038
+ // then release the ref before the branch's persist — the persisted
1039
+ // record is consistent and the live session graph is freed.
1040
+ captureAndReleaseSession();
1041
+ // Issue #122: fold any assistant turns the preflight/run did produce —
1042
+ // usually none (auth/model rejection) → honest zeros, never a crash.
1043
+ recordSessionUsage();
1044
+ agents.set(id, agent);
1045
+ persistAgent(agent);
1046
+ // Issue #98: mirror the agent-record flip in the session run entry.
1047
+ finalizeRunEntry('failed', sanitizedError);
1048
+ transcript.completeTranscript(id, 'failed');
1049
+ eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
1050
+ } catch (err) {
1051
+ // Issue #53: any throw in this handler would escape as an
1052
+ // uncaughtException and kill pi. Log and mark the record terminal —
1053
+ // never rethrow.
1054
+ log.error(`spawnBackgroundSession: completion handler failed for ${id}`, {
1055
+ error: (err as Error).message,
1056
+ });
1057
+ markTerminalBestEffort('failed');
1058
+ }
1059
+ });
1060
+
1061
+ return agent;
1062
+ } finally {
1063
+ resolveNext();
1064
+ }
1065
+ }