pi-crew 0.5.5 → 0.5.6

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 (72) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/migration-v0.4-v0.5.md +19 -2
  5. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  6. package/package.json +7 -5
  7. package/src/benchmark/benchmark-runner.ts +45 -0
  8. package/src/benchmark/feedback-loop.ts +5 -0
  9. package/src/config/config.ts +10 -0
  10. package/src/config/suggestions.ts +8 -0
  11. package/src/extension/async-notifier.ts +10 -1
  12. package/src/extension/cross-extension-rpc.ts +1 -1
  13. package/src/extension/notification-router.ts +18 -0
  14. package/src/extension/register.ts +13 -17
  15. package/src/extension/registration/subagent-tools.ts +1 -1
  16. package/src/extension/team-tool/anchor.ts +201 -0
  17. package/src/extension/team-tool/api.ts +2 -1
  18. package/src/extension/team-tool/auto-summarize.ts +154 -0
  19. package/src/extension/team-tool/run.ts +37 -2
  20. package/src/extension/team-tool.ts +44 -2
  21. package/src/hooks/registry.ts +1 -3
  22. package/src/observability/event-bus.ts +13 -4
  23. package/src/observability/event-to-metric.ts +0 -2
  24. package/src/runtime/anchor-manager.ts +473 -0
  25. package/src/runtime/async-runner.ts +8 -4
  26. package/src/runtime/auto-summarize.ts +350 -0
  27. package/src/runtime/background-runner.ts +2 -1
  28. package/src/runtime/budget-tracker.ts +354 -0
  29. package/src/runtime/chain-runner.ts +507 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crash-recovery.ts +5 -4
  32. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  33. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  34. package/src/runtime/delivery-coordinator.ts +10 -3
  35. package/src/runtime/dynamic-script-runner.ts +482 -0
  36. package/src/runtime/handoff-manager.ts +589 -0
  37. package/src/runtime/hidden-handoff.ts +424 -0
  38. package/src/runtime/live-agent-manager.ts +20 -4
  39. package/src/runtime/live-session-runtime.ts +39 -4
  40. package/src/runtime/manifest-cache.ts +2 -1
  41. package/src/runtime/model-resolver.ts +16 -4
  42. package/src/runtime/phase-tracker.ts +373 -0
  43. package/src/runtime/pipeline-runner.ts +514 -0
  44. package/src/runtime/retry-runner.ts +354 -0
  45. package/src/runtime/sandbox.ts +252 -0
  46. package/src/runtime/scheduler.ts +7 -2
  47. package/src/runtime/subagent-manager.ts +1 -1
  48. package/src/runtime/task-graph.ts +11 -1
  49. package/src/runtime/task-runner.ts +1 -1
  50. package/src/runtime/team-runner.ts +4 -3
  51. package/src/schema/team-tool-schema.ts +30 -0
  52. package/src/skills/discover-skills.ts +5 -0
  53. package/src/state/active-run-registry.ts +9 -2
  54. package/src/state/contracts.ts +9 -0
  55. package/src/state/crew-init.ts +3 -3
  56. package/src/state/decision-ledger.ts +26 -32
  57. package/src/state/event-log-rotation.ts +2 -2
  58. package/src/state/event-log.ts +9 -1
  59. package/src/state/mailbox.ts +10 -0
  60. package/src/state/run-cache.ts +18 -8
  61. package/src/tools/safe-bash-extension.ts +1 -0
  62. package/src/tools/safe-bash.ts +152 -20
  63. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  64. package/src/ui/powerbar-publisher.ts +1 -0
  65. package/src/ui/transcript-cache.ts +13 -0
  66. package/src/utils/bm25-search.ts +16 -8
  67. package/src/utils/env-filter.ts +8 -5
  68. package/src/utils/redaction.ts +169 -15
  69. package/src/utils/sse-parser.ts +10 -1
  70. package/src/worktree/cleanup.ts +6 -1
  71. package/workflows/chain.workflow.md +252 -0
  72. package/workflows/pipeline.workflow.md +27 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * AutoSummarizeService - Enables auto-summarization with token/tool thresholds.
