Skip to content

Bump drenalol/turn-speed to 1.2.1 - #1191

Merged
paseo-bot[bot] merged 1 commit into
mainfrom
bump/drenalol/turn-speed-1.2.1
Oct 25, 2026
Merged

paseo-bot[bot] merged 1 commit into
mainfrom
bump/drenalol/turn-speed-1.2.1

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

drenalol/turn-speed: 1.1.0 → 1.2.1

@Drenalol, this update is ready for review. The published version stays unchanged until approval.

Artifact and validation details

Package: paseo-turn-speed.

{
  "previous": {
    "kind": "npm",
    "package": "paseo-turn-speed",
    "version": "1.1.0",
    "resolved": "https://registry.npmjs.org/paseo-turn-speed/-/paseo-turn-speed-1.1.0.tgz",
    "integrity": "sha512-Eyn8H4rvuE4BgyYQTY0xjvS05wqKArQ3dc87/dogeJtbOs8FaSIi5ZVsCcs5JjYLDitWNxpFgBJsNKFBckPuzA=="
  },
  "proposed": {
    "kind": "npm",
    "package": "paseo-turn-speed",
    "version": "1.2.1",
    "resolved": "https://registry.npmjs.org/paseo-turn-speed/-/paseo-turn-speed-1.2.1.tgz",
    "integrity": "sha512-xkOmf7hISOV++iwr9lzujn+NwronEUg14lHdX4f9Vf3nBR3Ka6aZSjrT/VoEXxLLa6xAmMap2eHket3TF28f0w=="
  }
}

Inline validation passed: tests and artifact checks. Run node scripts/validate.ts --online --changed on the completed listing before merging. The default GITHUB_TOKEN does not trigger the PR validation workflow.

Artifact diff
diff -ruN a/package/OVERVIEW.md b/package/OVERVIEW.md
--- a/package/OVERVIEW.md	1985-10-26 08:15:00.000000000 +0000
+++ b/package/OVERVIEW.md	1985-10-26 08:15:00.000000000 +0000
@@ -11,7 +11,12 @@
 enable `options.includeUsage: true` in the provider config), numbers are exact.
 Without usage the plugin estimates tokens from streamed characters, marks them
 with `≈`, and auto-calibrates a per-model chars/token ratio from every step that
