@d3ara1n/pi-subagent 0.10.4 → 1.0.0
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.
- package/README.md +62 -13
- package/package.json +1 -1
- package/src/history.ts +1 -0
- package/src/index.ts +385 -340
- package/src/output.ts +10 -11
- package/src/render-async.ts +324 -0
- package/src/render.ts +49 -130
- package/src/roles.ts +8 -8
- package/src/run.test.ts +211 -0
- package/src/run.ts +352 -0
- package/src/spawn.ts +26 -26
- package/src/types.ts +55 -7
- package/src/utils.test.ts +321 -49
- package/src/utils.ts +336 -12
package/src/utils.ts
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure helpers for pi-subagent: formatting, sanitization,
|
|
3
|
-
* semaphore. No pi-API or
|
|
2
|
+
* Pure helpers for pi-subagent: formatting, sanitization, the concurrency
|
|
3
|
+
* semaphore, render-side timers, and notification throttling. No pi-API or
|
|
4
|
+
* I/O dependencies — safe to unit-test.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import * as os from "node:os";
|
|
7
8
|
import type {
|
|
8
9
|
ActivityEntry,
|
|
10
|
+
FallbackFrom,
|
|
11
|
+
RunState,
|
|
9
12
|
SubagentDetails,
|
|
10
13
|
SubagentRole,
|
|
11
14
|
SubagentResult,
|
|
15
|
+
SubagentUsage,
|
|
12
16
|
ToolStatus,
|
|
17
|
+
WaitDetails,
|
|
13
18
|
} from "./types.ts";
|
|
14
19
|
|
|
15
20
|
/** Max output chars fed to the main model and the expanded TUI. Larger outputs are compressed (or truncated) to fit. */
|
|
16
21
|
export const MAX_OUTPUT_CHARS = 50_000;
|
|
17
22
|
|
|
23
|
+
/** Coalesce bursty progress events so the TUI repaints at most this often. */
|
|
24
|
+
export const PROGRESS_THROTTLE_MS = 50;
|
|
25
|
+
|
|
26
|
+
/** A zeroed usage block — frames and synthesized failures start from this. */
|
|
27
|
+
export function emptyUsage(): SubagentUsage {
|
|
28
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export function formatTokens(count: number): string {
|
|
19
32
|
if (count < 1000) return count.toString();
|
|
20
33
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
@@ -22,16 +35,28 @@ export function formatTokens(count: number): string {
|
|
|
22
35
|
return `${(count / 1000000).toFixed(1)}M`;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Usage parts shared by the TUI stats line and the LLM usage footer.
|
|
40
|
+
* `withCache` adds the cache-read/write and peak-context figures (TUI only —
|
|
41
|
+
* the LLM footer stays lean).
|
|
42
|
+
*/
|
|
43
|
+
function usageParts(usage: SubagentUsage, model: string | undefined, withCache: boolean): string[] {
|
|
26
44
|
const parts: string[] = [];
|
|
27
45
|
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
28
46
|
if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`);
|
|
29
47
|
if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`);
|
|
30
|
-
if (
|
|
31
|
-
|
|
48
|
+
if (withCache) {
|
|
49
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
50
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
51
|
+
if (usage.contextTokens) parts.push(`ctx${formatTokens(usage.contextTokens)}`);
|
|
52
|
+
}
|
|
32
53
|
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
33
54
|
if (model) parts.push(model);
|
|
34
|
-
return parts
|
|
55
|
+
return parts;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatUsageStats(usage: SubagentUsage, model?: string): string {
|
|
59
|
+
return usageParts(usage, model, true).join(" ");
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
/**
|
|
@@ -57,6 +82,31 @@ export function elapsedSeconds(r: {
|
|
|
57
82
|
return undefined;
|
|
58
83
|
}
|
|
59
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Elapsed/budget time text for a frame: `42s/900s(+7s)` while budgeted (grace
|
|
87
|
+
* = time the child spent inside nested delegates, live-computed from an open
|
|
88
|
+
* pause), `42s` without a budget. null when no time is known (queued frames).
|
|
89
|
+
*/
|
|
90
|
+
export function formatTimePart(r: {
|
|
91
|
+
exitCode: number;
|
|
92
|
+
startTime?: number;
|
|
93
|
+
elapsedMs?: number;
|
|
94
|
+
budgetMs?: number;
|
|
95
|
+
graceMs?: number;
|
|
96
|
+
pauseStart?: number;
|
|
97
|
+
}): string | null {
|
|
98
|
+
const secs = elapsedSeconds(r);
|
|
99
|
+
if (secs == null) return null;
|
|
100
|
+
const budgetSec = r.budgetMs ? Math.round(r.budgetMs / 1000) : 0;
|
|
101
|
+
const liveGraceMs = (r.graceMs ?? 0) + (r.pauseStart ? Date.now() - r.pauseStart : 0);
|
|
102
|
+
const graceSec = Math.round(liveGraceMs / 1000);
|
|
103
|
+
return budgetSec > 0
|
|
104
|
+
? graceSec > 0
|
|
105
|
+
? `${secs}s/${budgetSec}s(+${graceSec}s)`
|
|
106
|
+
: `${secs}s/${budgetSec}s`
|
|
107
|
+
: `${secs}s`;
|
|
108
|
+
}
|
|
109
|
+
|
|
60
110
|
export type DisplayItem =
|
|
61
111
|
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
62
112
|
| { type: "thinking"; status?: ToolStatus };
|
|
@@ -84,8 +134,9 @@ export function formatToolCall(
|
|
|
84
134
|
fg: (color: string, text: string) => string,
|
|
85
135
|
): string {
|
|
86
136
|
switch (toolName) {
|
|
87
|
-
case "
|
|
137
|
+
case "subagent_delegate": {
|
|
88
138
|
const subRole = args.role as string | undefined;
|
|
139
|
+
// Compact display label — the full tool name is subagent_delegate.
|
|
89
140
|
return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
|
|
90
141
|
}
|
|
91
142
|
case "bash": {
|
|
@@ -191,7 +242,127 @@ export function renderDisplayItems(
|
|
|
191
242
|
return text.trimEnd();
|
|
192
243
|
}
|
|
193
244
|
|
|
194
|
-
|
|
245
|
+
// ── Shared result-view composition ─────────────────────────────
|
|
246
|
+
// Used by the foreground delegate row (./render.ts) and the background
|
|
247
|
+
// wait/check rows (./render-async.ts) so every view renders the same
|
|
248
|
+
// outcome the same way.
|
|
249
|
+
|
|
250
|
+
/** Plain-text fallback when details are missing (thrown errors, malformed results). */
|
|
251
|
+
export function contentText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
252
|
+
const text = result.content[0];
|
|
253
|
+
return text?.type === "text" && text.text ? text.text : "(no output)";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** First line of the task, truncated to one row (the always-visible anchor). */
|
|
257
|
+
export function taskPreview(task: string): string {
|
|
258
|
+
const firstLine = task.split("\n")[0];
|
|
259
|
+
return firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Status icon for a run frame: ⏸ queued / ⏳ running / ⏱ timeout / ⏲ budget / ✗ failed / ✓ ok */
|
|
263
|
+
export function runIcon(
|
|
264
|
+
r: { exitCode: number; queued?: boolean; stopReason?: string },
|
|
265
|
+
fg: (color: string, text: string) => string,
|
|
266
|
+
): string {
|
|
267
|
+
const state = deriveRunState(r);
|
|
268
|
+
if (state === "queued") return fg("warning", "\u23F8");
|
|
269
|
+
if (state === "running") return fg("warning", "\u23F3");
|
|
270
|
+
if (r.stopReason === "timeout") return fg("warning", "\u23F1");
|
|
271
|
+
if (r.stopReason === "budget_exceeded") return fg("warning", "\u23F2");
|
|
272
|
+
if (state === "failed") return fg("error", "\u2717");
|
|
273
|
+
return fg("success", "\u2713");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Failure result-line content: errorMessage → stop-reason label → "failed". */
|
|
277
|
+
function failureResultText(r: {
|
|
278
|
+
errorMessage?: string;
|
|
279
|
+
stopReason?: string;
|
|
280
|
+
}): { content: string; col: "warning" | "error" } {
|
|
281
|
+
const isTimeout = r.stopReason === "timeout";
|
|
282
|
+
const isBudget = r.stopReason === "budget_exceeded";
|
|
283
|
+
return {
|
|
284
|
+
content: r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed"),
|
|
285
|
+
col: isTimeout || isBudget ? "warning" : "error",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Success result-line content: AI summary → output first line → placeholder. */
|
|
290
|
+
function successResultText(r: {
|
|
291
|
+
summary?: string;
|
|
292
|
+
output: string;
|
|
293
|
+
}): { content: string; col: "text" | "muted" } {
|
|
294
|
+
const firstLine = r.output.trim().split("\n")[0] ?? "";
|
|
295
|
+
const preview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
296
|
+
const content = r.summary || preview;
|
|
297
|
+
return { content: content || "(no output)", col: content ? "text" : "muted" };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Composed terminal result line `<icon> <content>`. Budget stops count as the
|
|
302
|
+
* failure presentation (their output is partial and the reason matters), even
|
|
303
|
+
* though the run state itself is "finished". `finishedText` (wait's
|
|
304
|
+
* "finished") replaces the success chain for status-only views.
|
|
305
|
+
*/
|
|
306
|
+
export function terminalResultLine(
|
|
307
|
+
r: {
|
|
308
|
+
exitCode: number;
|
|
309
|
+
queued?: boolean;
|
|
310
|
+
stopReason?: string;
|
|
311
|
+
errorMessage?: string;
|
|
312
|
+
summary?: string;
|
|
313
|
+
output: string;
|
|
314
|
+
},
|
|
315
|
+
fg: (color: string, text: string) => string,
|
|
316
|
+
finishedText?: string,
|
|
317
|
+
): string {
|
|
318
|
+
const icon = runIcon(r, fg);
|
|
319
|
+
if (isFailedResult(r) || r.stopReason === "budget_exceeded") {
|
|
320
|
+
const t = failureResultText(r);
|
|
321
|
+
return `${icon} ${fg(t.col, t.content)}`;
|
|
322
|
+
}
|
|
323
|
+
if (finishedText !== undefined) return `${icon} ${fg("text", finishedText)}`;
|
|
324
|
+
const t = successResultText(r);
|
|
325
|
+
return `${icon} ${fg(t.col, t.content)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Render-side elapsed-time animation ─────────────────────────
|
|
329
|
+
|
|
330
|
+
/** Per-row render state slot holding the elapsed-time animation timer. */
|
|
331
|
+
interface ElapsedTimerState {
|
|
332
|
+
elapsedTimer?: ReturnType<typeof setInterval>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* While a run is live, force a TUI repaint every second so the elapsed time
|
|
337
|
+
* ticks up even when the child process is idle. Uses context.invalidate()
|
|
338
|
+
* (pi's official re-render hook) rather than pushing data via onUpdate — the
|
|
339
|
+
* render recomputes elapsed time fresh from Date.now().
|
|
340
|
+
*/
|
|
341
|
+
export function ensureElapsedTimer(context: {
|
|
342
|
+
state: Record<string, unknown>;
|
|
343
|
+
invalidate?: () => void;
|
|
344
|
+
}): void {
|
|
345
|
+
const state = context.state as ElapsedTimerState;
|
|
346
|
+
if (state.elapsedTimer) return;
|
|
347
|
+
if (typeof context.invalidate !== "function") return;
|
|
348
|
+
state.elapsedTimer = setInterval(() => {
|
|
349
|
+
try {
|
|
350
|
+
context.invalidate?.();
|
|
351
|
+
} catch {
|
|
352
|
+
/* ignore — invalidate must never break rendering */
|
|
353
|
+
}
|
|
354
|
+
}, 1000);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Stop the elapsed-time animation once the run reaches a terminal state. */
|
|
358
|
+
export function clearElapsedTimer(context: { state: Record<string, unknown> }): void {
|
|
359
|
+
const state = context.state as ElapsedTimerState;
|
|
360
|
+
if (!state.elapsedTimer) return;
|
|
361
|
+
clearInterval(state.elapsedTimer);
|
|
362
|
+
state.elapsedTimer = undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function isFailedResult(r: { exitCode: number; stopReason?: string }): boolean {
|
|
195
366
|
return (
|
|
196
367
|
r.exitCode !== 0 ||
|
|
197
368
|
r.stopReason === "error" ||
|
|
@@ -205,12 +376,135 @@ export function hasFailedSubagentResult(details: unknown): boolean {
|
|
|
205
376
|
return Array.isArray(d?.results) && d.results.some(isFailedResult);
|
|
206
377
|
}
|
|
207
378
|
|
|
379
|
+
/** Provider-error keywords that make a failed run worth retrying on the fallback role. */
|
|
380
|
+
const PROVIDER_ERROR_RE =
|
|
381
|
+
/429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i;
|
|
382
|
+
|
|
208
383
|
/** Heuristic: does this result look like a provider-side failure worth retrying on the fallback role? */
|
|
209
384
|
export function isProviderError(result: SubagentResult): boolean {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
385
|
+
return PROVIDER_ERROR_RE.test(`${result.stderr || ""}\n${result.errorMessage || ""}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Cap for the stderr tail kept in fallback diagnostics. */
|
|
389
|
+
export const FALLBACK_STDERR_TAIL = 400;
|
|
390
|
+
|
|
391
|
+
const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Best-effort diagnosis from stderr when the child died before any message_end
|
|
395
|
+
* (e.g. 429 on the very first request — model/errorMessage/stopReason all unset).
|
|
396
|
+
* Returns the last stderr line mentioning a provider-error keyword: pi prints
|
|
397
|
+
* the fatal error near exit, so the last match is the most specific.
|
|
398
|
+
*/
|
|
399
|
+
export function extractProviderReason(text: string): string | undefined {
|
|
400
|
+
const lines = text
|
|
401
|
+
.replace(ANSI_ESCAPE, "")
|
|
402
|
+
.split("\n")
|
|
403
|
+
.map((l) => l.trim())
|
|
404
|
+
.filter(Boolean);
|
|
405
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
406
|
+
if (PROVIDER_ERROR_RE.test(lines[i])) return lines[i];
|
|
407
|
+
}
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Snapshot a failed first attempt for fallback observability.
|
|
413
|
+
* The fallback retry overwrites the result, so this snapshot is the only
|
|
414
|
+
* trace of why the first attempt died. stderr is noisy (TUI teardown
|
|
415
|
+
* escape sequences) — only a truncated tail is kept.
|
|
416
|
+
*
|
|
417
|
+
* `requestedModel` fills the model field when the child died before any
|
|
418
|
+
* message_end — the parent always knows what it asked for.
|
|
419
|
+
*/
|
|
420
|
+
export function buildFallbackFrom(first: SubagentResult, requestedModel?: string): FallbackFrom {
|
|
421
|
+
const tail = first.stderr.slice(-FALLBACK_STDERR_TAIL).trim();
|
|
422
|
+
return {
|
|
423
|
+
model: first.model ?? requestedModel,
|
|
424
|
+
stopReason: first.stopReason,
|
|
425
|
+
errorMessage: first.errorMessage || extractProviderReason(tail),
|
|
426
|
+
stderrTail: tail || undefined,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* One-line human-readable fallback reason, e.g.
|
|
432
|
+
* `first attempt deepseek-v4-flash failed (Timed out after 900s)`.
|
|
433
|
+
* Prefer errorMessage; fall back to stopReason, else a generic label.
|
|
434
|
+
*/
|
|
435
|
+
export function formatFallback(f: FallbackFrom): string {
|
|
436
|
+
const reason = f.errorMessage || f.stopReason || "provider error";
|
|
437
|
+
const firstLine = reason.split("\n")[0];
|
|
438
|
+
const short = firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine;
|
|
439
|
+
return `first attempt ${f.model ?? "unknown model"} failed (${short})`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Background runs (delegate background:true / wait / check) ────────────
|
|
443
|
+
|
|
444
|
+
/** Derive the lifecycle state of a run from one of its frames (live or terminal). */
|
|
445
|
+
export function deriveRunState(r: { exitCode: number; queued?: boolean; stopReason?: string }): RunState {
|
|
446
|
+
if (r.exitCode === -1) return r.queued ? "queued" : "running";
|
|
447
|
+
return isFailedResult(r) ? "failed" : "finished";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** True when wait tool result details carries the timeout flag. */
|
|
451
|
+
export function isWaitTimedOut(details: unknown): boolean {
|
|
452
|
+
return (details as WaitDetails | undefined)?.timedOut === true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Human-readable description of what a running subagent is doing right now (latest activity item). */
|
|
456
|
+
export function describeCurrentActivity(r: { activityLog: ActivityEntry[] }): string {
|
|
457
|
+
const last = r.activityLog[r.activityLog.length - 1];
|
|
458
|
+
if (!last) return "waiting for first event";
|
|
459
|
+
if (last.kind === "thinking") return last.status === "running" ? "thinking" : "thought";
|
|
460
|
+
return formatToolCall(last.toolName ?? "?", last.args ?? {}, (_color, text) => text);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Footer appended to terminal results for the main model: `\n\n--- 3 turns ↑12k ↓1k $0.01 model ---` (empty when nothing to show). */
|
|
464
|
+
export function formatUsageFooter(r: { usage: SubagentUsage; model?: string }): string {
|
|
465
|
+
const parts = usageParts(r.usage, r.model, false);
|
|
466
|
+
return parts.length > 0 ? `\n\n--- ${parts.join(" ")} ---` : "";
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Fallback provenance note appended to terminal results (empty when no retry happened). */
|
|
470
|
+
export function formatFallbackNote(r: { fallbackFrom?: FallbackFrom; model?: string }): string {
|
|
471
|
+
return r.fallbackFrom
|
|
472
|
+
? `\n\n--- fallback: ${formatFallback(r.fallbackFrom)}; retried on ${r.model ?? "fallback role"} ---`
|
|
473
|
+
: "";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Budget-stop note appended to terminal results (empty unless the run was
|
|
478
|
+
* killed for exceeding its turn/cost budget). Budget stops are intentional
|
|
479
|
+
* successes, but the reader must know the output is partial.
|
|
480
|
+
*/
|
|
481
|
+
export function formatBudgetNote(r: { stopReason?: string; errorMessage?: string }): string {
|
|
482
|
+
return r.stopReason === "budget_exceeded"
|
|
483
|
+
? `\n\n--- ${r.errorMessage || "Budget exceeded"} ---`
|
|
484
|
+
: "";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** LLM-facing text returned by the check tool for one run snapshot. */
|
|
488
|
+
export function formatCheckText(id: string, role: string, r: SubagentResult): string {
|
|
489
|
+
const state = deriveRunState(r);
|
|
490
|
+
const head = `${id} (${role})`;
|
|
491
|
+
if (state === "queued") return `${head}: queued — waiting for a concurrency slot.`;
|
|
492
|
+
if (state === "running") return `${head}: running — ${describeCurrentActivity(r)}`;
|
|
493
|
+
if (state === "failed") {
|
|
494
|
+
return `${head}: failed — ${r.errorMessage || r.stderr || "unknown error"}\n\nPartial output:\n${r.output}${formatFallbackNote(r)}`;
|
|
495
|
+
}
|
|
496
|
+
return `${head}: finished\n\n${r.output}${formatBudgetNote(r)}${formatFallbackNote(r)}${formatUsageFooter(r)}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Freeze a live frame into a static snapshot: stop the elapsed clock and fold the open pause into grace. */
|
|
500
|
+
export function freezeFrame(r: SubagentResult): SubagentResult {
|
|
501
|
+
return {
|
|
502
|
+
...r,
|
|
503
|
+
startTime: undefined,
|
|
504
|
+
elapsedMs: r.startTime ? Date.now() - r.startTime : r.elapsedMs,
|
|
505
|
+
graceMs: (r.graceMs ?? 0) + (r.pauseStart ? Date.now() - r.pauseStart : 0),
|
|
506
|
+
pauseStart: undefined,
|
|
507
|
+
};
|
|
214
508
|
}
|
|
215
509
|
|
|
216
510
|
/**
|
|
@@ -298,6 +592,36 @@ export class AsyncSemaphore {
|
|
|
298
592
|
}
|
|
299
593
|
}
|
|
300
594
|
|
|
595
|
+
// ── Notification throttling ─────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Coalesce bursty notifications into at most one `fire` per
|
|
599
|
+
* {@link PROGRESS_THROTTLE_MS}. `cancel()` drops any pending fire — call it
|
|
600
|
+
* when the owning tool call exits, so a stale update never fires afterwards.
|
|
601
|
+
*/
|
|
602
|
+
export function createThrottler(fire: () => void): { notify(): void; cancel(): void } {
|
|
603
|
+
let pending = false;
|
|
604
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
605
|
+
return {
|
|
606
|
+
notify() {
|
|
607
|
+
pending = true;
|
|
608
|
+
if (handle !== undefined) return;
|
|
609
|
+
handle = setTimeout(() => {
|
|
610
|
+
handle = undefined;
|
|
611
|
+
if (pending) {
|
|
612
|
+
pending = false;
|
|
613
|
+
fire();
|
|
614
|
+
}
|
|
615
|
+
}, PROGRESS_THROTTLE_MS);
|
|
616
|
+
},
|
|
617
|
+
cancel() {
|
|
618
|
+
if (handle !== undefined) clearTimeout(handle);
|
|
619
|
+
handle = undefined;
|
|
620
|
+
pending = false;
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
301
625
|
// ── Timeout policy ────────────────────────────────────────
|
|
302
626
|
|
|
303
627
|
/**
|