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
package/src/state.ts ADDED
@@ -0,0 +1,590 @@
1
+ /**
2
+ * brl-subagent — State Management (F5, F7, F9)
3
+ *
4
+ * Session-bound state container with validation and migration support.
5
+ *
6
+ * F5: Type-safe state restoration — no `as any` casts.
7
+ * F7: Session-bound state — initialized in session_start, cleaned in session_shutdown.
8
+ * F9: State schema validation — validates on load, migrates legacy formats.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type {
13
+ SubagentState,
14
+ SubagentRun,
15
+ LiveSubagent,
16
+ ThinkingLevel,
17
+ ApprovalMode,
18
+ CompletionNotifyMode,
19
+ CircuitBreakerState,
20
+ Priority,
21
+ } from "./types";
22
+ import type { TranscriptMessage } from "./transcript-tail";
23
+ import {
24
+ isSubagentStateShape,
25
+ isSubagentRunShape,
26
+ CUSTOM_ENTRY_TYPES,
27
+ MAX_RUN_HISTORY_ENTRIES,
28
+ DEFAULT_MAX_SUBAGENT_DEPTH,
29
+ DEFAULT_SESSION_COST_LIMIT,
30
+ DEFAULT_COMPLETION_NOTIFY,
31
+ COMPLETION_NOTIFY_MODES,
32
+ MAX_CONSECUTIVE_FAILURES,
33
+ CIRCUIT_BREAKER_RESET_MS,
34
+ CIRCUIT_DEGRADED_THINKING,
35
+ } from "./types";
36
+ import { cleanupRuns } from "./history";
37
+ import type { Logger } from "./logging";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Live-monitor staleness sweep (issue #52 part 2)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * How long a finalized live entry stays visible in the monitor map before it
45
+ * is deleted (the poller's "brief reset window" — see finalizeLiveSubagent).
46
+ */
47
+ export const FINALIZE_RESET_WINDOW_MS = 3000;
48
+
49
+ /**
50
+ * Background-progress poller tick interval (src/index.ts, `setInterval(..., 2000)`
51
+ * in the background spawn branch).
52
+ */
53
+ const POLLER_TICK_MS = 2000;
54
+
55
+ /**
56
+ * Liveness boundary for the stale sweep. The poller finalizes a completed
57
+ * agent on its NEXT tick after `completedAt` is set, i.e. within at most one
58
+ * POLLER_TICK_MS. A terminal record that is OLDER than this boundary and
59
+ * still present in the live map therefore proves the poller can no longer be
60
+ * the one to finalize it — it died mid-run — so the sweep may claim the
61
+ * finalize (and its counter adjustment) without racing a live poller.
62
+ */
63
+ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // SessionState — session-bound mutable state
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export class SessionState {
70
+ /** Current configuration */
71
+ config: SubagentState;
72
+
73
+ /** Module-level progress counters (now session-bound) */
74
+ activeSubagents = 0;
75
+ completedSubagents = 0;
76
+ failedSubagents = 0;
77
+ unseenSubagents = 0;
78
+
79
+ /** Concurrency queue */
80
+ pendingQueue: Array<{
81
+ run: () => void;
82
+ signal: AbortSignal | undefined;
83
+ ctx: ExtensionContext;
84
+ priority: Priority;
85
+ }> = [];
86
+
87
+ /** Live subagent sessions for the monitor dashboard */
88
+ subagentSessions = new Map<string, LiveSubagent>();
89
+
90
+ /**
91
+ * Issue #133: multi-unit mode breakdown for the status bar — set by
92
+ * runGraphMode / runChainMode (src/index.ts) while a dispatch is in
93
+ * flight, composed into the status text by updateProgressStatus
94
+ * (src/concurrency.ts), cleared in the mode's finally. Undefined when no
95
+ * graph/chain dispatch is active.
96
+ */
97
+ modeContext?:
98
+ | { kind: "graph"; wave: number; totalWaves: number; node: number; totalNodes: number }
99
+ | { kind: "chain"; step: number; totalSteps: number };
100
+
101
+ /**
102
+ * Ids whose live entry has been finalized (deferred delete pending).
103
+ * Makes finalizeLiveSubagent idempotent so the stale sweep and the poller
104
+ * can race the same id without double-finalizing (issue #52).
105
+ */
106
+ private _finalizedLiveIds = new Set<string>();
107
+
108
+ /** Loaded built-in presets */
109
+ builtinPresets = new Array<import("./types").SubagentPreset>();
110
+
111
+ /** Loaded built-in task templates (companions to the builtin presets) */
112
+ builtinTemplates = new Array<import("./types").TaskTemplate>();
113
+
114
+ /** Loaded custom presets from user directories */
115
+ customPresets = new Array<import("./types").SubagentPreset>();
116
+
117
+ /**
118
+ * Migration: session-persisted presets read during restoreFromSession.
119
+ * These are stored here for src/presets.ts:migrateSessionPresets() to
120
+ * write to files in a subsequent step, then cleared.
121
+ */
122
+ _migratedPresets?: import("./types").SubagentPreset[];
123
+
124
+ /** Logger instance */
125
+ log: Logger | undefined;
126
+
127
+ constructor(log?: Logger) {
128
+ this.log = log;
129
+ this.config = {
130
+ maxThinkingLevel: "off",
131
+ maxParallel: 0,
132
+ maxSubagentDepth: DEFAULT_MAX_SUBAGENT_DEPTH,
133
+ gitMode: "none",
134
+ approvalMode: "writes",
135
+ completionNotify: DEFAULT_COMPLETION_NOTIFY,
136
+ maxHistoryEntries: MAX_RUN_HISTORY_ENTRIES,
137
+ sessionCostLimit: DEFAULT_SESSION_COST_LIMIT,
138
+ perTaskCostEstimate: 0,
139
+ seenRunIds: [],
140
+ presets: [],
141
+ templates: [],
142
+ circuitBreaker: this.defaultCircuitBreaker(),
143
+ slaTrackingEnabled: false,
144
+ slaWindowSize: 50,
145
+ updateCheckEnabled: true,
146
+ lastUpdateCheck: 0,
147
+ };
148
+ }
149
+
150
+ // -------------------------------------------------------------------
151
+ // State persistence
152
+ // -------------------------------------------------------------------
153
+
154
+ persistState(pi: ExtensionAPI): void {
155
+ pi.appendEntry(CUSTOM_ENTRY_TYPES.state, {
156
+ model: this.config.model,
157
+ maxThinkingLevel: this.config.maxThinkingLevel,
158
+ maxParallel: this.config.maxParallel,
159
+ maxSubagentDepth: this.config.maxSubagentDepth,
160
+ gitMode: this.config.gitMode,
161
+ approvalMode: this.config.approvalMode,
162
+ completionNotify: this.config.completionNotify,
163
+ maxHistoryEntries: this.config.maxHistoryEntries,
164
+ sessionCostLimit: this.config.sessionCostLimit,
165
+ perTaskCostEstimate: this.config.perTaskCostEstimate,
166
+ seenRunIds: this.config.seenRunIds,
167
+ circuitBreaker: this.config.circuitBreaker,
168
+ slaTrackingEnabled: this.config.slaTrackingEnabled,
169
+ slaWindowSize: this.config.slaWindowSize,
170
+ lastSLAMetrics: this.config.lastSLAMetrics,
171
+ updateCheckEnabled: this.config.updateCheckEnabled,
172
+ lastUpdateCheck: this.config.lastUpdateCheck,
173
+ });
174
+ }
175
+
176
+ persistRun(pi: ExtensionAPI, run: SubagentRun): void {
177
+ pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
178
+ }
179
+
180
+ // -------------------------------------------------------------------
181
+ // F5/F9: Safe state restoration from session entries
182
+ // -------------------------------------------------------------------
183
+
184
+ /**
185
+ * Restore state from the last persisted state entry.
186
+ * Uses type guards instead of `as any` casts.
187
+ * Returns true if state was restored, false if defaults were used.
188
+ */
189
+ restoreFromSession(ctx: ExtensionContext): boolean {
190
+ const entries = ctx.sessionManager.getEntries();
191
+ const stateEntry = entries
192
+ .filter(
193
+ (e: { type: string; customType?: string }) =>
194
+ e.type === "custom" && e.customType === CUSTOM_ENTRY_TYPES.state,
195
+ )
196
+ .pop() as { data?: unknown } | undefined;
197
+
198
+ if (!stateEntry?.data) {
199
+ this.log?.debug("No persisted state found, using defaults");
200
+ return false;
201
+ }
202
+
203
+ const data = stateEntry.data;
204
+
205
+ if (!isSubagentStateShape(data)) {
206
+ this.log?.warn("Corrupted state entry detected, falling back to defaults", {
207
+ dataType: typeof data,
208
+ });
209
+ return false;
210
+ }
211
+
212
+ // Restore fields with fallback to defaults
213
+ if (data.model) this.config.model = data.model;
214
+ if (data.maxThinkingLevel) this.config.maxThinkingLevel = data.maxThinkingLevel;
215
+ if (data.maxParallel !== undefined) this.config.maxParallel = data.maxParallel;
216
+ if (data.maxSubagentDepth !== undefined) this.config.maxSubagentDepth = data.maxSubagentDepth;
217
+ this.config.gitMode = data.gitMode === "branch" ? "branch" : "none";
218
+ this.config.approvalMode =
219
+ data.approvalMode === "auto" || data.approvalMode === "writes" || data.approvalMode === "always"
220
+ ? data.approvalMode
221
+ : "writes";
222
+ this.config.completionNotify =
223
+ COMPLETION_NOTIFY_MODES.includes(data.completionNotify as CompletionNotifyMode)
224
+ ? data.completionNotify
225
+ : DEFAULT_COMPLETION_NOTIFY;
226
+ if (data.maxHistoryEntries !== undefined) this.config.maxHistoryEntries = data.maxHistoryEntries;
227
+ if (data.sessionCostLimit !== undefined) this.config.sessionCostLimit = data.sessionCostLimit;
228
+ if (data.perTaskCostEstimate !== undefined) this.config.perTaskCostEstimate = data.perTaskCostEstimate;
229
+
230
+ if (Array.isArray(data.seenRunIds)) this.config.seenRunIds = data.seenRunIds;
231
+
232
+ // Migration: old session-persisted presets — store for file-backed migration
233
+ if (Array.isArray(data.presets) && data.presets.length > 0) {
234
+ this._migratedPresets = data.presets;
235
+ }
236
+
237
+ // Templates are file-backed only (issue #66) — never persisted to session state.
238
+ if (
239
+ data.circuitBreaker &&
240
+ typeof data.circuitBreaker === "object" &&
241
+ typeof (data.circuitBreaker as CircuitBreakerState).consecutiveFailures === "number"
242
+ ) {
243
+ const cb = data.circuitBreaker as CircuitBreakerState;
244
+ this.config.circuitBreaker.consecutiveFailures = cb.consecutiveFailures;
245
+ this.config.circuitBreaker.lastFailureTime = cb.lastFailureTime;
246
+ this.config.circuitBreaker.circuitOpen = cb.circuitOpen;
247
+ if (cb.degradedThinkingLevel) {
248
+ this.config.circuitBreaker.degradedThinkingLevel = cb.degradedThinkingLevel;
249
+ }
250
+ }
251
+
252
+ // Restore SLA fields (E4)
253
+ if (typeof data.slaTrackingEnabled === "boolean") this.config.slaTrackingEnabled = data.slaTrackingEnabled;
254
+ if (typeof data.slaWindowSize === "number" && data.slaWindowSize >= 10 && data.slaWindowSize <= 500) {
255
+ this.config.slaWindowSize = data.slaWindowSize;
256
+ }
257
+ if (data.lastSLAMetrics && typeof data.lastSLAMetrics === "object") {
258
+ this.config.lastSLAMetrics = data.lastSLAMetrics as import("./types").SLAMetrics;
259
+ }
260
+
261
+ // Restore update check fields
262
+ if (typeof data.updateCheckEnabled === "boolean") this.config.updateCheckEnabled = data.updateCheckEnabled;
263
+ if (typeof data.lastUpdateCheck === "number" && data.lastUpdateCheck >= 0) this.config.lastUpdateCheck = data.lastUpdateCheck;
264
+
265
+ this.log?.info("State restored from session", {
266
+ model: data.model ? `${data.model.provider}/${data.model.id}` : "none",
267
+ thinkingLevel: data.maxThinkingLevel,
268
+ });
269
+
270
+ return true;
271
+ }
272
+
273
+ // -------------------------------------------------------------------
274
+ // Run entry access
275
+ // -------------------------------------------------------------------
276
+
277
+ getRunEntries(ctx: ExtensionContext): SubagentRun[] {
278
+ const runs = ctx.sessionManager
279
+ .getEntries()
280
+ .filter((e: { type: string; customType?: string }) =>
281
+ e.type === "custom" && e.customType === CUSTOM_ENTRY_TYPES.run,
282
+ )
283
+ .map((e: { type: string; data?: unknown }) => {
284
+ const data = e.data;
285
+ if (isSubagentRunShape(data)) return data;
286
+ this.log?.warn("Corrupted run entry skipped", { entryType: typeof data });
287
+ return undefined;
288
+ })
289
+ .filter((r): r is SubagentRun => r !== undefined);
290
+ return cleanupRuns(runs, this.config.maxHistoryEntries);
291
+ }
292
+
293
+ findRunById(ctx: ExtensionContext, id: string): SubagentRun | undefined {
294
+ return this.getRunEntries(ctx).find((r) => r.id === id);
295
+ }
296
+
297
+ // -------------------------------------------------------------------
298
+ // Live subagent tracking
299
+ // -------------------------------------------------------------------
300
+
301
+ registerLiveSubagent(id: string, data: Omit<LiveSubagent, "liveOutput" | "usage">): void {
302
+ this.subagentSessions.set(id, { ...data, liveOutput: "", usage: { input: 0, output: 0 } });
303
+ }
304
+
305
+ updateLiveSubagent(
306
+ id: string,
307
+ output: string,
308
+ input: number,
309
+ outputTokens: number,
310
+ transcript?: TranscriptMessage[],
311
+ ): void {
312
+ const s = this.subagentSessions.get(id);
313
+ if (s) {
314
+ s.liveOutput = output;
315
+ s.usage = { input, output: outputTokens };
316
+ // Issue #105: foreground streaming transcript — stored by reference
317
+ // (the runner mutates the same tail array; the 200ms drill-in tick
318
+ // re-reads it, so no copy is needed).
319
+ if (transcript) s.transcript = transcript;
320
+ }
321
+ }
322
+
323
+ finalizeLiveSubagent(id: string): boolean {
324
+ // Idempotent: the poller (completion/crash paths) and the stale sweep
325
+ // can both race to finalize the same id — the first call claims it,
326
+ // repeats are no-ops. Returns true when THIS call claimed the finalize,
327
+ // so callers that adjust counters can gate on it (issue #52, PR #71
328
+ // review: the poller must not decrement when the sweep already did).
329
+ if (this._finalizedLiveIds.has(id)) return false;
330
+ this._finalizedLiveIds.add(id);
331
+ setTimeout(() => {
332
+ this.subagentSessions.delete(id);
333
+ this._finalizedLiveIds.delete(id);
334
+ }, FINALIZE_RESET_WINDOW_MS);
335
+ return true;
336
+ }
337
+
338
+ /** True when the live entry has been finalized (deferred delete pending). */
339
+ isLiveEntryFinalized(id: string): boolean {
340
+ return this._finalizedLiveIds.has(id);
341
+ }
342
+
343
+ /**
344
+ * Race-safe stale-entry finalize used by the monitor's staleness sweep
345
+ * (issue #52 part 2). Claims the finalize for `id` exactly once and
346
+ * removes the entry from the live map IMMEDIATELY (stale entries must not
347
+ * linger — a stale-only map must render as empty right away).
348
+ *
349
+ * Returns false (no-op) when:
350
+ * - the entry is already finalized (poller or an earlier sweep pass), or
351
+ * - `completedAt` is set but too fresh: a live poller finalizes within one
352
+ * poller tick of completion, so a fresher record means the poller may
353
+ * still fire — finalizing here would double-decrement the counter.
354
+ *
355
+ * NOTE: this method does NOT touch activeSubagents — callers that know the
356
+ * entry was counted (background agents) must mirror the poller's
357
+ * decrement+clamp after a truthy return.
358
+ */
359
+ finalizeStaleLiveSubagent(id: string, completedAt?: number): boolean {
360
+ if (this._finalizedLiveIds.has(id)) return false;
361
+ if (completedAt !== undefined && Date.now() - completedAt < STALE_FINALIZE_GRACE_MS) {
362
+ return false;
363
+ }
364
+ // Claim through finalizeLiveSubagent (the shared claim set makes this
365
+ // race-safe with the poller), then remove the entry synchronously —
366
+ // stale entries must not linger. Return the claim result explicitly so
367
+ // the sweep's caller knows WHO won the race (PR #71 review).
368
+ const claimed = this.finalizeLiveSubagent(id);
369
+ this.subagentSessions.delete(id);
370
+ return claimed;
371
+ }
372
+
373
+ /**
374
+ * Session-shutdown hygiene: drop all pending finalize claims so a fresh
375
+ * session can claim the same ids again. Claims are time-bounded anyway
376
+ * (FINALIZE_RESET_WINDOW_MS), but the shutdown handler clears the live
377
+ * map and counters too — the claim set must follow suit or a new session
378
+ * would no-op on an id a stale claim still remembers (PR #71 review).
379
+ */
380
+ resetLiveFinalizeClaims(): void {
381
+ this._finalizedLiveIds.clear();
382
+ }
383
+
384
+ // -------------------------------------------------------------------
385
+ // Seen/unseen tracking
386
+ // -------------------------------------------------------------------
387
+
388
+ markRunSeen(runId: string): boolean {
389
+ if (!this.config.seenRunIds.includes(runId)) {
390
+ this.config.seenRunIds.push(runId);
391
+ if (this.unseenSubagents > 0) this.unseenSubagents--;
392
+ return true;
393
+ }
394
+ return false;
395
+ }
396
+
397
+ // -------------------------------------------------------------------
398
+ // Circuit breaker
399
+ // -------------------------------------------------------------------
400
+
401
+ private defaultCircuitBreaker(): CircuitBreakerState {
402
+ return {
403
+ consecutiveFailures: 0,
404
+ lastFailureTime: 0,
405
+ circuitOpen: false,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Reset consecutiveFailures to 0, close the circuit, and clear
411
+ * the degraded thinking level.
412
+ */
413
+ recordSuccess(): void {
414
+ this.config.circuitBreaker.consecutiveFailures = 0;
415
+ this.config.circuitBreaker.circuitOpen = false;
416
+ this.config.circuitBreaker.degradedThinkingLevel = undefined;
417
+ this.config.circuitBreaker.lastFailureTime = 0;
418
+ }
419
+
420
+ /**
421
+ * Increment consecutiveFailures. If the threshold is reached,
422
+ * open the circuit, record the failure time, and set the
423
+ * degraded thinking level.
424
+ */
425
+ recordFailure(): void {
426
+ this.config.circuitBreaker.consecutiveFailures++;
427
+ if (this.config.circuitBreaker.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
428
+ this.config.circuitBreaker.circuitOpen = true;
429
+ this.config.circuitBreaker.lastFailureTime = Date.now();
430
+ this.config.circuitBreaker.degradedThinkingLevel = CIRCUIT_DEGRADED_THINKING;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Check whether the circuit breaker is currently open.
436
+ * Applies auto-recovery if enough time has passed since
437
+ * the last failure. Returns the result with status info.
438
+ */
439
+ checkCircuit(): { isOpen: boolean; message?: string; waitTimeRemaining?: number } {
440
+ const cb = this.config.circuitBreaker;
441
+
442
+ if (!cb.circuitOpen) {
443
+ return { isOpen: false };
444
+ }
445
+
446
+ const elapsed = Date.now() - cb.lastFailureTime;
447
+
448
+ // Auto-recover if the reset window has passed
449
+ if (elapsed >= CIRCUIT_BREAKER_RESET_MS) {
450
+ cb.consecutiveFailures = 0;
451
+ cb.circuitOpen = false;
452
+ cb.degradedThinkingLevel = undefined;
453
+ cb.lastFailureTime = 0;
454
+ return { isOpen: false };
455
+ }
456
+
457
+ const waitTimeRemaining = CIRCUIT_BREAKER_RESET_MS - elapsed;
458
+ return {
459
+ isOpen: true,
460
+ message:
461
+ `Circuit breaker is open: ${cb.consecutiveFailures} consecutive failures. ` +
462
+ `Auto-recovery in ${Math.ceil(waitTimeRemaining / 1000)}s. ` +
463
+ `Wait or reduce thinkingLevel to ${CIRCUIT_DEGRADED_THINKING} and try again.`,
464
+ waitTimeRemaining,
465
+ };
466
+ }
467
+
468
+ // -------------------------------------------------------------------
469
+ // Cost governance — R5
470
+ // -------------------------------------------------------------------
471
+
472
+ /**
473
+ * Sum the cost of all completed runs in the current session.
474
+ */
475
+ getSessionTotalCost(ctx: ExtensionContext): number {
476
+ const runs = this.getRunEntries(ctx);
477
+ return runs.reduce((acc, r) => acc + (r.cost ?? 0), 0);
478
+ }
479
+
480
+ /**
481
+ * Check if adding `cost` would exceed the session cost limit.
482
+ * Returns true if the limit would be exceeded (or is already exceeded).
483
+ * Returns false if the limit is 0 (unlimited) or the new total is within budget.
484
+ */
485
+ checkCostLimit(cost: number, ctx: ExtensionContext): boolean {
486
+ if (this.config.sessionCostLimit === 0) return false;
487
+ const currentTotal = this.getSessionTotalCost(ctx);
488
+ return currentTotal + cost > this.config.sessionCostLimit;
489
+ }
490
+
491
+ // -------------------------------------------------------------------
492
+ // Reset
493
+ // -------------------------------------------------------------------
494
+
495
+ reset(): void {
496
+ this.config.model = undefined;
497
+ this.config.maxThinkingLevel = "off";
498
+ this.config.maxParallel = 0;
499
+ this.config.maxSubagentDepth = DEFAULT_MAX_SUBAGENT_DEPTH;
500
+ this.config.gitMode = "none";
501
+ this.config.approvalMode = "writes";
502
+ this.config.completionNotify = DEFAULT_COMPLETION_NOTIFY;
503
+ this.config.maxHistoryEntries = MAX_RUN_HISTORY_ENTRIES;
504
+ this.config.sessionCostLimit = DEFAULT_SESSION_COST_LIMIT;
505
+ this.config.perTaskCostEstimate = 0;
506
+ this.config.templates = [];
507
+ this.config.circuitBreaker = this.defaultCircuitBreaker();
508
+ this.config.slaTrackingEnabled = false;
509
+ this.config.slaWindowSize = 50;
510
+ this.config.lastSLAMetrics = undefined;
511
+ this.config.updateCheckEnabled = true;
512
+ this.config.lastUpdateCheck = 0;
513
+ }
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Factory
518
+ // ---------------------------------------------------------------------------
519
+
520
+ export function createSessionState(log?: Logger): SessionState {
521
+ return new SessionState(log);
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Live-monitor staleness sweep (issue #52 part 2)
526
+ // ---------------------------------------------------------------------------
527
+
528
+ /**
529
+ * Self-healing sweep for stale live-monitor entries.
530
+ *
531
+ * Staleness vector: the live map (`state.subagentSessions`) is only emptied
532
+ * when the background poller's completion/crash paths call finalizeLiveSubagent
533
+ * or when session_shutdown clears the whole map. If the poller dies mid-run
534
+ * (extension reload, process kill, hard-cap crash), the map entry survives and
535
+ * the monitor shows "running" for an agent whose disk record is terminal.
536
+ *
537
+ * This sweep runs in the monitor/dashboard render path and finalizes every
538
+ * entry that is provably terminal:
539
+ * - `getAgent(id)` returns a record with `completedAt` set → the background
540
+ * agent is terminal (completed/failed/stopped) but the poller never
541
+ * finalized it → finalize + mirror the poller's activeSubagents bookkeeping
542
+ * (decrement + clamp at 0). The grace window inside
543
+ * finalizeStaleLiveSubagent guarantees the poller can no longer fire, so
544
+ * the counter cannot be double-decremented.
545
+ * - `getAgent(id)` returns null → no agent record. Background agents ALWAYS
546
+ * have a record (in-memory + disk), so a null record means either a
547
+ * FOREGROUND run (registered with a runId, never persisted as an agent) or
548
+ * a record removed out-of-band. Foreground runs persist run entries, so a
549
+ * 'running' run entry proves the entry is genuinely live; anything else
550
+ * (done/failed, or no run entry) is stale → finalize WITHOUT counter
551
+ * adjustment (foreground entries are never counted in activeSubagents).
552
+ *
553
+ * The sweep never touches `completedSubagents`/`failedSubagents`/
554
+ * `unseenSubagents`: those are incremented by the poller's own completion
555
+ * paths, and the sweep exists only to cover the poller-DEAD case.
556
+ *
557
+ * Returns the number of entries finalized (0 when nothing was stale).
558
+ */
559
+ export function sweepStaleLiveSubagents(
560
+ state: SessionState,
561
+ getAgent: (id: string) => { completedAt?: number } | null,
562
+ ): number {
563
+ let finalized = 0;
564
+
565
+ // Snapshot: sweep-finalized entries are deleted synchronously, so the map
566
+ // changes under iteration — iterate a copy to stay well-defined.
567
+ for (const [id, session] of Array.from(state.subagentSessions)) {
568
+ const agent = getAgent(id);
569
+ if (agent) {
570
+ // Background agent with a record. Terminal on disk but still in the
571
+ // live map = the poller died before its finalize tick.
572
+ if (agent.completedAt && state.finalizeStaleLiveSubagent(id, agent.completedAt)) {
573
+ // Mirror the poller's counter bookkeeping exactly (index.ts):
574
+ // finalize, decrement, clamp at 0.
575
+ state.activeSubagents--;
576
+ if (state.activeSubagents < 0) state.activeSubagents = 0;
577
+ finalized++;
578
+ }
579
+ } else {
580
+ // No agent record — foreground run or a removed record. A foreground
581
+ // run is live only while its run entry says 'running'.
582
+ const runStatus = state.getRunEntries(session.ctx).find((r) => r.id === id)?.status;
583
+ if (runStatus !== "running" && state.finalizeStaleLiveSubagent(id)) {
584
+ finalized++;
585
+ }
586
+ }
587
+ }
588
+
589
+ return finalized;
590
+ }