-does report usage.
+does report usage. Reasoning models whose provider subtracts thinking from the
+reported output are detected automatically; their rates include an estimate of
+the thinking tokens (streamed reasoning characters converted with a measured
+reasoning-specific chars/token ratio), and the popover shows it as a separate
+`thinking ≈` line. The verdict is per model: switching models or a provider
+config that starts including thinking revokes it, so nothing is double-counted.
 
 Options live under Settings → Plugins → Turn speed: what the pill leads with
 (live estimate, decode-window average, or whole-turn rate, switchable from the
@@ -21,5 +26,6 @@
 Turns are observed live; history from before a plugin reload is not
 reconstructed. Subagents keep their own pills, but the parent also knows about
 them: time delegated to running subagents is excluded from the parent's
-whole-turn rate, its pill grows a `· N sub` badge, and the popover adds a
-per-subagent breakdown with a combined team rate.
+whole-turn rate, its pill grows a `· N sub` badge, and for Paseo-native child
+agents the popover adds a per-subagent breakdown with a combined team rate.
+Subagent replies count as the parent's input tokens, never its output.
diff -ruN a/package/README.md b/package/README.md
--- a/package/README.md	1985-10-26 08:15:00.000000000 +0000
+++ b/package/README.md	1985-10-26 08:15:00.000000000 +0000
@@ -6,7 +6,11 @@
 once the turn ends — with a popover breakdown (prefill/TTFT, in/cached/out
 tokens, steps, tool wait, user wait, per-subagent rates, context occupancy).
 Numbers measured from real provider usage are exact; before the first usage
-event they are character estimates marked with `≈`.
+event they are character estimates marked with `≈`. For reasoning models whose
+provider subtracts thinking from the reported output (measured on OpenCode +
+local models via oMLX), the thinking tokens are estimated from the streamed
+reasoning text and shown as a separate `thinking ≈` line, so rates cover all
+generated tokens instead of only the visible answer.
 
 ## Install
 
@@ -97,13 +101,17 @@
   separate `waiting for you` row and the pill switches to a ⏸ icon meanwhile.
 - **Delegation to subagents** — while the agent's work runs in subagents, the
   parent's own model produces nothing, so that stretch is removed from its
-  whole-turn rate like a user wait. Delegation is detected from the parent's
-  timeline (`sub_agent` tool details where the provider reports them, and the
+  whole-turn rate like a user wait. A subagent's reply reaching the parent is
+  a tool result: it lands in the parent's *input* tokens (`in`/`cached`),
+  never in its output. Delegation is detected from the parent's timeline
+  (`sub_agent` tool details where the provider reports them, and the
   normalized `subagent` tool calls Paseo emits for provider subagents such as
   OpenCode's Task) and from linked Paseo child agents. The parent's pill grows
-  a `· N sub` badge, its accessible title carries the team's combined tok/s,
-  and the popover lists every subagent with its own live rate and tokens plus a
-  `team` total. Each linked subagent still gets its own pill in its own composer.
+  a `· N sub` badge and its accessible title carries the team rate. For
+  linked Paseo children the popover also lists each subagent with its own
+  live rate and tokens plus a `team` total; for provider subagents only the
+  running count and delegated time are knowable (see Known limitations).
+  Each linked subagent still gets its own pill in its own composer.
 - **Live number inside a model call** — real tokens only arrive at the end of a
   call, so the live value estimates tokens from characters over a 1/2/5 s
   smoothing window and is marked `≈` until the call's usage lands.
@@ -114,8 +122,27 @@
   a note lands in the app log (`turn-speed: step dropped as implausible`).
 - **Auto-calibration** — at every step end the observed `chars/token` ratio for
   the model is blended into a stored per-model calibration (EMA 0.7/0.3, clamped
-  to 2..8, persisted in plugin settings). After 2–3 steps the live estimate
-  tracks the real one closely. Settings → Turn speed shows and can override it.
+  to 2..8, persisted in plugin settings). Samples dominated by tool-call arguments
+  (far fewer visible chars than the step's output tokens) are rejected, so tool
+  runs do not drag the ratio down. After 2–3 steps the live estimate tracks the
+  real one closely. Settings → Turn speed shows and can override it.
+- **Thinking estimate has its own ratio** — reasoning prose encodes ~3.7
+  characters per token (measured against per-step reasoning-token ground truth),
+  so `thinking ≈` divides streamed reasoning characters by that instead of the
+  visible-text calibration.
+- **The exclusion verdict belongs to its model** — detecting that a provider
+  subtracts thinking from `output` is a property of the model behind the agent.
+  Switching models revokes the verdict immediately; a provider config change
+  under the same model (thinking suddenly inside `output`) is caught after
+  three consecutive contradicting steps, and heavy-thinking evidence re-arms it
+  the next step. When the agent's model identity is never reported, the verdict
+  is never permanent: each new turn re-derives it from fresh evidence.
+
+  Caveats: providers that never send usage cannot be judged at all (there is
+  nothing to compare the reasoning text against), and for them a reasoning
+  block delivered in one bulk after the answer text started is deliberately
+  left out of the character estimate — a bulk tells when it arrived, not how
+  long it took to generate. Such turns can be undercounted.
 
 ## Settings (Settings → Plugins → Turn speed)
 
@@ -126,7 +153,7 @@
 | Token format | 1.2k | compact or grouped digits |
 | Live smoothing window | 2 s | averaging window of the live tok/s |
 | Manual chars per token | off (auto-calibrated) | override the live character estimate |
-| Implausible rate limit | 400 tok/s | steps implying a faster decode are dropped as misattributed usage |
+| Implausible rate limit | 400 tok/s | steps implying a faster decode are dropped as misattributed usage; no pill ever shows a rate above the limit |
 
 ## Development
 
@@ -148,6 +175,11 @@
 
 ## Known limitations
 
+- Providers that subtract thinking from the reported `output` (OpenCode + local
+  reasoning models) get thinking tokens only as an estimate (`thinking ≈`):
+  real per-step reasoning counts never reach the plugin API. Upstream request
+  — your upvote helps:
+  [getpaseo/paseo#7863](https://github.com/getpaseo/paseo/discussions/7863).
 - Usage arrives via agent snapshots, so step boundaries are as fine as the
   provider's per-call usage updates. Providers without usage at all stay `≈`.
 - The plugin only counts what it watches live: after a reload or a reconnect the
diff -ruN a/package/client/details.tsx b/package/client/details.tsx
--- a/package/client/details.tsx	1985-10-26 08:15:00.000000000 +0000
+++ b/package/client/details.tsx	1985-10-26 08:15:00.000000000 +0000
@@ -87,7 +87,7 @@
     }, [teamActive]);
     const cpt = charsPerToken();
     const running = state.phase === "running";
-    const live = running ? store.liveTokPerSec(prefs.liveWindowSec) : null;
+    const live = running ? store.liveTokPerSec(prefs.liveWindowSec, prefs.maxPlausibleTokPerSec) : null;
     const basis = settings.status === "ready" ? settings.values.numerator : null;
     const setBasis = (value: "live" | "decode" | "turn") => {
       if (settings.status !== "ready" || settings.values.numerator === value) return;
@@ -101,6 +101,23 @@
       summary.firstStepPromptTokens !== null && summary.prefillMs !== null && summary.prefillMs > 0
         ? summary.firstStepPromptTokens / (summary.prefillMs / 1000)
         : null;
+    // With a warm prefix cache the model restores the prompt instead of
+    // prefilling it: dividing new tokens by TTFT then measures cache load and
+    // queueing, not prefill speed, so only the honest pieces are shown.
+    const cacheDominant =
+      summary.firstStepCachedTokens !== null &&
+      (summary.firstStepPromptTokens ?? 0) <= summary.firstStepCachedTokens;
+    const prefillValue =
+      summary.prefillMs === null
+        ? null
+        : cacheDominant
+          ? `${fmtTokens(summary.firstStepPromptTokens ?? 0, prefs.tokenFormat)} new + ${fmtTokens(
+              summary.firstStepCachedTokens ?? 0,
+              prefs.tokenFormat,
+            )} cached \u00b7 ${fmtDuration(summary.prefillMs)}`
+          : prefillRate !== null
+            ? `${fmtRate(prefillRate)} tok/s \u00b7 ${fmtDuration(summary.prefillMs)}`
+            : fmtDuration(summary.prefillMs);
     const noUsage = !state.sawUsage && state.phase !== "idle";
 
     const model = store.model ?? store.provider ?? "";
@@ -146,20 +163,23 @@
           value={fmtTokPerSec(summary.tokPerSecTurn, summary.approx)}
         />
         {summary.prefillMs !== null ? (
-          <Row
-            styles={styles}
-            label="prefill / TTFT"
-            value={`${prefillRate !== null ? `${fmtRate(prefillRate)} tok/s · ` : ""}${fmtDuration(summary.prefillMs)}`}
-          />
+          <Row styles={styles} label="prefill / TTFT" value={prefillValue ?? "\u2014"} />
         ) : null}
         <Row
           styles={styles}
           label="tokens"
-          value={`in ${fmtTokens(summary.inputTokens, prefs.tokenFormat)} · cached ${fmtTokens(
+          value={`in ${fmtTokens(summary.inputTokens, prefs.tokenFormat)} \u00b7 cached ${fmtTokens(
             summary.cachedTokens,
             prefs.tokenFormat,
-          )} · out ${approx(fmtTokens(summary.estTokens, prefs.tokenFormat), summary.approx)}`}
+          )} \u00b7 out ${approx(fmtTokens(summary.estTokens, prefs.tokenFormat), summary.approx)}`}
         />
+        {summary.thinkingTokens >= 10 ? (
+          <Row
+            styles={styles}
+            label={"thinking \u2248"}
+            value={`${approx(fmtTokens(summary.thinkingTokens, prefs.tokenFormat), true)} tok (not in reported out)`}
+          />
+        ) : null}
         {state.ctxUsed !== null && state.ctxMax !== null ? (
           <Row
             styles={styles}
diff -ruN a/package/client/meter.ts b/package/client/meter.ts
--- a/package/client/meter.ts	1985-10-26 08:15:00.000000000 +0000
+++ b/package/client/meter.ts	1985-10-26 08:15:00.000000000 +0000
@@ -21,6 +21,24 @@
  * whole-turn rate — the subagents' own pills carry their speed — but it stays
  * inside decode/wait windows, because a delegated call is just another tool
  * run between the agent's model calls.
+ *
+ * Thinking tokens: some providers (measured on OpenCode + local reasoning
+ * models) report `output` with the reasoning tokens subtracted while the
+ * thinking itself streams as reasoning text and its decode time sits in the
+ * step window. Counting only `output` then under-reports rates by the whole
+ * thinking share. When a closed step shows far more reasoning characters
+ * than its output tokens could pay for, the meter marks the turn as
+ * `thinkingExcluded` and credits an estimate of the thinking tokens (reasoning
+ * characters divided by the reasoning-specific chars/token ratio, measured
+ * well above the visible-text one), marked approximate: reasoning that streamed
+ * from the step start covers its own window time and enters the decode rate;
+ * reasoning delivered as a late bulk (after the step's visible text began)
+ * has no measured time, so it only enters turn totals and the whole-turn
+ * rate. Providers that report thinking inside `output` never trigger this.
+ *
+ * Usage deltas the plausibility guard rejects are not dropped: they land in
+ * `unattributedTokens`, counted in token totals and the whole-turn rate
+ * without a decode window of their own.
  */
 
 /** A live-window text gap longer than this is treated as a stall. */
@@ -36,6 +54,32 @@
 export const CAL_MIN = 2;
 export const CAL_MAX = 8;
 export const CAL_MIN_CHARS = 40;
+/** A closed step whose reasoning characters exceed its output tokens by this
+ * much cannot be reporting thinking inside `output`: mark the turn as one
+ * where the provider excludes reasoning tokens from the reported numbers. */
+export const EXCLUDED_EVIDENCE_RATIO = 8;
+/** Below this many reasoning characters the evidence above is too noisy. */
+export const EXCLUDED_MIN_CHARS = 200;
+/** Reasoning prose encodes more characters per token than visible answers,
+ * and the calibrated visible ratio is additionally biased low: usage
+ * `output` counts tool-call arguments, which never stream as text. Measured
+ * across ~41k reasoning tokens of real OpenCode + local reasoning sessions:
+ * streamed reasoning text runs ~3.7 characters per reasoning token, so the
+ * thinking estimate uses this dedicated ratio instead of the model's
+ * visible-text calibration. */
+export const REASONING_CHARS_PER_TOKEN = 3.7;
+/** A closed step whose reported output tokens already pay for its streamed
+ * reasoning characters — at most 1.4× the reasoning ratio, headroom for
+ * token-dense prose — says thinking is inside `output`: excluded heavy
+ * thinking could not be this cheap to report. Between this and
+ * EXCLUDED_EVIDENCE_RATIO sits a neutral band that concludes nothing. A
+ * single such step can also be a tool-heavy fluke on an excluding provider,
+ * so a streak of them is needed to revoke the verdict — e.g. after the
+ * provider config started including usage differently. One excluding-style
+ * step re-arms it, so a wrong revocation costs at most a step of
+ * undercounting; without revocation a wrong verdict double-counts forever. */
+export const INCLUSION_EVIDENCE_RATIO = REASONING_CHARS_PER_TOKEN * 1.4;
+export const INCLUSION_EVIDENCE_STEPS = 3;
 
 export interface UsageSnapshot {
   input?: number | null;
@@ -56,6 +100,13 @@
   inputTokens: number;
   /** Cached prompt tokens this call reported. */
   cachedTokens: number;
+  /** Reasoning characters streamed during this step (thinking estimate). */
+  thinkingChars: number;
+  /** The subset that arrived late (after the step's visible text started):
+   * a bulk delivery of the reasoning block, whose generation time the step
+   * window never saw. Their tokens count in turn totals but not in the
+   * decode-window rate. */
+  thinkingLateChars: number;
   /** Prefill/TTFT measured on the first step only, else 0. */
   prefillMs: number;
 }
@@ -78,6 +129,11 @@
   lastTextAt: number | null;
   /** First text since the last step boundary (null = re-anchored). */
   stepFirstTextAt: number | null;
+  /** First visible-text and first reasoning growth since the step boundary.
+   * Their order tells streamed thinking (window-covering) from a late bulk
+   * delivery of a reasoning block (whose generation time the window misses). */
+  stepFirstVisibleAt: number | null;
+  stepFirstReasoningAt: number | null;
   /** Usage arrival of the last completed step. */
   prevStepEndAt: number | null;
   /** Per-message observed text length (snapshot vs delta normalised). */
@@ -99,6 +155,24 @@
   liveChars: number;
   /** All characters streamed this turn (fallback estimate). */
   totalChars: number;
+  /** Reasoning-only characters since the last step boundary (⊆ liveChars). */
+  liveReasoningChars: number;
+  /** Reasoning-only characters streamed this turn (⊆ totalChars). */
+  totalReasoningChars: number;
+  /** Usage tokens the plausibility guard rejected, kept for whole-turn totals. */
+  unattributedTokens: number;
+  /** Set once a step showed reasoning characters no `output` figure could
+   * pay for: this provider excludes thinking from its reported output, so
+   * rates add an estimate from the streamed reasoning text. */
+  thinkingExcluded: boolean;
+  /** Model id (`model_changed`) the verdict belongs to: an exclusion is a
+   * provider-model property, so switching to a model that reports thinking
+   * inside `output` must not double-count it. Null while the identity was
+   * not yet known when the verdict was learned. */
+  thinkingExcludedModel: string | null;
+  /** Consecutive closed steps whose `output` already paid for the streamed
+   * reasoning characters; enough of them revoke a stale verdict. */
+  inclusionStreak: number;
   /** Completed user-wait intervals inside this turn. */
   userWaits: readonly UserWaitInterval[];
   /** Start of the open user-wait interval, null when the model is live. */
@@ -119,7 +193,7 @@
 
 export type MeterAction =
   | { t: "turn_started"; at: number; turnId: string | null }
-  | { t: "text"; at: number; key: string; text: string }
+  | { t: "text"; at: number; key: string; text: string; kind?: "text" | "reasoning" }
   | { t: "usage"; at: number; usage: UsageSnapshot; maxTokPerSec?: number }
   | { t: "compaction"; at: number }
   /** The model blocked on the user (question asked, permission pending). */
@@ -131,6 +205,9 @@
   /** All subagents finished: the agent works alone again. */
   | { t: "delegated_ended"; at: number }
   | { t: "turn_ended"; at: number; kind: "completed" | "failed" | "canceled"; usage?: UsageSnapshot; maxTokPerSec?: number; note?: string | null }
+  /** The agent switched model (or its identity just became known): a
+   * thinking-exclusion verdict learned on another model must not carry. */
+  | { t: "model_changed"; model: string | null }
   /** Drop the locally observed turn (epoch replacement, reconnect, reload). */
   | { t: "reset" };
 
@@ -142,6 +219,8 @@
     firstTextAt: null,
     lastTextAt: null,
     stepFirstTextAt: null,
+    stepFirstVisibleAt: null,
+    stepFirstReasoningAt: null,
     prevStepEndAt: null,
     textLens: {},
     textTails: {},
@@ -156,6 +235,12 @@
     steps: [],
     liveChars: 0,
     totalChars: 0,
+    liveReasoningChars: 0,
+    totalReasoningChars: 0,
+    unattributedTokens: 0,
+    thinkingExcluded: false,
+    thinkingExcludedModel: null,
+    inclusionStreak: 0,
     userWaits: [],
     pausedAt: null,
     delegatedWaits: [],
@@ -207,7 +292,13 @@
   return { lens, tails, growth };
 }
 
-function applyText(state: MeterState, at: number, key: string, text: string): MeterState {
+function applyText(
+  state: MeterState,
+  at: number,
+  key: string,
+  text: string,
+  isReasoning: boolean,
+): MeterState {
   if (state.phase !== "running" || text.length === 0) return state;
   const { lens, tails, growth } = growText(state, key, text);
   if (growth <= 0) return state;
@@ -217,9 +308,15 @@
     textTails: tails,
     firstTextAt: state.firstTextAt === null ? at : state.firstTextAt,
     stepFirstTextAt: state.stepFirstTextAt === null ? at : state.stepFirstTextAt,
+    stepFirstVisibleAt:
+      !isReasoning && state.stepFirstVisibleAt === null ? at : state.stepFirstVisibleAt,
+    stepFirstReasoningAt:
+      isReasoning && state.stepFirstReasoningAt === null ? at : state.stepFirstReasoningAt,
     lastTextAt: at,
     liveChars: state.liveChars + growth,
     totalChars: state.totalChars + growth,
+    liveReasoningChars: isReasoning ? state.liveReasoningChars + growth : state.liveReasoningChars,
+    totalReasoningChars: isReasoning ? state.totalReasoningChars + growth : state.totalReasoningChars,
   };
 }
 
@@ -348,6 +445,9 @@
     return {
       ...withCounters,
       calibInput: null,
+      // Real tokens with no trustworthy window of their own: keep them in
+      // the turn totals instead of letting them vanish from every rate.
+      unattributedTokens: state.unattributedTokens + outDelta.amount,
       stepRejected: { tokens: outDelta.amount, windowMs: stepDecodeMs },
     };
   }
@@ -355,23 +455,59 @@
     state.steps.length === 0 && state.firstTextAt !== null && state.startedAt !== null
       ? Math.max(0, state.firstTextAt - state.startedAt)
       : 0;
+  // Reasoning that showed up only after this step's visible text was already
+  // streaming is a bulk delivery of the finished block: the window missed
+  // its generation. Reasoning that led (or carried) the window covered it.
+  const reasoningLate =
+    state.liveReasoningChars > 0 &&
+    state.stepFirstVisibleAt !== null &&
+    (state.stepFirstReasoningAt === null || state.stepFirstReasoningAt > state.stepFirstVisibleAt);
   const step: StepRecord = {
     outTokens: outDelta.amount,
     decodeMs: stepDecodeMs,
     waitMs: stepWaitMs,
     inputTokens: inDelta.amount,
     cachedTokens: cachedDelta.amount,
+    thinkingChars: state.liveReasoningChars,
+    thinkingLateChars: reasoningLate ? state.liveReasoningChars : 0,
     prefillMs,
   };
+  // More streamed reasoning characters than this step's output tokens could
+  // possibly contain: the provider subtracts thinking from `output`.
+  const thinkingEvidence =
+    state.liveReasoningChars >= EXCLUDED_MIN_CHARS &&
+    state.liveReasoningChars > outDelta.amount * EXCLUDED_EVIDENCE_RATIO;
+  // The opposite signal: this step streamed meaningful reasoning yet its
+  // reported output tokens alone already pay for those characters, meaning
+  // the thinking is inside `output`. A single such step can also be a
+  // tool-heavy step on an excluding provider, so a streak of them is needed
+  // to revoke the verdict; one excluding-style step re-arms it.
+  const inclusionEvidence =
+    state.thinkingExcluded &&
+    !thinkingEvidence &&
+    state.liveReasoningChars >= EXCLUDED_MIN_CHARS &&
+    state.liveReasoningChars <= outDelta.amount * INCLUSION_EVIDENCE_RATIO;
+  const inclusionStreak = inclusionEvidence ? state.inclusionStreak + 1 : 0;
+  const revoked = inclusionStreak >= INCLUSION_EVIDENCE_STEPS;
   return {
     ...withCounters,
     steps: [...state.steps, step],
     stepFirstTextAt: null,
+    stepFirstVisibleAt: null,
+    stepFirstReasoningAt: null,
     prevStepEndAt: at,
     liveChars: 0,
+    liveReasoningChars: 0,
     sawUsage: true,
+    thinkingExcluded: revoked ? false : state.thinkingExcluded || thinkingEvidence,
+    inclusionStreak: revoked ? 0 : inclusionStreak,
     stepRejected: null,
-    calibInput: { chars: state.liveChars, tokens: outDelta.amount },
+    // Calibration pairs visible characters with visible tokens: reasoning
+    // text must not enter it, or the ratio saturates against excluded output.
+    calibInput: {
+      chars: state.liveChars - state.liveReasoningChars,
+      tokens: outDelta.amount,
+    },
   };
 }
 
@@ -414,16 +550,34 @@
         prevUsageOut: state.prevUsageOut,
         prevUsageIn: state.prevUsageIn,
         prevUsageCached: state.prevUsageCached,
+        // A provider-level property: survives turns like the usage baseline
+        // — but only when it has a known owner. A verdict that never got a
+        // model identity cannot tell an unchanged provider from an invisible
+        // model switch, so each new turn re-derives it from fresh evidence.
+        thinkingExcluded:
+          state.thinkingExcludedModel === null ? false : state.thinkingExcluded,
+        thinkingExcludedModel: state.thinkingExcludedModel,
+        inclusionStreak: state.thinkingExcludedModel === null ? 0 : state.inclusionStreak,
       };
     }
     case "text":
-      return applyText(state, action.at, action.key, action.text);
+      return applyText(
+        state,
+        action.at,
+        action.key,
+        action.text,
+        action.kind === "reasoning" ||
+          // Backwards compatibility with callers using the legacy key scheme.
+          (action.kind === undefined && action.key.startsWith("reasoning")),
+      );
     case "usage":
       return applyUsage(state, action.at, action.usage, action.maxTokPerSec);
     case "compaction":
       // Context was rewritten; streamed chars since the last step no longer
       // map to answer tokens. Drop the live window, keep step history.
-      return state.phase === "running" ? { ...state, liveChars: 0 } : state;
+      return state.phase === "running"
+        ? { ...state, liveChars: 0, liveReasoningChars: 0 }
+        : state;
     case "user_wait_started":
       return state.phase === "running" && state.pausedAt === null
         ? { ...state, pausedAt: action.at }
@@ -451,7 +605,29 @@
         prevUsageOut: state.prevUsageOut,
         prevUsageIn: state.prevUsageIn,
         prevUsageCached: state.prevUsageCached,
+        thinkingExcluded: state.thinkingExcluded,
+        thinkingExcludedModel: state.thinkingExcludedModel,
+        inclusionStreak: state.inclusionStreak,
+      };
+    case "model_changed": {
+      const model = action.model;
+      // An unknown identity must never clear a learned verdict: the model
+      // simply is not on record yet.
+      if (model === null || model === state.thinkingExcludedModel) return state;
+      // A verdict learned before identity was known belongs to it: adopt
+      // rather than revoke, the first concrete model is what we observed.
+      if (state.thinkingExcluded && state.thinkingExcludedModel === null) {
+        return { ...state, thinkingExcludedModel: model };
+      }
+      // Another model: the exclusion verdict was its property, not this
+      // model's. Steps of the current turn stay; only the verdict resets.
+      return {
+        ...state,
+        thinkingExcluded: false,
+        thinkingExcludedModel: model,
+        inclusionStreak: 0,
       };
+    }
     default:
       return state;
   }
