From ced597c86937f896b7505a150aba5d841a661d9e Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Tue, 22 Sep 2026 17:14:17 +0300
Subject: [PATCH 1/2] fix(tps): hold live rate across tools and steps
The live estimate kept decaying after the stream-end event while tools ran, and step settlement swapped the displayed value for the run-wide average. The tracker now captures the live rate at the stream-end boundary, falls back to the final content boundary for hosts that publish no such event, clamps the live clock at that boundary, and reopens the calculation when new bytes arrive after it. Settlement skips a boundary older than the newest sample, since those bytes would divide by the 250 ms floor and inflate the hold. Token and duration accounting and the frozen run average are unchanged; only the running display and the refresh timer differ.
---
README.md | 4 +-
docs/development.md | 8 +-
src/plugin.tsx | 9 +-
src/tracker.ts | 52 +++++++++--
tests/plugin.test.ts | 34 ++++++++
tests/tracker.test.ts | 197 ++++++++++++++++++++++++++++++++++++++++++
6 files changed, 290 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index a42eb56..5959b23 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,7 @@ OpenCode's reported output and reasoning usage replaces the byte estimate at the
OpenCode's built-in assistant-footer t/s divides visible output tokens by the same step spans, leaving hidden reasoning out of its numerator. This plugin counts output plus reasoning, so on reasoning models its settled figure reads higher than the built-in one — those tokens were generated too.
-TPS is always approximate (`~`) because OpenCode does not expose token-level timestamps. Proprietary reasoning may be encrypted or represented only by a short summary, and some providers buffer tool arguments until completion. During those opaque intervals the live rate holds or becomes unavailable instead of continuously falling. Opaque provider state is never counted by byte length.
+TPS is always approximate (`~`) because OpenCode does not expose token-level timestamps. Proprietary reasoning may be encrypted or represented only by a short summary, and some providers buffer tool arguments until completion. During those opaque intervals, across local tool execution, and between model steps, the live rate holds instead of falling. It freezes at the stream-end boundary, or at the final content boundary when the host publishes no such event, and only new observable output resumes it. Opaque provider state is never counted by byte length.
For more detail, see [Architecture](docs/development.md#architecture).
@@ -87,7 +87,7 @@ For more detail, see [Architecture](docs/development.md#architecture).
Every output event carries the ID of the session that produced it, so each session is measured on its own.
-A sub-agent streams under its own child session ID. The orchestrator's number stops moving while it works and holds the average of the output the orchestrator produced before delegating. Open the sub-agent's session to watch its live throughput.
+A sub-agent streams under its own child session ID. The orchestrator's number stops moving while it works and holds the last live rate it measured before delegating. Open the sub-agent's session to watch its live throughput.
diff --git a/docs/development.md b/docs/development.md
index 96222d3..0e68824 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -65,11 +65,11 @@ That means one directory and one log per PID. Hot reloads append to the same fil
- The plugin listens to session events to start a run, end a run, and collect the model's output.
- It estimates live tokens from observable UTF-8 bytes at 4.75 bytes per token by default. Complete block values reconcile buffered or missed deltas.
-- Live TPS is a bounded rolling rate over observable deltas. Its denominator stops after a short stale tail because silence may be encrypted reasoning or buffered tool input rather than inactivity.
+- Live TPS is a bounded rolling rate over observable deltas. Its denominator stops after a short stale tail because silence may be encrypted reasoning or buffered tool input rather than inactivity. At the `session.step.streamed` boundary the clock stops entirely. The live estimate is then held across tool execution and between steps, and only new observable bytes resume it.
- A completed model step reports exact generated usage as `tokens.output + tokens.reasoning`. This replaces that step's byte estimate.
- Settled TPS sums exact step tokens and divides once by the sum of observed step spans. Each span runs from `session.step.started` to `session.step.streamed`, the host's authoritative end of the model stream, published after the provider stream exits and before local tools join. Hosts that do not publish `session.step.streamed` fall back to the final `session.text.ended`, `session.reasoning.ended`, or `session.tool.input.ended` boundary. Delayed step settlement, local tool execution, and time between model steps are excluded.
- TPS remains approximate because the host does not expose token-level provider timestamps. Encrypted content, signatures, and other opaque provider state are never byte-counted.
-- A single timer draws the label, and it stops after the live stale tail or when a step settles.
+- A single timer draws the label, and it stops when the stream boundary is known, when a step settles without one, or when the live stale tail expires. A held rate cannot change with time, so lifecycle events repaint it through the dirty flag instead of restarting the timer.
- A finished run keeps its state until the next run replaces it, and the number of tracked sessions is bounded. See `MAX_TRACKED_RUNS` in `src/tracker.ts`.
- A generation guard makes sure only the newest generation of the plugin counts tokens and renders.
@@ -86,8 +86,8 @@ One user prompt becomes a stream of events; the tracker does the bookkeeping bel
| An output block begins | `session.*.started` (m1) | create an idempotent text, reasoning, or tool-input block |
| Observable output streams | `session.*.delta` (m1) | add UTF-8 bytes and a rolling-rate sample |
| The complete block becomes available | `session.*.ended` (m1) | reconcile its full byte count and record the model-content boundary |
-| The provider stream exits | `session.step.streamed` (m1) | record the authoritative span end, before local tools join |
-| The model step settles, possibly after a tool runs | `session.step.ended` / `failed` (m1) | replace the estimate with reported usage when available; add duration through the streamed boundary |
+| The provider stream exits | `session.step.streamed` (m1) | record the authoritative span end, before local tools join, and capture the live rate there |
+| The model step settles, possibly after a tool runs | `session.step.ended` / `failed` (m1) | replace the estimate with reported usage; add duration through the span end; capture the held rate there |
| The whole execution finishes | `session.execution.succeeded` / `failed` / `idle` | freeze exact settled tokens plus any explicitly estimated partial output |
Current betas no longer publish `session.tool.input.delta`; tool arguments arrive only as the complete `session.tool.input.ended` text. Older betas streamed both, and the plugin still subscribes to the delta event for them. Ended-value reconciliation supports either without double-counting.
diff --git a/src/plugin.tsx b/src/plugin.tsx
index 2b4b0bf..76f078c 100644
--- a/src/plugin.tsx
+++ b/src/plugin.tsx
@@ -78,7 +78,7 @@ const definition: Plugin.Definition = {
// Rendering is throttled: deltas arrive at 100-200/s, and every bump costs
// a memo recompute plus a terminal repaint to move a number no one can read
// faster than ~10 Hz. Handlers only set a flag; the timer does the work,
- // and it only runs while a session is actually streaming.
+ // and it only runs while a live rate can still change with time.
let dirty = false
let timer: ReturnType | undefined
@@ -92,8 +92,11 @@ const definition: Plugin.Definition = {
const running = tracker.hasRunning(Date.now())
- // The observable live rate decays only through a short stale tail. Opaque
- // provider work after that is not charged to a numerator we cannot see.
+ // The observable live rate decays only through a short stale tail while
+ // streaming, then freezes at the stream-end boundary. Opaque provider
+ // work after that is not charged to a numerator we cannot see. A dirty
+ // lifecycle event still flushes one final render even when the timer
+ // stops.
if (dirty || running) {
dirty = false
setVersion((v) => v + 1)
diff --git a/src/tracker.ts b/src/tracker.ts
index 820ffe5..ce14afc 100644
--- a/src/tracker.ts
+++ b/src/tracker.ts
@@ -1,6 +1,10 @@
// Measures the rate of the observable model stream (UI-free by design): bytes
// to a rolling estimate while output arrives, exact step usage once the host
// reports it, and a frozen average after the run ends.
+//
+// While a run is active, `value()` holds the last live estimate across tool
+// execution and between steps. New observable bytes resume it, and `finish()`
+// alone freezes the weighted run average.
import { mark } from "./debug.js"
@@ -70,6 +74,7 @@ interface RunState {
activeStep: StepState | null
readonly settledSteps: Set
frozen: Frozen | null
+ heldLiveTps: number | null
}
export interface TpsValue {
@@ -111,6 +116,7 @@ export class TpsTracker {
activeStep: null,
settledSteps: new Set(),
frozen: null,
+ heldLiveTps: null,
}
this.runs.set(sessionID, st)
}
@@ -128,6 +134,7 @@ export class TpsTracker {
st.activeStep = null
st.settledSteps.clear()
st.frozen = null
+ st.heldLiveTps = null
// Re-insert so this session becomes the newest in iteration order. Every
// entry is created through here, so the cap is checked on the one path that
// can grow the map.
@@ -219,6 +226,12 @@ export class TpsTracker {
const bytes = Buffer.byteLength(delta, "utf8")
block.streamedBytes += bytes
step.observableBytes += bytes
+
+ // New bytes after a stream-end boundary reopen the live clock. A resumed
+ // attempt reuses the same message ID, so the old boundary would otherwise
+ // freeze the new output too.
+ if (step.streamedAt !== null && now > step.streamedAt) step.streamedAt = null
+
step.samples.push({ bytes, timestamp: now })
const oldest = now - LIVE_WINDOW_MS
@@ -253,7 +266,8 @@ export class TpsTracker {
* The host's authoritative end of the model stream, published after the
* provider stream exits and before local tools join. Assigned rather than
* maxed so a retried attempt reusing the message ID moves the boundary to its
- * own completion.
+ * own completion. It also captures the live estimate here for the held rate,
+ * and a step with no observable samples leaves the previous hold alone.
*/
markStreamed(sessionID: string, assistantMessageID: string, now: number): void {
const st = this.runs.get(sessionID)
@@ -261,12 +275,28 @@ export class TpsTracker {
if (!step || step.assistantMessageID !== assistantMessageID) return
step.streamedAt = now
+ const live = this.liveTps(step, now)
+
+ if (live !== null && st) st.heldLiveTps = live
}
private settleActiveStep(st: RunState, generatedTokens: number | undefined): void {
const step = st.activeStep
if (!step) return
+
+ // Capture the hold before the samples are discarded. Use the boundary, not
+ // the delayed settlement time, and skip a boundary older than the newest
+ // sample: those bytes would divide by the 250 ms floor and inflate the rate.
+ const boundary = step.streamedAt ?? step.lastBoundaryAt
+ const lastSample = step.samples.at(-1)
+
+ if (boundary !== null && lastSample !== undefined && boundary >= lastSample.timestamp) {
+ const live = this.liveTps(step, boundary)
+
+ if (live !== null) st.heldLiveTps = live
+ }
+
const exact = generatedTokens !== undefined && Number.isFinite(generatedTokens) && generatedTokens >= 0
st.settledTokens += exact ? generatedTokens : estimateTokens(step.observableBytes, this.config.bytesPerToken)
@@ -322,9 +352,13 @@ export class TpsTracker {
hasRunning(now = Date.now()): boolean {
for (const st of this.runs.values()) {
- const last = st.activeStep?.samples.at(-1)
+ if (st.phase !== "running") continue
+ const step = st.activeStep
+
+ if (!step || step.streamedAt !== null) continue
+ const last = step.samples.at(-1)
- if (st.phase === "running" && last && now < last.timestamp + LIVE_STALE_MS) return true
+ if (last && now < last.timestamp + LIVE_STALE_MS) return true
}
return false
@@ -334,7 +368,12 @@ export class TpsTracker {
const last = step.samples.at(-1)
if (!last) return null
- const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS)
+
+ // Once the stream end is known the clock stops there: no further
+ // time-driven decay while tools run. The stale-tail clamp still applies
+ // up to that boundary.
+ const streamCap = step.streamedAt ?? Number.POSITIVE_INFINITY
+ const effectiveNow = Math.min(now, last.timestamp + LIVE_STALE_MS, streamCap)
const oldest = effectiveNow - LIVE_WINDOW_MS
const samples = step.samples.filter((sample) => sample.timestamp >= oldest)
const first = samples[0]
@@ -366,8 +405,11 @@ export class TpsTracker {
if (tokens <= 0) return null
const settledTps = st.settledDurationMs > 0 ? st.settledTokens / (st.settledDurationMs / 1000) : null
+ // A settled average never displaces the held live rate.
+ const tps = active ? (this.liveTps(active, now) ?? st.heldLiveTps ?? settledTps) : (st.heldLiveTps ?? settledTps)
+
return {
- tps: active ? (this.liveTps(active, now) ?? settledTps) : settledTps,
+ tps,
tokens,
frozen: false,
tokensEstimated: st.tokensEstimated || active !== null,
diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts
index 8140e92..433ccc3 100644
--- a/tests/plugin.test.ts
+++ b/tests/plugin.test.ts
@@ -226,6 +226,40 @@ describe("plugin setup", () => {
h.restore()
})
+ test("stops the timer once the stream boundary is known", () => {
+ const h = createHarness()
+ const realNow = Date.now
+
+ Date.now = () => 1_000
+
+ try {
+ h.emit("session.step.started", { sessionID: "s", assistantMessageID: "m1" })
+ h.emit("session.text.delta", { sessionID: "s", assistantMessageID: "m1", ordinal: 0, delta: "a".repeat(95) })
+ expect(h.timer.created).toBe(1)
+ h.tick() // fresh sample inside the stale tail: still ticking
+ expect(h.timer.cleared).toBe(0)
+
+ h.emit("session.step.streamed", { sessionID: "s", assistantMessageID: "m1" })
+ h.tick() // the held rate cannot change with time, so the timer stops
+ expect(h.timer.cleared).toBe(1)
+
+ h.emit("session.step.ended", {
+ sessionID: "s",
+ assistantMessageID: "m1",
+ tokens: { output: 20, reasoning: 0 },
+ })
+ // The lifecycle event calls touch() again, which recreates the timer.
+ // With no live rate left to follow, the next flush stops it again.
+ h.tick()
+ expect(h.timer.created).toBeGreaterThan(1)
+ expect(h.timer.cleared).toBeGreaterThan(1)
+ } finally {
+ Date.now = realNow
+ h.cleanup()
+ h.restore()
+ }
+ })
+
test("honours refreshHz", () => {
const h = createHarness({ refreshHz: 20 })
h.emit("session.text.delta", { sessionID: "s", delta: "hello" })
diff --git a/tests/tracker.test.ts b/tests/tracker.test.ts
index fb006ae..126b74c 100644
--- a/tests/tracker.test.ts
+++ b/tests/tracker.test.ts
@@ -440,4 +440,201 @@ describe("TpsTracker", () => {
tracker.finish("s0", 100)
expect(tracker.value("s0", 100)).toBeNull()
})
+
+ test("holds the live rate across tool execution instead of switching to the run average", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginRun("s")
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 3200, "m1", "text:0")
+ tracker.finishBlock("s", "m1", "text:0", "a".repeat(95), 3200)
+ tracker.markStreamed("s", "m1", 3200)
+ expect(tracker.value("s", 3200)?.tps).toBeCloseTo(80)
+
+ tracker.finishStep("s", "m1", 20, 10_000)
+ const running = tracker.value("s", 10_000)
+
+ expect(running?.tokens).toBe(20)
+ expect(running?.tps).toBeCloseTo(80)
+ expect(running?.frozen).toBe(false)
+
+ // A new step with no samples keeps the held rate.
+ tracker.beginStep("s", "m2", 20_000)
+ expect(tracker.value("s", 20_500)?.tps).toBeCloseTo(80)
+
+ // New observable output replaces the held value, including downward.
+ tracker.push("s", "a".repeat(50), 20_250, "m2", "text:0")
+ expect(tracker.value("s", 20_250)?.tps).toBeCloseTo(44)
+ })
+
+ test("keeps the frozen run average distinct from the held running display", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginRun("s")
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 3200, "m1", "text:0")
+ tracker.finishBlock("s", "m1", "text:0", "a".repeat(95), 3200)
+ tracker.markStreamed("s", "m1", 3200)
+ tracker.finishStep("s", "m1", 20, 10_000)
+ tracker.finish("s", 11_000)
+ const frozen = tracker.value("s", 11_000)
+
+ expect(frozen?.frozen).toBe(true)
+ expect(frozen?.tokens).toBe(20)
+ expect(frozen?.tps).toBeCloseTo(6.25)
+ })
+
+ test("freezes the live rate at the stream-end boundary while tools run", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.markStreamed("s", "m1", 1250)
+ expect(tracker.value("s", 1250)?.tps).toBeCloseTo(80)
+ expect(tracker.value("s", 1500)?.tps).toBeCloseTo(80)
+ expect(tracker.value("s", 5000)?.tps).toBeCloseTo(80)
+ expect(tracker.value("s", 30_000)?.tps).toBeCloseTo(80)
+ })
+
+ test("keeps open-stream decay when no stream-end boundary exists", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ expect(tracker.value("s", 1250)?.tps).toBeCloseTo(80)
+ // Without markStreamed the existing stale-tail decay still applies.
+ expect(tracker.value("s", 1500)?.tps).toBeLessThan(80)
+ expect(tracker.value("s", 2500)?.tps).toBeCloseTo(20 / 1.5)
+ expect(tracker.value("s", 30_000)?.tps).toBeCloseTo(20 / 1.5)
+ })
+
+ test("held rate does not depend on read frequency", () => {
+ const withReads = new TpsTracker()
+ withReads.beginStep("s", "m1", 0)
+ withReads.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ withReads.markStreamed("s", "m1", 1250)
+ withReads.value("s", 1500)
+ withReads.value("s", 2000)
+ withReads.finishStep("s", "m1", 20, 10_000)
+ withReads.beginStep("s", "m2", 20_000)
+
+ const withoutReads = new TpsTracker()
+ withoutReads.beginStep("s", "m1", 0)
+ withoutReads.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ withoutReads.markStreamed("s", "m1", 1250)
+ withoutReads.finishStep("s", "m1", 20, 10_000)
+ withoutReads.beginStep("s", "m2", 20_000)
+
+ expect(withReads.value("s", 20_500)?.tps).toBeCloseTo(
+ withoutReads.value("s", 20_500)?.tps ?? -1,
+ )
+ expect(withoutReads.value("s", 20_500)?.tps).toBeCloseTo(80)
+ })
+
+ test("buffered-only tool input preserves the prior held rate", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.finishBlock("s", "m1", "text:0", "a".repeat(95), 1000)
+ tracker.markStreamed("s", "m1", 1250)
+ tracker.finishStep("s", "m1", 20, 5000)
+
+ tracker.beginStep("s", "m2", 6000)
+ tracker.beginBlock("s", "m2", "tool:t1", 6100)
+ tracker.finishBlock("s", "m2", "tool:t1", "a".repeat(95), 6500)
+ tracker.markStreamed("s", "m2", 6600)
+ expect(tracker.value("s", 6600)?.tps).toBeCloseTo(80)
+ tracker.finishStep("s", "m2", 20, 9000)
+ expect(tracker.value("s", 9000)?.tps).toBeCloseTo(80)
+ })
+
+ test("missing streamed event falls back to the content boundary without charging tool wait", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.finishBlock("s", "m1", "text:0", "a".repeat(95), 1200)
+ tracker.finishStep("s", "m1", 20, 10_000)
+ // At the content boundary the span is only 200 ms, below the 250 ms floor,
+ // so the rate is 20 tokens over 250 ms: 80 t/s.
+ expect(tracker.value("s", 10_000)?.tps).toBeCloseTo(80)
+ tracker.finish("s", 11_000)
+ // Accounting still uses the content boundary: 20 tokens / 1.2 s.
+ expect(tracker.value("s", 11_000)?.tps).toBeCloseTo(20 / 1.2)
+ })
+
+ test("a fallback boundary older than the newest sample preserves the prior hold", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.finishBlock("s", "m1", "text:0", "a".repeat(95), 1000)
+ tracker.markStreamed("s", "m1", 1250)
+ tracker.finishStep("s", "m1", 20, 5000)
+ expect(tracker.value("s", 5000)?.tps).toBeCloseTo(80)
+
+ // m2's guessed boundary, its last content block at 6500, predates the
+ // newest sample at 7000. Capturing there would divide 400 tokens by the
+ // 250 ms floor for a rate of 1600 t/s, so the stale fallback must leave
+ // the prior hold alone.
+ tracker.beginStep("s", "m2", 6000)
+ tracker.finishBlock("s", "m2", "text:0", "", 6500)
+ tracker.push("s", "b".repeat(1900), 7000, "m2", "text:1")
+ tracker.finishStep("s", "m2", undefined, 9000)
+ expect(tracker.value("s", 9000)?.tps).toBeCloseTo(80)
+ })
+
+ test("a retried message reopens live calculation with new samples", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.markStreamed("s", "m1", 1250)
+ expect(tracker.value("s", 5000)?.tps).toBeCloseTo(80)
+ expect(tracker.hasRunning(2000)).toBe(false)
+
+ tracker.push("s", "a".repeat(95), 6000, "m1", "text:0")
+ expect(tracker.hasRunning(6000)).toBe(true)
+ expect(tracker.value("s", 6000)).not.toBeNull()
+ tracker.markStreamed("s", "m1", 6250)
+ expect(tracker.value("s", 6250)?.tps).toBeCloseTo(80)
+ })
+
+ test("implicit settlement through a step replacement preserves the held rate", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.finishBlock("s", "m1", "text:0", "a".repeat(95), 1000)
+ tracker.markStreamed("s", "m1", 1250)
+ // Replacing the active step settles m1 without an explicit finishStep.
+ tracker.beginStep("s", "m2", 5000)
+ expect(tracker.value("s", 5000)?.tps).toBeCloseTo(80)
+ })
+
+ test("a new run clears the held rate and sessions stay isolated", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ tracker.markStreamed("s", "m1", 1250)
+ tracker.finishStep("s", "m1", 20, 5000)
+ expect(tracker.value("s", 5000)?.tps).toBeCloseTo(80)
+ expect(tracker.value("other", 5000)).toBeNull()
+
+ tracker.beginRun("s")
+ expect(tracker.value("s", 5001)).toBeNull()
+ })
+
+ test("stops the refresh timer once the stream boundary is known", () => {
+ const tracker = new TpsTracker()
+
+ tracker.beginStep("s", "m1", 0)
+ tracker.push("s", "a".repeat(95), 1000, "m1", "text:0")
+ expect(tracker.hasRunning(1100)).toBe(true)
+ tracker.markStreamed("s", "m1", 1250)
+ expect(tracker.hasRunning(1300)).toBe(false)
+ expect(tracker.hasRunning(5000)).toBe(false)
+ })
})
From 2060e18620b656885ffb977c375f7d7e14fdcc5a Mon Sep 17 00:00:00 2001
From: P-Theo
Date: Tue, 22 Sep 2026 17:22:54 +0300
Subject: [PATCH 2/2] docs(tps): correct timer restart claim for held-rate
repaints
touch() does recreate a stopped interval, and the following flush clears it again, so the old wording ("repaint through the dirty flag instead of restarting the timer") described the opposite of what happens. Lifecycle events schedule exactly one final repaint and do not keep the timer running.
---
docs/development.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/development.md b/docs/development.md
index 0e68824..1ecef67 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -69,7 +69,7 @@ That means one directory and one log per PID. Hot reloads append to the same fil
- A completed model step reports exact generated usage as `tokens.output + tokens.reasoning`. This replaces that step's byte estimate.
- Settled TPS sums exact step tokens and divides once by the sum of observed step spans. Each span runs from `session.step.started` to `session.step.streamed`, the host's authoritative end of the model stream, published after the provider stream exits and before local tools join. Hosts that do not publish `session.step.streamed` fall back to the final `session.text.ended`, `session.reasoning.ended`, or `session.tool.input.ended` boundary. Delayed step settlement, local tool execution, and time between model steps are excluded.
- TPS remains approximate because the host does not expose token-level provider timestamps. Encrypted content, signatures, and other opaque provider state are never byte-counted.
-- A single timer draws the label, and it stops when the stream boundary is known, when a step settles without one, or when the live stale tail expires. A held rate cannot change with time, so lifecycle events repaint it through the dirty flag instead of restarting the timer.
+- A single timer draws the label, and it stops when the stream boundary is known, when a step settles without one, or when the live stale tail expires. A held rate cannot change with time, so lifecycle events schedule one final dirty repaint without keeping the timer running.
- A finished run keeps its state until the next run replaces it, and the number of tracked sessions is bounded. See `MAX_TRACKED_RUNS` in `src/tracker.ts`.
- A generation guard makes sure only the newest generation of the plugin counts tokens and renders.