mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(telemetry): read out Claude Code's prompt cache state per session
Claude Code's statusline has carried a prompt_cache object since v2.1.251, and Codeman's statusline exporter already received it, but StatusTelemetrySchema stripped the whole object, so Codeman never knew whether a session's conversation was still cached. Keep it and show it. StatusTelemetrySchema now lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer), while parsePromptCache normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. Session.promptCache rides toState with a change-only session:promptCache event, and the route broadcasts the stored object. The Session Options (Respawn tab) readout shows warm-until or cold with the re-read stake, the cache hit rate, the last miss's causes, and, for Claude Code's own idle compaction (recache_tokens_if_cold null while expected_rebuilds rose), a "compacted at HH:MM" note that is never raised as an alert. setPromptCache stamps that on a new object rather than mutating the parsed report, so the stamp reaches the broadcast. Sessions without a statusline of their own also get a cache:until HH:MM / cache:cold group in the terminal footer. The last report is restored on a Codeman restart (like displayModel) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose expiry has passed reads as cold, not stale-warm. A launch without the exporter records why (collection off, workspace statusline, remote) so the row says the cause instead of "not reported yet" forever. This is the sensor a later opt-in keep-warm would read, and the TTL it reports is the source of truth rather than the plan tier.
This commit is contained in:
1 parent
aa3730aace
commit
551a7f2792
24 files changed
+946
-30
No files matched your search
@@ -25,6 +25,7 @@ import type {
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
StatusLineTelemetry,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,8 @@ export interface MuxSession {
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
|
||||
statusLine?: StatusLineTelemetry;
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
type PromptCacheStatus,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
@@ -585,6 +586,8 @@ export class Session extends EventEmitter {
|
||||
* running. A session that comes back with no label has no chip on its screen.
|
||||
*/
|
||||
private _watching: string | null = null;
|
||||
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
|
||||
private _promptCache: PromptCacheStatus | null = null;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
@@ -873,6 +876,8 @@ export class Session extends EventEmitter {
|
||||
paneExit?: PaneExit;
|
||||
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||
displayModel?: DisplayModel;
|
||||
/** The previous run's last prompt-cache report, restored so the readout is not blank (see `promptCache`). */
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
@@ -1041,6 +1046,11 @@ export class Session extends EventEmitter {
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||
// Last prompt-cache report, restored so the Session Options readout is not blank until
|
||||
// the pane's next statusline render. The readout is time-aware, so a value whose expiry
|
||||
// has passed renders as cold rather than stale-warm. Set directly, not via
|
||||
// setPromptCache, so recovery does not re-stamp a compaction it did not just observe.
|
||||
this._promptCache = config.promptCache ?? null;
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1244,6 +1254,11 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
if (newId === this._claudeSessionId) return;
|
||||
this._claudeSessionId = newId;
|
||||
// The conversation itself changed (a post-/clear switch), so the old prefix's cache
|
||||
// report and its compaction baseline belong to a conversation that is gone. Drop them
|
||||
// rather than show the previous conversation's "warm until" against the new one; the
|
||||
// next statusline render repopulates. A same-id reattach returned above, untouched.
|
||||
this._promptCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1414,6 +1429,53 @@ export class Session extends EventEmitter {
|
||||
return this._watching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt-cache state of the main conversation as Claude Code last reported it on the
|
||||
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
|
||||
* report. It rides `toState()` for the Session Options readout and is restored on a
|
||||
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
|
||||
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
|
||||
* as cold rather than as a stale warm.
|
||||
*/
|
||||
get promptCache(): PromptCacheStatus | null {
|
||||
return this._promptCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
setPromptCache(status: PromptCacheStatus): boolean {
|
||||
const prev = this._promptCache;
|
||||
const inPostCompactWindow = status.recacheTokensIfCold == null && status.warm;
|
||||
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
|
||||
) {
|
||||
compactedAt = Date.now();
|
||||
} else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) {
|
||||
compactedAt = prev.compactedAt;
|
||||
}
|
||||
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
|
||||
// route broadcasts the STORED object, so the stamp lives here and never leaks back into the
|
||||
// parser's output (which would make the route's "broadcast the stored object" choice vacuous).
|
||||
const next = compactedAt != null ? { ...status, compactedAt } : status;
|
||||
const changed = JSON.stringify(next) !== JSON.stringify(prev);
|
||||
this._promptCache = next;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
@@ -1893,6 +1955,8 @@ export class Session extends EventEmitter {
|
||||
autoCompactPrompt: this._autoOps.autoCompactPrompt,
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
promptCache: this._promptCache ?? undefined,
|
||||
statusLineTelemetry: this._muxSession?.statusLine,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
|
||||
+37
-13
@@ -59,6 +59,7 @@ import {
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
type PaneExit,
|
||||
type StatusLineTelemetry,
|
||||
} from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
@@ -2039,6 +2040,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The statusLine exporter for one claude launch, and why it is missing when it is.
|
||||
* Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns
|
||||
* 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
|
||||
* 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 }> {
|
||||
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
|
||||
if (remoteOrDocker) return { state: 'remote' };
|
||||
const enabled = await readPlanUsageTelemetryEnabled();
|
||||
const command = await resolveStatusLineCliCommand(workingDir, enabled);
|
||||
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the user's own REAL statusLine command (found by
|
||||
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
|
||||
@@ -2156,14 +2178,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
// Registry-gated (capabilities.statusLineTelemetry — claude only today), local
|
||||
// spawns only (remote/docker have their own separate command builders — out of
|
||||
// scope here). Also self-heals: strips any legacy disk-written exporter from an
|
||||
// older Codeman build the first time a session starts in that workspace again.
|
||||
const statusLineCommand =
|
||||
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
|
||||
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
|
||||
: undefined;
|
||||
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
|
||||
mode,
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
// settings precedence) — exported below so the shared exporter script
|
||||
// can wrap it. Only worth discovering when we're actually injecting.
|
||||
@@ -2327,6 +2346,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
statusLine,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -2451,11 +2471,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
// See createSession()'s identical resolution for rationale.
|
||||
const statusLineCommand =
|
||||
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
|
||||
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
|
||||
: undefined;
|
||||
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
|
||||
mode,
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
@@ -2518,6 +2538,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
if (session.statusLine !== statusLine) {
|
||||
session.statusLine = statusLine;
|
||||
this.saveSessions();
|
||||
}
|
||||
return pid;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to respawn pane:', err);
|
||||
|
||||
@@ -724,6 +724,49 @@ export interface PaneExit {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized prompt-cache state of a session's main conversation, from the statusline's
|
||||
* `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only
|
||||
* field Claude always reports: once the prefix has gone cold it sends `warm:false` with a
|
||||
* null `expires_at`.
|
||||
*/
|
||||
export interface PromptCacheStatus {
|
||||
warm: boolean;
|
||||
/** Cache lifetime of the current prefix; Claude Code only ever reports these two. */
|
||||
ttl?: '5m' | '1h';
|
||||
/** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */
|
||||
expiresAt?: number;
|
||||
/** Tokens the next request re-writes if the cache has gone cold by then. */
|
||||
recacheTokensIfCold?: number;
|
||||
/** Requests this session that re-processed content the cache already held. */
|
||||
misses?: number;
|
||||
/** Cache rebuilds that followed a compaction or a clearing of old tool results. */
|
||||
expectedRebuilds?: number;
|
||||
/** Cache read tokens as a fraction of all input tokens this session, 0-1. */
|
||||
hitRatio?: number;
|
||||
/** 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).
|
||||
*/
|
||||
compactedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch carries Codeman's statusLine exporter, the only source of
|
||||
* `promptCache`, and why not when it does not. Decided once per claude spawn
|
||||
* (`TmuxManager`), so a setting flipped later does not change it until the next launch.
|
||||
* - `injected`: the exporter is on the launch.
|
||||
* - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch.
|
||||
* - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a
|
||||
* 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 interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -775,6 +818,16 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/**
|
||||
* Prompt-cache state of the main conversation as Claude Code last reported it on the
|
||||
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first
|
||||
* report. Restored on a restart (the Session constructor's `promptCache`) so the readout
|
||||
* is not blank until the pane's next render; the readout is time-aware, so a value whose
|
||||
* `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;
|
||||
/** 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 */
|
||||
|
||||
+92
-8
@@ -15,6 +15,13 @@
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the
|
||||
* main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`,
|
||||
* `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset
|
||||
* Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and
|
||||
* `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes,
|
||||
* so the footer flips to cold on its own.
|
||||
*
|
||||
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
|
||||
* response and selects only the main `codex` bucket, excluding model-specific
|
||||
* buckets. All functions are pure for testability. See
|
||||
@@ -23,6 +30,10 @@
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
|
||||
import type { PromptCacheStatus } from './types/index.js';
|
||||
|
||||
export type { PromptCacheStatus };
|
||||
|
||||
/** A single normalized plan-usage window. */
|
||||
export interface UsageWindow {
|
||||
/** Percent of the window consumed, 0–100. */
|
||||
@@ -52,6 +63,24 @@ export interface RawStatuslinePayload {
|
||||
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
|
||||
prompt_cache?: RawPromptCache | null;
|
||||
}
|
||||
|
||||
/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */
|
||||
export interface RawPromptCache {
|
||||
warm?: boolean | null;
|
||||
ttl?: string | null;
|
||||
/** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */
|
||||
expires_at?: number | null;
|
||||
/** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */
|
||||
recache_tokens_if_cold?: number | null;
|
||||
misses?: number | null;
|
||||
/** Cache rebuilds that followed a compaction or a clearing of old tool results (the compaction signal). */
|
||||
expected_rebuilds?: number | null;
|
||||
/** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */
|
||||
hit_ratio?: number | null;
|
||||
last_miss_cause?: { causes?: unknown } | null;
|
||||
}
|
||||
|
||||
interface RawCodexRateLimitWindow {
|
||||
@@ -107,6 +136,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the statusline's `prompt_cache` object. Null when the payload has none
|
||||
* (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not
|
||||
* a boolean, so the caller can treat the cache state as unknown rather than cold.
|
||||
*/
|
||||
export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null {
|
||||
const pc = data?.prompt_cache;
|
||||
if (!pc || typeof pc.warm !== 'boolean') return null;
|
||||
const s: PromptCacheStatus = { warm: pc.warm };
|
||||
if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl;
|
||||
if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) {
|
||||
// Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to
|
||||
// Infinity or lands outside the Date range after *1000, which renders "cache:until
|
||||
// NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent.
|
||||
const ms = Math.round(pc.expires_at * 1000);
|
||||
if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms;
|
||||
}
|
||||
if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) {
|
||||
s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold));
|
||||
}
|
||||
if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) {
|
||||
s.misses = Math.max(0, Math.round(pc.misses));
|
||||
}
|
||||
if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) {
|
||||
s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds));
|
||||
}
|
||||
if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) {
|
||||
s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio));
|
||||
}
|
||||
const causes = pc.last_miss_cause?.causes;
|
||||
if (Array.isArray(causes)) {
|
||||
const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60));
|
||||
if (names.length) s.lastMissCauses = names;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
|
||||
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
@@ -140,6 +206,8 @@ export interface SessionStatus {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
contextUsedPercentage?: number;
|
||||
/** Prompt-cache state of the main conversation, when the statusline reports it. */
|
||||
cache?: PromptCacheStatus;
|
||||
}
|
||||
|
||||
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
|
||||
@@ -166,19 +234,34 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
|
||||
if (typeof cw?.used_percentage === 'number') {
|
||||
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||
}
|
||||
const cache = parsePromptCache(data);
|
||||
if (cache) s.cache = cache;
|
||||
return Object.keys(s).length ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back
|
||||
* when it has no statusline of the user's own to wrap. With nothing to show it
|
||||
* returns '' rather than a brand word: a bare `codeman` on the statusline is
|
||||
* the symptom discussion #405 opened with.
|
||||
* `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
|
||||
* once Claude reports it cold or the render happened past `expires_at`. A clock
|
||||
* time rather than a countdown because the footer only re-renders on statusline
|
||||
* triggers, so a countdown would sit stale on screen between turns.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
function formatCacheGroup(c: PromptCacheStatus, now: number): string {
|
||||
if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold';
|
||||
const d = new Date(c.expiresAt);
|
||||
return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT
|
||||
* the plan limits, which live in the Codeman header chip. Claude requires a
|
||||
* statusLine command to emit the rate_limits JSON at all, so this is what that
|
||||
* command prints back when it has no statusline of the user's own to wrap. With
|
||||
* nothing to show it returns '' rather than a brand word: a bare `codeman` on the
|
||||
* statusline is the symptom discussion #405 opened with. `now` is injectable so
|
||||
* the cache group is testable.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string {
|
||||
if (!s) return '';
|
||||
const groups: string[] = [];
|
||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||
@@ -187,6 +270,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||
if (tok.length) groups.push(tok.join(' '));
|
||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||
if (s.cache) groups.push(formatCacheGroup(s.cache, now));
|
||||
return groups.length ? groups.join(' ') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
|
||||
|
||||
// Scheduled runs
|
||||
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
|
||||
|
||||
@@ -711,6 +711,45 @@ 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
|
||||
// it says why instead of "not reported yet" forever.
|
||||
const PROMPT_CACHE_NOT_COLLECTED = {
|
||||
'collection-off':
|
||||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
|
||||
'workspace-statusline':
|
||||
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
|
||||
remote: 'Not collected for remote and Docker sessions.',
|
||||
};
|
||||
function formatPromptCacheStatus(cache, now, statusLine) {
|
||||
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
|
||||
if (!cache || typeof cache.warm !== 'boolean') {
|
||||
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
|
||||
}
|
||||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
|
||||
const stake = cache.recacheTokensIfCold;
|
||||
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
|
||||
const parts = [];
|
||||
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.`);
|
||||
} else {
|
||||
parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : '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)}.`);
|
||||
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(' ');
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1447,6 +1486,9 @@ if (typeof window !== 'undefined') {
|
||||
compute: computeSseStale,
|
||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||
};
|
||||
window.CodemanPromptCache = {
|
||||
format: formatPromptCacheStatus,
|
||||
};
|
||||
window.CodemanSessionOrder = {
|
||||
RANK: SESSION_ACTIVITY_RANK,
|
||||
anchor: sessionActivityAnchor,
|
||||
@@ -1632,6 +1674,7 @@ const SSE_EVENTS = {
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
SESSION_PROMPT_CACHE: 'session:promptCache',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
|
||||
@@ -1286,6 +1286,22 @@
|
||||
<!-- Hidden wholesale for non-claude sessions and for a session with
|
||||
no live process (session-ui.js: openSessionOptions). -->
|
||||
<div id="sessionRespawnSection">
|
||||
<!-- Claude Code (v2.1.251+) reports the main conversation's prompt-cache
|
||||
state on the statusline; the exporter keeps it per session and this
|
||||
row shows it live (session-ui.js: updatePromptCacheStatus). -->
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Prompt cache</h4></div>
|
||||
<p class="set-group-hint">While the cached conversation is warm, a turn re-reads it at the cheap cached rate. Once it sits idle past the cache lifetime, the next turn re-processes the whole context at the cache-write rate.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Loop control</h4></div>
|
||||
<div class="set-group-body">
|
||||
|
||||
@@ -2534,6 +2534,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Populate auto-resume on usage limit (token pause control)
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -2784,6 +2785,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Session Options → Respawn → "Prompt cache": the main conversation's cache state as
|
||||
* Claude Code reports it on the statusline (`session.promptCache`, v2.1.251+), worded by
|
||||
* the pure `CodemanPromptCache.format` (constants.js). Refreshed on open and on the
|
||||
* `session:promptCache` SSE event while the modal shows that session.
|
||||
*/
|
||||
updatePromptCacheStatus(sessionId) {
|
||||
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);
|
||||
},
|
||||
|
||||
_onSessionPromptCache(data) {
|
||||
const { sessionId, ...promptCache } = data;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.promptCache = promptCache;
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
|
||||
@@ -20,13 +20,14 @@ import { FastifyInstance } from 'fastify';
|
||||
import { StatusTelemetrySchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parsePromptCache,
|
||||
parseStatusTelemetry,
|
||||
parseSessionStatus,
|
||||
formatSessionStatusText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../../usage-telemetry.js';
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
import { getCli } from '../../config/cli-registry/index.js';
|
||||
@@ -59,6 +60,19 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
session.noteReportedModel('statusline', payload?.model?.display_name);
|
||||
}
|
||||
|
||||
// Prompt-cache state of the main conversation (Claude Code v2.1.251+): kept on the
|
||||
// session for the Session Options readout and `toState()`, broadcast only when the
|
||||
// report changes (the statusline fires on every assistant message). The broadcast
|
||||
// carries the STORED object, so a compaction stamp set in setPromptCache rides along.
|
||||
// Reports are applied in arrival order. When the exporter wraps a user's own statusline
|
||||
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
|
||||
// arrive out of order; a stale one then briefly overwrites newer state until the next
|
||||
// render corrects it. Accepted for a display-only readout rather than ordered here.
|
||||
const promptCache = parsePromptCache(payload);
|
||||
if (promptCache && session?.setPromptCache(promptCache)) {
|
||||
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
||||
}
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
// present and changed (the statusline fires on every assistant message).
|
||||
const telemetry = parseStatusTelemetry(payload);
|
||||
|
||||
@@ -653,6 +653,34 @@ export const StatusTelemetrySchema = z.object({
|
||||
.nullish(),
|
||||
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
|
||||
model: z.object({ display_name: z.string().max(100).nullish() }).nullish(),
|
||||
// Main-conversation prompt-cache statistics (Claude Code v2.1.251+). Every documented
|
||||
// scalar field is listed nullish: `expires_at`/`hit_ratio`/`last_miss_at`/
|
||||
// `recache_tokens_if_cold` are all documented as possibly null, and the object gains
|
||||
// fields over CLI versions, so a stricter shape would 400 the whole post and blank the
|
||||
// footer (the recurring .optional()/null trap). Causes stay `unknown` (the parser keeps
|
||||
// the strings); `last_miss_cause`'s extra numeric keys and the `miss_causes` record are
|
||||
// stripped by Zod unread, which keeps the stored object bounded — a hostile localhost
|
||||
// payload cannot bloat state, since only the normalized subset below is ever stored.
|
||||
prompt_cache: z
|
||||
.object({
|
||||
warm: z.boolean().nullish(),
|
||||
caching_observed: z.boolean().nullish(),
|
||||
// No length cap: a future TTL longer than today's `5m`/`1h` must not 400 the whole
|
||||
// post and blank the footer. parsePromptCache discards any TTL it does not know, so
|
||||
// the stored value stays bounded to those two regardless.
|
||||
ttl: z.string().nullish(),
|
||||
expires_at: z.number().nullish(),
|
||||
requests: z.number().nullish(),
|
||||
misses: z.number().nullish(),
|
||||
expected_rebuilds: z.number().nullish(),
|
||||
hit_ratio: z.number().nullish(),
|
||||
cache_write_tokens: z.number().nullish(),
|
||||
miss_recache_tokens: z.number().nullish(),
|
||||
last_miss_at: z.number().nullish(),
|
||||
recache_tokens_if_cold: z.number().nullish(),
|
||||
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
@@ -3569,6 +3569,11 @@ export class WebServer extends EventEmitter {
|
||||
// tile header keeps naming it until the next report; an idle claude
|
||||
// session re-renders its statusline only on its next turn.
|
||||
displayModel: savedState?.displayModel,
|
||||
// The last prompt-cache report, restored for the same reason: an idle claude
|
||||
// session re-renders its statusline only on its next turn, so without this the
|
||||
// Session Options readout would read "not reported yet" after every restart. The
|
||||
// readout is time-aware, so a value past its expiry shows as cold, not stale-warm.
|
||||
promptCache: savedState?.promptCache,
|
||||
// A record rebuilt from the socket has no provenance, so its
|
||||
// apparent locality is a guess (see `MuxSession.discovered`).
|
||||
discoveredMuxSession: muxSession.discovered,
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 161 event constants organized by category:
|
||||
* 162 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Transport** (1): sse:heartbeat
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
@@ -119,6 +119,8 @@ export const SessionInteractive = 'session:interactive' as const;
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
/** Combined Claude and main Codex plan-usage telemetry for the shared header chip. */
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */
|
||||
export const SessionPromptCache = 'session:promptCache' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -526,6 +528,7 @@ export const SseEvent = {
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
SessionPromptCache,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
|
||||
Reference in new issue
Block a user