@@ -492,6 +668,14 @@
   cachedTokens: number;
   /** Prompt tokens of the first step, for the prefill rate. */
   firstStepPromptTokens: number | null;
+  /** Cached prompt tokens of the first step: with a warm cache the prompt
+   * was restored, not prefilled, so the prefill rate is hidden then. */
+  firstStepCachedTokens: number | null;
+  /** Thinking tokens estimated from streamed reasoning characters (0 when
+   * the provider reports thinking inside `output` or none was streamed). */
+  thinkingTokens: number;
+  /** Tokens from rejected usage bursts, counted in totals without a window. */
+  unattributedTokens: number;
 }
 
 export function summarizeTurn(state: MeterState, charsPerToken: number, now: number): TurnSummary {
@@ -499,24 +683,64 @@
   const inputTokens = state.steps.reduce((sum, s) => sum + s.inputTokens, 0);
   const cachedTokens = state.steps.reduce((sum, s) => sum + s.cachedTokens, 0);
   const waitMs = state.steps.reduce((sum, s) => sum + s.waitMs, 0);
+  const hasReal = state.sawUsage && realOut > 0;
   // Without any usage step there is no anchored window; the span of streamed
   // text stands in for it so estimate-only providers still get a decode rate.
+  // While a step is open, its streamed characters already sit in `estTokens`
+  // (as the tail estimate), so the running window from its first text counts
+  // in the decode denominator too: freezing it at the closed steps would
+  // divide a growing token count by stale windows and print a burst rate.
+  const openWindowMs =
+    state.phase === "running" && state.stepFirstTextAt !== null && state.liveChars > 0
+      ? Math.max(
+          0,
+          now - state.stepFirstTextAt - waitOverlap(state, state.stepFirstTextAt, now),
+        )
+      : 0;
   const decodeMs =
     state.steps.length > 0
-      ? state.steps.reduce((sum, s) => sum + s.decodeMs, 0)
+      ? state.steps.reduce((sum, s) => sum + s.decodeMs, 0) + openWindowMs
       : state.firstTextAt !== null && state.lastTextAt !== null
         ? Math.max(0, state.lastTextAt - state.firstTextAt)
         : 0;
-  const hasReal = state.sawUsage && realOut > 0;
-  const tailEst = state.liveChars / charsPerToken;
-  const wholeEst = state.totalChars / charsPerToken;
+  // Thinking estimate for providers detected as excluding reasoning from
+  // `output`: reasoning characters converted with the reasoning-specific
+  // chars/token ratio (visible-text calibration is biased by tool args).
+  // Early thinking (reasoning led the step window) both adds tokens and was
+  // paid for by window time; late bulks add tokens only, so they may count
+  // in turn totals but never in the decode-window rate.
+  const stepThinkingChars = state.steps.reduce((sum, s) => sum + s.thinkingChars, 0);
+  const earlyThinking = state.thinkingExcluded
+    ? state.steps.reduce((sum, s) => sum + (s.thinkingChars - s.thinkingLateChars), 0) /
+      REASONING_CHARS_PER_TOKEN
+    : 0;
+  const lateThinking = state.thinkingExcluded
+    ? state.steps.reduce((sum, s) => sum + s.thinkingLateChars, 0) / REASONING_CHARS_PER_TOKEN
+    : 0;
+  // Live reasoning characters enter the tail estimate only while reasoning
+  // leads the open step (its arrival was paced with generation). A trailing
+  // bulk of a finished reasoning block would fake a token burst.
+  const liveReasoningEarly =
+    state.stepFirstReasoningAt !== null &&
+    (state.stepFirstVisibleAt === null || state.stepFirstReasoningAt <= state.stepFirstVisibleAt);
+  const thinkingTokens = state.thinkingExcluded && hasReal
+    ? (stepThinkingChars + (liveReasoningEarly ? state.liveReasoningChars : 0)) /
+      REASONING_CHARS_PER_TOKEN
+    : 0;
+  const tailEst =
+    (state.liveChars - state.liveReasoningChars) / charsPerToken +
+    (liveReasoningEarly ? state.liveReasoningChars / REASONING_CHARS_PER_TOKEN : 0);
+  const wholeEst =
+    (state.totalChars - state.totalReasoningChars) / charsPerToken +
+    (state.totalReasoningChars - (liveReasoningEarly ? 0 : state.liveReasoningChars)) /
+      REASONING_CHARS_PER_TOKEN;
   let outTokens: number | null = null;
   let estTokens: number;
   let approx: boolean;
   if (hasReal) {
     outTokens = realOut;
-    estTokens = realOut + tailEst;
-    approx = state.liveChars > 0;
+    estTokens = realOut + earlyThinking + lateThinking + tailEst + state.unattributedTokens;
+    approx = state.liveChars > 0 || earlyThinking + lateThinking > 0 || state.unattributedTokens > 0;
   } else {
     estTokens = wholeEst;
     approx = true;
@@ -543,10 +767,14 @@
     totalMs !== null && state.startedAt !== null && end !== null
       ? Math.max(0, totalMs - unionOverlapMs(asleep, state.startedAt, end))
       : null;
+  // Rejected bursts and late thinking bulks have no decode window of their
+  // own, so they count in the whole-turn totals but never inflate the
+  // decode-window rate.
+  const decodeTokens = hasReal ? estTokens - state.unattributedTokens - lateThinking : estTokens;
   const tokPerSecDecode =
-    decodeMs > 0 && estTokens > 0 ? (outTokens ?? estTokens) / (decodeMs / 1000) : null;
+    decodeMs > 0 && decodeTokens > 0 ? decodeTokens / (decodeMs / 1000) : null;
   const tokPerSecTurn =
-    activeMs !== null && activeMs > 0 && estTokens > 0 ? (outTokens ?? estTokens) / (activeMs / 1000) : null;
+    activeMs !== null && activeMs > 0 && estTokens > 0 ? estTokens / (activeMs / 1000) : null;
   return {
     outTokens,
     estTokens,
@@ -565,6 +793,9 @@
     inputTokens,
     cachedTokens,
     firstStepPromptTokens: state.steps.length > 0 && state.steps[0].inputTokens > 0 ? state.steps[0].inputTokens : null,
+    firstStepCachedTokens: state.steps.length > 0 && state.steps[0].cachedTokens > 0 ? state.steps[0].cachedTokens : null,
+    thinkingTokens,
+    unattributedTokens: state.unattributedTokens,
   };
 }
 
@@ -579,7 +810,12 @@
 ): number | null {
   if (!(tokens > 0) || !(chars >= minChars)) return null;
   const observed = chars / tokens;
-  if (!Number.isFinite(observed) || observed < 1) return null;
+  // Below 1.5 char/token the sample is dominated by tool-call arguments
+  // (counted in `output` but never streamed as text), not this model's text
+  // ratio; above 12 it is usage bookkeeping noise (misattributed deltas).
+  // Either end poisons the EMA toward the clamps (a single 20+ sample used to
+  // pin the blend at CAL_MAX).
+  if (!Number.isFinite(observed) || observed < 1.5 || observed > 12) return null;
   const base = typeof prev === "number" && Number.isFinite(prev) ? prev : DEFAULT_CHARS_PER_TOKEN;
   const next = 0.7 * base + 0.3 * observed;
   return Math.min(CAL_MAX, Math.max(CAL_MIN, next));
diff -ruN a/package/client/pill.tsx b/package/client/pill.tsx
--- a/package/client/pill.tsx	1985-10-26 08:15:00.000000000 +0000
+++ b/package/client/pill.tsx	1985-10-26 08:15:00.000000000 +0000
@@ -13,15 +13,20 @@
   title: string;
 }
 
