/** * Completion batch window: holds notices briefly so jobs settling within a * short window arrive as one message. Port of pi-subagents' completion-batcher * (debounce + max-wait), trimmed to the no-straggler case: jobs that finish * late just form their own group. */ export interface TimerApi { setTimeout(handler: () => void, delayMs: number): unknown; clearTimeout(handle: unknown): void; } const defaultTimers: TimerApi = { setTimeout: (handler, delayMs) => setTimeout(handler, delayMs), clearTimeout: (handle) => clearTimeout(handle as ReturnType), }; function unrefHandle(handle: unknown): void { if (handle && typeof handle === "object" && "unref" in handle && typeof (handle as { unref: unknown }).unref === "function") { (handle as { unref: () => void }).unref(); } } export interface BatcherOptions { /** debounceMs: quiet window after the last push; maxWaitMs: hard cap from the first push. */ config: { debounceMs: number; maxWaitMs: number }; emit: (items: T[]) => void; timers?: TimerApi; } export interface Batcher { push(item: T): void; /** Emit any held items immediately as one group. */ flush(): void; /** Drop timers and return never-emitted items (session teardown). */ dispose(): T[]; } export function createBatcher(options: BatcherOptions): Batcher { const timers = options.timers ?? defaultTimers; let pending: T[] = []; let debounceTimer: unknown = null; let maxWaitTimer: unknown = null; const clearTimers = (): void => { if (debounceTimer !== null) { timers.clearTimeout(debounceTimer); debounceTimer = null; } if (maxWaitTimer !== null) { timers.clearTimeout(maxWaitTimer); maxWaitTimer = null; } }; const emitGroup = (): void => { clearTimers(); if (pending.length === 0) return; const items = pending; pending = []; options.emit(items); }; return { push(item: T) { pending.push(item); if (debounceTimer !== null) timers.clearTimeout(debounceTimer); debounceTimer = timers.setTimeout(emitGroup, options.config.debounceMs); unrefHandle(debounceTimer); if (maxWaitTimer === null) { maxWaitTimer = timers.setTimeout(emitGroup, options.config.maxWaitMs); unrefHandle(maxWaitTimer); } }, flush: emitGroup, dispose() { clearTimers(); const abandoned = pending; pending = []; return abandoned; }, }; }