3
+ *
4
+ * Based on pi-boomerang's autoBoomerang pattern:
5
+ * - toggle() enables/disables auto-summarization
6
+ * - shouldAutoSummarize() checks if task should auto-summarize
7
+ * - Token and tool thresholds control when summarization triggers
8
+ *
9
+ * @see docs/pi-boomerang-integration-plan.md
10
+ */
11
+
12
+ import type { TaskPacket, TaskResult } from "./handoff-manager.ts";
13
+
14
+ /**
15
+ * Configuration for AutoSummarizeService.
16
+ */
17
+ export interface AutoSummarizeConfig {
18
+ /** Whether auto-summarize is enabled */
19
+ enabled: boolean;
20
+ /** Token threshold to trigger summarization */
21
+ threshold: number;
22
+ /** Minimum tools used to trigger summarization (default: 5) */
23
+ minToolsUsed?: number;
24
+ /** Whether to collapse context after summarization */
25
+ collapseContext?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Default configuration values.
30
+ */
31
+ export const DEFAULT_AUTO_SUMMARIZE_CONFIG: Required<Omit<AutoSummarizeConfig, "enabled">> = {
32
+ threshold: 5000,
33
+ minToolsUsed: 5,
34
+ collapseContext: true,
35
+ };
36
+
37
+ /**
38
+ * Options for AutoSummarizeService.
39
+ */
40
+ export interface AutoSummarizeServiceOptions {
41
+ /** Initial configuration */
42
+ config?: Partial<AutoSummarizeConfig>;
43
+ /** Custom event emitter */
44
+ eventEmitter?: AutoSummarizeEventEmitter;
45
+ }
46
+
47
+ /**
48
+ * Event emitter for auto-summarize events.
49
+ */
50
+ export interface AutoSummarizeEventEmitter {
51
+ emit(event: string, data: unknown): void;
52
+ }
53
+
54
+ /**
55
+ * Event data for auto-summarize toggle event.
56
+ */
57
+ export interface AutoSummarizeToggledEventData {
58
+ enabled: boolean;
59
+ previousEnabled: boolean;
60
+ }
61
+
62
+ /**
63
+ * Event data for auto-summarize triggered event.
64
+ */
65
+ export interface AutoSummarizeTriggeredEventData {
66
+ packet: TaskPacket;
67
+ result: TaskResult;
68
+ trigger: AutoSummarizeTrigger;
69
+ tokenCount: number;
70
+ }
71
+
72
+ /**
73
+ * What triggered the auto-summarize.
74
+ */
75
+ export type AutoSummarizeTrigger =
76
+ | "token_threshold"
77
+ | "tools_threshold"
78
+ | "manual"
79
+ | "high_usage";
80
+
81
+ /**
82
+ * AutoSummarizeService enables automatic summarization based on configurable thresholds.
83
+ * When enabled, it monitors task completion and triggers summarization for tasks
84
+ * that exceed token or tool usage thresholds.
85
+ */
86
+ export class AutoSummarizeService {
87
+ private config: AutoSummarizeConfig & Required<Omit<AutoSummarizeConfig, "enabled">>;
88
+ private eventEmitter: AutoSummarizeEventEmitter | null = null;
89
+
90
+ constructor(options: AutoSummarizeServiceOptions = {}) {
91
+ this.config = {
92
+ enabled: options.config?.enabled ?? false,
93
+ threshold: options.config?.threshold ?? DEFAULT_AUTO_SUMMARIZE_CONFIG.threshold,
94
+ minToolsUsed: options.config?.minToolsUsed ?? DEFAULT_AUTO_SUMMARIZE_CONFIG.minToolsUsed,
95
+ collapseContext: options.config?.collapseContext ?? DEFAULT_AUTO_SUMMARIZE_CONFIG.collapseContext,
96
+ };
97
+
98
+ if (options.eventEmitter) {
99
+ this.eventEmitter = options.eventEmitter;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Check if auto-summarization is currently enabled.
105
+ */
106
+ isEnabled(): boolean {
107
+ return this.config.enabled;
108
+ }
109
+
110
+ /**
111
+ * Toggle auto-summarize mode.
112
+ * Returns the new enabled state.
113
+ */
114
+ toggle(): boolean {
115
+ const previousEnabled = this.config.enabled;
116
+ this.config.enabled = !this.config.enabled;
117
+
118
+ this.eventEmitter?.emit("auto-summarize:toggled", {
119
+ enabled: this.config.enabled,
120
+ previousEnabled,
121
+ } as AutoSummarizeToggledEventData);
122
+
123
+ return this.config.enabled;
124
+ }
125
+
126
+ /**
127
+ * Enable auto-summarize.
128
+ */
129
+ enable(): void {
130
+ if (!this.config.enabled) {
131
+ this.toggle();
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Disable auto-summarize.
137
+ */
138
+ disable(): void {
139
+ if (this.config.enabled) {
140
+ this.toggle();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Check if a task should auto-summarize.
146
+ *
147
+ * @param packet - The task packet
148
+ * @param result - The task result
149
+ * @returns True if the task should auto-summarize
150
+ */
151
+ shouldAutoSummarize(packet: TaskPacket, result: TaskResult): boolean {
152
+ if (!this.config.enabled) {
153
+ return false;
154
+ }
155
+
156
+ const tokenCount = result.usage?.totalTokens ?? 0;
157
+
158
+ // Check token threshold
159
+ if (tokenCount >= this.config.threshold) {
160
+ return true;
161
+ }
162
+
163
+ // Check tools threshold
164
+ const toolsUsed = result.toolsUsed?.length ?? 0;
165
+ if (toolsUsed >= (this.config.minToolsUsed ?? 5)) {
166
+ return true;
167
+ }
168
+
169
+ // High usage check: high token count relative to tools
170
+ // More tokens per tool suggests complex work that should be summarized
171
+ if (tokenCount > 2000 && toolsUsed >= 3) {
172
+ const tokensPerTool = tokenCount / toolsUsed;
173
+ if (tokensPerTool > 1000) {
174
+ return true;
175
+ }
176
+ }
177
+
178
+ return false;
179
+ }
180
+
181
+ /**
182
+ * Get the reason why a task should (or should not) auto-summarize.
183
+ *
184
+ * @param packet - The task packet
185
+ * @param result - The task result
186
+ * @returns Object with shouldSummarize flag and reason
187
+ */
188
+ getAutoSummarizeDecision(packet: TaskPacket, result: TaskResult): AutoSummarizeDecision {
189
+ if (!this.config.enabled) {
190
+ return {
191
+ shouldSummarize: false,
192
+ reason: "auto-summarize is disabled",
193
+ trigger: undefined,
194
+ tokenCount: result.usage?.totalTokens ?? 0,
195
+ toolsUsed: result.toolsUsed?.length ?? 0,
196
+ };
197
+ }
198
+
199
+ const tokenCount = result.usage?.totalTokens ?? 0;
200
+ const toolsUsed = result.toolsUsed?.length ?? 0;
201
+
202
+ // Check token threshold
203
+ if (tokenCount >= this.config.threshold) {
204
+ return {
205
+ shouldSummarize: true,
206
+ reason: `Token count ${tokenCount} exceeds threshold ${this.config.threshold}`,
207
+ trigger: "token_threshold",
208
+ tokenCount,
209
+ toolsUsed,
210
+ };
211
+ }
212
+
213
+ // Check tools threshold
214
+ const minTools = this.config.minToolsUsed ?? 5;
215
+ if (toolsUsed >= minTools) {
216
+ return {
217
+ shouldSummarize: true,
218
+ reason: `Tool count ${toolsUsed} meets minimum ${minTools}`,
219
+ trigger: "tools_threshold",
220
+ tokenCount,
221
+ toolsUsed,
222
+ };
223
+ }
224
+
225
+ // High usage check
226
+ if (tokenCount > 2000 && toolsUsed >= 3) {
227
+ const tokensPerTool = tokenCount / toolsUsed;
228
+ if (tokensPerTool > 1000) {
229
+ return {
230
+ shouldSummarize: true,
231
+ reason: `High token-to-tool ratio: ${Math.round(tokensPerTool)} tokens/tool`,
232
+ trigger: "high_usage",
233
+ tokenCount,
234
+ toolsUsed,
235
+ };
236
+ }
237
+ }
238
+
239
+ return {
240
+ shouldSummarize: false,
241
+ reason: `Below thresholds (tokens: ${tokenCount}/${this.config.threshold}, tools: ${toolsUsed}/${minTools})`,
242
+ trigger: undefined,
243
+ tokenCount,
244
+ toolsUsed,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Get the current configuration.
250
+ */
251
+ getConfig(): AutoSummarizeConfig & Required<Omit<AutoSummarizeConfig, "enabled">> {
252
+ return { ...this.config };
253
+ }
254
+
255
+ /**
256
+ * Update configuration.
257
+ */
258
+ updateConfig(config: Partial<AutoSummarizeConfig>): void {
259
+ const previousEnabled = this.config.enabled;
260
+
261
+ if (config.enabled !== undefined) {
262
+ this.config.enabled = config.enabled;
263
+ }
264
+ if (config.threshold !== undefined) {
265
+ this.config.threshold = config.threshold;
266
+ }
267
+ if (config.minToolsUsed !== undefined) {
268
+ this.config.minToolsUsed = config.minToolsUsed;
269
+ }
270
+ if (config.collapseContext !== undefined) {
271
+ this.config.collapseContext = config.collapseContext;
272
+ }
273
+
274
+ // Emit event if enabled state changed
275
+ if (config.enabled !== undefined && config.enabled !== previousEnabled) {
276
+ this.eventEmitter?.emit("auto-summarize:toggled", {
277
+ enabled: this.config.enabled,
278
+ previousEnabled,
279
+ } as AutoSummarizeToggledEventData);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Get current threshold value.
285
+ */
286
+ getThreshold(): number {
287
+ return this.config.threshold;
288
+ }
289
+
290
+ /**
291
+ * Set token threshold.
292
+ */
293
+ setThreshold(threshold: number): void {
294
+ if (threshold < 0) {
295
+ throw new Error("Threshold must be non-negative");
296
+ }
297
+ this.config.threshold = threshold;
298
+ }
299
+
300
+ /**
301
+ * Get current minToolsUsed value.
302
+ */
303
+ getMinToolsUsed(): number {
304
+ return this.config.minToolsUsed ?? 5;
305
+ }
306
+
307
+ /**
308
+ * Set minimum tools threshold.
309
+ */
310
+ setMinToolsUsed(minTools: number): void {
311
+ if (minTools < 0) {
312
+ throw new Error("minToolsUsed must be non-negative");
313
+ }
314
+ this.config.minToolsUsed = minTools;
315
+ }
316
+
317
+ /**
318
+ * Check if context should be collapsed after summarization.
319
+ */
320
+ shouldCollapseContext(): boolean {
321
+ return this.config.collapseContext ?? true;
322
+ }
323
+
324
+ /**
325
+ * Set event emitter.
326
+ */
327
+ setEventEmitter(eventEmitter: AutoSummarizeEventEmitter): void {
328
+ this.eventEmitter = eventEmitter;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Decision result from shouldAutoSummarize check.
334
+ */
335
+ export interface AutoSummarizeDecision {
336
+ shouldSummarize: boolean;
337
+ reason: string;
338
+ trigger: AutoSummarizeTrigger | undefined;
339
+ tokenCount: number;
340
+ toolsUsed: number;
341
+ }
342
+
343
+ /**
344
+ * Create an AutoSummarizeService with default options.
345
+ */
346
+ export function createAutoSummarizeService(
347
+ options?: AutoSummarizeServiceOptions,
348
+ ): AutoSummarizeService {
349
+ return new AutoSummarizeService(options);
350
+ }
@@ -24,6 +24,7 @@ import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
24
24
  import { expandParallelResearchWorkflow } from "./parallel-research.ts";
25
25
  import { writeAsyncStartMarker } from "./async-marker.ts";
26
26
  import { startParentGuard, stopParentGuard } from "./parent-guard.ts";
27
+ import { logInternalError } from "../utils/internal-error.ts";
27
28
 
28
29
  /**
29
30
  * Heartbeat mechanism: periodically write a heartbeat file so the stale reconciler
@@ -323,7 +324,7 @@ async function main(): Promise<void> {
323
324
  if (loaded) {
324
325
  // LAZY: live-agent-manager only needed on failure cleanup path; avoid module load at hot path.
325
326
  const { terminateLiveAgentsForRun } = await import("./live-agent-manager.ts");
326
- void terminateLiveAgentsForRun(loaded.manifest.runId, "failed", appendEvent, loaded.manifest.eventsPath).catch(() => {});
327
+ void terminateLiveAgentsForRun(loaded.manifest.runId, "failed", appendEvent, loaded.manifest.eventsPath).catch((error) => logInternalError("background-runner.terminate", error, `runId=${loaded.manifest.runId}`));
327
328
  }
328
329
  } catch { /* best-effort */ }
329
330
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Budget Tracker — token budget tracking for team/subagent execution.
3
+ *
4
+ * Tracks token usage with configurable warning (default 80%) and abort
5
+ * (default 95%) thresholds. Provides spent(), remaining(), warning(),
6
+ * exhausted(), and createAbortSignal() for integration with team-runner.
7
+ *
8
+ * @file src/runtime/budget-tracker.ts
9
+ */
10
+
11
+ import { EventEmitter } from "node:events";
12
+
13
+ /** Budget configuration passed to TeamBudgetTracker constructor. */
14
+ export interface BudgetConfig {
15
+ /** Total token budget for the run. */
16
+ total: number;
17
+ /** Warning threshold as fraction of total (default: 0.8 = 80%). */
18
+ warningThreshold?: number;
19
+ /** Abort threshold as fraction of total (default: 0.95 = 95%). */
20
+ abortThreshold?: number;
21
+ }
22
+
23
+ /** Internal phase-level accounting for trackUsage breakdown. */
24
+ interface PhaseUsage {
25
+ phaseName: string;
26
+ tokens: number;
27
+ startTime: number;
28
+ }
29
+
30
+ /** Public usage record returned by trackUsage. */
31
+ export interface BudgetUsageRecord {
32
+ /** Total tokens spent after this update. */
33
+ totalSpent: number;
34
+ /** Tokens added in this update. */
35
+ delta: number;
36
+ /** Warning state after this update. */
37
+ isWarning: boolean;
38
+ /** Exhausted state after this update. */
39
+ isExhausted: boolean;
40
+ }
41
+
42
+ /** Event emitted when budget crosses thresholds. */
43
+ export interface BudgetEvent {
44
+ type: "budget:warning" | "budget:exhausted";
45
+ budget: BudgetSnapshot;
46
+ }
47
+
48
+ /** Snapshot of budget state for event payloads. */
49
+ export interface BudgetSnapshot {
50
+ total: number;
51
+ spent: number;
52
+ remaining: number;
53
+ percentUsed: number;
54
+ }
55
+
56
+ /**
57
+ * TeamBudgetTracker tracks token usage against a configurable budget.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * const tracker = new TeamBudgetTracker({ total: 100000 });
62
+ * tracker.trackUsage(5000);
63
+ * console.log(tracker.spent()); // 5000
64
+ * console.log(tracker.warning()); // false (50% of 100k)
65
+ * ```
66
+ */
67
+ export class TeamBudgetTracker extends EventEmitter {
68
+ private used = 0;
69
+ private readonly total: number;
70
+ private readonly warningThreshold: number;
71
+ private readonly abortThreshold: number;
72
+ private phaseUsage: PhaseUsage[] = [];
73
+ private warningEmitted = false;
74
+ private exhaustedEmitted = false;
75
+ private abortController: AbortController | null = null;
76
+ private abortInterval: NodeJS.Timeout | null = null;
77
+
78
+ /**
79
+ * Create a new budget tracker.
80
+ * @param config - Budget configuration with total and optional thresholds.
81
+ */
82
+ constructor(config: BudgetConfig) {
83
+ super();
84
+ this.total = config.total;
85
+ this.warningThreshold = config.warningThreshold ?? 0.8;
86
+ this.abortThreshold = config.abortThreshold ?? 0.95;
87
+ }
88
+
89
+ /**
90
+ * Total budget tokens.
91
+ */
92
+ get totalBudget(): number {
93
+ return this.total;
94
+ }
95
+
96
+ /**
97
+ * Get total tokens spent.
98
+ */
99
+ spent(): number {
100
+ return this.used;
101
+ }
102
+
103
+ /**
104
+ * Get remaining tokens.
105
+ */
106
+ remaining(): number {
107
+ return this.total - this.used;
108
+ }
109
+
110
+ /**
111
+ * Percentage used as decimal (0-1).
112
+ */
113
+ percentUsed(): number {
114
+ return this.total > 0 ? this.used / this.total : 0;
115
+ }
116
+
117
+ /**
118
+ * Check if usage has crossed the warning threshold.
119
+ */
120
+ warning(): boolean {
121
+ return this.percentUsed() >= this.warningThreshold;
122
+ }
123
+
124
+ /**
125
+ * Check if usage has crossed the abort threshold.
126
+ */
127
+ exhausted(): boolean {
128
+ return this.percentUsed() >= this.abortThreshold;
129
+ }
130
+
131
+ /**
132
+ * Check if both warning and exhausted events have been emitted for current usage.
133
+ */
134
+ isWarningEmitted(): boolean {
135
+ return this.warningEmitted;
136
+ }
137
+
138
+ /**
139
+ * Check if exhausted event has been emitted.
140
+ */
141
+ isExhaustedEmitted(): boolean {
142
+ return this.exhaustedEmitted;
143
+ }
144
+
145
+ /**
146
+ * Track token usage and emit threshold-crossed events.
147
+ *
148
+ * @param tokens - Number of tokens to add to usage.
149
+ * @param phaseName - Optional phase name for breakdown tracking.
150
+ * @returns BudgetUsageRecord with updated totals and thresholds.
151
+ */
152
+ trackUsage(tokens: number, phaseName?: string): BudgetUsageRecord {
153
+ if (tokens < 0) {
154
+ throw new Error("trackUsage: tokens must be non-negative");
155
+ }
156
+
157
+ const prevSpent = this.used;
158
+ this.used += tokens;
159
+
160
+ // Phase-level tracking for breakdown reporting
161
+ if (phaseName) {
162
+ const existing = this.phaseUsage.find((p) => p.phaseName === phaseName);
163
+ if (existing) {
164
+ existing.tokens += tokens;
165
+ } else {
166
+ this.phaseUsage.push({ phaseName, tokens, startTime: Date.now() });
167
+ }
168
+ }
169
+
170
+ const snapshot: BudgetSnapshot = {
171
+ total: this.total,
172
+ spent: this.used,
173
+ remaining: this.remaining(),
174
+ percentUsed: this.percentUsed(),
175
+ };
176
+
177
+ // Emit warning event on threshold crossing
178
+ if (this.warning() && !this.warningEmitted) {
179
+ this.warningEmitted = true;
180
+ this.emit("warning", { type: "budget:warning", budget: snapshot } as BudgetEvent);
181
+ }
182
+
183
+ // Emit exhausted event on threshold crossing
184
+ if (this.exhausted() && !this.exhaustedEmitted) {
185
+ this.exhaustedEmitted = true;
186
+ this.emit("exhausted", { type: "budget:exhausted", budget: snapshot } as BudgetEvent);
187
+ }
188
+
189
+ return {
190
+ totalSpent: this.used,
191
+ delta: tokens,
192
+ isWarning: this.warning(),
193
+ isExhausted: this.exhausted(),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Create an AbortSignal that fires when the budget is exhausted.
199
+ *
200
+ * The signal will be aborted automatically once the abort threshold
201
+ * is crossed. If already exhausted when called, the signal is
202
+ * immediately aborted.
203
+ *
204
+ * @returns AbortSignal that can be passed to subagent execution.
205
+ */
206
+ createAbortSignal(): AbortSignal {
207
+ // If already exhausted, return immediately aborted signal
208
+ if (this.exhausted()) {
209
+ const controller = new AbortController();
210
+ controller.abort(new Error("Budget exhausted before signal creation"));
211
+ return controller.signal;
212
+ }
213
+
214
+ // Clear any existing interval before creating new one
215
+ if (this.abortInterval) {
216
+ clearInterval(this.abortInterval);
217
+ this.abortInterval = null;
218
+ }
219
+
220
+ // Create controller and set up threshold check
221
+ this.abortController = new AbortController();
222
+
223
+ // Store reference for potential external abort
224
+ const tracker = this;
225
+
226
+ // Return a signal that checks threshold on each access
227
+ // The actual abort happens once exhausted() first returns true
228
+ const signal = this.abortController.signal;
229
+
230
+ // Set up interval check and store the ID for cleanup
231
+ this.abortInterval = setInterval(() => {
232
+ if (tracker.exhausted() && !signal.aborted) {
233
+ tracker.abortController!.abort(
234
+ new Error(`Budget exhausted: ${tracker.spent()}/${tracker.total}`),
235
+ );
236
+ if (tracker.abortInterval) {
237
+ clearInterval(tracker.abortInterval);
238
+ tracker.abortInterval = null;
239
+ }
240
+ }
241
+ }, 1000);
242
+
243
+ // Clean up interval when signal is aborted
244
+ const cleanup = (): void => {
245
+ if (tracker.abortInterval) {
246
+ clearInterval(tracker.abortInterval);
247
+ tracker.abortInterval = null;
248
+ }
249
+ };
250
+ signal.addEventListener("abort", cleanup, { once: true });
251
+
252
+ return signal;
253
+ }
254
+
255
+ /**
256
+ * Get phase-level usage breakdown.
257
+ * @returns Array of phase usage records.
258
+ */
259
+ getPhaseBreakdown(): { phaseName: string; tokens: number }[] {
260
+ return this.phaseUsage.map((p) => ({
261
+ phaseName: p.phaseName,
262
+ tokens: p.tokens,
263
+ }));
264
+ }
265
+
266
+ /**
267
+ * Reset usage for re-use (e.g., in testing or recovery scenarios).
268
+ * Does not reset emitted flags — use resetAll() for full reset.
269
+ */
270
+ resetUsage(): void {
271
+ this.used = 0;
272
+ this.phaseUsage = [];
273
+ }
274
+
275
+ /**
276
+ * Full reset including emitted flags.
277
+ */
278
+ resetAll(): void {
279
+ this.used = 0;
280
+ this.phaseUsage = [];
281
+ this.warningEmitted = false;
282
+ this.exhaustedEmitted = false;
283
+ this.abortController = null;
284
+ if (this.abortInterval) {
285
+ clearInterval(this.abortInterval);
286
+ this.abortInterval = null;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Get current snapshot of budget state.
292
+ */
293
+ snapshot(): BudgetSnapshot {
294
+ return {
295
+ total: this.total,
296
+ spent: this.used,
297
+ remaining: this.remaining(),
298
+ percentUsed: this.percentUsed(),
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Dispose of resources (EventEmitter listeners, timers).
304
+ * Call this when the tracker is no longer needed.
305
+ */
306
+ dispose(): void {
307
+ this.removeAllListeners();
308
+ if (this.abortInterval) {
309
+ clearInterval(this.abortInterval);
310
+ this.abortInterval = null;
311
+ }
312
+ this.abortController = null;
313
+ this.used = 0;
314
+ this.phaseUsage = [];
315
+ this.warningEmitted = false;
316
+ this.exhaustedEmitted = false;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Create a BudgetConfig with reasonable defaults.
322
+ * @param total - Total token budget.
323
+ * @param warningThreshold - Warning threshold (default 0.8).
324
+ * @param abortThreshold - Abort threshold (default 0.95).
325
+ */
326
+ export function createBudgetConfig(
327
+ total: number,
328
+ warningThreshold = 0.8,
329
+ abortThreshold = 0.95,
330
+ ): BudgetConfig {
331
+ return { total, warningThreshold, abortThreshold };
332
+ }
333
+
334
+ /**
335
+ * Check if a budget config is valid.
336
+ * @param config - Budget configuration to validate.
337
+ */
338
+ export function validateBudgetConfig(config: BudgetConfig): { valid: boolean; error?: string } {
339
+ if (typeof config.total !== "number" || config.total <= 0) {
340
+ return { valid: false, error: "total must be a positive number" };
341
+ }
342
+ const warning = config.warningThreshold ?? 0.8;
343
+ const abort = config.abortThreshold ?? 0.95;
344
+ if (warning < 0 || warning > 1) {
345
+ return { valid: false, error: "warningThreshold must be between 0 and 1" };
346
+ }
347
+ if (abort < 0 || abort > 1) {
348
+ return { valid: false, error: "abortThreshold must be between 0 and 1" };
349
+ }
350
+ if (warning >= abort) {
351
+ return { valid: false, error: "warningThreshold must be less than abortThreshold" };
352
+ }
353
+ return { valid: true };
354
+ }