-/** Number the pill leads with, per the speed-basis preference. */
+/** Number the pill leads with, per the speed-basis preference. Rates above
+ * the plausibility limit are bookkeeping artifacts, not speed: show nothing
+ * rather than a fabricated burst. */
 function headlineTokPerSec(store: AgentSpeedStore, prefs: Preferences, now: number): number | null {
   const summary = store.summary(now);
+  const plausible = (rate: number | null): number | null =>
+    rate !== null && rate > prefs.maxPlausibleTokPerSec ? null : rate;
   if (store.isRunning() && prefs.numerator === "live") {
-    const live = store.liveTokPerSec(prefs.liveWindowSec);
+    const live = store.liveTokPerSec(prefs.liveWindowSec, prefs.maxPlausibleTokPerSec);
     if (live !== null) return live;
   }
-  if (prefs.numerator === "turn") return summary.tokPerSecTurn ?? summary.tokPerSecDecode;
-  return summary.tokPerSecDecode ?? summary.tokPerSecTurn;
+  if (prefs.numerator === "turn")
+    return plausible(summary.tokPerSecTurn ?? summary.tokPerSecDecode);
+  return plausible(summary.tokPerSecDecode ?? summary.tokPerSecTurn);
 }
 
 export function computePillView(
@@ -56,19 +61,16 @@
   if (phase === "failed") labelParts.push("\u00b7 failed");
   if (phase === "canceled") labelParts.push("\u00b7 canceled");
   const titleParts = [`Turn speed ${rateText} decode`];
-  if (waiting) titleParts.push("waiting for you");
+  if (waiting) titleParts.push("waiting");
   if (teamRunning) {
-    const teamRate = teamRunning.liveTokPerSec;
-    const teamLabel = `${teamRunning.running} subagent${teamRunning.running === 1 ? "" : "s"}`;
-    if (teamRate !== null) {
-      titleParts.push(`${teamLabel} at ${approx(fmtRate(teamRate), teamRunning.approx)} tok/s`);
-    } else if (teamRunning.estTokens > 0) {
-      titleParts.push(
-        `${teamLabel} working (${approx(fmtTokens(teamRunning.estTokens, prefs.tokenFormat), teamRunning.approx)} tok)`,
-      );
-    } else {
-      titleParts.push(`${teamLabel} working`);
-    }
+    // Keep the sheet title on one phone line: the short `N sub` form matches
+    // the pill label; the full breakdown is in the popover body.
+    const teamLabel = `${teamRunning.running} sub`;
+    titleParts.push(
+      teamRunning.liveTokPerSec === null
+        ? teamLabel
+        : `${teamLabel} @ ${approx(fmtRate(teamRunning.liveTokPerSec), teamRunning.approx)} tok/s`,
+    );
   }
   if (summary.estTokens > 0) {
     titleParts.push(`${approx(fmtTokens(summary.estTokens, prefs.tokenFormat), isApprox)} tok`);
diff -ruN a/package/client/store.ts b/package/client/store.ts
--- a/package/client/store.ts	1985-10-26 08:15:00.000000000 +0000
+++ b/package/client/store.ts	1985-10-26 08:15:00.000000000 +0000
@@ -4,6 +4,7 @@
   reduceMeter,
   summarizeTurn,
   DEFAULT_CHARS_PER_TOKEN,
+  REASONING_CHARS_PER_TOKEN,
   type MeterAction,
   type MeterState,
   type TurnSummary,
@@ -47,7 +48,7 @@
   readonly everSawUsage: boolean;
   setIdentity(provider: string | null | undefined, model: string | null | undefined): void;
   dispatch(action: MeterAction): void;
-  liveTokPerSec(windowSec: number): number | null;
+  liveTokPerSec(windowSec: number, maxTokPerSec?: number): number | null;
   summary(now?: number): TurnSummary;
   isRunning(): boolean;
 }
@@ -124,8 +125,14 @@
       const resolvedProvider = nextProvider ?? provider;
       const resolvedModel = nextModel ?? model;
       if (resolvedProvider === provider && resolvedModel === model) return;
+      const modelChanged = resolvedModel !== null && resolvedModel !== model;
       provider = resolvedProvider;
       model = resolvedModel;
+      // The exclusion verdict is a property of the model behind the agent:
+      // switching models clears it in the reducer. A not-yet-known identity
+      // dispatches nothing, so the first `setIdentity(…, null)` cannot undo
+      // a verdict learned earlier.
+      if (modelChanged) store.dispatch({ t: "model_changed", model: resolvedModel });
       emit();
     },
     dispatch(action) {
@@ -134,12 +141,26 @@
       if (action.t === "usage" && action.usage.output != null) everSawUsage = true;
       if (next === state) return;
       state = next;
+      if (action.t === "model_changed") {
+        console.log(
+          `turn-speed: model [${deps.agentId.slice(0, 8)}] now=${action.model ?? "?"}` +
+            ` thinking-verdict=${next.thinkingExcluded ? "excluded" : "unknown"}` +
+            `${next.thinkingExcludedModel ? ` (learned on ${next.thinkingExcludedModel})` : ""}`,
+        );
+      }
       if (next.steps.length > prev.steps.length) {
         const step = next.steps[next.steps.length - 1];
-        const rate = step.decodeMs > 0 ? (step.outTokens / (step.decodeMs / 1000)).toFixed(1) : "inf";
+        const thinking = next.thinkingExcluded
+          ? (step.thinkingChars - step.thinkingLateChars) / REASONING_CHARS_PER_TOKEN
+          : 0;
+        const rate =
+          step.decodeMs > 0 ? ((step.outTokens + thinking) / (step.decodeMs / 1000)).toFixed(1) : "inf";
+        const thinkPart =
+          step.thinkingChars > 0 ? ` thinkChars=${step.thinkingChars} think~${Math.round(thinking)}` : "";
         console.log(
-          `turn-speed: step ok [${deps.agentId.slice(0, 8)}] out=${step.outTokens} in=${step.inputTokens}` +
-            ` cached=${step.cachedTokens} decodeMs=${Math.round(step.decodeMs)} waitMs=${Math.round(step.waitMs)} rate=${rate}`,
+          `turn-speed: step ok [${deps.agentId.slice(0, 8)}] out=${step.outTokens}${thinkPart} in=${step.inputTokens}` +
+            ` cached=${step.cachedTokens} decodeMs=${Math.round(step.decodeMs)} waitMs=${Math.round(step.waitMs)}` +
+            ` rate=${rate} verdict=${next.thinkingExcluded ? "excluded" : "off"}`,
         );
       }
       if (next.stepRejected) {
@@ -149,7 +170,7 @@
           console.log(
             `turn-speed: step dropped as implausible [${deps.agentId.slice(0, 8)}]` +
               ` tokens=${next.stepRejected.tokens} windowMs=${Math.round(next.stepRejected.windowMs)}` +
-              " (likely a previous message's late usage; absorbed into the baseline)",
+              " (late or aggregated usage; kept in the turn totals as unattributed tokens)",
           );
         }
       }
