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,589 @@
1
+ // HandoffManager defaults
2
+ const DEFAULT_SUMMARIZE_THRESHOLD = 5000;
3
+ const DEFAULT_HANDOVER_TIMEOUT_MS = 300000; // 5 minutes
4
+ const DEFAULT_CLEANUP_INTERVAL_MS = 60000; // 1 minute
5
+ const MAX_PENDING_HANDOFFS = 1000; // Prevent unbounded growth
6
+
7
+ /**
8
+ * Type guard for HandoffSummary structure validation.
9
+ */
10
+ export function isValidHandoffSummary(value: unknown): value is HandoffSummary {
11
+ if (!value || typeof value !== 'object') {
12
+ return false;
13
+ }
14
+ const obj = value as Record<string, unknown>;
15
+
16
+ // Check required fields
17
+ if (typeof obj.taskId !== 'string' || !obj.taskId) return false;
18
+ if (typeof obj.runId !== 'string' || !obj.runId) return false;
19
+ if (typeof obj.timestamp !== 'number') return false;
20
+ if (typeof obj.task !== 'string' || !obj.task) return false;
21
+ if (typeof obj.outcome !== 'string') return false;
22
+ if (!['success', 'failure', 'partial'].includes(obj.outcome)) return false;
23
+
24
+ // Check arrays
25
+ if (!Array.isArray(obj.filesCreated)) return false;
26
+ if (!Array.isArray(obj.filesModified)) return false;
27
+ if (!Array.isArray(obj.filesDeleted)) return false;
28
+ if (!Array.isArray(obj.decisions)) return false;
29
+ if (!Array.isArray(obj.blockers)) return false;
30
+ if (!Array.isArray(obj.nextSteps)) return false;
31
+
32
+ // Check metrics object
33
+ if (!obj.metrics || typeof obj.metrics !== 'object') return false;
34
+ const metrics = obj.metrics as Record<string, unknown>;
35
+ if (typeof metrics.tokensUsed !== 'number') return false;
36
+ if (typeof metrics.duration !== 'number') return false;
37
+ if (typeof metrics.iterations !== 'number') return false;
38
+ if (!Array.isArray(metrics.toolsUsed)) return false;
39
+
40
+ // Check contextSnapshot
41
+ if (typeof obj.contextSnapshot !== 'string') return false;
42
+
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * HandoffManager - Generates structured summaries for agent handoffs.
48
+ *
49
+ * Based on pi-boomerang's session_before_tree hook pattern:
50
+ * - Detects task completion via agent_end hook
51
+ * - Generates structured summaries with token metrics, artifacts, decisions
52
+ * - Optionally collapses context to reduce token usage
53
+ *
54
+ * @see docs/pi-boomerang-integration-plan.md
55
+ */
56
+
57
+ import type { TeamEvent } from "../state/event-log.ts";
58
+ import { appendEventAsync } from "../state/event-log.ts";
59
+
60
+ /**
61
+ * Represents a key decision made during task execution.
62
+ */
63
+ export interface Decision {
64
+ rationale: string;
65
+ outcome: string;
66
+ alternativesConsidered: string[];
67
+ }
68
+
69
+ /**
70
+ * Structured handoff summary for passing context between agents.
71
+ */
72
+ export interface HandoffSummary {
73
+ taskId: string;
74
+ runId: string;
75
+ timestamp: number;
76
+
77
+ // Core summary
78
+ task: string;
79
+ outcome: "success" | "failure" | "partial";
80
+
81
+ // Structured artifacts
82
+ filesCreated: string[];
83
+ filesModified: string[];
84
+ filesDeleted: string[];
85
+
86
+ // Key decisions made
87
+ decisions: Decision[];
88
+
89
+ // Open issues / next steps
90
+ blockers: string[];
91
+ nextSteps: string[];
92
+
93
+ // Metrics
94
+ metrics: {
95
+ tokensUsed: number;
96
+ duration: number;
97
+ iterations: number;
98
+ toolsUsed: string[];
99
+ };
100
+
101
+ // Context snapshot
102
+ contextSnapshot: string;
103
+ }
104
+
105
+ /**
106
+ * Task result interface (simplified for handoff generation).
107
+ */
108
+ export interface TaskResult {
109
+ outcome: "success" | "failure" | "partial";
110
+ usage?: {
111
+ inputTokens?: number;
112
+ outputTokens?: number;
113
+ totalTokens?: number;
114
+ };
115
+ duration?: number;
116
+ iterations?: number;
117
+ toolsUsed?: string[];
118
+ blockers?: string[];
119
+ nextSteps?: string[];
120
+ filesCreated?: string[];
121
+ filesModified?: string[];
122
+ filesDeleted?: string[];
123
+ decisions?: Decision[];
124
+ error?: string;
125
+ }
126
+
127
+ /**
128
+ * Task packet interface (minimal for handoff generation).
129
+ */
130
+ export interface TaskPacket {
131
+ taskId: string;
132
+ runId: string;
133
+ goal: string;
134
+ sessionId?: string;
135
+ summarizeThreshold?: number;
136
+ collapseContext?: boolean;
137
+ forceSummarize?: boolean;
138
+ context?: Record<string, unknown>;
139
+ }
140
+
141
+ export interface HandoffManagerOptions {
142
+ /** Default token threshold for triggering summarization */
143
+ defaultSummarizeThreshold?: number;
144
+ /** Enable context collapse after handoff */
145
+ enableContextCollapse?: boolean;
146
+ /** Custom event emitter for handoff events */
147
+ eventEmitter?: HandoffEventEmitter;
148
+ /** Timeout for pending handoffs in ms (default: 300000 = 5 minutes) */
149
+ handoffTimeoutMs?: number;
150
+ /** Interval for cleanup of old pending handoffs in ms (default: 60000 = 1 minute) */
151
+ cleanupIntervalMs?: number;
152
+ }
153
+
154
+ export interface HandoffEventEmitter {
155
+ emit(event: string, data: unknown): void;
156
+ }
157
+
158
+ /**
159
+ * Result of shouldSummarize check.
160
+ */
161
+ export interface SummarizeDecision {
162
+ shouldSummarize: boolean;
163
+ reason: string;
164
+ tokenCount: number;
165
+ }
166
+
167
+ /**
168
+ * HandoffManager generates structured summaries when agents complete tasks,
169
+ * enabling efficient context passing to subsequent agents.
170
+ *
171
+ * H1: Includes memory management to prevent unbounded growth of pendingHandoffs Map.
172
+ */
173
+ export class HandoffManager {
174
+ private pendingHandoffs = new Map<string, HandoffSummary>();
175
+ private options: HandoffManagerOptions;
176
+ private handoffTimestamps = new Map<string, number>(); // Track when handoffs were added
177
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
178
+ private disposed = false;
179
+
180
+ constructor(options: HandoffManagerOptions = {}) {
181
+ this.options = {
182
+ defaultSummarizeThreshold: DEFAULT_SUMMARIZE_THRESHOLD,
183
+ handoffTimeoutMs: DEFAULT_HANDOVER_TIMEOUT_MS,
184
+ cleanupIntervalMs: DEFAULT_CLEANUP_INTERVAL_MS,
185
+ ...options,
186
+ };
187
+
188
+ // Start cleanup timer
189
+ this.startCleanupTimer();
190
+ }
191
+
192
+ /**
193
+ * Start the periodic cleanup timer for stale pending handoffs.
194
+ * H1: Prevents memory leak by clearing old entries.
195
+ */
196
+ private startCleanupTimer(): void {
197
+ if (this.disposed) {
198
+ return;
199
+ }
200
+ if (this.cleanupTimer) {
201
+ return;
202
+ }
203
+ this.cleanupTimer = setInterval(() => {
204
+ this.cleanupStaleHandoffs();
205
+ }, this.options.cleanupIntervalMs);
206
+ }
207
+
208
+ /**
209
+ * Clean up stale pending handoffs that have exceeded the timeout.
210
+ * H1: Prevents memory leak by removing old entries.
211
+ * FIX: Iterate over entries() instead of mutating Map during iteration.
212
+ */
213
+ private cleanupStaleHandoffs(): void {
214
+ if (this.disposed) return;
215
+
216
+ const now = Date.now();
217
+ const timeout = this.options.handoffTimeoutMs ?? DEFAULT_HANDOVER_TIMEOUT_MS;
218
+ const cutoff = now - timeout;
219
+
220
+ // Collect keys to delete to avoid mutation during iteration
221
+ const toDelete: string[] = [];
222
+ for (const [sessionId, timestamp] of this.handoffTimestamps.entries()) {
223
+ if (timestamp < cutoff) {
224
+ toDelete.push(sessionId);
225
+ }
226
+ }
227
+ for (const sessionId of toDelete) {
228
+ this.pendingHandoffs.delete(sessionId);
229
+ this.handoffTimestamps.delete(sessionId);
230
+ }
231
+
232
+ // Also enforce max handoffs limit
233
+ if (this.pendingHandoffs.size > MAX_PENDING_HANDOFFS) {
234
+ // Remove oldest entries
235
+ const sortedEntries = [...this.handoffTimestamps.entries()]
236
+ .sort((a, b) => a[1] - b[1]);
237
+ const removeCount = sortedEntries.length - MAX_PENDING_HANDOFFS;
238
+ for (let i = 0; i < removeCount; i++) {
239
+ const sessionId = sortedEntries[i][0];
240
+ this.pendingHandoffs.delete(sessionId);
241
+ this.handoffTimestamps.delete(sessionId);
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Hook: agent_end
248
+ * Called when agent completes a task.
249
+ *
250
+ * @param packet - The task packet
251
+ * @param result - The task result
252
+ */
253
+ async onAgentEnd(packet: TaskPacket, result: TaskResult): Promise<HandoffSummary | null> {
254
+ if (this.disposed) {
255
+ return null;
256
+ }
257
+
258
+ // Check if summarization is needed
259
+ if (!this.shouldSummarize(packet, result).shouldSummarize) {
260
+ return null;
261
+ }
262
+
263
+ // Generate handoff summary
264
+ const summary = await this.generateSummary(packet, result);
265
+
266
+ // H7: Validate generated summary structure
267
+ if (!isValidHandoffSummary(summary)) {
268
+ this.options.eventEmitter?.emit("handoff:validation_failed", {
269
+ packet,
270
+ error: "Generated summary failed structure validation",
271
+ });
272
+ return null;
273
+ }
274
+
275
+ // Store pending handoff for tree navigation
276
+ if (packet.sessionId) {
277
+ this.pendingHandoffs.set(packet.sessionId, summary);
278
+ this.handoffTimestamps.set(packet.sessionId, Date.now());
279
+ }
280
+
281
+ // Emit handoff event
282
+ this.options.eventEmitter?.emit("handoff:generated", { packet, summary });
283
+
284
+ // Optionally collapse context
285
+ if (packet.collapseContext) {
286
+ await this.collapseContext(packet, summary);
287
+ }
288
+
289
+ return summary;
290
+ }
291
+
292
+ /**
293
+ * Hook: session_before_tree
294
+ * Called before navigating to tree view.
295
+ * Injects pending handoff summaries into the tree.
296
+ *
297
+ * @param sessionId - The session ID
298
+ * @param targetId - The target tree node ID
299
+ */
300
+ async onBeforeTreeNavigation(sessionId: string, targetId: string): Promise<HandoffSummary | null> {
301
+ if (this.disposed) {
302
+ return null;
303
+ }
304
+
305
+ const pendingHandoff = this.pendingHandoffs.get(sessionId);
306
+
307
+ if (pendingHandoff) {
308
+ // Clear the pending handoff after injection
309
+ this.pendingHandoffs.delete(sessionId);
310
+ this.handoffTimestamps.delete(sessionId);
311
+ return pendingHandoff;
312
+ }
313
+
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * Check if summarization should be performed.
319
+ * H7: Validates input parameters before generating summary.
320
+ */
321
+ shouldSummarize(packet: TaskPacket, result: TaskResult): SummarizeDecision {
322
+ // H7: Validate packet structure
323
+ if (!packet || typeof packet.taskId !== 'string' || !packet.taskId) {
324
+ return {
325
+ shouldSummarize: false,
326
+ reason: 'Invalid task packet structure',
327
+ tokenCount: 0,
328
+ };
329
+ }
330
+
331
+ // H7: Validate result structure
332
+ if (!result || typeof result.outcome !== 'string') {
333
+ return {
334
+ shouldSummarize: false,
335
+ reason: 'Invalid task result structure',
336
+ tokenCount: 0,
337
+ };
338
+ }
339
+
340
+ const threshold = packet.summarizeThreshold ?? this.options.defaultSummarizeThreshold ?? DEFAULT_SUMMARIZE_THRESHOLD;
341
+ const tokenCount = result.usage?.totalTokens ?? 0;
342
+
343
+ // Summarize if:
344
+ // 1. Task exceeded threshold tokens
345
+ if (tokenCount > threshold) {
346
+ return {
347
+ shouldSummarize: true,
348
+ reason: `Token count ${tokenCount} exceeds threshold ${threshold}`,
349
+ tokenCount,
350
+ };
351
+ }
352
+
353
+ // 2. Task completed with significant work (3 or more tools used)
354
+ if (result.outcome === "success" && (result.toolsUsed?.length ?? 0) >= 3) {
355
+ return {
356
+ shouldSummarize: true,
357
+ reason: `Task used ${result.toolsUsed?.length ?? 0} tools, exceeding minimum of 3`,
358
+ tokenCount,
359
+ };
360
+ }
361
+
362
+ // 3. Explicitly requested
363
+ if (packet.forceSummarize === true) {
364
+ return {
365
+ shouldSummarize: true,
366
+ reason: "Forced summarization requested",
367
+ tokenCount,
368
+ };
369
+ }
370
+
371
+ // 4. Task has significant artifacts or decisions
372
+ const hasArtifacts = (result.filesCreated?.length ?? 0) > 0 ||
373
+ (result.filesModified?.length ?? 0) > 0;
374
+ const hasDecisions = (result.decisions?.length ?? 0) > 0;
375
+
376
+ if (hasArtifacts || hasDecisions) {
377
+ return {
378
+ shouldSummarize: true,
379
+ reason: "Task produced significant artifacts or decisions",
380
+ tokenCount,
381
+ };
382
+ }
383
+
384
+ // 5. Task outcome is not success (failure or partial)
385
+ if (result.outcome !== "success") {
386
+ return {
387
+ shouldSummarize: true,
388
+ reason: `Task outcome is ${result.outcome}`,
389
+ tokenCount,
390
+ };
391
+ }
392
+
393
+ return {
394
+ shouldSummarize: false,
395
+ reason: "Task below summarization threshold",
396
+ tokenCount,
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Generate a structured handoff summary.
402
+ */
403
+ async generateSummary(packet: TaskPacket, result: TaskResult): Promise<HandoffSummary> {
404
+ const artifacts = this.extractArtifacts(result);
405
+ // Use extractDecisionsFromResult to handle empty array and generate defaults
406
+ const decisions = this.extractDecisionsFromResult(result);
407
+ const contextSnapshot = await this.generateContextSnapshot(
408
+ packet.runId,
409
+ packet.taskId,
410
+ result
411
+ );
412
+
413
+ return {
414
+ taskId: packet.taskId,
415
+ runId: packet.runId,
416
+ timestamp: Date.now(),
417
+
418
+ task: packet.goal,
419
+ outcome: result.outcome,
420
+
421
+ filesCreated: artifacts.created,
422
+ filesModified: artifacts.modified,
423
+ filesDeleted: artifacts.deleted,
424
+
425
+ decisions,
426
+ blockers: result.blockers ?? [],
427
+ nextSteps: result.nextSteps ?? [],
428
+
429
+ metrics: {
430
+ tokensUsed: result.usage?.totalTokens ?? 0,
431
+ duration: result.duration ?? 0,
432
+ iterations: result.iterations ?? 1,
433
+ toolsUsed: result.toolsUsed ?? [],
434
+ },
435
+
436
+ contextSnapshot,
437
+ };
438
+ }
439
+
440
+ /**
441
+ * Collapse context after handoff.
442
+ * Signals to other extensions not to prompt during collapse.
443
+ */
444
+ async collapseContext(packet: TaskPacket, summary: HandoffSummary): Promise<void> {
445
+ // Set global flag to signal collapse in progress
446
+ (globalThis as Record<string, unknown>).__boomerangCollapseInProgress = true;
447
+
448
+ try {
449
+ // Emit event that context will be collapsed
450
+ this.options.eventEmitter?.emit("handoff:context_collapse", {
451
+ sessionId: packet.sessionId,
452
+ taskId: packet.taskId,
453
+ summary,
454
+ });
455
+ } finally {
456
+ // Clear the flag
457
+ (globalThis as Record<string, unknown>).__boomerangCollapseInProgress = false;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Get pending handoff for a session.
463
+ */
464
+ getPendingHandoff(sessionId: string): HandoffSummary | undefined {
465
+ return this.pendingHandoffs.get(sessionId);
466
+ }
467
+
468
+ /**
469
+ * Clear pending handoff for a session.
470
+ */
471
+ clearPendingHandoff(sessionId: string): void {
472
+ this.pendingHandoffs.delete(sessionId);
473
+ this.handoffTimestamps.delete(sessionId);
474
+ }
475
+
476
+ /**
477
+ * Clear all pending handoffs.
478
+ * H1: Manual cleanup method for memory management.
479
+ */
480
+ clearAllPendingHandoffs(): void {
481
+ this.pendingHandoffs.clear();
482
+ this.handoffTimestamps.clear();
483
+ }
484
+
485
+ /**
486
+ * Get the count of pending handoffs.
487
+ * Useful for monitoring and debugging.
488
+ */
489
+ getPendingCount(): number {
490
+ return this.pendingHandoffs.size;
491
+ }
492
+
493
+ /**
494
+ * Extract file artifacts from task result.
495
+ */
496
+ private extractArtifacts(result: TaskResult): {
497
+ created: string[];
498
+ modified: string[];
499
+ deleted: string[];
500
+ } {
501
+ return {
502
+ created: result.filesCreated ?? [],
503
+ modified: result.filesModified ?? [],
504
+ deleted: result.filesDeleted ?? [],
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Extract decisions from task result.
510
+ */
511
+ private extractDecisionsFromResult(result: TaskResult): Decision[] {
512
+ if (result.decisions && result.decisions.length > 0) {
513
+ return result.decisions;
514
+ }
515
+
516
+ // Generate a default decision for failure outcomes
517
+ if (result.outcome === "failure") {
518
+ return [{
519
+ rationale: "Task failed",
520
+ outcome: result.error ?? "Unknown error",
521
+ alternativesConsidered: [],
522
+ }];
523
+ }
524
+
525
+ return [];
526
+ }
527
+
528
+ /**
529
+ * Generate context snapshot for handoff.
530
+ */
531
+ private async generateContextSnapshot(
532
+ runId: string,
533
+ taskId: string,
534
+ result: TaskResult
535
+ ): Promise<string> {
536
+ const parts: string[] = [];
537
+
538
+ parts.push(`Task: ${taskId}`);
539
+ parts.push(`Outcome: ${result.outcome}`);
540
+
541
+ if (result.usage?.totalTokens) {
542
+ parts.push(`Tokens: ${result.usage.totalTokens}`);
543
+ }
544
+
545
+ if (result.toolsUsed?.length) {
546
+ parts.push(`Tools: ${result.toolsUsed.join(", ")}`);
547
+ }
548
+
549
+ if (result.blockers?.length) {
550
+ parts.push(`Blockers: ${result.blockers.join("; ")}`);
551
+ }
552
+
553
+ if (result.nextSteps?.length) {
554
+ parts.push(`Next Steps: ${result.nextSteps.join("; ")}`);
555
+ }
556
+
557
+ return parts.join("\n");
558
+ }
559
+
560
+ /**
561
+ * H8: Dispose of resources. Call when manager is no longer needed.
562
+ * Clears all pending handoffs and stops cleanup timer.
563
+ */
564
+ dispose(): void {
565
+ this.disposed = true;
566
+
567
+ if (this.cleanupTimer) {
568
+ clearInterval(this.cleanupTimer);
569
+ this.cleanupTimer = null;
570
+ }
571
+
572
+ this.pendingHandoffs.clear();
573
+ this.handoffTimestamps.clear();
574
+ }
575
+
576
+ /**
577
+ * Check if the manager has been disposed.
578
+ */
579
+ isDisposed(): boolean {
580
+ return this.disposed;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Create a HandoffManager with default options.
586
+ */
587
+ export function createHandoffManager(options?: HandoffManagerOptions): HandoffManager {
588
+ return new HandoffManager(options);
589
+ }