@caph42/pi-ccline 0.0.0-stage → 0.1.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.
@@ -0,0 +1,241 @@
1
+ // Maps pi session state to the JSON shape CCometixLine reads from stdin.
2
+ // Every helper here is pure so the mapping can be tested without a session.
3
+ // The upstream schema (src/input.rs, ccline 2.0.1) is entirely optional and
4
+ // lenient, so unknown values are omitted instead of guessed.
5
+
6
+ /** Thinking levels ccline renders in its effort segment. */
7
+ const EFFORT_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]);
8
+
9
+ export interface PiSnapshotModel {
10
+ id?: string | undefined;
11
+ /** Human readable model name; ccline shows this as `display_name`. */
12
+ name?: string | undefined;
13
+ /** Provider id from pi ("anthropic", "openai-codex", ...). */
14
+ provider?: string | undefined;
15
+ contextWindow?: number | undefined;
16
+ }
17
+
18
+ export interface PiSnapshotContextUsage {
19
+ tokens: number | null;
20
+ contextWindow: number | null;
21
+ percent: number | null;
22
+ }
23
+
24
+ export interface PiSnapshotTotals {
25
+ /** Cumulative USD cost over the visible branch. */
26
+ costUsd?: number | undefined;
27
+ /** Wall-clock time since the first visible branch entry. */
28
+ durationMs?: number | undefined;
29
+ }
30
+
31
+ export interface PiSnapshotSession {
32
+ id?: string | undefined;
33
+ name?: string | undefined;
34
+ }
35
+
36
+ export interface PiSnapshot {
37
+ cwd: string;
38
+ model?: PiSnapshotModel | undefined;
39
+ thinkingLevel?: string | undefined;
40
+ contextUsage?: PiSnapshotContextUsage | undefined;
41
+ totals?: PiSnapshotTotals | undefined;
42
+ session?: PiSnapshotSession | undefined;
43
+ }
44
+
45
+ /** The subset of pi's session entries this extension reads. */
46
+ export interface BranchEntryLike {
47
+ type?: string | undefined;
48
+ /** ISO timestamp written by pi when the entry was appended. */
49
+ timestamp?: string | undefined;
50
+ message?:
51
+ | {
52
+ role?: string | undefined;
53
+ usage?:
54
+ | {
55
+ input?: number | undefined;
56
+ output?: number | undefined;
57
+ cacheRead?: number | undefined;
58
+ cacheWrite?: number | undefined;
59
+ cost?: { total?: number | undefined } | undefined;
60
+ }
61
+ | undefined;
62
+ }
63
+ | undefined;
64
+ }
65
+
66
+ /** The subset of `ExtensionContext` used to build a snapshot. */
67
+ export interface SnapshotContextLike {
68
+ cwd: string;
69
+ model?:
70
+ | {
71
+ id?: string | undefined;
72
+ name?: string | undefined;
73
+ provider?: string | undefined;
74
+ contextWindow?: number | undefined;
75
+ }
76
+ | undefined;
77
+ thinkingLevel?: string | undefined;
78
+ getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
79
+ sessionManager: {
80
+ getBranch(): readonly BranchEntryLike[];
81
+ getSessionId?(): string;
82
+ getSessionName?(): string | undefined;
83
+ };
84
+ }
85
+
86
+ function num(value: unknown): number {
87
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
88
+ }
89
+
90
+ function finite(value: unknown): number | undefined {
91
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
92
+ }
93
+
94
+ function round1(value: number): number {
95
+ return Math.round(value * 10) / 10;
96
+ }
97
+
98
+ function trimmed(value: unknown): string | undefined {
99
+ if (typeof value !== "string") return undefined;
100
+ const text = value.trim();
101
+ return text ? text : undefined;
102
+ }
103
+
104
+ function attempt<T>(read: () => T): T | undefined {
105
+ try {
106
+ return read();
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Collect the state pi exposes into a plain snapshot.
114
+ * Values that cannot be read are left undefined so they are omitted downstream.
115
+ */
116
+ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnapshot {
117
+ const usage = attempt(() => ctx.getContextUsage());
118
+ const branch = attempt(() => ctx.sessionManager.getBranch()) ?? [];
119
+
120
+ let costUsd = 0;
121
+ let sawAssistantUsage = false;
122
+ let firstTimestamp: number | undefined;
123
+
124
+ for (const entry of branch) {
125
+ const timestamp = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
126
+ if (Number.isFinite(timestamp) && (firstTimestamp === undefined || timestamp < firstTimestamp)) {
127
+ firstTimestamp = timestamp;
128
+ }
129
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
130
+ const entryUsage = entry.message.usage;
131
+ if (!entryUsage) continue;
132
+ sawAssistantUsage = true;
133
+ costUsd += num(entryUsage.cost?.total);
134
+ }
135
+
136
+ const totals: PiSnapshotTotals | undefined = sawAssistantUsage
137
+ ? {
138
+ costUsd,
139
+ durationMs: firstTimestamp === undefined ? undefined : Math.max(0, nowMs - firstTimestamp),
140
+ }
141
+ : undefined;
142
+
143
+ const sessionId = attempt(() => ctx.sessionManager.getSessionId?.());
144
+ const sessionName = attempt(() => ctx.sessionManager.getSessionName?.());
145
+
146
+ return {
147
+ cwd: ctx.cwd,
148
+ model: ctx.model,
149
+ thinkingLevel: ctx.thinkingLevel,
150
+ contextUsage: usage
151
+ ? {
152
+ tokens: usage.tokens,
153
+ contextWindow: finite(usage.contextWindow) ?? null,
154
+ percent: usage.percent,
155
+ }
156
+ : undefined,
157
+ totals,
158
+ session: { id: trimmed(sessionId), name: trimmed(sessionName) },
159
+ };
160
+ }
161
+
162
+ export interface BuildCclineInputOptions {
163
+ /** Append the provider to the model segment: `Sonnet 4.5 (anthropic)`. */
164
+ showProvider?: boolean | undefined;
165
+ }
166
+
167
+ /**
168
+ * ccline 2.0.1 has no provider field: the only place a provider can surface is
169
+ * the model segment, which renders `display_name` verbatim. So the provider is
170
+ * appended there, and skipped when the name already carries it.
171
+ */
172
+ export function withProvider(name: string, provider: string | undefined, enabled: boolean): string {
173
+ if (!name || !provider || !enabled) return name;
174
+ const suffix = `(${provider})`;
175
+ return name.toLowerCase().endsWith(suffix.toLowerCase()) ? name : `${name} ${suffix}`;
176
+ }
177
+
178
+ /**
179
+ * Build the ccline stdin payload.
180
+ *
181
+ * `version` is intentionally never set: ccline 2.0.1 parses it as the Claude
182
+ * Code version and rejects anything older than 2.1.153.
183
+ */
184
+ export function buildCclineInput(snapshot: PiSnapshot, options: BuildCclineInputOptions = {}): Record<string, unknown> {
185
+ const input: Record<string, unknown> = {};
186
+
187
+ const cwd = trimmed(snapshot.cwd);
188
+ if (cwd) {
189
+ input.cwd = cwd;
190
+ input.workspace = { current_dir: cwd };
191
+ }
192
+
193
+ const modelId = trimmed(snapshot.model?.id);
194
+ const modelName = trimmed(snapshot.model?.name);
195
+ if (modelId || modelName) {
196
+ const model: Record<string, string> = {};
197
+ if (modelId) model.id = modelId;
198
+ model.display_name = withProvider(modelName ?? modelId ?? "", trimmed(snapshot.model?.provider), options.showProvider !== false);
199
+ input.model = model;
200
+ }
201
+
202
+ const level = trimmed(snapshot.thinkingLevel);
203
+ if (level && EFFORT_LEVELS.has(level)) {
204
+ input.effort = { level };
205
+ }
206
+
207
+ const usage = snapshot.contextUsage;
208
+ const totals = snapshot.totals;
209
+ const contextWindowSize = finite(usage?.contextWindow) ?? finite(snapshot.model?.contextWindow);
210
+ // Only the live context goes into context_window: pi reports an aggregate
211
+ // estimate, carried as input tokens. Cumulative session totals are not sent,
212
+ // because ccline prefers them over current_usage and would then show a token
213
+ // count that disagrees with used_percentage.
214
+ const currentTokens = finite(usage?.tokens);
215
+ const percent = finite(usage?.percent);
216
+
217
+ if (contextWindowSize !== undefined || currentTokens !== undefined || percent !== undefined) {
218
+ const contextWindow: Record<string, unknown> = {};
219
+ if (contextWindowSize !== undefined) contextWindow.context_window_size = Math.round(contextWindowSize);
220
+ if (currentTokens !== undefined) contextWindow.current_usage = { input_tokens: Math.round(currentTokens) };
221
+ if (percent !== undefined) contextWindow.used_percentage = round1(percent);
222
+ input.context_window = contextWindow;
223
+ }
224
+
225
+ const costUsd = finite(totals?.costUsd);
226
+ const durationMs = finite(totals?.durationMs);
227
+ if (costUsd !== undefined || durationMs !== undefined) {
228
+ const cost: Record<string, unknown> = {};
229
+ if (costUsd !== undefined) cost.total_cost_usd = costUsd;
230
+ if (durationMs !== undefined) cost.total_duration_ms = Math.round(durationMs);
231
+ input.cost = cost;
232
+ }
233
+
234
+ const sessionId = trimmed(snapshot.session?.id);
235
+ if (sessionId) input.session_id = sessionId;
236
+ const sessionName = trimmed(snapshot.session?.name);
237
+ if (sessionName) input.session_name = sessionName;
238
+
239
+ // rate_limits has no pi data source; omitting it removes the quota segment.
240
+ return input;
241
+ }
@@ -0,0 +1,285 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { RefreshScheduler, RefreshTicker, type TimerHandle } from "./refresh.ts";
5
+
6
+ /** Deterministic clock plus a manual timer queue. */
7
+ class FakeClock {
8
+ time = 0;
9
+ private nextId = 1;
10
+ private timers = new Map<number, { at: number; callback: () => void }>();
11
+
12
+ now = (): number => this.time;
13
+
14
+ setTimer = (callback: () => void, delayMs: number): TimerHandle => {
15
+ const id = this.nextId++;
16
+ this.timers.set(id, { at: this.time + delayMs, callback });
17
+ return id as unknown as TimerHandle;
18
+ };
19
+
20
+ clearTimer = (handle: TimerHandle): void => {
21
+ this.timers.delete(handle as unknown as number);
22
+ };
23
+
24
+ get pending(): number {
25
+ return this.timers.size;
26
+ }
27
+
28
+ /** Advance time and fire due timers, letting awaited microtasks settle. */
29
+ async advance(ms: number): Promise<void> {
30
+ const target = this.time + ms;
31
+ for (;;) {
32
+ await flush();
33
+ const due = [...this.timers.entries()]
34
+ .filter(([, timer]) => timer.at <= target)
35
+ .sort((a, b) => a[1].at - b[1].at)[0];
36
+ if (!due) break;
37
+ const [id, timer] = due;
38
+ this.timers.delete(id);
39
+ this.time = Math.max(this.time, timer.at);
40
+ timer.callback();
41
+ }
42
+ this.time = Math.max(this.time, target);
43
+ await flush();
44
+ }
45
+ }
46
+
47
+ async function flush(): Promise<void> {
48
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
49
+ }
50
+
51
+ function deferred() {
52
+ let resolve!: () => void;
53
+ const promise = new Promise<void>((resolvePromise) => {
54
+ resolve = resolvePromise;
55
+ });
56
+ return { promise, resolve };
57
+ }
58
+
59
+ function makeScheduler(clock: FakeClock, task: () => Promise<void>, throttleMs = 1000) {
60
+ return new RefreshScheduler({ throttleMs, task, now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer });
61
+ }
62
+
63
+ /** Deterministic clock for interval timers; unlike FakeClock they fire repeatedly. */
64
+ class FakeIntervalClock {
65
+ time = 0;
66
+ private nextId = 1;
67
+ private timers = new Map<number, { every: number; at: number; callback: () => void }>();
68
+
69
+ setInterval = (callback: () => void, delayMs: number): TimerHandle => {
70
+ const id = this.nextId++;
71
+ this.timers.set(id, { every: Math.max(1, delayMs), at: this.time + delayMs, callback });
72
+ return id as unknown as TimerHandle;
73
+ };
74
+
75
+ clearInterval = (handle: TimerHandle): void => {
76
+ this.timers.delete(handle as unknown as number);
77
+ };
78
+
79
+ get pending(): number {
80
+ return this.timers.size;
81
+ }
82
+
83
+ async advance(ms: number): Promise<void> {
84
+ const target = this.time + ms;
85
+ for (;;) {
86
+ await flush();
87
+ const due = [...this.timers.entries()]
88
+ .filter(([, timer]) => timer.at <= target)
89
+ .sort((a, b) => a[1].at - b[1].at)[0];
90
+ if (!due) break;
91
+ const [id, timer] = due;
92
+ this.time = Math.max(this.time, timer.at);
93
+ timer.at += timer.every;
94
+ timer.callback();
95
+ if (!this.timers.has(id)) continue;
96
+ }
97
+ this.time = Math.max(this.time, target);
98
+ await flush();
99
+ }
100
+ }
101
+
102
+ function makeTicker(clock: FakeIntervalClock, intervalMs: number, onTick: () => void) {
103
+ return new RefreshTicker({ intervalMs, onTick, setInterval: clock.setInterval, clearInterval: clock.clearInterval });
104
+ }
105
+
106
+ test("the ticker asks for a refresh once per interval", async () => {
107
+ const clock = new FakeIntervalClock();
108
+ let ticks = 0;
109
+ const ticker = makeTicker(clock, 1000, () => {
110
+ ticks += 1;
111
+ });
112
+
113
+ ticker.start();
114
+ assert.equal(ticks, 0, "starting must not tick immediately");
115
+ await clock.advance(3500);
116
+ assert.equal(ticks, 3);
117
+ ticker.stop();
118
+ });
119
+
120
+ test("starting twice does not stack intervals and stopping halts them", async () => {
121
+ const clock = new FakeIntervalClock();
122
+ let ticks = 0;
123
+ const ticker = makeTicker(clock, 1000, () => {
124
+ ticks += 1;
125
+ });
126
+
127
+ ticker.start();
128
+ ticker.start();
129
+ assert.equal(clock.pending, 1);
130
+ await clock.advance(2000);
131
+ assert.equal(ticks, 2);
132
+
133
+ ticker.stop();
134
+ assert.equal(clock.pending, 0);
135
+ await clock.advance(5000);
136
+ assert.equal(ticks, 2, "no ticks after stop");
137
+ });
138
+
139
+ test("a zero interval never starts a timer", async () => {
140
+ const clock = new FakeIntervalClock();
141
+ let ticks = 0;
142
+ const ticker = makeTicker(clock, 0, () => {
143
+ ticks += 1;
144
+ });
145
+
146
+ ticker.start();
147
+ assert.equal(ticker.isRunning, false);
148
+ assert.equal(clock.pending, 0);
149
+ await clock.advance(10_000);
150
+ assert.equal(ticks, 0);
151
+ });
152
+
153
+ test("a throwing tick does not kill the ticker", async () => {
154
+ const clock = new FakeIntervalClock();
155
+ let ticks = 0;
156
+ const ticker = makeTicker(clock, 1000, () => {
157
+ ticks += 1;
158
+ throw new Error("boom");
159
+ });
160
+
161
+ ticker.start();
162
+ await clock.advance(2500);
163
+ assert.equal(ticks, 2);
164
+ ticker.stop();
165
+ });
166
+
167
+ test("request never runs the task synchronously", async () => {
168
+ const clock = new FakeClock();
169
+ let runs = 0;
170
+ const scheduler = makeScheduler(clock, async () => {
171
+ runs += 1;
172
+ });
173
+
174
+ scheduler.request();
175
+ assert.equal(runs, 0, "render passes must not spawn inline");
176
+
177
+ await clock.advance(0);
178
+ assert.equal(runs, 1);
179
+ scheduler.dispose();
180
+ });
181
+
182
+ test("a burst of requests collapses into one run", async () => {
183
+ const clock = new FakeClock();
184
+ let runs = 0;
185
+ const scheduler = makeScheduler(clock, async () => {
186
+ runs += 1;
187
+ });
188
+
189
+ scheduler.request();
190
+ scheduler.request();
191
+ scheduler.request();
192
+ await clock.advance(0);
193
+ assert.equal(runs, 1);
194
+ scheduler.dispose();
195
+ });
196
+
197
+ test("requests during a run collapse into a single follow-up that sees latest state", async () => {
198
+ const clock = new FakeClock();
199
+ const gate = deferred();
200
+ let runs = 0;
201
+ let observed = -1;
202
+ let state = 0;
203
+ const scheduler = makeScheduler(clock, async () => {
204
+ runs += 1;
205
+ observed = state;
206
+ if (runs === 1) await gate.promise;
207
+ });
208
+
209
+ scheduler.request();
210
+ await clock.advance(0);
211
+ assert.equal(runs, 1);
212
+
213
+ state = 1;
214
+ scheduler.request();
215
+ state = 2;
216
+ scheduler.request();
217
+ state = 3;
218
+ scheduler.request();
219
+ assert.equal(runs, 1, "still single-flight");
220
+
221
+ gate.resolve();
222
+ await clock.advance(1000);
223
+ assert.equal(runs, 2);
224
+ assert.equal(observed, 3, "latest wins");
225
+ scheduler.dispose();
226
+ });
227
+
228
+ test("refreshes stay at least throttleMs apart", async () => {
229
+ const clock = new FakeClock();
230
+ const starts: number[] = [];
231
+ const scheduler = makeScheduler(clock, async () => {
232
+ starts.push(clock.time);
233
+ });
234
+
235
+ scheduler.request();
236
+ await clock.advance(0);
237
+ assert.deepEqual(starts, [0]);
238
+
239
+ // Immediate second request has to wait for the throttle window.
240
+ await clock.advance(100);
241
+ scheduler.request();
242
+ await clock.advance(100);
243
+ assert.deepEqual(starts, [0], "not yet");
244
+
245
+ await clock.advance(800);
246
+ assert.deepEqual(starts, [0, 1000]);
247
+ scheduler.dispose();
248
+ });
249
+
250
+ test("dispose stops pending and future runs", async () => {
251
+ const clock = new FakeClock();
252
+ let runs = 0;
253
+ const scheduler = makeScheduler(clock, async () => {
254
+ runs += 1;
255
+ });
256
+
257
+ scheduler.request();
258
+ scheduler.dispose();
259
+ await clock.advance(5000);
260
+ assert.equal(runs, 0);
261
+ assert.equal(clock.pending, 0);
262
+
263
+ scheduler.request();
264
+ await clock.advance(5000);
265
+ assert.equal(runs, 0);
266
+ });
267
+
268
+ test("a throwing task does not wedge the scheduler", async () => {
269
+ const clock = new FakeClock();
270
+ let runs = 0;
271
+ const scheduler = makeScheduler(clock, async () => {
272
+ runs += 1;
273
+ throw new Error("boom");
274
+ });
275
+
276
+ scheduler.request();
277
+ await clock.advance(0);
278
+ assert.equal(runs, 1);
279
+ assert.equal(scheduler.isRunning, false);
280
+
281
+ scheduler.request();
282
+ await clock.advance(1000);
283
+ assert.equal(runs, 2);
284
+ scheduler.dispose();
285
+ });
@@ -0,0 +1,145 @@
1
+ // Single-flight, throttled refresh scheduler.
2
+ // The footer's render(width) runs on every repaint, so refresh work must never
3
+ // start synchronously from there: request() always defers to a timer, and at
4
+ // most one task runs at a time. Extra requests made while a task is running
5
+ // collapse into one follow-up run that reads the latest state (latest wins).
6
+
7
+ export type TimerHandle = ReturnType<typeof setTimeout>;
8
+
9
+ export interface RefreshSchedulerOptions {
10
+ /** Minimum delay between two starts. Callers pass a value already clamped to >= 1000. */
11
+ throttleMs: number;
12
+ task: () => Promise<void>;
13
+ now?: () => number;
14
+ setTimer?: (callback: () => void, delayMs: number) => TimerHandle;
15
+ clearTimer?: (handle: TimerHandle) => void;
16
+ }
17
+
18
+ export interface RefreshTickerOptions {
19
+ /** 0 (or any non-positive value) disables ticking. */
20
+ intervalMs: number;
21
+ onTick: () => void;
22
+ setInterval?: (callback: () => void, delayMs: number) => TimerHandle;
23
+ clearInterval?: (handle: TimerHandle) => void;
24
+ }
25
+
26
+ /**
27
+ * Drives time-based parts of the footer. Event-driven refreshes cannot advance
28
+ * a clock, so while the agent runs a fixed interval asks the scheduler for a
29
+ * fresh frame. stop() only clears the timer: a request already queued in the
30
+ * scheduler may still land once, and nothing is scheduled after that.
31
+ */
32
+ export class RefreshTicker {
33
+ private readonly intervalMs: number;
34
+ private readonly onTick: () => void;
35
+ private readonly setTimer: (callback: () => void, delayMs: number) => TimerHandle;
36
+ private readonly clearTimer: (handle: TimerHandle) => void;
37
+ private handle: TimerHandle | null = null;
38
+
39
+ constructor(options: RefreshTickerOptions) {
40
+ this.intervalMs = Number.isFinite(options.intervalMs) ? options.intervalMs : 0;
41
+ this.onTick = options.onTick;
42
+ this.setTimer = options.setInterval ?? ((callback, delayMs) => setInterval(callback, delayMs));
43
+ this.clearTimer = options.clearInterval ?? ((handle) => clearInterval(handle));
44
+ }
45
+
46
+ get isRunning(): boolean {
47
+ return this.handle !== null;
48
+ }
49
+
50
+ /** Idempotent: a second start does not stack another interval. */
51
+ start(): void {
52
+ if (this.handle !== null || this.intervalMs <= 0) return;
53
+ this.handle = this.setTimer(() => {
54
+ // The scheduler reports its own failures; a throw must not kill the timer.
55
+ try {
56
+ this.onTick();
57
+ } catch {
58
+ // ignore
59
+ }
60
+ }, this.intervalMs);
61
+ }
62
+
63
+ stop(): void {
64
+ if (this.handle === null) return;
65
+ this.clearTimer(this.handle);
66
+ this.handle = null;
67
+ }
68
+ }
69
+
70
+ export class RefreshScheduler {
71
+ private readonly throttleMs: number;
72
+ private readonly task: () => Promise<void>;
73
+ private readonly now: () => number;
74
+ private readonly setTimer: (callback: () => void, delayMs: number) => TimerHandle;
75
+ private readonly clearTimer: (handle: TimerHandle) => void;
76
+
77
+ private timer: TimerHandle | null = null;
78
+ private pending = false;
79
+ private running = false;
80
+ private disposed = false;
81
+ private lastRunAt = Number.NEGATIVE_INFINITY;
82
+
83
+ /** Number of task runs; used by tests and `/pi-ccline status`. */
84
+ runs = 0;
85
+
86
+ constructor(options: RefreshSchedulerOptions) {
87
+ this.throttleMs = Number.isFinite(options.throttleMs) ? Math.max(0, options.throttleMs) : 0;
88
+ this.task = options.task;
89
+ this.now = options.now ?? (() => Date.now());
90
+ this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
91
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
92
+ }
93
+
94
+ get isRunning(): boolean {
95
+ return this.running;
96
+ }
97
+
98
+ get isDisposed(): boolean {
99
+ return this.disposed;
100
+ }
101
+
102
+ /** Ask for a refresh. Safe to call from a render pass: it never runs the task inline. */
103
+ request(): void {
104
+ if (this.disposed) return;
105
+ this.pending = true;
106
+ this.arm();
107
+ }
108
+
109
+ dispose(): void {
110
+ this.disposed = true;
111
+ this.pending = false;
112
+ if (this.timer !== null) {
113
+ this.clearTimer(this.timer);
114
+ this.timer = null;
115
+ }
116
+ }
117
+
118
+ private arm(): void {
119
+ if (this.disposed || this.running || this.timer !== null || !this.pending) return;
120
+ const elapsed = this.now() - this.lastRunAt;
121
+ const delay = elapsed >= this.throttleMs ? 0 : Math.max(0, this.throttleMs - elapsed);
122
+ this.timer = this.setTimer(() => {
123
+ this.timer = null;
124
+ void this.flush();
125
+ }, delay);
126
+ }
127
+
128
+ private async flush(): Promise<void> {
129
+ if (this.disposed || this.running || !this.pending) return;
130
+ this.pending = false;
131
+ this.running = true;
132
+ this.lastRunAt = this.now();
133
+ this.runs += 1;
134
+ try {
135
+ await this.task();
136
+ } catch {
137
+ // The task reports its own failures through the frame; a throw must not
138
+ // leave the scheduler stuck in the running state.
139
+ } finally {
140
+ this.running = false;
141
+ }
142
+ if (this.disposed) return;
143
+ this.arm();
144
+ }
145
+ }