@@ -165,7 +186,7 @@
       pushSample();
       emit();
     },
-    liveTokPerSec(windowSec) {
+    liveTokPerSec(windowSec: number, maxTokPerSec?: number) {
       const windowMs = Math.max(0.5, windowSec) * 1000;
       const at = now();
       const cutoff = at - windowMs;
@@ -186,7 +207,11 @@
       if (spanMs < 250) return null;
       const tokens = last.est - first.est;
       if (tokens <= 0) return null;
-      return tokens / (spanMs / 1000);
+      const rate = tokens / (spanMs / 1000);
+      // Beyond the plausibility cap the window holds a bookkeeping jump
+      // (aggregated usage, reasoning bulk), not streaming speed: no number.
+      if (maxTokPerSec !== undefined && rate > maxTokPerSec) return null;
+      return rate;
     },
     summary,
     isRunning() {
diff -ruN a/package/client/team.ts b/package/client/team.ts
--- a/package/client/team.ts	1985-10-26 08:15:00.000000000 +0000
+++ b/package/client/team.ts	1985-10-26 08:15:00.000000000 +0000
@@ -54,6 +54,7 @@
   members: readonly TeamMemberInput[],
   windowSec: number,
   now: number,
+  maxTokPerSec?: number,
 ): TeamView {
   const views: TeamMemberView[] = [];
   let running = 0;
@@ -63,7 +64,7 @@
   for (const member of members) {
     const isRunning = member.store.isRunning();
     const summary = member.store.summary(now);
-    const liveTokPerSec = isRunning ? member.store.liveTokPerSec(windowSec) : null;
+    const liveTokPerSec = isRunning ? member.store.liveTokPerSec(windowSec, maxTokPerSec) : null;
     views.push({
       id: member.id,
       title: member.title,
diff -ruN a/package/index.client.tsx b/package/index.client.tsx
--- a/package/index.client.tsx	1985-10-26 08:15:00.000000000 +0000
+++ b/package/index.client.tsx	1985-10-26 08:15:00.000000000 +0000
@@ -14,7 +14,7 @@
   type MeterStateDoc,
   type Preferences,
 } from "./shared/preferences";
-import { DEFAULT_CHARS_PER_TOKEN } from "./client/meter";
+import { DEFAULT_CHARS_PER_TOKEN, CAL_MAX } from "./client/meter";
 
 // Structural type derivation keeps `@getpaseo/client` out of the client
 // bundle: only types provided through the plugin context are used.
@@ -150,8 +150,20 @@
         const parsed = meterState.schema.safeParse(read.values);
         if (parsed.success) {
           calibration.clear();
+          const saturated: string[] = [];
           for (const [model, cpt] of Object.entries(parsed.data.calibration)) {
-            calibration.set(model, cpt);
+            // Entries pinned at CAL_MAX were produced by the pre-1.2 bug
+            // that charged reasoning characters against reasoning-free
+            // output tokens: drop them and let them relearn cleanly.
+            if (cpt >= CAL_MAX) saturated.push(model);
+            else calibration.set(model, cpt);
+          }
+          if (saturated.length > 0) {
+            void mutateDoc(meterState, (doc) => {
+              const next = { ...doc.calibration };
+              for (const model of saturated) delete next[model];
+              return { calibration: next };
+            });
           }
         }
       }
@@ -295,6 +307,7 @@
             at,
             key: `reasoning:${event.turnId ?? "current"}`,
             text: item.text,
+            kind: "reasoning",
           });
         } else if (item.type === "compaction") {
           store.dispatch({ t: "compaction", at });
@@ -457,7 +470,12 @@
       store.dispatch({ t: should ? "delegated_started" : "delegated_ended", at: Date.now() });
     };
     const team = () => {
-      const view = summarizeTeam(teamMembers(agent.id), prefs.liveWindowSec, Date.now());
+      const view = summarizeTeam(
+        teamMembers(agent.id),
+        prefs.liveWindowSec,
+        Date.now(),
+        prefs.maxPlausibleTokPerSec,
+      );
       // Open sub_agent tool calls count too: on provider subagents (no
       // linked Paseo child) they are the only running signal.
       const running = Math.max(view.running, subCalls.size);
diff -ruN a/package/package.json b/package/package.json
--- a/package/package.json	1985-10-26 08:15:00.000000000 +0000
+++ b/package/package.json	1985-10-26 08:15:00.000000000 +0000
@@ -1,6 +1,6 @@
 {
   "name": "paseo-turn-speed",
-  "version": "1.1.0",
+  "version": "1.2.1",
   "description": "Live decode tok/s, prefill and token breakdown for every agent turn",
   "license": "MIT",
   "author": "Bogdan Yanysh <acidlol@live.ru>",

@github-actions github-actions Bot added the bump Pinned artifact update label Oct 25, 2026

@paseo-bot paseo-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turn speed 1.2.1 is approved, @Drenalol. Registry checks passed. Your listing and screenshots are retained; no registry edits or action from you are needed.

For the next release, clarify in your listing overview that thinking-token detection is heuristic and requires several contradicting steps to revoke a verdict. That explains the estimate's limits more accurately than “nothing is double-counted.”

Review data
  • Reviewed head: 291b3954f8c2159c73c1ab2742a0e5757600003a. Main policy blob: 8da1286fd8870c87ff53767e8644858627b7371f.
  • Independently downloaded the npm tarballs for paseo-turn-speed 1.1.0 and 1.2.1, checked archive paths, verified both SHA-512 values, versions and tarball URLs against npm and the records, and compared the extracted artifacts. New integrity: sha512-xkOmf7hISOV++iwr9lzujn+NwronEUg14lHdX4f9Vf3nBR3Ka6aZSjrT/VoEXxLLa6xAmMap2eHket3TF28f0w==. npm maintainer and repository attribution remain unchanged.
  • Read all 872 diff lines and traced the changed client entry, meter, store, team summary, pill and details code, including host settings writes that discard saturated calibration entries. Inspected manifest, package metadata, server registration, preferences, description and shipped English overview. Entry files and local imports are present. No runtime dependencies, preparation command or install lifecycle scripts; only development dependencies and host libraries.
  • Timeline reasoning text feeds character counts and per-model estimates; reported usage and timing feed the meter. Client subscriptions read agent snapshots/timelines, and settings RPCs persist this plugin's preferences and calibration. The server only registers those settings. No independent network request, credential read, subprocess, dynamic code execution or filesystem operation was added. Arithmetic/display changes introduce no new access destination.
  • Existing listing media: all three HTTPS PNGs returned 200 with image/png; reviewed the interface image. No fallback overview or description needed, and no translations supplied. Registry edits: none. Trusted validation passed: 386 records well formed; one pinned artifact matched. Approved the held validation workflow; it passed.
  • Static inspection only: no plugin code or dependencies installed, built, imported or run. Live provider accuracy and UI behavior were not exercised. Optional wording advice above does not block acceptance.

@paseo-bot
paseo-bot Bot merged commit c1d08b7 into main Oct 25, 2026
1 check passed
@paseo-bot paseo-bot Bot mentioned this pull request Oct 25, 2026
2 of 9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bump Pinned artifact update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants