Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ 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).

## Sub-agents

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.

<p align="center">
<img src="docs/screenshots/subagent_tps.png" width="750" alt="Sub-agent session showing its own live throughput indicator" />
Expand Down
8 changes: 4 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

Expand All @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions src/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | undefined

Expand All @@ -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)
Expand Down
52 changes: 47 additions & 5 deletions src/tracker.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -70,6 +74,7 @@ interface RunState {
activeStep: StepState | null
readonly settledSteps: Set<string>
frozen: Frozen | null
heldLiveTps: number | null
}

export interface TpsValue {
Expand Down Expand Up @@ -111,6 +116,7 @@ export class TpsTracker {
activeStep: null,
settledSteps: new Set(),
frozen: null,
heldLiveTps: null,
}
this.runs.set(sessionID, st)
}
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -253,20 +266,37 @@ 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)
const step = st?.activeStep

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)

Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
Loading
Loading