fix(telemetry): stamp a compaction on entering the null window

Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window.

Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands.
This commit is contained in:
JD committed 2026-10-11 12:36:59 -04:00
1 parent 551a7f2792
commit ecb2e3d8f3
16 files changed
+110 -125

No files matched your search

+2 -2
View File
@@ -25,7 +25,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
StatusLineTelemetry,
StatusLineExporter,
} from './types.js';
/**
@@ -59,7 +59,7 @@ export interface MuxSession {
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
statusLine?: StatusLineTelemetry;
statusLine?: StatusLineExporter;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
+11 -18
View File
@@ -1443,28 +1443,21 @@ export class Session extends EventEmitter {
/**
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
* `compactedAt` when this report shows Claude Code's own idle compaction of the current
* prefix: `recacheTokensIfCold` is null (the transient post-compaction window the docs
* describe) while `expectedRebuilds` rose above the last report. The stamp is carried
* forward while that window holds, so the readout can say "compacted at HH:MM"; it is
* never an alert. A tool-result clearing bumps `expectedRebuilds` the same way, which the
* readout wording tolerates.
* `compactedAt` when this report ENTERS the window in which Claude Code reads
* `recacheTokensIfCold` as null: a compaction (`/compact` or its own idle compaction) or
* a clearing of old tool results opened it, and the previous report still had a known
* value. Claude Code closes the window on the next request (that request is the one that
* counts as an expected rebuild), so `expectedRebuilds` never rises inside it and is not
* read here. The stamp is carried while the window holds and dropped once it closes; a
* first report already inside the window gets none. It is never an alert.
*/
setPromptCache(status: PromptCacheStatus): boolean {
const prev = this._promptCache;
const inPostCompactWindow = status.recacheTokensIfCold == null && status.warm;
const inWindow = status.warm && status.recacheTokensIfCold == null;
let compactedAt: number | undefined;
// A rise is only a rise against a KNOWN prior counter. Treating an absent prior as 0
// would invent a compaction out of the first report that already carries a high count
// (a restart mid-session, or `4 -> omitted -> 4`), so both counters must be present.
if (
inPostCompactWindow &&
prev?.expectedRebuilds != null &&
status.expectedRebuilds != null &&
status.expectedRebuilds > prev.expectedRebuilds
) {
if (inWindow && prev?.recacheTokensIfCold != null) {
compactedAt = Date.now();
} else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) {
} else if (inWindow && prev?.compactedAt != null) {
compactedAt = prev.compactedAt;
}
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
@@ -1956,7 +1949,7 @@ export class Session extends EventEmitter {
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineTelemetry: this._muxSession?.statusLine,
statusLineExporter: this._muxSession?.statusLine,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
+3 -3
View File
@@ -59,7 +59,7 @@ import {
type SessionDocker,
type DockerCommandMode,
type PaneExit,
type StatusLineTelemetry,
type StatusLineExporter,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -2046,14 +2046,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* only (remote/docker have their own separate command builders). Resolving still runs
* with collection off, because it also self-heals: it strips any legacy disk-written
* exporter from an older Codeman build the first time a session starts in that
* workspace again. `state` lands on the mux record so the Status row and keep-warm can
* workspace again. `state` lands on the mux record so the Status row can
* say why no cache report will come, instead of "not reported yet" forever.
*/
private async _resolveStatusLine(
mode: SessionMode,
workingDir: string,
remoteOrDocker: boolean
): Promise<{ command?: string; state?: StatusLineTelemetry }> {
): Promise<{ command?: string; state?: StatusLineExporter }> {
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
if (remoteOrDocker) return { state: 'remote' };
const enabled = await readPlanUsageTelemetryEnabled();
+9 -7
View File
@@ -747,10 +747,12 @@ export interface PromptCacheStatus {
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
lastMissCauses?: string[];
/**
* Epoch MS when Codeman first saw Claude Code's own idle compaction for the current prefix
* (`recacheTokensIfCold` null while `expectedRebuilds` rose). Derived in `Session.setPromptCache`,
* not reported by the statusline, so it is never set by `parsePromptCache`. Lets the readout say
* "compacted at HH:MM"; it is never an alert (CC's idle compaction is benign).
* Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle
* compaction, or a clearing of old tool results): the first warm report whose
* `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds,
* dropped at the next request. Derived in `Session.setPromptCache`, not reported by the
* statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted
* at HH:MM"; it is never an alert.
*/
compactedAt?: number;
}
@@ -765,7 +767,7 @@ export interface PromptCacheStatus {
* statusLine (or could not be read), and Codeman never replaces one there.
* - `remote`: remote and Docker launches never carry the exporter.
*/
export type StatusLineTelemetry = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
export interface SessionState {
/** Unique session identifier */
@@ -826,8 +828,8 @@ export interface SessionState {
* `expiresAt` has passed reads as cold rather than as a stale warm.
*/
promptCache?: PromptCacheStatus;
/** Why a cache report will or will not arrive (see `StatusLineTelemetry`); absent when unknown. */
statusLineTelemetry?: StatusLineTelemetry;
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
statusLineExporter?: StatusLineExporter;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
+8 -8
View File
@@ -709,14 +709,12 @@ function computeConnectionLossUi(input) {
};
}
// SSE staleness policy: is this stream a zombie?
//
// Session Options → Respawn → "Prompt cache": one sentence of the main
// conversation's cache state as Claude Code reports it on the statusline
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
// because the row refreshes on SSE, not on a timer, and a countdown would sit
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
// session's `statusLineTelemetry`: a launch without the exporter never reports, so
// session's `statusLineExporter`: a launch without the exporter never reports, so
// it says why instead of "not reported yet" forever.
const PROMPT_CACHE_NOT_COLLECTED = {
'collection-off':
@@ -738,18 +736,20 @@ function formatPromptCacheStatus(cache, now, statusLine) {
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
if (tokens) parts.push(`About ${tokens} tokens would be re-read if it goes cold.`);
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
} else {
parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : 'Cold.');
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
}
// Claude Code's own idle compaction kept the prefix warm but smaller, so there is no
// re-read stake to show; naming when it happened explains the shrink. Never an alert.
if (Number.isFinite(cache.compactedAt)) parts.push(`Compacted at ${clock(cache.compactedAt)}.`);
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
// request; naming when it happened explains the gap. Never an alert.
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
return parts.join(' ');
}
// SSE staleness policy: is this stream a zombie?
//
// An EventSource that stops delivering does not always error. A proxy that
// idle-closed the connection, a laptop resumed from sleep, a tailnet
// reconnect: `onerror` never fires, the header dot stays green, and every
+1 -1
View File
@@ -2795,7 +2795,7 @@ Object.assign(CodemanApp.prototype, {
const el = document.getElementById('promptCacheStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineTelemetry);
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter);
},
_onSessionPromptCache(data) {
@@ -71,6 +71,8 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const promptCache = parsePromptCache(payload);
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when