diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc2..9308f6108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a53134e5..ce307e823 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -625,6 +625,41 @@ most 64 characters, but treat it as untrusted text. A `statusline` or `screen` v persisted and restored after a server restart until the next report replaces it; a `config` value is read again at every pane start, attach and relaunch instead. +## Prompt cache (`promptCache`) + +Claude-mode session state (`GET /api/v1/sessions`) carries the main conversation's +prompt-cache state as Claude Code last reported it on the statusline (v2.1.251+), with a +change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): + +```json +"promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 } +``` + +| Field | Meaning | +| --------------------- | --------------------------------------------------------------------------------------------------------- | +| `warm` | Whether the cached prefix is still within its TTL. The only field always present. | +| `ttl` | `5m` or `1h`; absent when Claude reported neither. | +| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | +| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | +| `misses` | Requests that re-processed content the cache already held. | +| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | +| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | +| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | +| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. | + +The whole object is absent until the first report, and restored after a server restart +(so the readout is not blank until the pane's next render) rather than recomputed; a value +whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm cache hits +`expiresAt`, so the cold flip arrives on its own. A post-`/clear` conversation switch drops +it. Shown in Session Options → Respawn and, for sessions without a statusline of their own, +as a `cache:until HH:MM` / `cache:cold` group in the terminal footer. + +Beside it, `statusLineExporter` says whether this launch carries the statusline exporter +that sends the report, decided when the pane starts: `injected` (it does), `collection-off` +(Plan Usage was off at launch), `workspace-statusline` (the workspace's +`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or +`remote` (remote and Docker sessions). Absent when unknown. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ce51923b8..5a19a5703 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,7 +157,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 { + 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); diff --git a/src/types/session.ts b/src/types/session.ts index ae62638d6..61b3d450b 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -724,6 +724,51 @@ 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 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; +} + +/** + * 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 StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; + export interface SessionState { /** Unique session identifier */ id: string; @@ -775,6 +820,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 `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 */ diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index f8a823d33..abf4c7a61 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -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; } +/** + * `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. + */ +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%` — 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. + * `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): string { +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(' ') : ''; } diff --git a/src/web/public/app.js b/src/web/public/app.js index dda356446..10d919b18 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 8a1d500fc..15c4c7cec 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -709,6 +709,45 @@ function computeConnectionLossUi(input) { }; } +// 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 `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': + '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-cached if it goes cold.`); + } else { + parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.'); + } + // 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 @@ -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', diff --git a/src/web/public/index.html b/src/web/public/index.html index cb6d38d02..6d58dcb30 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1286,6 +1286,22 @@
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.
+