Audit, fix, and monitor Claude Code context window usage. Find the ghost tokens.

<b>English</b> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.ja.md">日本語</a>
<img src="skills/token-optimizer/assets/logo.svg" alt="Token Optimizer" width="780">
<a href="https://github.com/alexgreensh/token-optimizer/releases/latest"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=version&color=green" alt="Latest stable version"></a> <a href="https://github.com/alexgreensh/token-optimizer/commits/main"><img src="https://badgen.net/github/last-commit/alexgreensh/token-optimizer?label=last%20commit" alt="Last commit"></a> <a href="https://github.com/alexgreensh/token-optimizer"><img src="https://img.shields.io/badge/Claude_Code-Plugin-blueviolet" alt="Claude Code Plugin"></a> <a href="https://github.com/alexgreensh/token-optimizer/tree/main/openclaw"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2Fopenclaw%2Fpackage.json&query=%24.version&prefix=v&label=OpenClaw&color=brightgreen" alt="OpenClaw version"></a> <a href="https://github.com/alexgreensh/token-optimizer/tree/main/opencode"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2Fopencode%2Fpackage.json&query=%24.version&prefix=v&label=OpenCode&color=58a6ff" alt="OpenCode version"></a> <a href="https://github.com/alexgreensh/token-optimizer/blob/main/docs/codex.md"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2F.codex-plugin%2Fplugin.json&query=%24.version&prefix=v&label=Codex&color=orange" alt="Codex version"></a> <a href="https://github.com/alexgreensh/token-optimizer/tree/main/hermes"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=Hermes&color=0d9488" alt="Hermes version"></a> <a href="https://github.com/alexgreensh/token-optimizer/blob/main/docs/copilot.md"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=Copilot&color=6e40c9&logo=githubcopilot&logoColor=white" alt="GitHub Copilot version"></a> <a href="https://github.com/alexgreensh/token-optimizer/blob/main/docs/cursor.md"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Falexgreensh%2Ftoken-optimizer%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&prefix=v&label=Cursor&color=0c0c0c&logo=cursor&logoColor=white" alt="Cursor version"></a> <a href="https://github.com/alexgreensh/token-optimizer/blob/main/docs/antigravity.md"><img src="https://img.shields.io/badge/Antigravity-beta-0ea5e9" alt="Google Antigravity beta"></a> <img src="https://img.shields.io/badge/cuts%20context%20waste-3fb950" alt="Cuts context waste"> <img src="https://img.shields.io/badge/survives%20compaction-checkpoint%20%2B%20restore-58a6ff" alt="Survives compaction"> <img src="https://img.shields.io/badge/saves%20real%20%24-every%20session-2ea043" alt="Saves real dollars every session"> <img src="https://img.shields.io/badge/live%20dashboard-tokens%20%2B%20%24%20%2B%20turns-8B5CF6?logo=chartdotjs&logoColor=white" alt="Live dashboard"> <img src="https://img.shields.io/badge/context%20quality-live%20score-blue" alt="Live context quality score"> <img src="https://img.shields.io/badge/tests-passing-brightgreen" alt="Tests passing"> <img src="https://img.shields.io/badge/dependencies-zero-brightgreen" alt="Zero Dependencies"> <img src="https://img.shields.io/badge/telemetry-none-brightgreen" alt="Zero Telemetry"> <img src="https://img.shields.io/badge/python-3.9+-blue" alt="Python 3.9+"> <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey" alt="Platform"> <a href="https://github.com/alexgreensh/token-optimizer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"></a> <a href="https://github.com/alexgreensh/token-optimizer/stargazers"><img src="https://badgen.net/github/stars/alexgreensh/token-optimizer" alt="GitHub Stars"></a> <a href="https://github.com/alexgreensh/token-optimizer/commits/main"><img src="https://badgen.net/github/commits/alexgreensh/token-optimizer?label=commits" alt="Commits"></a> <a href="https://linkedin.com/in/alexgreensh"><img src="https://img.shields.io/badge/LinkedIn-Connect-0A66C2?logo=linkedin&logoColor=white" alt="Connect on LinkedIn"></a> <a href="https://github.com/sponsors/alexgreensh"><img src="https://img.shields.io/badge/%E2%99%A5%20Support%20this%20project%20to%20keep%20it%20open%20source-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Support this project to keep it open source"></a>
<h2 align="center">Cut the tokens you waste. Keep the work you'd lose.</h2>
<a href="https://alexgreensh.github.io/token-optimizer/"><img src="https://img.shields.io/badge/%F0%9F%93%96%20Read%20the%20Docs-alexgreensh.github.io%2Ftoken--optimizer-e85329?style=for-the-badge&logoColor=white" alt="Read the documentation"></a> <a href="https://alexgreensh.github.io/token-optimizer/start/quickstart/"><img src="https://img.shields.io/badge/Quickstart-2%20minutes-3fb950?style=for-the-badge" alt="Quickstart in 2 minutes"></a>
Token Optimizer cuts the tokens your AI coding assistant wastes, keeps your work alive across sessions and compactions, and shows you where every dollar went on a live dashboard. Most of it runs automatically. You install it, run the audit once, and the hooks do the rest.
Why not just use Headroom or RTK? They compress command output, which covers 15-25% of your context. Token Optimizer covers that plus the other 75%: bloated configs, unused skills, stale memory, compaction loss, model misrouting, behavioral waste. Every saving is cache-safe and measured. The dashboard updates after every session, automatically.
Works on Claude Code (CLI and VS Code), OpenCode, OpenClaw, Codex, Hermes, GitHub Copilot, Pi Coding Agent, Cursor, Google Antigravity (beta), and Grok Build (beta). Windsurf is next on the roadmap.
<img src="skills/token-optimizer/assets/hero-terminal.svg" alt="Token Optimizer Quick Scan" width="800">
Claude Code (recommended):
/plugin marketplace add alexgreensh/token-optimizer
/plugin install token-optimizer@alexgreensh-token-optimizer
Then in Claude Code: /token-optimizer
Enable auto-update after installing. Claude Code ships third-party marketplaces with auto-update off by default.
/plugin→ Marketplaces tab → selectalexgreensh-token-optimizer→ Enable auto-update. One-time, 10 seconds.After install, run
/token-optimizeronce to set up hooks. From there, everything runs automatically: compression, checkpoints, quality scoring, dashboard updates. You don't need to run any command again unless you want an audit.
Claude Code cloud sessions (claude.ai/code). A cloud session runs in a fresh container and never reads the plugins installed on your machine, so the two commands above are not enough there (
/pluginitself is not available in cloud sessions). Either enable Token Optimizer for your claude.ai account (Desktop app → Customize → plugins), or commit this to the repo's.claude/settings.jsonso every cloud session on that repo installs it at start:{ "extraKnownMarketplaces": { "alexgreensh-token-optimizer": { "source": { "source": "github", "repo": "alexgreensh/token-optimizer" } } }, "enabledPlugins": { "token-optimizer@alexgreensh-token-optimizer": true } }Hooks, compression and redaction behave the same inside the container. Each cloud session starts with an empty state directory, so the dashboard and audit history there cover that session only.
Codex:
codex plugin marketplace add alexgreensh/token-optimizer
Then in the Codex TUI: /plugins and install Token Optimizer. See docs/codex.md.
OpenCode V2: add token-optimizer-opencode to the plugins array in your opencode.json (V1 1.18.29+ uses plugin):
{ "$schema": "https://opencode.ai/config.json", "plugins": ["token-optimizer-opencode"] }
See opencode/README.md.
OpenClaw:
openclaw plugins install github:alexgreensh/token-optimizer
See openclaw/README.md.
Hermes:
git clone https://github.com/alexgreensh/token-optimizer.git
token-optimizer/install.sh --hermes
See hermes/README.md.
Pi Coding Agent:
pi install git:github.com/alexgreensh/token-optimizer
Then run /token-optimizer enable to opt in. Native model costs are reported on the active branch; local tool archives and continuity markers require separate opt-in. See pi/README.md for commands, privacy, update, uninstall, and limits.
GitHub Copilot:
git clone --depth 1 https://github.com/alexgreensh/token-optimizer.git
cd token-optimizer
bash install.sh --copilot
See docs/copilot.md.
Cursor:
git clone --depth 1 https://github.com/alexgreensh/token-optimizer.git
cd token-optimizer
bash install.sh --cursor
See docs/cursor.md.
Google Antigravity:
git clone --depth 1 https://github.com/alexgreensh/token-optimizer.git
cd token-optimizer
bash install.sh --antigravity
See docs/antigravity.md.
Grok Build (beta, contract-only):
git clone --depth 1 https://github.com/alexgreensh/token-optimizer.git
cd token-optimizer
bash install.sh --grok
See docs/grok.md.
macOS/Linux script install (alternative to plugin):
tmp="$(mktemp -d)"
release_json="$(curl -fsSL https://api.github.com/repos/alexgreensh/token-optimizer/releases/latest)"
tag="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' <<<"$release_json")"
git clone --branch "$tag" --depth 1 https://github.com/alexgreensh/token-optimizer.git ~/.claude/token-optimizer
bash ~/.claude/token-optimizer/install.sh
rm -rf "$tmp"
Windows users: Use the plugin install only. Do not run install.sh on Windows. If you hit EBUSY errors, close all Claude Code and Git Bash windows, kill lingering git.exe processes, delete C:\Users\<you>\.claude\token-optimizer and C:\Users\<you>\.claude\plugins\marketplaces\alexgreensh-token-optimizer, then retry.
If install.sh fails with $'\r': command not found (a clone made before LF line endings were enforced converted the script to CRLF), strip the carriage returns once and re-run — the repo now ships a .gitattributes that prevents this on fresh clones:
sed -i 's/\r$//' ~/.claude/token-optimizer/install.sh
# already have the repo? re-normalize line endings in place:
git -C ~/.claude/token-optimizer add --renormalize . && git -C ~/.claude/token-optimizer checkout -- .
Token Optimizer is additive and reversible. Every runtime has a clean uninstall that removes only what we installed, leaving your own hooks, config, and session data intact. Full per-runtime steps live in docs/uninstall.md.
Quickest path (Claude Code plugin install):
/plugin uninstall token-optimizer@alexgreensh-token-optimizer
Runs automatically, every session, you do nothing:
When you ask for it:
/token-optimizer: full audit with guided fixes/token-coach: 30-day trend analysis with specific fixesquick: 10-second health checkroute: the model and effort this task actually needs, before you spenddoctor: installation checksavings: dollar savings reportreport: per-component token breakdowndashboard: open the full dashboardmemory-review: MEMORY.md structural auditexpand: retrieve archived tool resultresume-lean: reopen a cold sessionInstall, run /token-optimizer once, everything else runs automatically.
Most token tools compress command output. That covers 15-25% of your context. The other 75% goes untouched.
Compression coverage. Headroom, RTK, and JFrog Boost compress bash and command output. Token Optimizer compresses eight surfaces of the output stack. Status: 🟢 supported, 🟡 partial, 🔴 not supported.
| Compression surface | Token Optimizer | Headroom | RTK | Boost |
|---|---|---|---|---|
| Bash / command output (git, tests, lint, build, logs) | 🟢 111 commands across 22 pattern families, credential-safe; 564 → 115 tokens on a pytest run | 🟢 SmartCrusher, CodeCompressor, Kompress-v2 | 🟢 100+ filters | 🟢 Command-aware filters |
| Search / grep output | 🟢 Top hits plus a count; 500 lines → 20 | 🔴 | 🔴 | — |
| Tabular / JSON output (jq, yq, csvtool, mlr) | 🟢 Value-preserving columnar | 🟢 SmartCrusher | 🔴 | — |
| File re-reads, delta mode | 🟢 Diff only; 2,000-token re-read → ~50 | 🔴 | 🔴 | — |
| File re-reads, structure map | 🟢 Skeleton of signatures and imports; 720KB → 250 tokens | 🔴 | 🔴 | — |
| Large tool results (over 4K chars) | 🟢 Archived to disk, expandable on demand | 🔴 | 🔴 | — |
| Model output verbosity | 🟢 Lean-output nudge, cache-safe (savings estimated, not metered) | 🔴 | 🔴 | 🔴 |
| Structural context (configs, skills, MCP, memory) | 🟢 Per-component audit, each source scored | 🔴 | 🔴 | 🔴 |
RTK and Boost reach the first surface. Headroom reaches the first and the third. Token Optimizer covers all eight, then keeps going into what happens around compression:
<img src="skills/token-optimizer/assets/automated-flow.svg" alt="How Token Optimizer works automatically every session" width="900">
These ten rows are the ones where Token Optimizer is the only 🟢 in the row: what survives beyond compression, and the waste no compressor looks for. Compression mechanics, cost and safety, and the two rows where we lose are behind the fold.
| Token Optimizer | Headroom | RTK | Boost | context-mode | /context | |
|---|---|---|---|---|---|---|
| Session continuity | 🟢 Progressive checkpoints before compaction + restore after, cross-session hints, cold-resume, tool-output digest; measured ~3.9M tokens / ~$19 recovered in a 30-day snapshot (checkpoint restores + lean resumes) | 🔴 | 🔴 | — | 🟡 Session guide only | 🔴 |
| Structural waste audit | 🟢 Deep per-component (CLAUDE.md, skills, MCP, memory) | 🔴 | 🔴 | 🔴 | 🔴 | 🟡 Summary only |
| CLAUDE.md and MEMORY.md health | 🟢 8 auditors + attention-curve scoring | 🔴 | 🔴 | 🔴 | 🔴 | 🔴 |
| Model routing and behavioral coaching | 🟢 12 detectors, subagent cost breakdown, anti-patterns | 🔴 | 🔴 | — | 🔴 | 🟡 Basic suggestions |
| Historical trend analysis | 🟢 30-day trends, quality/cost/cache/duration correlation, model-switch detection | 🔴 | 🔴 | — | 🔴 | 🔴 |
| Context quality scoring | 🟢 7-signal quality score with grades | 🔴 | 🔴 | — | 🔴 | 🟡 Capacity % only |
| Loop and spin detection | 🟢 Catches behavioral loops before they burn | 🔴 | 🔴 | — | 🔴 | 🔴 |
| Measures if compression helped | 🟢 Local telemetry, before/after tokens, dollar savings | 🔴 | 🟡 rtk gain (token counts only) | 🟡 boost report, vendor-side | 🔴 | 🔴 |
| Fleet-level cross-agent analysis | 🟢 | 🔴 | 🔴 | — | 🔴 | 🔴 |
| Token Optimizer | Headroom | RTK | Boost | context-mode | /context | |
|---|---|---|---|---|---|---|
| No command rewriting required | 🟢 Hook-wired, fires automatically | 🟢 Transparent proxy | 🟢 Hook-based rewrite | 🟢 boost init wires supported agents; terminal use can be prefixed | 🟡 Automatic on hook-capable platforms | N/A Native command |
| Full original recoverable | 🟢 Raw archived before compression, expand retrieves it; failures never compressed | 🟢 Reversible retrieval | 🟡 Full output saved on command failure | 🟢 Vendor documents command-output recovery | — | N/A Does not compress |
| Register a custom command filter | 🟢 Custom TOML filters (command-filters.toml, additive + exclude, safety-gated) | — | 🟢 Custom TOML filters | 🟢 TOML filters | — | N/A |
| User-tunable configuration | 🟢 92 code-referenced TOKEN_OPTIMIZER_* names, explicitly split into user-facing and internal controls; additive allowlist; .contextignore | 🟢 Documented configuration | 🟢 config.toml and environment controls | 🟢 Filter TOML | 🟢 Documented configuration | N/A |
| Cache-safe | 🟢 Never modifies existing context prefix | 🟡 Proxy mode rewrites in-flight | 🟢 Pre-shell only | 🟢 Pre-shell only | 🟡 MCP overhead | 🟢 |
| Zero baseline context overhead | 🟢 External process, no context injection | 🔴 Injects instructions | 🟢 Shell-level only | 🟢 Shell-level only | 🔴 MCP server overhead | 🟢 Native |
| Zero runtime dependencies | 🟢 Pure stdlib (Python/TypeScript) | 🟡 Python + Rust + optional model | 🟢 Single Rust binary | 🟢 Single binary | 🟡 SQLite adapter required | 🟢 N/A |
| Zero telemetry | 🟢 Nothing leaves the machine | 🟡 HEADROOM_TELEMETRY opt-in, off by default | 🟡 Opt-in | 🔴 Collects commands invoked, command arguments, exit codes, duration, CI attributes, IP | 🟡 Varies | 🟢 |
| Multi-platform | 🟢 Claude Code, VS Code, Cowork, Codex, OpenClaw, OpenCode, Hermes, Copilot, Cursor, Antigravity | 🟢 Claude Code, Cursor, Codex, Aider, Copilot | 🟢 15 integrations | 🟡 Cursor, Claude Code, Copilot, Codex CLI | 🟢 17 integrations | 🔴 Claude Code only |
| Per-task model and effort advice | 🟢 route sizes the task before you spend | — | — | — | — | — |
| Keep-Warm (cache TTL refresh) | 🟢 Opt-in ping before cache expiry, tripwire auto-off | 🔴 | 🔴 | — | 🔴 | 🔴 |
| End-to-end task-outcome benchmark | 🟡 Controlled A/B on 7 real tasks (output tokens) + measured real-session with/without savings; pass-rate study not yet run | — | — | 🟢 Vendor reports Terminal-Bench 2.0 with the same pass rate and ~12% lower cost | — | N/A |
| Signed and checksum-verified install | 🟢 CHECKSUMS.sha256 per release, verified at install, CI-enforced | — | — | 🔴 Installer verifies neither a checksum nor a signature | — | N/A |
An em dash means the capability was not verified from first-party material in the 2026-07-26 source audit (Boost's first-party material re-verified 2026-08-26); it is not a claim that the capability is absent. This page scores command-output compression only; JFrog Boost's separately-shipped BoostGraph, a local code-map its agent queries on request, is a different capability and out of scope here. Boost's "pre-shell" and "shell-level" cells are sourced from its documented wrapper form (boost <command>); the mechanism boost init uses to wire an agent is not described in its first-party material. Token Optimizer's compression claims are tested against real sessions and an 87-fixture suite you can run yourself. Full benchmark methodology and results → Compaction timing now has a separate, session-grouped evaluator for advisory precision, future context need, and checkpoint/cold-resume coverage. Run the compaction timing evaluator →
<a href="https://alexgreensh.github.io/token-optimizer/reference/comparison/"><img src="https://img.shields.io/badge/Read%20the%20official%20full%20comparison-in%20our%20docs-0b7285?style=for-the-badge" alt="Read the official full comparison page in our documentation"></a>

One HTML page, auto-regenerates after every session via the SessionEnd hook, no manual trigger needed. The bookmarkable URL http://localhost:24842/token-optimizer is opt-in: it only works after you run python3 skills/token-optimizer/scripts/measure.py setup-daemon to start the local server. Until then, open the file path that measure.py dashboard prints on the Dashboard: line.
Per-turn token breakdowns, cost across four pricing tiers, cache analysis with TTL mix and hit rate, quality scores overlaid on every session, subagent cost breakdown, savings tracker with metered and estimated tiers kept apart. Zero setup after install. Full dashboard docs →
Six levers, all automatic: tool-output compression, cross-turn dedup, delta-read, structure-map skeletons, checkpoint-restore, and model routing. What they save is tracked in three tiers.
Three tiers, labelled by how each is known (one real month, the author's last 30 days, all six levers on):
About \~$1,396 in API-equivalent value for the month, and 59.6M tokens never sent to the model, roughly 28% of the workload (150.8M spent, about 210.4M without Token Optimizer). Tokens ar
desktop/token-optimizer-desktop/hooks/register.tsx 1479 lines1// The Token Optimizer band above the prompt in the desktop app.
2//
3// This is the plugin's one hooks module and the only file that touches `$`.
4// Every decision lives in ../src (pure, Node-tested) and ./data.ts (the data
5// gatherer over a port). The engine allows `$` only into functions declared
6// at the top of this file, so each `$` helper below is one, and closures made
7// inside hooks only ever hand `$` on to them.
8//
9// State the drawing reads lives in `$.state` atoms (types/index.d.ts) so a hot
10// reload keeps it; the pose reducer also runs from a module copy so streaming
11// chunks do not write on every piece.
12import { atom, read, update } from 'claude-code'
13import type { Elements, EngineInterface, Register } from 'claude-code'
14
15import type { TokenOptimizerDesktopSession } from '../types/index.d.ts'
16import {
17 BUSY_TIMEOUT_MS,
18 CAPTURE_TIMEOUT_MS,
19 HANDOFF_KEY,
20 HANDOFF_TTL_MS,
21 RESUME_TIMEOUT_MS,
22 WARM_PROMPT,
23 attachesHandoff,
24 busyNow,
25 handoffFate,
26 initialUi,
27 isArmed,
28 lifetimeFromUsage,
29 noteNow,
30 prepareHandoff,
31 requestContextTokens,
32 stripCrossSessionPointer,
33 warmToast,
34 withBusy,
35 withNote,
36 type Busy,
37 type Handoff,
38 type HandoffPort,
39 type UiState,
40} from '../src/actions.ts'
41import { canKeepWarm, initialClock, reduceClock, view, type ClockEvent, type ClockState } from '../src/clock.ts'
42import { LIGHT, clawdSvg, type Gaze, type Palette } from '../src/clawd.ts'
43import type { Snapshot } from '../src/contracts.ts'
44import { ICONS, ICON_ALT, iconSvg, type IconName } from '../src/icons.ts'
45import { COMPACT_HEAVY, QUALITY_FLOOR, moodOf, sentence, type ActionId, type Run } from '../src/ladder.ts'
46import { cards, marks, row, type Card, type Mark, type MarkTone } from '../src/marks.ts'
47import { DEBOUNCE_MS, initialPose, reducePose, type PoseEvent, type PoseState } from '../src/pose.ts'
48import {
49 QUALITY_REFRESH_MS,
50 STATUS_AFTER_TURN_MS,
51 TICK_IDLE_MS,
52 TICK_WARNING_MS,
53 cleanId,
54 findTokenOptimizerRoot,
55 gather,
56 mergeStored,
57 readHome,
58 runMeasure,
59 type DataIo,
60 type GatherOptions,
61} from './data.ts'
62
63const sessionAtom = atom({ plugin: 'token-optimizer', key: 'session' } as const, null)
64const handoffAtom = atom({ plugin: 'token-optimizer', key: 'handoff' } as const, null)
65const clockAtom = atom({ plugin: 'token-optimizer', key: 'clock' } as const, null)
66const poseAtom = atom({ plugin: 'token-optimizer', key: 'pose' } as const, null)
67const engineCallAtom = atom({ plugin: 'token-optimizer', key: 'engineCall' } as const, null)
68const uiAtom = atom({ plugin: 'token-optimizer', key: 'ui' } as const, null)
69const frameAtom = atom({ plugin: 'token-optimizer', key: 'frame' } as const, 0)
70
71type Desktop = Elements['desktop']
72type Tones = { good: string; caution: string; bad: string; cold: string; ink: string; track: string; card: string; line: string }
73
74/** The design page's tone colours (light; each picture follows dark mode itself, see DARK_STYLE). */
75function tonesFor(p: Palette): Tones {
76 return { good: '#2f9e55', caution: '#c98a1b', bad: p.bad, cold: p.cold, ink: p.ink, track: '#d9d6cd', card: p.card, line: '#e2dfd6' }
77}
78
79// ---- module state: plain variables, rebuilt from the atoms after a reload ----
80
81let enabled = true
82let animate = true
83let active = false
84/** Whether the TOKEN_OPTIMIZER_STATUS_BAR switches have been read in this environment. */
85let switchesRead = false
86let timers: { cancel: () => void }[] = []
87let statusTimer: { cancel: () => void } | null = null
88let poseLive: PoseState | null = null
89let poseSig = ''
90let settleQueued = false
91/** The cache coldness last told to Clawd; null until the first tick tells him, so a cold pose kept across a reload never sticks. */
92let lastCold: boolean | null = null
93let frameText = ''
94/** The transcript Claude Code named for one session: never handed to another session's read. */
95let transcript: { sessionId: string; path: string } | null = null
96
97function noteTranscript(sessionId: unknown, path: unknown): void {
98 if (typeof path === 'string' && path && typeof sessionId === 'string' && sessionId) transcript = { sessionId: cleanId(sessionId), path }
99}
100
101function transcriptFor(sid: string): string | undefined {
102 return transcript !== null && sid !== '' && transcript.sessionId === sid ? transcript.path : undefined
103}
104let warmInFlight = false
105/**
106 * Which session the band is serving: bumped when one starts, is cleared or is
107 * swapped in. Work begun under one generation drops its result in the next.
108 */
109let sessionGen = 0
110/**
111 * A press is being claimed: a second press meanwhile is ignored (no double
112 * compaction). A claim older than the busy timeout is stale: a stuck press
113 * never holds the buttons past it.
114 */
115let claim: { at: number } | null = null
116/**
117 * The one engine call the buttons may have running: our `$.session.compact()`
118 * or our `$.command.run({ command: 'clear' })`. Set before the call's first
119 * await, cleared only when it settles, whatever the busy timeout says: a
120 * second compaction or clear never overlaps it. `engineCallAtom` records it
121 * too, so a reload mid-call still refuses (see runningCall).
122 */
123let engineCall: 'compact' | 'clear' | null = null
124/** When engineCall was claimed: a call that never settles stops blocking after HANDOFF_TTL_MS. */
125let engineCallAt = 0
126/** The live session a render last asked to re-read after finding another session's figures. */
127let resyncFor = ''
128/** Compactions that landed in this process, so Clean up can tell one happened during its /compact. */
129let compactionsLanded = 0
130
131const attempt = async <T,>(work: () => Promise<T>, fallback: T): Promise<T> => {
132 try {
133 return await work()
134 } catch {
135 return fallback
136 }
137}
138
139/** Session ids become file names; keep what Token Optimizer's sanitizer keeps (as data.ts does). */
140
141// ---- data ----
142
143/** The gatherer's port over `$` (data.ts never sees `$`). */
144function dataIo($: EngineInterface): DataIo {
145 return {
146 now: () => $.clock.now(),
147 sessionId: () => $.session.id(),
148 cwd: () => $.session.cwd(),
149 envHome: () => $.env.get('HOME'),
150 envUserProfile: () => $.env.get('USERPROFILE'),
151 envConfigDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
152 usage: () => $.session.usage(),
153 list: path => $.fs.list(path),
154 stat: path => $.fs.stat(path),
155 read: async path => {
156 const text = await $.fs.read(path)
157 return typeof text === 'string' ? text : ''
158 },
159 run: (argv, init) => $.process.run(argv, init),
160 pluginRoot: () => $.plugin.root,
161 log: async text => {
162 await $.ui.log(text, { to: 'debug' })
163 },
164 }
165}
166
167/** Gather and store the session's figures, then let the cache clock learn from them. */
168async function refresh($: EngineInterface, options: GatherOptions = {}): Promise<void> {
169 const gen = sessionGen
170 const current = await attempt(() => read($, sessionAtom), null)
171 const liveSid = cleanId(options.sessionId ?? (await attempt(() => $.session.id(), '')))
172 const path = options.transcript ?? transcriptFor(liveSid)
173 const fresh = await gather(dataIo($), current, path ? { ...options, transcript: path } : options)
174 // Begun before a clear or a session change: its figures belong to the old session.
175 if (gen !== sessionGen) return
176 if (!options.reset && current !== null && fresh.sessionId !== '' && current.sessionId !== fresh.sessionId) {
177 // Another session (a new one, or a resume): nothing of the last one carries over.
178 sessionGen += 1
179 toolsPending = 0
180 await feedClock($, { type: 'clear' })
181 await setUi($, () => initialUi())
182 lastCold = null
183 await feedPose($, { type: 'session-start' })
184 }
185 const latest = await attempt(() => read($, sessionAtom), null)
186 const merged = mergeStored(latest, fresh, options.reset, options.savings === true)
187 // Nothing shown changed (gatheredAt always does): no write, so no redraw.
188 if (latest !== null && sameShown(latest, merged)) return void (await syncClock($, fresh))
189 await update($, sessionAtom, cur => mergeStored(cur, fresh, options.reset, options.savings === true))
190 await syncClock($, fresh)
191}
192
193function sameShown(a: TokenOptimizerDesktopSession, b: TokenOptimizerDesktopSession): boolean {
194 return JSON.stringify({ ...a, gatheredAt: 0 }) === JSON.stringify({ ...b, gatheredAt: 0 })
195}
196
197/**
198 * Runs work off the current event (the status command can take seconds). Where no
199 * clock can schedule it, it runs now rather than not at all.
200 */
201async function later($: EngineInterface, work: () => Promise<void>): Promise<void> {
202 // Work for the session as it is now: a clear or session change before it runs drops it.
203 const gen = sessionGen
204 const guarded = async () => {
205 if (gen === sessionGen) await work()
206 }
207 try {
208 $.clock.after(0, () => void attempt(guarded, undefined))
209 } catch {
210 await attempt(guarded, undefined)
211 }
212}
213
214/** A prompt is taking the hand-off: a second prompt in the same moment does not. */
215let takingHandoff = false
216
217/** Tool calls the band counted since the last write: written alongside a redraw that happens anyway. */
218let toolsPending = 0
219
220async function flushTools($: EngineInterface): Promise<void> {
221 if (toolsPending === 0) return
222 const n = toolsPending
223 toolsPending = 0
224 await attempt(() => update($, sessionAtom, cur => (cur ? { ...cur, toolCallsSeen: (cur.toolCallsSeen ?? 0) + n } : cur)), undefined)
225}
226
227/** The status command's anchor and measured lifetime feed the clock when they are newer than what it holds. */
228async function syncClock($: EngineInterface, s: TokenOptimizerDesktopSession): Promise<void> {
229 await attempt(
230 () =>
231 update($, clockAtom, cur => {
232 let c: ClockState = cur ?? initialClock()
233 if (s.cacheLifetime && c.lifetime !== s.cacheLifetime) c = reduceClock(c, { type: 'lifetime-measured', lifetime: s.cacheLifetime })
234 if (s.lastRequestEpoch !== null) {
235 const at = s.lastRequestEpoch * 1000
236 if (c.anchor === null || at > c.anchor) {
237 c = reduceClock(c, { type: 'request-done', at, lifetime: s.cacheLifetime, contextTokens: s.contextTokens ?? c.contextTokens ?? 0 })
238 }
239 }
240 if (c.contextTokens === null && s.contextTokens !== null) c = { ...c, contextTokens: s.contextTokens }
241 return c
242 }),
243 undefined,
244 )
245}
246
247async function feedClock($: EngineInterface, event: ClockEvent, now?: number): Promise<void> {
248 await attempt(() => update($, clockAtom, cur => reduceClock(cur ?? initialClock(), event, now)), undefined)
249}
250
251/**
252 * One pose event. The reducer runs on the module copy; the atom is written
253 * only when something the drawing or a reload needs has changed.
254 */
255async function feedPose($: EngineInterface, event: PoseEvent): Promise<void> {
256 try {
257 const now = await $.clock.now()
258 if (!poseLive) poseLive = (await read($, poseAtom)) ?? initialPose(now)
259 poseLive = reducePose(poseLive, event, now)
260 const s = poseLive
261 const sig = JSON.stringify([s.pose, s.since, s.working, s.sub, s.agents, s.permissions, s.questions, s.compacting, s.cold, s.until])
262 if (sig !== poseSig) {
263 poseSig = sig
264 await update($, poseAtom, () => s)
265 }
266 // A sub-pose waiting out its debounce settles on a tick of its own.
267 if (s.rawSub !== s.sub && !settleQueued) {
268 settleQueued = true
269 $.clock.after(DEBOUNCE_MS, () => {
270 settleQueued = false
271 void feedPose($, { type: 'tick', now: 0 })
272 })
273 }
274 } catch {
275 // A pose that cannot be stored never breaks the event it rode on.
276 }
277}
278
279/** Permission prompts and questions have no closing event of their own: close them on the next sign of life. */
280async function closeAsks($: EngineInterface): Promise<void> {
281 // After a reload the module copy is empty until the first pose event: read the stored one.
282 if (!poseLive) poseLive = await attempt(() => read($, poseAtom), null)
283 for (let i = poseLive?.permissions ?? 0; i > 0; i--) await feedPose($, { type: 'permission-closed' })
284 for (let i = poseLive?.questions ?? 0; i > 0; i--) await feedPose($, { type: 'question-closed' })
285}
286
287function planDefault(s: TokenOptimizerDesktopSession | null): 3600 | 300 {
288 // Rate limits mean a Claude plan: an hour of cache; the API keeps five minutes.
289 // A limit seen once this session keeps it a plan, even if usage briefly stops reporting one.
290 return s && (s.fiveHour || s.week || s.sawLimits) ? 3600 : 300
291}
292
293/**
294 * Keeps Clawd's transients and naps moving and redraws when what the band
295 * shows changes: by the second near a deadline or during a hold, otherwise
296 * every 5 s, with at most one redraw a minute while nothing moves.
297 */
298/** Last tick that did its full work: when nothing moves by the second, every 5 s is enough. */
299let fullTickAt = 0
300const QUIET_TICK_MS = 5000
301/** Something on screen changes by the second (the last cache minutes, a note, a pending press). */
302let nearDeadline = false
303
304async function tick($: EngineInterface): Promise<void> {
305 try {
306 const now = await $.clock.now()
307 // By the second only while a countdown is near its end, a transient pose holds, or a
308 // press is pending; otherwise every 5 s (each tick is several engine calls).
309 // A hold still running at the last full tick keeps ticking by the second until it ends.
310 const urgent = poseLive !== null && Object.values(poseLive.until).some(t => t > fullTickAt)
311 if (!urgent && !settleQueued && now - fullTickAt < QUIET_TICK_MS && !nearDeadline) return
312 fullTickAt = now
313 await feedPose($, { type: 'tick', now })
314 const clock = (await read($, clockAtom)) ?? initialClock()
315 const session = await read($, sessionAtom)
316 const ui = (await read($, uiAtom)) ?? initialUi()
317 const v = view(clock, now, planDefault(session))
318 const cold = v.state === 'cold'
319 if (cold !== lastCold) {
320 lastCold = cold
321 await feedPose($, { type: 'cache-cold-changed', cold })
322 }
323 // Only a change the band shows redraws it, at most once a minute: the desktop restarts
324 // Clawd's picture on every redraw, so a per-second countdown made him blink each second.
325 // One minute clock: the cache countdown's when it runs, else the wall clock's (for "27m ago").
326 const minute = v.secondsLeft != null ? `c${Math.ceil(v.secondsLeft / 60)}` : `w${Math.floor(now / TICK_IDLE_MS)}`
327 const text = [v.state, minute, busyNow(ui, now), noteNow(ui, now), isArmed(ui, now)].join('|')
328 nearDeadline = v.state === 'warning' || v.state === 'warming' || busyNow(ui, now) !== null || noteNow(ui, now) !== null || isArmed(ui, now)
329 if (text !== frameText) await flushTools($)
330 if (text !== frameText) {
331 frameText = text
332 await update($, frameAtom, n => (n ?? 0) + 1)
333 }
334 } catch {
335 // The next tick tries again.
336 }
337}
338
339function startCadence($: EngineInterface): void {
340 for (const t of timers) t.cancel()
341 timers = []
342 try {
343 timers.push($.clock.every(TICK_WARNING_MS, () => void tick($)))
344 timers.push($.clock.every(QUALITY_REFRESH_MS, () => void attempt(() => refresh($), undefined)))
345 } catch {
346 // No timers: the band still redraws on events.
347 }
348}
349
350/** Everything a desktop session needs once: a pending hand-off from disk, figures, the cadence. */
351async function start($: EngineInterface): Promise<void> {
352 sessionGen += 1
353 // A warm-up marked running with no fork in flight here was cut off by a reload.
354 const clock = await attempt(() => read($, clockAtom), null)
355 if (clock?.warming && !warmInFlight) await feedClock($, { type: 'warm-failed' })
356 const held = await heldHandoff($)
357 if (held) await handoffThatFits($, held)
358 await feedPose($, { type: 'session-start' })
359 // The quick reads now, so the band fills at once; the status command (seconds, at worst)
360 // runs off the start event, so the session never waits on it.
361 await attempt(() => refresh($), undefined)
362 await later($, () => refresh($, { savings: true }))
363 startCadence($)
364}
365
366function isHandoff(v: unknown): v is Handoff {
367 if (!v || typeof v !== 'object') return false
368 const h = v as Record<string, unknown>
369 return typeof h.fromSessionId === 'string' && typeof h.cwd === 'string' && typeof h.text === 'string' && h.text !== '' && typeof h.checkpointPath === 'string' && typeof h.createdAt === 'number'
370}
371
372/**
373 * The pending hand-off. `$.store` is the truth (it survives restarts and is
374 * deleted when one session takes it); the atom only mirrors it for drawing. A
375 * store that cannot be read holds nothing: no stale mirror is ever attached.
376 */
377async function heldHandoff($: EngineInterface): Promise<Handoff | null> {
378 let stored: unknown
379 try {
380 stored = await $.store.get(HANDOFF_KEY)
381 } catch {
382 return null
383 }
384 const held = isHandoff(stored) ? stored : null
385 if (stored != null && !held) await attempt(() => $.store.delete(HANDOFF_KEY), undefined)
386 const mirror = await attempt(() => read($, handoffAtom), null)
387 if (JSON.stringify(mirror) !== JSON.stringify(held)) await attempt(() => update($, handoffAtom, () => held), undefined)
388 return held
389}
390
391/** When the live session began (`$.clock.now()` ms), or null when the engine cannot say. */
392async function sessionStartedAt($: EngineInterface): Promise<number | null> {
393 const usage = await attempt(() => $.session.usage(), null)
394 return typeof usage?.startedAt === 'number' && Number.isFinite(usage.startedAt) ? usage.startedAt : null
395}
396
397/**
398 * The held hand-off when it joins this session: another session than
399 * the one that saved it, in its project, started since the save, within 10
400 * minutes of it. An expired one in this project is dropped with a one-line
401 * note; another project's is left alone. `as` names the session when the
402 * caller knows its id better than the engine does yet (a clear's own start event).
403 */
404async function handoffThatFits($: EngineInterface, h: Handoff, as?: { sessionId: string; startedAt: number | null }): Promise<Handoff | null> {
405 const sessionId = as?.sessionId ?? cleanId(await attempt(() => $.session.id(), ''))
406 const cwd = await attempt(() => $.session.cwd(), '')
407 const now = await $.clock.now()
408 const startedAt = as ? as.startedAt : await sessionStartedAt($)
409 const fate = handoffFate(h, { sessionId, cwd, startedAt, now })
410 if (fate === 'attach') return h
411 if (fate === 'skip') return null
412 await dropHandoff($)
413 const line = `Start fresh's saved hand-off was discarded: ${fate.drop}.`
414 await setUi($, u => withNote(u, line, now))
415 toast($, line)
416 return null
417}
418
419/** The refusal for a press while our compaction or clear still runs. */
420function stillRunning(kind: 'compact' | 'clear'): string {
421 return kind === 'compact' ? 'Still finishing the last clean-up.' : 'Still clearing.'
422}
423
424/**
425 * Our compaction or clear still running: this module's own, or one recorded
426 * before a reload that is under 10 minutes old (an older record is cleared).
427 */
428async function runningCall($: EngineInterface): Promise<'compact' | 'clear' | null> {
429 if (engineCall) {
430 const at = await attempt(() => $.clock.now(), null)
431 if (at === null || at - engineCallAt < HANDOFF_TTL_MS) return engineCall
432 // Never settled: the record's own expiry below decides, as after a reload.
433 engineCall = null
434 }
435 const held = await attempt(() => read($, engineCallAtom), null)
436 if (engineCall) return engineCall
437 if (!held) return null
438 // A clock that cannot be read keeps the record: refuse rather than overlap.
439 const now = await attempt(() => $.clock.now(), null)
440 if (now === null || now - held.startedAt < HANDOFF_TTL_MS) return held.kind
441 await attempt(() => update($, engineCallAtom, cur => (cur && cur.startedAt === held.startedAt && cur.kind === held.kind ? null : cur)), undefined)
442 return null
443}
444
445/**
446 * Claims the one engine call (the caller checked runningCall with no await
447 * since) and records it for a reload. False when it cannot be recorded: the
448 * call is not made.
449 */
450async function claimCall($: EngineInterface, kind: 'compact' | 'clear'): Promise<{ kind: 'compact' | 'clear'; startedAt: number } | null> {
451 // Never over a call already in flight, whatever the caller checked before.
452 if (engineCall !== null) return null
453 engineCall = kind
454 try {
455 const mine = { kind, startedAt: await $.clock.now() }
456 engineCallAt = mine.startedAt
457 await update($, engineCallAtom, () => mine)
458 return mine
459 } catch {
460 engineCall = null
461 return null
462 }
463}
464
465/** The call settled: release it here and in the record (only our own record). */
466async function releaseCall($: EngineInterface, mine: { kind: 'compact' | 'clear'; startedAt: number }): Promise<void> {
467 engineCall = null
468 await attempt(() => update($, engineCallAtom, cur => (cur && cur.kind === mine.kind && cur.startedAt === mine.startedAt ? null : cur)), undefined)
469}
470
471// ---- buttons ----
472
473function toast($: EngineInterface, text: string): void {
474 try {
475 $.ui.toast(text)
476 } catch {
477 // A toast that cannot show is not worth failing a press over.
478 }
479}
480
481async function setUi($: EngineInterface, fn: (ui: UiState) => UiState): Promise<void> {
482 await attempt(() => update($, uiAtom, cur => fn(cur ?? initialUi())), undefined)
483}
484
485async function disarm($: EngineInterface): Promise<void> {
486 const ui = await attempt(() => read($, uiAtom), null)
487 if (ui?.freshArmedAt != null) await setUi($, u => ({ ...u, freshArmedAt: null }))
488}
489
490const BUSY_WORDS: Record<Exclude<Busy, null>, string> = {
491 clean: 'Clean up',
492 'fresh-capture': 'Start fresh',
493 'fresh-clear': 'Start fresh',
494}
495
496/** A busy state ends by itself: a step label never outlives its work. */
497function armBusyTimeout($: EngineInterface, busy: Exclude<Busy, null>, since: number): void {
498 try {
499 $.clock.after(BUSY_TIMEOUT_MS, () => void expireBusy($, busy, since))
500 } catch {
501 // busyNow() still treats it as over after the timeout.
502 }
503}
504
505async function expireBusy($: EngineInterface, busy: Exclude<Busy, null>, since: number): Promise<void> {
506 const ui = await attempt(() => read($, uiAtom), null)
507 if (!ui || ui.busy !== busy || ui.busySince !== since) return
508 const now = await $.clock.now()
509 // The clear waits for the turn to end and can still land: the hand-off stays
510 // for the conversation it creates, within 10 minutes of the save.
511 const line = busy === 'fresh-clear' ? 'Start fresh clears when the current turn ends.' : `${BUSY_WORDS[busy]} timed out.`
512 await setUi($, u => withNote(withBusy(u, null, now), line, now))
513 toast($, line)
514}
515
516/** Deletes the held hand-off, store first; true only when the delete succeeded (the mirror follows it). */
517async function dropHandoff($: EngineInterface): Promise<boolean> {
518 try {
519 await $.store.delete(HANDOFF_KEY)
520 } catch {
521 return false
522 }
523 await attempt(() => update($, handoffAtom, () => null), undefined)
524 return true
525}
526
527async function isTurnRunning($: EngineInterface): Promise<boolean> {
528 const clock = await attempt(() => read($, clockAtom), null)
529 return Boolean(clock?.working || poseLive?.working)
530}
531
532/** Clean up: compaction with Token Optimizer's own PreCompact guidance, run outside the press. */
533async function cleanUp($: EngineInterface): Promise<void> {
534 const now = await $.clock.now()
535 const ui = (await attempt(() => read($, uiAtom), null)) ?? initialUi()
536 if (busyNow(ui, now) !== null) return
537 await disarm($)
538 const running = await runningCall($)
539 if (running) {
540 toast($, stillRunning(running))
541 return
542 }
543 if (await isTurnRunning($)) {
544 toast($, 'Clean up waits until the turn finishes.')
545 return
546 }
547 await setUi($, u => withBusy(u, 'clean', now))
548 armBusyTimeout($, 'clean', now)
549 $.clock.after(0, () => void attempt(() => runCompact($, now), undefined))
550}
551
552async function runCompact($: EngineInterface, since: number): Promise<void> {
553 // A turn that started since the press would be cut short.
554 if (await isTurnRunning($)) {
555 const ui = await attempt(() => read($, uiAtom), null)
556 if (!ui || ui.busy !== 'clean' || ui.busySince !== since) return
557 const now = await $.clock.now()
558 await setUi($, u => withBusy(u, null, now))
559 toast($, 'Clean up waits until the turn finishes.')
560 return
561 }
562 const running = await runningCall($)
563 if (running || engineCall) {
564 const ui = await attempt(() => read($, uiAtom), null)
565 if (!ui || ui.busy !== 'clean' || ui.busySince !== since) return
566 const now = await $.clock.now()
567 await setUi($, u => withBusy(u, null, now))
568 toast($, stillRunning(running ?? engineCall ?? 'compact'))
569 return
570 }
571 let skip: string | null = null
572 // Claimed with no await between the check and the claim; recorded before the call.
573 const mine = await claimCall($, 'compact')
574 if (!mine) {
575 skip = 'it could not be recorded'
576 } else {
577 try {
578 // The command, as if typed: $.session.compact() is refused in a headless
579 // session, and the desktop app runs its sessions headless.
580 const landedBefore = compactionsLanded
581 const answer = await $.command.run({ command: 'compact' })
582 // Claude Code answers a refused /compact ("Not enough messages to compact.")
583 // without throwing: no compaction landed, and its line says why.
584 const said = answer?.text?.trim().split('\n')[0] ?? ''
585 if (compactionsLanded === landedBefore && said !== '' && !/^compacted\b/i.test(said)) skip = said
586 } catch (error) {
587 skip = error instanceof Error && error.message ? error.message.split('\n')[0] ?? 'it failed' : 'it failed'
588 } finally {
589 await releaseCall($, mine)
590 }
591 }
592 const ui = await attempt(() => read($, uiAtom), null)
593 // Timed out meanwhile, or another step took over: nothing to report here.
594 if (!ui || ui.busy !== 'clean' || ui.busySince !== since) return
595 const now = await $.clock.now()
596 if (skip !== null) {
597 const line = `Clean up skipped: ${skip.replace(/\.$/, '')}.`
598 await setUi($, u => withNote(withBusy(u, null, now), line, now))
599 toast($, line)
600 } else {
601 await setUi($, u => withNote(withBusy(u, null, now), 'Cleaned up.', now))
602 }
603 $.clock.after(0, () => void attempt(() => refresh($), undefined))
604}
605
606/** Keep warm: guarded at press time, one fork, never on a timer. */
607async function keepWarm($: EngineInterface): Promise<void> {
608 const now = await $.clock.now()
609 await disarm($)
610 const clock = (await attempt(() => read($, clockAtom), null)) ?? initialClock()
611 if (warmInFlight || clock.warming) {
612 toast($, 'A warm-up is already running.')
613 return
614 }
615 if (!canKeepWarm(clock, now)) {
616 toast($, clock.working || (await isTurnRunning($)) ? 'Keep warm waits until the turn finishes.' : 'The cache is too close to dropping to keep it warm.')
617 return
618 }
619 warmInFlight = true
620 const gen = sessionGen
621 await feedClock($, { type: 'warm-start' }, now)
622 $.clock.after(0, () => void attempt(() => runWarm($, clock.contextTokens, gen), undefined))
623}
624
625type ForkReply = Awaited<ReturnType<EngineInterface['model']['fork']>>
626
627/** The warm-up fork, or null once `ms` pass without an answer; a late answer is then ignored. */
628function forkWithin($: EngineInterface, ms: number): Promise<ForkReply | null> {
629 return new Promise<ForkReply | null>((resolve, reject) => {
630 let timer: { cancel: () => void } | null = null
631 try {
632 timer = $.clock.after(ms, () => resolve(null))
633 } catch {
634 // No timer: the fork alone decides.
635 }
636 $.model.fork({ prompt: WARM_PROMPT }).then(
637 reply => {
638 timer?.cancel()
639 resolve(reply)
640 },
641 error => {
642 timer?.cancel()
643 reject(error)
644 },
645 )
646 })
647}
648
649async function runWarm($: EngineInterface, known: number | null, gen: number): Promise<void> {
650 try {
651 const session = await attempt(() => read($, sessionAtom), null)
652 // A recorded 0 is no size at all: fall back to the session's own.
653 const contextTokens = known || session?.contextTokens || 0
654 const reply = await forkWithin($, BUSY_TIMEOUT_MS)
655 // Cleared meanwhile: this warm-up says nothing about the new session.
656 if (gen !== sessionGen) return
657 const at = await $.clock.now()
658 if (reply === null) {
659 await feedClock($, { type: 'warm-failed' })
660 toast($, warmToast({ ok: false, reason: 'the request failed' }))
661 } else if (reply.isAnswered) {
662 await feedClock($, { type: 'warm-done', at, cacheReadTokens: reply.usage.cache_read_input_tokens, contextTokens })
663 const after = await attempt(() => read($, clockAtom), null)
664 const line = warmToast({
665 ok: true,
666 lapsed: Boolean(after?.lapsed),
667 contextTokens,
668 cacheRead: reply.usage.cache_read_input_tokens,
669 lifetimeS: view(after ?? initialClock(), at, planDefault(session)).lifetime,
670 })
671 toast($, line)
672 // Also in the sentence for a few seconds, where the eye already is.
673 await setUi($, u => withNote(u, line, at))
674 } else {
675 await feedClock($, { type: 'warm-failed' })
676 toast($, warmToast({ ok: false, reason: reply.reason }))
677 }
678 } catch {
679 if (gen !== sessionGen) return
680 await feedClock($, { type: 'warm-failed' })
681 toast($, warmToast({ ok: false, reason: 'the request failed' }))
682 } finally {
683 warmInFlight = false
684 }
685}
686
687/** Start fresh: first press arms, a second within 5 s runs it. */
688async function startFresh($: EngineInterface): Promise<void> {
689 const now = await $.clock.now()
690 const ui = (await attempt(() => read($, uiAtom), null)) ?? initialUi()
691 if (busyNow(ui, now) !== null) return
692 if (!isArmed(ui, now)) {
693 await setUi($, u => ({ ...u, freshArmedAt: now }))
694 return
695 }
696 const running = await runningCall($)
697 if (running) {
698 await disarm($)
699 toast($, stillRunning(running))
700 return
701 }
702 if (await isTurnRunning($)) {
703 await disarm($)
704 toast($, 'Start fresh waits until the turn finishes.')
705 return
706 }
707 const sid = cleanId(await attempt(() => $.session.id(), ''))
708 if (!sid) {
709 await disarm($)
710 toast($, 'Start fresh stopped: no session to save. Nothing was cleared.')
711 return
712 }
713 const gen = sessionGen
714 await setUi($, u => withBusy(u, 'fresh-capture', now))
715 armBusyTimeout($, 'fresh-capture', now)
716 $.clock.after(0, () => void attempt(() => runFresh($, sid, now, gen), undefined))
717}
718
719/** Still the session Start fresh was pressed in: a typed /clear meanwhile means stand down. */
720async function stillSession($: EngineInterface, sid: string, gen: number): Promise<boolean> {
721 return gen === sessionGen && cleanId(await attempt(() => $.session.id(), '')) === sid
722}
723
724async function runFresh($: EngineInterface, sid: string, since: number, gen: number): Promise<void> {
725 const io = dataIo($)
726 const stop = async (reason: string): Promise<void> => {
727 const ui = await attempt(() => read($, uiAtom), null)
728 if (!ui || ui.busySince !== since) return
729 const now = await $.clock.now()
730 const line = `Start fresh stopped: ${reason}. Nothing was cleared.`
731 await setUi($, u => withNote(withBusy(u, null, now), line, now))
732 toast($, line)
733 }
734
735 const root = await findTokenOptimizerRoot(io, await readHome(io))
736 if (!root) return stop('Token Optimizer was not found')
737 const port: HandoffPort = {
738 run: (args, stdin) =>
739 runMeasure(io, root, args, {
740 timeoutMs: args[0] === 'compact-capture' ? CAPTURE_TIMEOUT_MS : RESUME_TIMEOUT_MS,
741 ...(stdin === undefined ? {} : { stdin }),
742 }),
743 read: path => io.read(path),
744 }
745 const cwd = await attempt(() => $.session.cwd(), '')
746 // `now` is a placeholder: the hand-off is stamped once its save lands (below).
747 const result = await prepareHandoff(port, { sessionId: sid, transcriptPath: transcriptFor(cleanId(sid)) ?? null, cwd, now: since })
748 const standDown = async (): Promise<void> => {
749 const now = await $.clock.now()
750 await setUi($, u => (u.busySince === since ? withBusy(u, null, now) : u))
751 }
752 // The session changed under it (a typed /clear, a resume): clear nothing, and say so.
753 const changed = async (): Promise<void> => {
754 await standDown()
755 toast($, 'Start fresh stopped: the session changed.')
756 }
757 if (!(await stillSession($, sid, gen))) return changed()
758 const ui = await attempt(() => read($, uiAtom), null)
759 // Timed out meanwhile: the person was told; clear nothing.
760 if (!ui || ui.busy !== 'fresh-capture' || ui.busySince !== since) return
761 if (!result.ok) return stop(result.reason)
762 // Our compaction or clear still running: never overlap it.
763 const running = await runningCall($)
764 if (running) return stop(running === 'compact' ? 'the last clean-up is still running' : 'the last clear is still running')
765 // A turn started during the capture: a queued clear would wipe it.
766 if (await isTurnRunning($)) {
767 await standDown()
768 toast($, 'Start fresh waits until the turn finishes.')
769 return
770 }
771
772 // Saved, then stamped from the clock right after the save lands: the
773 // 10-minute window and "started since the save" both count from there.
774 let handoff: Handoff
775 try {
776 // First kept with a stamp in the far future, so no session can count as "started
777 // since the save" until the real stamp below lands.
778 await $.store.set(HANDOFF_KEY, { ...result.handoff, createdAt: Number.MAX_SAFE_INTEGER, pendingSince: since })
779 } catch {
780 return stop('the hand-off could not be kept on disk')
781 }
782 try {
783 handoff = { ...result.handoff, createdAt: await $.clock.now() }
784 await $.store.set(HANDOFF_KEY, handoff)
785 } catch {
786 await dropHandoff($)
787 return stop('the hand-off could not be kept on disk')
788 }
789 try {
790 await update($, handoffAtom, () => handoff)
791 } catch {
792 // The new session would never see it.
793 await dropHandoff($)
794 return stop('the hand-off could not be kept on disk')
795 }
796 if (!(await stillSession($, sid, gen))) {
797 await dropHandoff($)
798 return changed()
799 }
800 const now = await $.clock.now()
801 await setUi($, u => withBusy(u, 'fresh-clear', now))
802 armBusyTimeout($, 'fresh-clear', now)
803 $.clock.after(0, () => void attempt(() => runClear($, now, handoff, sid, gen), undefined))
804}
805
806async function runClear($: EngineInterface, since: number, handoff: Handoff, sid: string, gen: number): Promise<void> {
807 const ours = (h: Handoff | null) => h !== null && h.fromSessionId === handoff.fromSessionId && h.createdAt === handoff.createdAt
808 const fail = async (line: string): Promise<void> => {
809 const held = await attempt(() => read($, handoffAtom), null)
810 if (ours(held)) await dropHandoff($)
811 const now = await $.clock.now()
812 await setUi($, u => withNote(u.busy === 'fresh-clear' && u.busySince === since ? withBusy(u, null, now) : u, line, now))
813 toast($, line)
814 }
815 // The session changed while the clear waited its turn: clear nothing.
816 if (gen !== sessionGen || cleanId(await attempt(() => $.session.id(), '')) !== sid) {
817 const held = await attempt(() => read($, handoffAtom), null)
818 if (ours(held)) await dropHandoff($)
819 const now = await $.clock.now()
820 await setUi($, u => (u.busy === 'fresh-clear' && u.busySince === since ? withBusy(u, null, now) : u))
821 toast($, 'Start fresh stopped: the session changed.')
822 return
823 }
824 const running = await runningCall($)
825 if (running || engineCall) return fail(`Start fresh stopped: ${(running ?? engineCall) === 'compact' ? 'the last clean-up is still running' : 'the last clear is still running'}. Nothing was cleared.`)
826 // Claimed with no await between the check and the claim; recorded before the call.
827 const mine = await claimCall($, 'clear')
828 if (!mine) return fail('Start fresh could not clear. Your conversation is unchanged.')
829 let cleared = false
830 try {
831 await $.command.run({ command: 'clear' })
832 cleared = true
833 } catch {
834 // Reported below, once the call has settled.
835 } finally {
836 await releaseCall($, mine)
837 }
838 if (!cleared) return fail('Start fresh could not clear. Your conversation is unchanged.')
839 // The hand-off now waits for the first prompt of a conversation started since the save.
840 const now = await $.clock.now()
841 await setUi($, u => (u.busy === 'fresh-clear' && u.busySince === since ? withBusy(u, null, now) : u))
842}
843
844async function toggleDetails($: EngineInterface): Promise<void> {
845 await disarm($)
846 await flushTools($)
847 const current = await attempt(() => read($, sessionAtom), null)
848 if (!current) return
849 const opening = !current.sheetOpen
850 await update($, sessionAtom, cur => (cur ? { ...cur, sheetOpen: !cur.sheetOpen } : cur))
851 // Savings read when the row opens.
852 if (opening) $.clock.after(0, () => void attempt(() => refresh($, { savings: true }), undefined))
853}
854
855async function act($: EngineInterface, id: ActionId): Promise<void> {
856 // One press at a time: the claim is taken before the first await, so a second press
857 // arriving while this one reads the clock sees it (an unread time counts as now).
858 const prior = claim
859 if (prior !== null && prior.at === Number.POSITIVE_INFINITY) return
860 const mine = { at: Number.POSITIVE_INFINITY }
861 claim = mine
862 const now = await attempt(() => $.clock.now(), 0)
863 if (prior !== null && now - prior.at < BUSY_TIMEOUT_MS) {
864 if (claim === mine) claim = prior
865 return
866 }
867 mine.at = now
868 try {
869 if (id === 'clean' || id === 'clean-first') await cleanUp($)
870 else if (id === 'fresh') await startFresh($)
871 else await keepWarm($)
872 } catch {
873 toast($, 'That did not work. Try again in a moment.')
874 } finally {
875 if (claim === mine) claim = null
876 }
877}
878
879// ---- drawing ----
880
881const ring = (p: number, color: string, track: string, cold: boolean): string => {
882 const c = 2 * Math.PI * 7
883 const bg = cold ? `stroke="${color}" stroke-dasharray="2.2 2.2"` : `class="t" stroke="${track}"`
884 return (
885 `<circle cx="10" cy="10" r="7" fill="none" stroke-width="3.2" ${bg}/>` +
886 (p > 0 ? `<circle cx="10" cy="10" r="7" fill="none" stroke-width="3.2" stroke="${color}" stroke-linecap="round" stroke-dasharray="${((c * p) / 100).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 10 10)"/>` : '')
887 )
888}
889
890/**
891 * The desktop gives the band no light/dark signal (the config's theme is the
892 * terminal's), so the marks and icons follow the app's own appearance through their
893 * colour-scheme query; the drawn colours are the light ones, the fallback.
894 * Classes: k = ink stroke, kf = ink fill, t = track stroke, tf = track fill.
895 */
896const DARK_STYLE = '<style>@media (prefers-color-scheme: dark){.k{stroke:#f3f1ea}.kf{fill:#f3f1ea}.t{stroke:#4b4a46}.tf{fill:#4b4a46}}</style>'
897
898function themed(svg: string, rootClass?: string): string {
899 const open = svg.indexOf('>') + 1
900 const head = rootClass ? svg.slice(0, open - 1).replace('<svg ', `<svg class="${rootClass}" `) + '>' : svg.slice(0, open)
901 return head + DARK_STYLE + svg.slice(open)
902}
903
904const esc = (v: string): string => v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
905
906/** A mark's ring or grade badge, as the artifact draws it: the colour lives here, the text stays ink. */
907function markSvg(mark: Mark, t: Tones): string {
908 const color = toneColor(mark.tone, t)
909 const right =
910 mark.badge !== undefined
911 ? `<rect x="1" y="1" width="18" height="18" rx="5"${mark.tone === 'none' ? ' class="tf"' : ''} fill="${mark.tone === 'none' ? t.track : color}"/>` +
912 `<text x="10" y="14" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11.5" font-weight="700" fill="${t.card}">${esc(mark.badge)}</text>`
913 : ring(mark.ringPercent ?? 0, color, t.track, mark.tone === 'cold')
914 return themed(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" role="img" aria-label="${esc(mark.alt)}"><title>${esc(mark.alt)}</title>${right}</svg>`)
915}
916
917function toneColor(tone: MarkTone, t: Tones): string {
918 return tone === 'none' ? t.track : t[tone]
919}
920
921function runsOf(D: Desktop, runs: Run[], t: Tones) {
922 const { Text } = D
923 return runs.map(r => (r.lose ? <Text bold color={t.bad}>{r.text}</Text> : r.strong ? <Text bold>{r.text}</Text> : r.text))
924}
925
926function icon(D: Desktop, name: IconName, color: string, alt?: string) {
927 const { Svg } = D
928 const label = alt ?? ICON_ALT[name]
929 const svg = iconSvg(name, color, { alt: label })
930 return <Svg source={color === LIGHT.ink ? themed(svg, 'k') : svg} alt={label} width={16} height={16} />
931}
932
933type Model = {
934 snap: Snapshot
935 tones: Tones
936 sheetOpen: boolean
937 savingsReason: string | null
938 /** Keep warm can run now (the one guard): only then does the row offer it. */
939 canWarm: boolean
940 /** Clawd's pictures, bottom first: the previous pose stays beneath a new one while it fades in. */
941 clawd: ClawdLayer[]
942 /** While watching: one picture per look, each shown while the pointer is over its part of the band. */
943 gazes: (ClawdLayer & { gaze: Gaze })[]
944}
945
946type ClawdLayer = { key: string; source: string; alt: string }
947
948/** How long the previous pose stays beneath a new one: the fade plus the picture's own load. */
949const UNDERLAY_MS = 900
950let clawdTop: ClawdLayer | null = null
951let clawdUnder: (ClawdLayer & { until: number }) | null = null
952
953/**
954 * The desktop shows nothing while a changed picture loads, so a bare swap
955 * blinks. Keep the old pose drawn, unchanged and under its own key, beneath
956 * the new one until the new one has faded in, then drop it.
957 */
958function clawdLayers($: EngineInterface, layer: ClawdLayer, now: number): ClawdLayer[] {
959 if (clawdTop !== null && clawdTop.key !== layer.key) {
960 clawdUnder = { ...clawdTop, until: now + UNDERLAY_MS }
961 $.clock.after(UNDERLAY_MS + 50, () => void attempt(() => update($, frameAtom, n => (n ?? 0) + 1), undefined))
962 }
963 clawdTop = layer
964 if (clawdUnder !== null && (clawdUnder.until <= now || clawdUnder.key === layer.key)) clawdUnder = null
965 return clawdUnder !== null ? [clawdUnder, layer] : [layer]
966}
967
968/** Below this, "saved this session" is noise and stays off the row. */
969const SESSION_SAVED_MIN = 1000
970
971type Handlers = { act: (id: ActionId) => void; details: () => void }
972
973function cardBox(D: Desktop, card: Card, index: number, t: Tones, on: Handlers) {
974 const { Box, Text, Button } = D
975 const side = index < 2 ? { left: 0 } : { right: 0 }
976 return (
977 <Box
978 position="absolute"
979 bottom={1}
980 {...side}
981 display="none"
982 hover={{ display: 'flex' }}
983 flexDirection="column"
984 width={34}
985 paddingX={1}
986 borderStyle="round"
987 borderColor={t.line}
988 backgroundColor={t.card}
989 >
990 <Text bold>{card.title}</Text>
991 <Text wrap="wrap">{runsOf(D, card.body, t)}</Text>
992 {card.actions.length > 0 ? (
993 <Box flexDirection="row" columnGap={1} marginTop={1}>
994 {card.actions.map(a => (
995 <Button key={`card-${card.id}-${a.id}`} label={a.label} onPress={() => on.act(a.id)} />
996 ))}
997 </Box>
998 ) : (
999 ''
1000 )}
1001 </Box>
1002 )
1003}
1004
1005function drawBand(D: Desktop, m: Model, on: Handlers) {
1006 const { Box, Text, Button, Svg } = D
1007 const { snap, tones: t } = m
1008 const say = sentence(snap)
1009 const markList = marks(snap)
1010 const cardList = cards(snap)
1011 const detail = row(snap)
1012 // A card action joins the row only when the moment calls for it (quality sagging, the cache
1013 // about to drop) and the sentence's own button is not already offering it.
1014 const q = snap.quality
1015 const sagging = q !== null && (q.score < QUALITY_FLOOR || q.compactions >= COMPACT_HEAVY)
1016 const rowActions = [
1017 ...(sagging ? cardList.find(c => c.id === 'quality')?.actions ?? [] : []),
1018 ...(m.canWarm && snap.cache.state === 'warning' ? [{ id: 'warm' as const, label: 'Keep warm' }] : []),
1019 ].filter(a => a.id !== say.action?.id && !(a.id === 'clean' && say.action?.id === 'clean-first'))
1020
1021 // Said plainly: a bare "47M past 30 days" reads like tokens spent, and a 30-bar chart
1022 // ruled by one big day said nothing. The total is the dashboard's own.
1023 const sessionSaved = (detail.savings.sessionTokens ?? 0) >= SESSION_SAVED_MIN
1024 // No figure yet: say why (or that it is being measured), never "Saved -- tokens".
1025 const savingsBlock =
1026 detail.savings.last30Tokens == null ? (
1027 <Box flexDirection="row" alignItems="center" columnGap={1}>
1028 {icon(D, 'saved', t.ink)}
1029 <Text>{detail.savings.state === 'loading' ? 'Measuring savings…' : (m.savingsReason ?? detail.savings.reason ?? 'Savings appear once Token Optimizer has measured some.')}</Text>
1030 </Box>
1031 ) : (
1032 <Box flexDirection="row" alignItems="center" columnGap={1}>
1033 {icon(D, 'saved', t.good)}
1034 <Text>
1035 Saved <Text bold>{detail.savings.last30Text}</Text> tokens in 30 days
1036 {sessionSaved ? (
1037 <Text>
1038 {' '}(<Text bold>{detail.savings.sessionText}</Text> this session)
1039 </Text>
1040 ) : (
1041 ''
1042 )}
1043 </Text>
1044 </Box>
1045 )
1046
1047 // The artifact's layout: Clawd and his arrow beside the sentence and the marks; the
1048 // unfolded row under all of it, from Clawd's left edge, savings on the right.
1049 return (
1050 // Keyed: the whole band is one hover zone (the desktop reveals within a keyed Box,
1051 // not across separate hover groups), so a pointer anywhere on it wakes Clawd to look.
1052 <Box key="band" flexDirection="column" paddingX={1} rowGap={1}>
1053 <Box flexDirection="row" alignItems="center" columnGap={2}>
1054 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
1055 {/* Box sizes count text cells on desktop, so the bottom picture sizes the stack and the new one sits over it.
1056 Only a Button can be pressed, and its label is text, so the arrow beside him opens the row. */}
1057 {/* No hover on this Box: the desktop rebuilds a hover box's pictures on every redraw (Clawd blanked once a second). */}
1058 <Box position="relative">
1059 {m.clawd.map((c, i) => (
1060 <Box key={c.key} {...(i === 0 ? {} : { position: 'absolute' as const, top: 0, left: 0 })}>
1061 {/* Not isInteractive: the desktop reloads an interactive picture on every redraw (a blank frame); a plain one keeps its animation. */}
1062 <Svg source={c.source} alt={c.alt} width={72} height={57} />
1063 </Box>
1064 ))}
1065 {/* Hover can reveal but not move: his look toward the band is its own picture, drawn hidden over him. */}
1066 {m.gazes.map(g => (
1067 // No key of its own: a keyed Box drawn hidden is a hover zone nobody can point at.
1068 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
1069 <Svg source={g.source} alt={g.alt} width={72} height={57} />
1070 </Box>
1071 ))}
1072 </Box>
1073 <Box>
1074 {/* A native button, not a bare glyph: its frame says "press me". */}
1075 <Button
1076 key="details"
1077 label={m.sheetOpen ? '▴' : '▾'}
1078 onPress={() => on.details()}
1079 />
1080 </Box>
1081 </Box>
1082 <Box flexDirection="column" flexGrow={1} flexShrink={1} rowGap={1}>
1083 <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={2}>
1084 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={1}>
1085 <Text bold>Token Optimizer</Text>
1086 {icon(D, say.icon, toneColor(say.tone, t))}
1087 <Text wrap="wrap">{runsOf(D, say.runs, t)}</Text>
1088 </Box>
1089 {say.action ? <Button key="action" variant="primary" label={say.action.label} onPress={() => on.act(say.action!.id)} /> : ''}
1090 </Box>
1091 {/* One line, never wrapped: a wrapped mark's card would open over the marks above it. */}
1092 <Box flexDirection="row" flexWrap="nowrap" columnGap={3}>
1093 {markList.map((mark, i) => (
1094 <Box key={`mark-${mark.id}`} position="relative" flexDirection="row" alignItems="center" columnGap={1}>
1095 <Svg source={markSvg(mark, t)} alt={mark.alt} width={20} height={20} />
1096 <Text bold>{mark.value}</Text>
1097 {/* Always labelled: without the word, nobody knows which number is which. */}
1098 <Text>{mark.label}</Text>
1099 {(() => {
1100 // Matched by id, not position: a missing limit never shifts a card under the wrong mark.
1101 const card = cardList.find(c => c.id === mark.id)
1102 return card ? cardBox(D, card, i, t, on) : ''
1103 })()}
1104 </Box>
1105 ))}
1106 </Box>
1107 </Box>
1108 </Box>
1109 {m.sheetOpen ? (
1110 <Box key="row" flexDirection="row" flexWrap="wrap" alignItems="center" justifyContent="space-between" columnGap={2} rowGap={1}>
1111 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2} rowGap={1}>
1112 {detail.facts.map(f => (
1113 <Box flexDirection="row" alignItems="center" columnGap={1}>
1114 {icon(D, f.icon, t.ink)}
1115 <Text>{runsOf(D, f.runs, t)}</Text>
1116 </Box>
1117 ))}
1118 {rowActions.length > 0 ? (
1119 <Box flexDirection="row" alignItems="center" columnGap={1}>
1120 {rowActions.map(a => (
1121 <Button key={`row-${a.id}`} label={a.label} onPress={() => on.act(a.id)} />
1122 ))}
1123 </Box>
1124 ) : (
1125 ''
1126 )}
1127 </Box>
1128 {savingsBlock}
1129 </Box>
1130 ) : (
1131 ''
1132 )}
1133 </Box>
1134 )
1135}
1136
1137/** The band's side of a finished turn: Clawd, the clock, and the refreshes after it. */
1138async function endTurn($: EngineInterface, reason: Extract<PoseEvent, { type: 'turn-complete' }>['reason'], agentId: string | undefined): Promise<void> {
1139 try {
1140 if (agentId !== undefined) {
1141 await feedPose($, { type: 'turn-complete', reason, agentId })
1142 return
1143 }
1144 await closeAsks($)
1145 await flushTools($)
1146 await feedClock($, { type: 'working-changed', working: false })
1147 await feedPose($, { type: 'turn-complete', reason })
1148 await feedPose($, { type: 'working-changed', working: false })
1149 // Quality after each turn now; savings and the clock facts a little later.
1150 $.clock.after(0, () => void attempt(() => refresh($), undefined))
1151 statusTimer?.cancel()
1152 statusTimer = $.clock.after(STATUS_AFTER_TURN_MS, () => void attempt(() => refresh($, { savings: true }), undefined))
1153 } catch {
1154 // The band never breaks the turn it watched.
1155 }
1156}
1157
1158/** Set to 0, false, off or no, a Token Optimizer switch turns its feature off. */
1159function switchedOff(value: string | undefined): boolean {
1160 return /^(0|false|off|no)$/i.test((value ?? '').trim())
1161}
1162
1163/** Reads the TOKEN_OPTIMIZER_STATUS_BAR switches once per environment. */
1164async function readSwitches($: EngineInterface): Promise<void> {
1165 if (switchesRead) return
1166 switchesRead = true
1167 if (switchedOff(await attempt(() => $.env.get('TOKEN_OPTIMIZER_STATUS_BAR'), undefined))) enabled = false
1168 if (switchedOff(await attempt(() => $.env.get('TOKEN_OPTIMIZER_STATUS_BAR_ANIMATE'), undefined))) animate = false
1169}
1170
1171/**
1172 * Reads the switches after the band is live (a wait before that would drop a
1173 * turn ending meanwhile) and, when switched off, stops it: no timers, no drawing.
1174 */
1175async function applySwitches($: EngineInterface): Promise<void> {
1176 await readSwitches($)
1177 if (enabled) return
1178 active = false
1179 for (const t of timers) t.cancel()
1180 timers = []
1181 statusTimer?.cancel()
1182 statusTimer = null
1183}
1184
1185// ---- hooks ----
1186
1187export const register: Register = on => {
1188 enabled = true
1189 animate = true
1190
1191 on('session.start', async ($, e, next) => {
1192 const result = await next(e)
1193 active = enabled && e.isInteractive && e.surface !== 'terminal' && e.surface !== 'vscode'
1194 if (active) await start($)
1195 return result
1196 })
1197
1198 // A clear: no session.start follows; this start carries the new id.
1199 on('classic.SessionStart', async ($, e, next) => {
1200 const result = await next(e)desktop/token-optimizer-desktop/types/index.d.ts 153 lines1// The status bar's state contract. Plain JSON only, and
2// self-contained: the engine ships this file to other plugins as is.
3//
4// `session` is one atom keyed by the session it describes. Readers compare its
5// sessionId to the live session and reset on a mismatch, so a /clear never
6// shows the previous session's figures. null means nothing read yet.
7//
8// `handoff` is Start fresh's pending hand-off. It deliberately lives outside
9// `session`: it is written in the old session and read in the new one, so a
10// session reset must not wipe it.
11
12export type TokenOptimizerDesktopLimit = { percentUsed: number; resetsAt: string | null }
13
14export type TokenOptimizerDesktopQuality = {
15 score: number
16 grade: string
17 drag: string | null
18 toolCalls: number | null
19 compactions: number
20 /** Epoch seconds. */
21 checkpointEpoch: number | null
22 /** Epoch seconds. */
23 sessionStartEpoch: number | null
24 /** Token Optimizer's own context fill, %. */
25 fillPct?: number | null
26}
27
28export type TokenOptimizerDesktopSavings = {
29 sessionTokens: number | null
30 last30Tokens: number | null
31 /** 30 entries, oldest first; today last. */
32 daily: number[]
33}
34
35export type TokenOptimizerDesktopSession = {
36 sessionId: string
37 /** `$.clock.now()` when these figures were gathered (ms). */
38 gatheredAt: number
39 quality: TokenOptimizerDesktopQuality | null
40 contextPercent: number | null
41 contextTokens: number | null
42 contextWindow: number | null
43 fiveHour: TokenOptimizerDesktopLimit | null
44 week: TokenOptimizerDesktopLimit | null
45 /** Current git branch; null outside a repository or on a detached HEAD. */
46 branch: string | null
47 /** Last known savings; kept while a refresh loads or times out. */
48 savings: TokenOptimizerDesktopSavings | null
49 savingsState: 'fresh' | 'stale' | 'loading' | 'unavailable'
50 /** One short reason when savings is null. */
51 savingsReason: string | null
52 /** Last main-thread request, epoch seconds (the cache clock's anchor). */
53 lastRequestEpoch: number | null
54 /** Last measured cache lifetime; null while unmeasured. */
55 cacheLifetime: '1h' | '5m' | null
56 /** When Token Optimizer last saved a checkpoint, epoch seconds. */
57 checkpointEpoch: number | null
58 /** The earlier session's checkpoint flagged as resumable for this one (epoch seconds). */
59 /** A rate limit was reported at least once this session: it runs on a Claude plan. */
60 sawLimits?: boolean
61 /** When the live session began (ms), from the engine. */
62 startedAtMs?: number | null
63 /** Main-thread tool calls and compactions the band watched itself: the row never waits on Token Optimizer's quality file. */
64 toolCallsSeen?: number
65 compactionsSeen?: number
66 /** Compactions counted in the transcript by the status command. */
67 compactions?: number | null
68 earlierCheckpoint?: { epoch: number; about: string | null } | null
69 /** The detail row under Clawd is unfolded. */
70 sheetOpen: boolean
71}
72
73export type TokenOptimizerDesktopState = TokenOptimizerDesktopSession | null
74
75/**
76 * Start fresh's hand-off, waiting for the first prompt of a session in the
77 * same project that started since it was saved, within 10 minutes.
78 */
79export type TokenOptimizerDesktopHandoff = {
80 fromSessionId: string
81 /** The project it was saved in; another project's session never touches it. */
82 cwd: string
83 checkpointPath: string
84 text: string
85 /** `$.clock.now()` when it was recorded (ms). */
86 createdAt: number
87 /** While the save is being stamped: the press time (createdAt is a far-future placeholder). */
88 pendingSince?: number
89} | null
90
91/** The cache clock's reducer state (src/clock.ts ClockState). */
92export type TokenOptimizerDesktopClock = {
93 anchor: number | null
94 lifetime: '1h' | '5m' | null
95 contextTokens: number | null
96 working: boolean
97 warming: boolean
98 lapsed: boolean
99}
100
101/** Clawd's pose reducer state (src/pose.ts PoseState). */
102export type TokenOptimizerDesktopPose = {
103 pose: 'wake' | 'idle' | 'think' | 'read' | 'type' | 'lift' | 'ask' | 'write' | 'compact' | 'done' | 'stop' | 'error' | 'cold' | 'sleep'
104 since: number
105 now: number
106 working: boolean
107 rawSub: 'think' | 'read' | 'type' | 'write' | 'lift' | null
108 rawSince: number
109 sub: 'think' | 'read' | 'type' | 'write' | 'lift' | null
110 agents: Readonly<Record<string, boolean>>
111 permissions: number
112 questions: number
113 compacting: boolean
114 cold: boolean
115 lastActivity: number
116 until: Readonly<Record<'wake' | 'done' | 'stop' | 'error', number>>
117}
118
119/** What a button is doing, the last outcome, and the Start fresh arm (src/actions.ts UiState). */
120export type TokenOptimizerDesktopUi = {
121 busy: 'clean' | 'fresh-capture' | 'fresh-clear' | null
122 busySince: number | null
123 note: string | null
124 noteUntil: number
125 freshArmedAt: number | null
126}
127
128/**
129 * The compaction or clear the buttons have running, recorded before the call
130 * and cleared when it settles, so a reload mid-call still refuses a second
131 * one. A record older than 10 minutes is treated as gone.
132 */
133export type TokenOptimizerDesktopEngineCall = {
134 kind: 'compact' | 'clear'
135 /** `$.clock.now()` when the call was recorded (ms). */
136 startedAt: number
137} | null
138
139declare module 'claude-code' {
140 interface PluginState {
141 'token-optimizer': {
142 session: TokenOptimizerDesktopState
143 handoff: TokenOptimizerDesktopHandoff
144 clock: TokenOptimizerDesktopClock | null
145 pose: TokenOptimizerDesktopPose | null
146 ui: TokenOptimizerDesktopUi | null
147 engineCall: TokenOptimizerDesktopEngineCall
148 /** Bumped by the clock tick when the visible clock changes. */
149 frame: number
150 }
151 }
152}
153desktop/token-optimizer-desktop/src/actions.ts 227 lines1// The three buttons' pure parts. register.tsx owns
2// every `$` call; this file decides. Nothing here imports from 'claude-code'.
3import { tokens } from './format.ts'
4
5/** Start fresh waits this long for its second click. */
6export const FRESH_ARM_MS = 5_000
7/** A busy state ends by itself after this, so no step label outlives its work. */
8export const BUSY_TIMEOUT_MS = 120_000
9/** How long an outcome note replaces "All clear." */
10export const NOTE_MS = 8_000
11/** Keep warm's one-line fork prompt. */
12export const WARM_PROMPT = 'Reply with exactly: ok'
13/** The `$.store` key of a pending Start fresh hand-off (survives restarts). */
14export const HANDOFF_KEY = 'handoff'
15/** compact-capture reads a transcript; generous, still bounded. */
16export const CAPTURE_TIMEOUT_MS = 30_000
17/** The budget compact-capture is given (`--budget-seconds`), under CAPTURE_TIMEOUT_MS so it answers first. */
18export const CAPTURE_BUDGET_SECONDS = 25
19/** A saved hand-off joins a new session only this soon after it was saved; older ones are dropped. */
20export const HANDOFF_TTL_MS = 10 * 60_000
21/** resume-lean is a token-free read of checkpoints and the session log. */
22export const RESUME_TIMEOUT_MS = 20_000
23
24export type Busy = 'clean' | 'fresh-capture' | 'fresh-clear' | null
25
26/** The band's own UI state: what a button is doing, the last outcome, the Start fresh arm. Plain JSON. */
27export type UiState = {
28 busy: Busy
29 /** When `busy` was set (ms); the timeout counts from here. */
30 busySince: number | null
31 note: string | null
32 /** The note shows until this time (ms). */
33 noteUntil: number
34 /** First Start fresh click (ms); null when disarmed. */
35 freshArmedAt: number | null
36}
37
38/** Start fresh's saved hand-off: who saved it, where, when (`$.clock.now()` ms), and the text it carries. */
39export type Handoff = {
40 fromSessionId: string
41 cwd: string
42 checkpointPath: string
43 text: string
44 createdAt: number
45 /** While the save is being stamped: createdAt is the far-future placeholder, this the press time. */
46 pendingSince?: number
47}
48
49/** A hand-off still being stamped this long after its press was cut off by a crash. */
50const PENDING_MAX_MS = 60_000
51
52export type HandoffFate = 'attach' | 'skip' | { drop: string }
53
54/**
55 * What the session `at.sessionId` in `at.cwd` does with a held hand-off.
56 * It joins a different session than the one that saved it, in the
57 * same project, that started at or after it was saved (`startedAt`, null when
58 * unknown), within 10 minutes of the save. No marker ties it to one clear, so
59 * a typed /clear, a reload or a lost start event cannot strand or steal it.
60 * Another project's hand-off is never this session's to touch; an
61 * expired one in this project is dropped (the reason ends a one-line note).
62 */
63export function handoffFate(h: Handoff, at: { sessionId: string; cwd: string; startedAt: number | null; now: number }): HandoffFate {
64 if (h.cwd !== at.cwd) return 'skip'
65 if (h.pendingSince !== undefined) {
66 // Being stamped right now; one a crash left half-saved is dropped after a minute.
67 return at.now - h.pendingSince > PENDING_MAX_MS ? { drop: 'its save never finished' } : 'skip'
68 }
69 if (at.now - h.createdAt > HANDOFF_TTL_MS) return { drop: 'it is more than 10 minutes old' }
70 if (at.sessionId === '' || at.sessionId === h.fromSessionId) return 'skip'
71 return at.startedAt !== null && at.startedAt >= h.createdAt ? 'attach' : 'skip'
72}
73
74export function initialUi(): UiState {
75 return { busy: null, busySince: null, note: null, noteUntil: 0, freshArmedAt: null }
76}
77
78export function isArmed(ui: UiState, now: number): boolean {
79 return ui.freshArmedAt !== null && now - ui.freshArmedAt < FRESH_ARM_MS
80}
81
82/** The busy state as it stands at `now`: a stale one has timed out. */
83export function busyNow(ui: UiState, now: number): Busy {
84 if (ui.busy === null || ui.busySince === null) return null
85 return now - ui.busySince < BUSY_TIMEOUT_MS ? ui.busy : null
86}
87
88export function withBusy(ui: UiState, busy: Busy, now: number): UiState {
89 return { ...ui, busy, busySince: busy === null ? null : now, freshArmedAt: null }
90}
91
92export function withNote(ui: UiState, note: string, now: number): UiState {
93 return { ...ui, note, noteUntil: now + NOTE_MS }
94}
95
96export function noteNow(ui: UiState, now: number): string | null {
97 return ui.note !== null && now < ui.noteUntil ? ui.note : null
98}
99
100/** The path compact-capture printed (`[Token Optimizer] Checkpoint saved: <path>`), or null. */
101export function checkpointPathFrom(stdout: string): string | null {
102 const match = /Checkpoint saved: (.+?)\s*$/m.exec(stdout)
103 return match?.[1] ? match[1] : null
104}
105
106/** compact_capture writes this note when it found no transcript to read. */
107export function isStubCheckpoint(contents: string): boolean {
108 if (!contents.includes('No transcript data available')) return false
109 // Empty means the phrase with almost nothing else; a real checkpoint that quotes it is not.
110 const rest = contents.replace('No transcript data available', '').replace(/^#.*$/gm, '').replace(/\s+/g, '')
111 return rest.length < STUB_MAX_CHARS
112}
113
114/** A checkpoint with less than this much besides its headings and the empty notice saved nothing. */
115const STUB_MAX_CHARS = 200
116
117const POINTER = /^.*Cross-session checkpoint.*$/
118
119/**
120 * Removes Token Optimizer's "Cross-session checkpoint" pointer lines from a
121 * SessionStart's context: after Start fresh the held hand-off replaces it.
122 * Every other line stays; an entry left empty is dropped.
123 */
124export function stripCrossSessionPointer(entries: readonly string[] | undefined): string[] | undefined {
125 if (!entries) return undefined
126 const out: string[] = []
127 for (const entry of entries) {
128 const kept = entry.split('\n').filter(line => !POINTER.test(line)).join('\n')
129 if (kept.trim() !== '') out.push(kept)
130 }
131 return out
132}
133
134type Json = Record<string, unknown>
135const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
136
137/**
138 * The lifetime one request's cache writes used, when its usage carries the
139 * 1h/5m split (transcript rows do; `TurnUsage` may not). Null when unknown.
140 */
141export function lifetimeFromUsage(usage: unknown): '1h' | '5m' | null {
142 if (!usage || typeof usage !== 'object') return null
143 const split = (usage as Json).cache_creation
144 if (!split || typeof split !== 'object') return null
145 if (num((split as Json).ephemeral_1h_input_tokens) > 0) return '1h'
146 if (num((split as Json).ephemeral_5m_input_tokens) > 0) return '5m'
147 return null
148}
149
150/** Prompt tokens one request carried: what the next request re-reads (per request, never the turn's sum). */
151export function requestContextTokens(usage: unknown): number {
152 if (!usage || typeof usage !== 'object') return 0
153 const u = usage as Json
154 return num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens)
155}
156
157export type WarmOutcome =
158 | { ok: true; lapsed: boolean; contextTokens: number; cacheRead?: number; lifetimeS?: number }
159 | { ok: false; reason: string }
160
161/** One line for the Keep warm outcome; a lapsed cache names what the warm-up cost. */
162export function warmToast(o: WarmOutcome): string {
163 if (!o.ok) return `Keep warm did not run: ${o.reason}.`
164 if (o.lapsed) return `The cache had already dropped, so the warm-up re-read ${tokens(o.contextTokens)} tokens at full price.`
165 // The receipt: what the warm-up read from the cache, and that the clock restarted.
166 const read = o.cacheRead && o.cacheRead > 0 ? `: re-read ${tokens(o.cacheRead)} tokens from the cache at a tenth of the price` : ''
167 const clock = o.lifetimeS ? ` The clock is back to ${Math.round(o.lifetimeS / 60)}m.` : ''
168 return `Cache kept warm${read}.${clock}`
169}
170
171const TYPED = new Set(['composer', 'sdk', 'bridge', 'unclassified'])
172
173/** The hand-off joins a prompt the person sent, never a notification, peer or automatic one. */
174export function attachesHandoff(originKind: string | undefined): boolean {
175 return originKind === undefined || TYPED.has(originKind)
176}
177
178/** What Start fresh needs from the host: measure.py by arguments (stdin optional), and a file read. */
179export type HandoffPort = {
180 run: (args: string[], stdin?: string) => Promise<{ exitCode: number; stdout: string }>
181 read: (path: string) => Promise<string>
182}
183
184export type HandoffResult = { ok: true; handoff: Handoff } | { ok: false; reason: string }
185
186/**
187 * Start fresh up to the clear: save a checkpoint, check it is real,
188 * build the lean resume text. Any failure returns a one-line reason and
189 * nothing is cleared.
190 */
191export async function prepareHandoff(
192 port: HandoffPort,
193 input: { sessionId: string; transcriptPath: string | null; cwd: string; now: number },
194): Promise<HandoffResult> {
195 const fail = (reason: string): HandoffResult => ({ ok: false, reason })
196 const stdin = JSON.stringify(input.transcriptPath ? { session_id: input.sessionId, transcript_path: input.transcriptPath } : { session_id: input.sessionId })
197
198 let captured: { exitCode: number; stdout: string }
199 try {
200 captured = await port.run(['compact-capture', '--trigger', 'start-fresh', '--budget-seconds', String(CAPTURE_BUDGET_SECONDS)], stdin)
201 } catch {
202 return fail('the checkpoint could not be saved')
203 }
204 if (captured.exitCode !== 0) return fail('the checkpoint could not be saved')
205 const path = checkpointPathFrom(captured.stdout)
206 if (!path) return fail('the checkpoint could not be saved')
207
208 let contents: string
209 try {
210 contents = await port.read(path)
211 } catch {
212 return fail('the saved checkpoint could not be read')
213 }
214 if (isStubCheckpoint(contents)) return fail('the checkpoint came out empty')
215
216 let lean: { exitCode: number; stdout: string }
217 try {
218 lean = await port.run(['resume-lean', input.sessionId, '--print'])
219 } catch {
220 return fail('the hand-off text could not be built')
221 }
222 const text = lean.exitCode === 0 ? lean.stdout.trim() : ''
223 if (!text) return fail('the hand-off text came out empty')
224
225 return { ok: true, handoff: { fromSessionId: input.sessionId, cwd: input.cwd, checkpointPath: path, text, createdAt: input.now } }
226}
227desktop/token-optimizer-desktop/src/clock.ts 124 lines1// The cache clock as a pure state machine. The anchor is the last
2// main-thread request; the deadline is anchor plus lifetime. Time is passed
3// in; `canKeepWarm` is the single guard every Keep warm caller uses.
4import type { CacheState, CacheView } from './contracts.ts'
5
6/** Keep warm is refused this close to expiry. Agent default (plan Assumptions). */
7export const KEEP_WARM_MARGIN_MS = 15_000
8/** Lifetime the warning and cold rules use until a measurement lands. */
9const UNMEASURED_RULE_SECONDS = 300
10/** A warm-up that read less than this share of the context found the cache lapsed. */
11const LAPSED_READ_SHARE = 0.5
12/** Below this many tokens read, a warm-up of unknown context size found the cache gone. */
13const LAPSED_FLOOR_TOKENS = 1024
14
15export type Lifetime = '1h' | '5m'
16
17export type ClockEvent =
18 | { type: 'request-done'; at: number; lifetime: Lifetime | null; contextTokens: number }
19 | { type: 'working-changed'; working: boolean }
20 | { type: 'warm-start' }
21 | { type: 'warm-done'; at: number; cacheReadTokens: number; contextTokens: number }
22 | { type: 'warm-failed' }
23 | { type: 'clear' }
24 | { type: 'lifetime-measured'; lifetime: Lifetime }
25 | { type: 'tick'; now: number }
26
27export type ClockState = {
28 /** Time of the last main-thread request (ms); null until one lands. */
29 anchor: number | null
30 /** Measured lifetime; null while unmeasured. */
31 lifetime: Lifetime | null
32 contextTokens: number | null
33 working: boolean
34 warming: boolean
35 /** A warm-up found the cache lapsed; cold until a real request lands. */
36 lapsed: boolean
37}
38
39export function initialClock(): ClockState {
40 return { anchor: null, lifetime: null, contextTokens: null, working: false, warming: false, lapsed: false }
41}
42
43const seconds = (l: Lifetime): number => (l === '1h' ? 3600 : 300)
44
45/**
46 * One step. `now` matters only for `warm-start`, which re-checks the guard at
47 * press time because a button handle can outlive the drawing that showed it.
48 */
49export function reduceClock(state: ClockState, event: ClockEvent, now?: number): ClockState {
50 switch (event.type) {
51 case 'request-done':
52 // An older request than the anchor says nothing about the cache now.
53 if (state.anchor !== null && event.at < state.anchor) return state
54 return {
55 ...state,
56 anchor: event.at,
57 lifetime: event.lifetime ?? state.lifetime,
58 contextTokens: event.contextTokens,
59 lapsed: false,
60 }
61 case 'working-changed':
62 return { ...state, working: event.working }
63 case 'warm-start':
64 // No press time given: refuse rather than guess.
65 return now !== undefined && canKeepWarm(state, now) ? { ...state, warming: true } : state
66 case 'warm-done':
67 // With no known context size, a warm-up that read (almost) nothing from the cache found it gone.
68 if (event.contextTokens > 0 ? event.cacheReadTokens < LAPSED_READ_SHARE * event.contextTokens : event.cacheReadTokens < LAPSED_FLOOR_TOKENS) {
69 return { ...state, warming: false, lapsed: true, contextTokens: event.contextTokens }
70 }
71 return {
72 ...state,
73 warming: false,
74 lapsed: false,
75 anchor: Math.max(state.anchor ?? event.at, event.at),
76 contextTokens: event.contextTokens,
77 }
78 case 'warm-failed':
79 return { ...state, warming: false }
80 case 'clear':
81 // A new session: re-measure before trusting a lifetime again.
82 return { ...initialClock(), working: state.working }
83 case 'lifetime-measured':
84 return { ...state, lifetime: event.lifetime }
85 case 'tick':
86 return state
87 }
88}
89
90/** The single Keep warm guard. */
91export function canKeepWarm(state: ClockState, now: number): boolean {
92 if (state.working || state.warming || state.lapsed) return false
93 if (state.anchor === null || state.lifetime === null) return false
94 const deadline = state.anchor + seconds(state.lifetime) * 1000
95 return now < deadline - KEEP_WARM_MARGIN_MS
96}
97
98/**
99 * The clock as the band shows it. While unmeasured, the countdown runs against
100 * the plan default (1h on Claude plans, 5m on the API) but warning and cold use
101 * five minutes, so a lapsed cache is never shown as warm.
102 */
103export function view(state: ClockState, now: number, planDefault: 3600 | 300): CacheView {
104 const measured = state.lifetime !== null
105 const lifetime = state.lifetime !== null ? seconds(state.lifetime) : planDefault
106 const base = { lifetime, measured, tokensAtStake: state.contextTokens }
107
108 if (state.working) return { ...base, state: 'refreshing', secondsLeft: lifetime }
109 if (state.anchor === null) return { ...base, state: 'unknown', secondsLeft: null, tokensAtStake: null }
110
111 const left = Math.max(0, Math.ceil((state.anchor + lifetime * 1000 - now) / 1000))
112 if (state.warming) return { ...base, state: 'warming', secondsLeft: left }
113 if (state.lapsed) return { ...base, state: 'cold', secondsLeft: 0 }
114
115 const rule = measured ? lifetime : UNMEASURED_RULE_SECONDS
116 const window = rule >= 3600 ? 300 : 60
117 const ruleDeadline = state.anchor + rule * 1000
118 let cache: CacheState
119 if (now >= ruleDeadline) cache = 'cold'
120 else if (now >= ruleDeadline - window * 1000) cache = 'warning'
121 else cache = 'warm'
122 return { ...base, state: cache, secondsLeft: cache === 'cold' ? 0 : left }
123}
124desktop/token-optimizer-desktop/src/clawd.ts 280 lines1// Clawd, drawn on an 18 x 14.2 pixel grid, one SVG document per pose.
2//
3// The desktop draws an interactive Svg in a sandboxed frame where SMIL plays
4// but page CSS never reaches, so every loop is an <animate>/<animateTransform>
5// and every colour is inlined from the palette. With `animate` off the same
6// markup is emitted without any animation element: the pose's still frame.
7
8import type { Mood, Pose } from './contracts.ts'
9
10export type Palette = {
11 skin: string
12 eye: string
13 blush: string
14 ink: string
15 laptop: string
16 spark: string
17 card: string
18 bad: string
19 cold: string
20 ground: string
21}
22
23export const LIGHT: Palette = {
24 skin: '#d97757',
25 eye: '#2a1a12',
26 blush: '#f4a58a',
27 ink: '#1f1e1d',
28 laptop: '#3d3b37',
29 spark: '#e9a820',
30 card: '#ffffff',
31 bad: '#d6453d',
32 cold: '#4f8fd0',
33 ground: '#1f1e1d',
34}
35
36export const DARK: Palette = {
37 skin: '#e08a6c',
38 eye: '#1b100b',
39 blush: '#f7b9a2',
40 ink: '#f3f1ea',
41 laptop: '#d8d4c8',
42 spark: '#f5c451',
43 card: '#3a3a37',
44 bad: '#f0685f',
45 cold: '#7db4ee',
46 ground: '#f3f1ea',
47}
48
49/** Where Clawd looks while the pointer is on the band: over at it. */
50export type Gaze = 'right'
51
52/** Eye offsets per gaze, in the picture's own units (the face is 9 wide). */
53export const GAZE: Record<Gaze, [number, number]> = {
54 right: [1.5, 0],
55}
56
57export type ClawdOptions = {
58 animate: boolean
59 palette: Palette
60 /** A watching Clawd's steady look toward the pointer; no glance, no fade in. */
61 gaze?: Gaze
62 /** Fade in when drawn (a new pose); off for the gaze pictures stacked over him. */
63 fadeIn?: boolean
64}
65
66const ALT: Record<Pose, string> = {
67 wake: 'Clawd: waking up',
68 idle: 'Clawd: watching',
69 think: 'Clawd: thinking',
70 read: 'Clawd: reading',
71 type: 'Clawd: typing',
72 lift: 'Clawd: heavy lifting',
73 ask: 'Clawd: needs you',
74 write: 'Clawd: writing',
75 compact: 'Clawd: compacting',
76 done: 'Clawd: done',
77 stop: 'Clawd: stopped',
78 error: 'Clawd: dizzy',
79 cold: 'Clawd: cold',
80 sleep: 'Clawd: napping',
81}
82
83export function clawdAlt(pose: Pose): string {
84 return ALT[pose]
85}
86
87// The CSS design used ease-in-out; SMIL takes it as a spline per segment.
88const EASE = '0.42 0 0.58 1'
89
90type Loop = { dur: number; begin?: number; ease?: boolean; keyTimes?: string }
91
92function splines(values: string, ease: boolean): string {
93 if (!ease) return ''
94 const segments = values.split(';').length - 1
95 return ` calcMode="spline" keySplines="${Array(segments).fill(EASE).join(';')}"`
96}
97
98function timing(o: Loop, values: string): string {
99 const begin = o.begin ? ` begin="${o.begin}s"` : ''
100 const times = o.keyTimes ? ` keyTimes="${o.keyTimes}"` : ''
101 return ` dur="${o.dur}s" repeatCount="indefinite"${begin}${times}${splines(values, o.ease ?? true)}`
102}
103
104/** Builds SMIL loops, or nothing when the still frame is wanted. */
105function smil(animate: boolean) {
106 return {
107 attr(name: string, values: string, o: Loop): string {
108 return animate ? `<animate attributeName="${name}" values="${values}"${timing(o, values)}/>` : ''
109 },
110 move(type: 'translate' | 'rotate' | 'scale', values: string, o: Loop): string {
111 return animate ? `<animateTransform attributeName="transform" type="${type}" values="${values}"${timing(o, values)} additive="sum"/>` : ''
112 },
113 }
114}
115
116/** Wraps `inner` so a scale animation pivots on (cx, cy) instead of the origin. */
117function pivot(cx: number, cy: number, anim: string, inner: string): string {
118 if (!anim) return inner
119 return `<g transform="translate(${cx} ${cy})"><g>${anim}<g transform="translate(${-cx} ${-cy})">${inner}</g></g></g>`
120}
121
122function rect(x: number, y: number, w: number, h: number, fill: string, extra = ''): string {
123 return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
124}
125
126const BODY = 'M4 4H13V9H12.5V11H11.5V9H10.5V11H9.5V9H7.5V11H6.5V9H5.5V11H4.5V9H4Z'
127
128/** Seconds a new pose takes to fade in. */
129export const FADE_IN_S = 0.35
130
131export function clawdSvg(pose: Pose, mood: Mood, opts: ClawdOptions): string {
132 const p = opts.palette
133 const a = smil(opts.animate)
134 const stroke = (color: string, width: number) => ` fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`
135
136 // Eyes.
137 const eyeH = mood === 'calm' ? 1.6 : 2
138 const openEyes = pivot(8.5, 6.2, a.move('scale', '1 1;1 1;1 0.1;1 1', { dur: 5.2, keyTimes: '0;0.93;0.96;1', ease: false }),
139 rect(6, 5.2, 1, eyeH, p.eye) + rect(10, 5.2, 1, eyeH, p.eye) +
140 rect(6, 5.2, 0.4, 0.4, '#ffffff', ' opacity="0.9"') + rect(10, 5.2, 0.4, 0.4, '#ffffff', ' opacity="0.9"'))
141 const smile = (d: string) => `<path d="${d}"${stroke(p.eye, 0.55)}/>`
142 const eyesFor: Partial<Record<Pose, string>> = {
143 done: smile('M5.9 6.5L6.5 5.6L7.1 6.5') + smile('M9.9 6.5L10.5 5.6L11.1 6.5'),
144 lift: rect(5.8, 5.9, 1.4, 0.6, p.eye) + rect(9.8, 5.9, 1.4, 0.6, p.eye),
145 compact: smile('M5.8 5.6l1.2.6-1.2.6M11.2 5.6l-1.2.6 1.2.6'),
146 sleep: smile('M5.8 6.3h1.4M9.8 6.3h1.4'),
147 wake: rect(6, 5.9, 1, 0.8, p.eye) + rect(10, 5.9, 1, 0.8, p.eye),
148 error: smile('M6.5 6.1a.45.45 0 1 1 .45.45a.9.9 0 1 1-.9-.9M10.5 6.1a.45.45 0 1 1 .45.45a.9.9 0 1 1-.9-.9'),
149 stop: rect(5.8, 5, 1.3, 2.3, p.eye) + rect(9.8, 5, 1.3, 2.3, p.eye) +
150 rect(5.8, 5, 0.5, 0.5, '#ffffff', ' opacity="0.9"') + rect(9.8, 5, 0.5, 0.5, '#ffffff', ' opacity="0.9"'),
151 }
152 const look = pose === 'think' ? ' transform="translate(0.5 -0.45)"' : pose === 'write' ? ' transform="translate(0.2 0.5)"' : ''
153 // Watching with no pointer to follow: centred eyes that glance left, then right, about every 7 s.
154 const gazeAt = opts.gaze ? GAZE[opts.gaze] : null
155 const scan = gazeAt ? '' : pose === 'type'
156 ? a.move('translate', '-0.5 0.55;0.5 0.55;-0.5 0.55', { dur: 2.6 })
157 : pose === 'idle'
158 ? a.move('translate', '0 0;0 0;-0.6 0;-0.6 0;0.6 0;0.6 0;0 0;0 0', { dur: 7, keyTimes: '0;0.7;0.74;0.8;0.85;0.91;0.95;1' })
159 : ''
160 const eyes = `<g${gazeAt ? ` transform="translate(${gazeAt[0]} ${gazeAt[1]})"` : look}>${scan}${eyesFor[pose] ?? openEyes}</g>`
161
162 // Arms.
163 const arm = (side: 'l' | 'r', y: number, h = 1.2, anim = '', x?: number, w = 1.3) =>
164 `<rect x="${x ?? (side === 'l' ? 2.9 : 12.8)}" y="${y}" width="${w}" height="${h}" fill="${p.skin}">${anim}</rect>`
165 const key = (begin: number) => a.move('translate', '0 0;0 0.9;0 0', { dur: 0.26, begin, ease: false })
166 const reach = (side: 'l' | 'r') => pivot(side === 'l' ? 3.55 : 13.45, 7.2,
167 a.move('scale', '1 1;1 1;1 1.75;1 1.75;1 1', { dur: 1.7, keyTimes: '0;0.12;0.45;0.62;1' }), arm(side, 3.5, 3.7))
168 const sign = `<rect x="12.6" y="-1.5" width="3.9" height="2.9" rx="0.35" fill="${p.card}" stroke="${p.ink}" stroke-width="0.25"/>` +
169 `<path d="M13.95 -0.6q.6-.75 1.25 0q0 .55-.62.8v.35"${stroke(p.ink, 0.4)}/><circle cx="14.58" cy="1.05" r="0.17" fill="${p.ink}"/>`
170 const waveArm = `<g transform="translate(13.45 5.2)"><g>${a.move('rotate', '-8;8;-8', { dur: 1.8 })}<g transform="translate(-13.45 -5.2)">${arm('r', 1.8, 3.4)}${sign}</g></g></g>`
171 const armsFor: Partial<Record<Pose, string>> = {
172 think: arm('l', 6) + arm('r', 4.7),
173 type: arm('l', 7, 1.2, key(0)) + arm('r', 7, 1.2, key(0.13)),
174 done: arm('l', 4.3) + arm('r', 4.3),
175 lift: reach('l') + reach('r'),
176 write: arm('l', 7.6) + arm('r', 7.6),
177 read: arm('l', 6) + arm('r', 7.2),
178 stop: arm('l', 2.4, 2.8) + arm('r', 2.4, 2.8),
179 wake: arm('l', 1.6, 3.6) + arm('r', 1.6, 3.6),
180 compact: arm('l', 4.6) + arm('r', 4.6),
181 ask: arm('l', 6) + waveArm,
182 }
183 const arms = armsFor[pose] ?? arm('l', 6) + arm('r', 6)
184
185 // What Clawd holds.
186 const hold = pose === 'lift'
187 ? `<g>${a.move('translate', '0 0;0 0;0 -2.7;0 -2.7;0 0', { dur: 1.7, keyTimes: '0;0.12;0.45;0.62;1' })}` +
188 rect(0.6, 3.1, 15.8, 0.55, '#6b6861', ' rx="0.2"') +
189 rect(0.9, 2, 0.9, 2.75, p.ink, ' rx="0.25"') + rect(1.95, 2.45, 0.6, 1.85, p.ink, ' rx="0.2"') +
190 rect(15.2, 2, 0.9, 2.75, p.ink, ' rx="0.25"') + rect(14.45, 2.45, 0.6, 1.85, p.ink, ' rx="0.2"') + '</g>'
191 : ''
192
193 // The sweat drop shows when the session is in trouble or Clawd is straining.
194 const sweating = mood !== 'calm' || pose === 'lift'
195 const drop = sweating
196 ? `<path d="M13.4 2.7c.5.7.7 1 .7 1.3a.7.7 0 0 1-1.4 0c0-.3.2-.6.7-1.3z" fill="#5aa9e6">` +
197 `${a.move('translate', '0 0;0 2.4', { dur: 1.8, ease: false })}${a.attr('opacity', '0;1;0', { dur: 1.8, keyTimes: '0;0.2;1', ease: false })}</path>`
198 : ''
199
200 // The whole body's motion, pivoting on the feet.
201 const rigMotion: Partial<Record<Pose, string>> = {
202 idle: mood === 'panic'
203 ? a.move('translate', '0 0;-0.3 0;0.3 0;0 0', { dur: 0.28, ease: false })
204 : a.move('translate', '0 0;0 -0.4;0 0', { dur: 2.6 }),
205 think: a.move('rotate', '-1.4 8.5 11;1.4 8.5 11;-1.4 8.5 11', { dur: 3.6 }),
206 type: a.move('translate', '0 0;0 0.3;0 0', { dur: 0.22, ease: false }),
207 lift: pivotScale('1 0.95;1 0.95;1 1.03;1 1.03;1 0.95', 1.7, '0;0.12;0.45;0.62;1'),
208 done: a.move('translate', '0 0;0 -1.9;0 0;0 -0.7;0 0;0 0', { dur: 1.5, keyTimes: '0;0.22;0.44;0.58;0.72;1' }),
209 ask: a.move('translate', '0 0;0 -0.4;0 0', { dur: 1.2 }),
210 write: a.move('translate', '0 0;0 0.3;0 0', { dur: 0.3, ease: false }),
211 read: a.move('rotate', '-1.4 8.5 11;1.4 8.5 11;-1.4 8.5 11', { dur: 4 }),
212 compact: pivotScale('1 1;1.05 0.84;1 1', 1.3),
213 stop: a.move('translate', '0 0;0 -1.2;0 0;0 0', { dur: 1.6, keyTimes: '0;0.08;0.2;1' }),
214 error: a.move('rotate', '-3 8.5 11;3 8.5 11;-3 8.5 11', { dur: 1.2 }),
215 cold: a.move('translate', '0 0;-0.3 0;0.3 0;0 0', { dur: 0.18, ease: false }),
216 sleep: pivotScale('1 1;1 1.03;1 1', 3),
217 wake: pivotScale('1 1;1 1.07;1 1.07;1 1', 2.4, '0;0.35;0.55;1'),
218 }
219 function pivotScale(values: string, dur: number, keyTimes?: string): string {
220 // Scaling pivots on the feet: shift the origin down, scale, shift back.
221 return opts.animate
222 ? `<animateTransform attributeName="transform" type="translate" values="8.5 11" dur="${dur}s" repeatCount="indefinite" additive="sum"/>` +
223 a.move('scale', values, { dur, keyTimes }) +
224 `<animateTransform attributeName="transform" type="translate" values="-8.5 -11" dur="${dur}s" repeatCount="indefinite" additive="sum"/>`
225 : ''
226 }
227
228 // Props in front of the body.
229 const sparkle = (d: string, begin: number) =>
230 `<path d="${d}" fill="${p.spark}">${a.attr('opacity', '0.2;1;0.2', { dur: 1.1, begin })}</path>`
231 const sparks = sparkle('M1.6 1.4L2 2.5L3.1 2.9L2 3.3L1.6 4.4L1.2 3.3L0.1 2.9L1.2 2.5Z', 0) +
232 sparkle('M15.6 0L15.9 0.9L16.8 1.2L15.9 1.5L15.6 2.4L15.3 1.5L14.4 1.2L15.3 0.9Z', 0.35)
233 const dot = (cx: number, cy: number, r: number, begin: number) =>
234 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#6b6861">${a.attr('r', `${r * 0.55};${r};${r * 0.55}`, { dur: 1.5, begin })}${a.attr('opacity', '0.5;1;0.5', { dur: 1.5, begin })}</circle>`
235 const codeLine = (x: number, y: number, w: number, begin: number) =>
236 `<rect x="${x}" y="${y}" width="${w}" height="0.36" rx="0.18" fill="${p.ink}" opacity="${opts.animate ? 0 : 0.6}">` +
237 `${a.move('translate', '0 0;0 -3.4', { dur: 1.5, begin, ease: false })}${a.attr('opacity', '0;0.9;0', { dur: 1.5, begin, keyTimes: '0;0.25;1', ease: false })}</rect>`
238 const inkLine = (d: string, begin: number) =>
239 `<path d="${d}"${stroke(p.ink, 0.3)} stroke-dasharray="6" stroke-dashoffset="${opts.animate ? 6 : 0}">` +
240 `${a.attr('stroke-dashoffset', '6;0;0', { dur: 1.8, begin, keyTimes: '0;0.6;1', ease: false })}</path>`
241 const flake = (d: string, begin: number) =>
242 `<path d="${d}"${stroke(p.cold, 0.3)}>${a.move('translate', '0 -1;0 5', { dur: 2, begin, ease: false })}${a.attr('opacity', '0;1;0', { dur: 2, begin, keyTimes: '0;0.2;1', ease: false })}</path>`
243 const zz = (d: string, begin: number) =>
244 `<path d="${d}"${stroke(p.ink, 0.3)} opacity="${opts.animate ? 0 : 1}">${a.move('translate', '0 0;0.8 -2', { dur: 2.7, begin })}${a.attr('opacity', '0;1;0', { dur: 2.7, begin, keyTimes: '0;0.25;1' })}</path>`
245 const frontFor: Partial<Record<Pose, string>> = {
246 think: dot(14.4, 3.4, 0.38, 0) + dot(15.5, 2.2, 0.55, 0.2) + dot(16.9, 0.7, 0.8, 0.4),
247 type: rect(5.1, 7.5, 6.8, 3.5, p.laptop, ' rx="0.5"') + `<circle cx="8.5" cy="9.2" r="0.48" fill="${p.skin}"/>` +
248 rect(4.2, 10.75, 8.6, 0.6, p.laptop, ' rx="0.3"') + codeLine(14.6, 6, 1.7, 0) + codeLine(14.9, 7, 1.1, 0.5) + codeLine(14.4, 8, 1.4, 1),
249 done: sparks,
250 write: rect(4.4, 8.9, 8.2, 2.5, p.card, ` rx="0.3" stroke="${p.ink}" stroke-width="0.2"`) +
251 inkLine('M5.2 9.75h5.4', 0) + inkLine('M5.2 10.6h3.6', 0.9) +
252 `<g>${a.move('translate', '-4 0;0 0;-4 0', { dur: 1.8 })}<rect x="10.9" y="7.3" width="0.55" height="2.3" rx="0.2" fill="${p.spark}" transform="rotate(28 11.2 8.5)"/></g>`,
253 read: `<g>${a.move('translate', '-4 0;0.3 0;-4 0', { dur: 4.4 })}<circle cx="10.5" cy="6.1" r="1.75" fill="#ffffff" fill-opacity="0.35" stroke="${p.ink}" stroke-width="0.35"/>` +
254 `<path d="M11.8 7.4L13.5 9.1"${stroke(p.ink, 0.6)}/></g>`,
255 compact: `<g>${a.move('translate', '0 0;0 1.1;0 0', { dur: 1.3 })}<path d="M6 -1.2v2.1M5.25 0.25l.75.75.75-.75"${stroke(p.ink, 0.4)}/><path d="M11 -1.2v2.1M10.25 0.25l.75.75.75-.75"${stroke(p.ink, 0.4)}/></g>`,
256 stop: rect(8.15, -0.9, 0.75, 2.1, p.bad, ' rx="0.2"') + rect(8.15, 1.6, 0.75, 0.7, p.bad, ' rx="0.2"'),
257 error: `<g>${a.move('rotate', '0 8.5 2.6;360 8.5 2.6', { dur: 1.6, ease: false })}` +
258 ['M4.6 2.2', 'M12.4 3.1', 'M8.5 0.2'].map(m => `<path d="${m}l.25.6.6.25-.6.25-.25.6-.25-.6-.6-.25.6-.25Z" fill="${p.spark}"/>`).join('') + '</g>',
259 cold: flake('M2 1v1.4M1.3 1.7h1.4', 0) + flake('M15.4 0v1.4M14.7 0.7h1.4', 0.7) + flake('M12 -1v1.4M11.3 -0.3h1.4', 1.3),
260 sleep: zz('M13.6 2.6h1.1l-1.1 1.1h1.1', 0) + zz('M14.9 0.9h1.4l-1.4 1.4h1.4', 0.9) + zz('M16.4 -1h1.6l-1.6 1.6h1.6', 1.8),
261 }
262
263 const glow = pose === 'type'
264 ? `<rect x="4" y="6.4" width="9" height="1.1" fill="#ffffff" opacity="0.16">${a.attr('opacity', '0.08;0.22;0.08', { dur: 2.2 })}</rect>`
265 : ''
266 const frost = pose === 'cold' ? `<path d="M4 4H13V9H4Z" fill="${p.cold}" opacity="0.22"/>` : ''
267
268 const body = `<path d="${BODY}" fill="${p.skin}"/>` +
269 rect(4, 8.2, 9, 0.8, '#000000', ' opacity="0.13"') + rect(4.6, 4.4, 2.4, 0.45, '#ffffff', ' opacity="0.26"') +
270 rect(4.9, 7.25, 1.1, 0.5, p.blush, ' opacity="0.85"') + rect(11, 7.25, 1.1, 0.5, p.blush, ' opacity="0.85"')
271
272 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1.6 18 14.2" role="img" aria-label="${ALT[pose]}">` +
273 `<title>${ALT[pose]}</title>` +
274 // A new pose fades in over the old one the band keeps beneath it, so a switch never cuts.
275 (opts.animate && opts.fadeIn !== false ? `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="0s" dur="${FADE_IN_S}s" fill="freeze"/>` : '<g>') +
276 `<ellipse cx="8.5" cy="11.2" rx="5.2" ry="0.5" fill="${p.ground}" opacity="0.1"/>` +
277 `<g>${rigMotion[pose] ?? ''}${arms}${body}${glow}${frost}${eyes}${drop}${hold}</g>` +
278 (frontFor[pose] ?? '') + '</g></svg>'
279}
280desktop/token-optimizer-desktop/src/contracts.ts 95 lines1// Shapes shared by the pure modules. Nothing here imports from 'claude-code':
2// register.tsx maps the engine's events and values onto these.
3
4/** Clawd's 14 moments, one pose each. */
5export type Pose =
6 | 'wake'
7 | 'idle'
8 | 'think'
9 | 'read'
10 | 'type'
11 | 'lift'
12 | 'ask'
13 | 'write'
14 | 'compact'
15 | 'done'
16 | 'stop'
17 | 'error'
18 | 'cold'
19 | 'sleep'
20
21/** How healthy the session looks; drives Clawd's eyes and sweat drop. */
22export type Mood = 'calm' | 'worried' | 'panic'
23
24/** Where the cache clock stands. */
25export type CacheState = 'unknown' | 'refreshing' | 'warm' | 'warning' | 'cold' | 'warming'
26
27/** The cache clock as the view model reads it. */
28export type CacheView = {
29 state: CacheState
30 /** Whole seconds until the cache lapses; null when unknown. */
31 secondsLeft: number | null
32 /** Lifetime in seconds the clock is counting against (3600 or 300). */
33 lifetime: number
34 /** True when the lifetime came from a measurement, not the plan default. */
35 measured: boolean
36 /** Tokens the next message would re-read at full price if the cache lapsed. */
37 tokensAtStake: number | null
38}
39
40/** A usage limit as `$.session.usage()` reports it. */
41export type Limit = { percentUsed: number; resetsAt: string | null }
42
43/** Token Optimizer's quality cache fields the band reads. */
44export type Quality = {
45 score: number
46 grade: string
47 drag: string | null
48 toolCalls: number | null
49 compactions: number
50 checkpointEpoch: number | null
51 sessionStartEpoch: number | null
52 /** Token Optimizer's own context fill, %; known right after a compact, before the next reply. */
53 fillPct?: number | null
54}
55
56/** What `measure.py status-bar` returns, reduced to what the band shows. */
57export type Savings = {
58 sessionTokens: number | null
59 last30Tokens: number | null
60 /** 30 entries, oldest first; today last. */
61 daily: number[]
62}
63
64/** Everything the view model needs for one frame. */
65export type Snapshot = {
66 now: number
67 working: boolean
68 quality: Quality | null
69 contextPercent: number | null
70 contextTokens: number | null
71 contextWindow: number | null
72 fiveHour: Limit | null
73 week: Limit | null
74 cache: CacheView
75 branch: string | null
76 savings: Savings | null
77 savingsLoading: boolean
78 busy: 'clean' | 'fresh-capture' | 'fresh-clear' | 'warming' | null
79 /** One-line outcome shown in place of "All clear." for a few seconds. */
80 note: string | null
81 /** A Start fresh hand-off waits for the first prompt. */
82 handoffPending: boolean
83 /** Start fresh is armed and waiting for its second click. */
84 freshArmed: boolean
85 /** When the session began (ms), from the engine: the row's session time before Token Optimizer reports one. */
86 startedAtMs?: number | null
87 /** Tool calls and compactions the band watched itself. */
88 toolCallsSeen?: number
89 compactionsSeen?: number
90 /** When this session last saved a checkpoint, any trigger (epoch seconds). */
91 checkpointEpoch?: number | null
92 /** An earlier session's checkpoint on this work, when this session has none of its own (epoch seconds). */
93 earlierCheckpoint?: { epoch: number; about: string | null } | null
94}
95desktop/token-optimizer-desktop/src/icons.ts 51 lines1// 16x16 stroke icons from the design page, plus a standalone SVG builder for
2// the drawing layer's Svg element. Pure.
3
4export const ICONS = {
5 branch: '<circle cx="4" cy="3.5" r="1.6"></circle><circle cx="4" cy="12.5" r="1.6"></circle><circle cx="12" cy="5" r="1.6"></circle><path d="M4 5.1v5.8M12 6.6c0 2.6-4 2.2-6.6 4.2"></path>',
6 clock: '<circle cx="8" cy="8" r="5.8"></circle><path d="M8 4.8V8l2.2 1.4"></path>',
7 tool: '<path d="M10.2 2.2a3.2 3.2 0 0 0-3 4.3L2.6 11a1.4 1.4 0 0 0 2 2l4.6-4.6a3.2 3.2 0 0 0 4.2-3.7l-1.9 1.9-1.6-.4-.4-1.6 1.9-1.9a3.2 3.2 0 0 0-1.2-.5z"></path>',
8 compact: '<path d="M3 2.5h10M3 13.5h10M8 4.3v2.8M6.4 5.7 8 7.2l1.6-1.5M8 11.7V8.9M6.4 10.3 8 8.8l1.6 1.5"></path>',
9 bookmark: '<path d="M4.5 2.5h7v11L8 10.8l-3.5 2.7z"></path>',
10 cold: '<path d="M8 2v12M2.8 5l10.4 6M13.2 5 2.8 11"></path>',
11 hourglass: '<path d="M4.5 2.5h7M4.5 13.5h7M5 2.5c0 3 3 3.6 3 5.5s-3 2.5-3 5.5M11 2.5c0 3-3 3.6-3 5.5s3 2.5 3 5.5"></path>',
12 gauge: '<path d="M2.8 11.5a5.8 5.8 0 1 1 10.4 0M8 9l2.6-3.2"></path>',
13 check: '<circle cx="8" cy="8" r="5.8"></circle><path d="M5.6 8.2 7.3 9.9l3.2-3.6"></path>',
14 slip: '<path d="M2.5 4.5 6.5 8.5l2.5-2.5 4.5 4.5M13.5 7v3.5H10"></path>',
15 ask: '<circle cx="8" cy="8" r="5.8"></circle><path d="M6.3 6.4a1.75 1.75 0 1 1 2.4 1.6c-.45.2-.7.55-.7 1v.35M8 11.3v.05"></path>',
16 saved: '<ellipse cx="8" cy="4.2" rx="4.8" ry="1.9"></ellipse><path d="M3.2 4.2v3.8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V4.2M3.2 8v3.8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V8"></path>',
17} as const
18
19export type IconName = keyof typeof ICONS
20
21/** Default alt text naming each icon, used when the caller passes none. */
22export const ICON_ALT: Record<IconName, string> = {
23 branch: 'Branch',
24 clock: 'Clock',
25 tool: 'Tool calls',
26 compact: 'Compaction',
27 bookmark: 'Checkpoint',
28 cold: 'Cold cache',
29 hourglass: 'Cache countdown',
30 gauge: 'Gauge',
31 check: 'All clear',
32 slip: 'Quality slipping',
33 ask: 'Question',
34 saved: 'Tokens saved',
35}
36
37function attr(value: string): string {
38 return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
39}
40
41/** A complete standalone SVG string for an icon stroked in `color`. */
42export function iconSvg(name: IconName, color: string, opts: { size?: number; alt?: string } = {}): string {
43 const size = opts.size ?? 16
44 const alt = opts.alt ?? ICON_ALT[name]
45 return (
46 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" ` +
47 `stroke="${attr(color)}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="${attr(alt)}">` +
48 `<title>${attr(alt)}</title>${ICONS[name]}</svg>`
49 )
50}
51desktop/token-optimizer-desktop/src/ladder.ts 107 lines1// The sentence ladder: the single most urgent thing to say, and
2// at most one button. Pure.
3import type { Limit, Mood, Snapshot } from './contracts.ts'
4import type { IconName } from './icons.ts'
5import { clock, gradeOf, renewalPhrase, tokens, type FormatOptions } from './format.ts'
6
7export type Tone = 'good' | 'caution' | 'bad' | 'cold'
8
9/** A run of text. Text is full ink; only `lose` (bold red) carries colour. `strong` is bold ink. */
10export type Run = { text: string; lose?: true; strong?: true }
11
12export type ActionId = 'clean' | 'clean-first' | 'fresh' | 'warm'
13export type Action = { id: ActionId; label: string }
14
15export type Sentence = { icon: IconName; tone: Tone; runs: Run[]; action: Action | null }
16
17/** Quality below grade B (score under 70) is worth a sentence. */
18export const QUALITY_FLOOR = 70
19export const LIMIT_WARN = 90
20export const COMPACT_HEAVY = 3
21
22const say = (icon: IconName, tone: Tone, runs: Run[], action: Action | null = null): Sentence => ({ icon, tone, runs, action })
23const plain = (text: string): Run => ({ text })
24
25/** The tokens the next message would re-read, as the one bold red run. */
26export function loseRun(n: number): Run {
27 return { text: `${tokens(n)} tokens`, lose: true }
28}
29
30function renews(limit: Limit, now: number, opts: FormatOptions): string {
31 const when = renewalPhrase(limit.resetsAt, now, opts)
32 return when ? ` Renews ${when}.` : ''
33}
34
35function limitSentence(name: string, limit: Limit, now: number, opts: FormatOptions): Sentence {
36 const head = limit.percentUsed >= 100 ? `${name} limit reached.` : `${name} limit ${Math.round(limit.percentUsed)}% used.`
37 return say('gauge', 'bad', [plain(head + renews(limit, now, opts))])
38}
39
40export function sentence(s: Snapshot, opts: FormatOptions = {}): Sentence {
41 // What the band is doing right now leads, with no button.
42 if (s.busy === 'clean') return say('compact', 'good', [plain('Cleaning up.')])
43 if (s.busy === 'fresh-capture') return say('bookmark', 'good', [plain('Saving checkpoint.')])
44 if (s.busy === 'fresh-clear') return say('bookmark', 'good', [plain('Clearing.')])
45 if (s.busy === 'warming') return say('hourglass', 'good', [plain('Keeping the cache warm.')])
46 if (s.freshArmed) {
47 return say('bookmark', 'caution', [plain('Start fresh clears this conversation after saving a checkpoint.')], {
48 id: 'fresh',
49 label: 'Click again to clear',
50 })
51 }
52 if (s.handoffPending) return say('bookmark', 'good', [plain('Checkpoint ready, it joins your first message.')])
53
54 // The ladder, first match wins. Null limits (API users) skip their rules.
55 const five = s.fiveHour
56 const week = s.week
57 if (five && five.percentUsed >= 100) return limitSentence('5-hour', five, s.now, opts)
58 if (week && week.percentUsed >= 100) return limitSentence('Weekly', week, s.now, opts)
59 if (five && five.percentUsed >= LIMIT_WARN) return limitSentence('5-hour', five, s.now, opts)
60 if (week && week.percentUsed >= LIMIT_WARN) return limitSentence('Weekly', week, s.now, opts)
61
62 const q = s.quality
63 if (q && q.compactions >= COMPACT_HEAVY) {
64 return say('compact', 'bad', [plain(`Compacted ${q.compactions} times. Early detail is mostly gone.`)], {
65 id: 'fresh',
66 label: 'Start fresh',
67 })
68 }
69 if (q && q.score < QUALITY_FLOOR) {
70 return say('slip', 'bad', [plain(`Quality slipped to ${gradeOf(q.score)}.`)], { id: 'clean', label: 'Clean up' })
71 }
72
73 // Cache rules hold still while a turn runs: every request refreshes the cache.
74 if (!s.working) {
75 const c = s.cache
76 if (c.state === 'cold') {
77 const runs: Run[] =
78 c.tokensAtStake != null
79 ? [plain('Cache is cold. Next message re-reads '), loseRun(c.tokensAtStake), plain(' at full price.')]
80 : [plain('Cache is cold. Next message re-reads the whole context at full price.')]
81 runs.push(plain(' Cleaning up first makes later messages cheaper.'))
82 return say('cold', 'cold', runs, { id: 'clean-first', label: 'Clean up first' })
83 }
84 if (c.state === 'warning') {
85 const head = c.secondsLeft != null ? `Cache drops in ${clock(c.secondsLeft)}.` : 'Cache drops soon.'
86 const runs: Run[] =
87 c.tokensAtStake != null
88 ? [plain(`${head} Then the next message re-reads `), loseRun(c.tokensAtStake), plain('.')]
89 : [plain(`${head} Then the next message re-reads the whole context.`)]
90 // Keep warm only on a measured lifetime.
91 return say('hourglass', 'caution', runs, c.measured ? { id: 'warm', label: 'Keep warm' } : null)
92 }
93 }
94
95 return say('check', 'good', [plain(s.note ?? 'All clear.')])
96}
97
98/** How healthy the session looks, for Clawd's face. */
99export function moodOf(s: Snapshot): Mood {
100 const limits = [s.fiveHour, s.week].filter((l): l is Limit => l !== null)
101 const q = s.quality
102 if (limits.some((l) => l.percentUsed >= LIMIT_WARN) || (q && q.compactions >= COMPACT_HEAVY)) return 'panic'
103 if (q && q.score < QUALITY_FLOOR) return 'worried'
104 if (!s.working && (s.cache.state === 'cold' || s.cache.state === 'warning')) return 'worried'
105 return 'calm'
106}
107desktop/token-optimizer-desktop/src/marks.ts 274 lines1// The five marks, their hover cards and the detail row as plain
2// data the drawing layer maps to elements. Pure.
3import type { CacheView, Limit, Snapshot } from './contracts.ts'
4import type { IconName } from './icons.ts'
5import { ago, clock, duration, minutes, gradeOf, relative, renewal, tokens, type FormatOptions } from './format.ts'
6import { KEEP_WARM_MARGIN_MS } from './clock.ts'
7import { LIMIT_WARN, QUALITY_FLOOR, loseRun, type Action, type Run, type Tone } from './ladder.ts'
8
9const KEEP_WARM_MARGIN_S = KEEP_WARM_MARGIN_MS / 1000
10
11export type MarkId = 'quality' | 'context' | 'cache' | 'fiveHour' | 'week'
12/** 'none' means no data: the drawing layer shows the mark uncoloured. */
13export type MarkTone = Tone | 'none'
14
15export type Mark = {
16 id: MarkId
17 icon: IconName
18 /** The figure on the bar, "--" when unavailable. */
19 value: string
20 label: string
21 tone: MarkTone
22 /** 0..100 for ring marks (context, cache, limits). */
23 ringPercent?: number
24 /** The grade letter on the quality mark. */
25 badge?: string
26 /** Alt text naming the state. */
27 alt: string
28}
29
30export type Card = { id: MarkId; title: string; body: Run[]; actions: Action[] }
31
32export type Fact = { icon: IconName; runs: Run[] }
33
34export type SavingsBlock = {
35 state: 'ready' | 'loading' | 'unavailable'
36 sessionTokens: number | null
37 last30Tokens: number | null
38 sessionText: string
39 last30Text: string
40 /** Raw daily figures, oldest first, today last; empty when unavailable. */
41 daily: number[]
42 /** Bar heights in percent of the tallest day, at least 8 so a quiet day still shows. */
43 bars: number[]
44 /** One short reason when unavailable. */
45 reason: string | null
46}
47
48export type Row = { facts: Fact[]; savings: SavingsBlock }
49
50const clamp = (p: number) => Math.max(0, Math.min(100, Math.round(p)))
51const plain = (text: string): Run => ({ text })
52const strong = (text: string): Run => ({ text, strong: true })
53
54export function qualityTone(score: number): Tone {
55 return score >= 80 ? 'good' : score >= QUALITY_FLOOR ? 'caution' : 'bad'
56}
57export function fillTone(p: number): Tone {
58 return p >= 80 ? 'bad' : p >= 60 ? 'caution' : 'good'
59}
60export function limitTone(p: number): Tone {
61 return p >= LIMIT_WARN ? 'bad' : p >= 75 ? 'caution' : 'good'
62}
63
64function estimateNote(c: CacheView): string {
65 return c.measured ? 'an estimate' : 'an estimate, lifetime not measured yet'
66}
67
68function cacheMark(c: CacheView): Mark {
69 const base = { id: 'cache' as const, label: 'cache' }
70 const est = estimateNote(c)
71 switch (c.state) {
72 case 'warm':
73 case 'warning': {
74 const left = c.secondsLeft ?? 0
75 const tone: Tone = c.state === 'warm' ? 'good' : 'caution'
76 const word = c.state === 'warm' ? 'warm' : 'about to drop'
77 return { ...base, icon: 'hourglass', value: minutes(left), tone, ringPercent: clamp((left / c.lifetime) * 100), alt: `Cache ${word}, ${minutes(left)} left, ${est}` }
78 }
79 case 'cold':
80 return { ...base, icon: 'cold', value: 'cold', tone: 'cold', ringPercent: 0, alt: `Cache cold, ${est}` }
81 case 'refreshing':
82 return { ...base, icon: 'hourglass', value: minutes(c.lifetime), tone: 'good', ringPercent: 100, alt: 'Cache refreshing while the turn runs' }
83 case 'warming':
84 return {
85 ...base,
86 icon: 'hourglass',
87 value: c.secondsLeft != null ? minutes(c.secondsLeft) : '--',
88 tone: 'good',
89 ringPercent: c.secondsLeft != null ? clamp((c.secondsLeft / c.lifetime) * 100) : 0,
90 alt: 'Cache warm-up running',
91 }
92 default:
93 return { ...base, icon: 'hourglass', value: '--', tone: 'none', ringPercent: 0, alt: 'Cache clock starts after the first reply' }
94 }
95}
96
97function limitMark(id: 'fiveHour' | 'week', limit: Limit, now: number, opts: FormatOptions): Mark {
98 const p = Math.round(limit.percentUsed)
99 const name = id === 'fiveHour' ? '5-hour' : 'Weekly'
100 const when = renewal(limit.resetsAt, now, opts)
101 return {
102 id,
103 icon: 'clock',
104 value: `${p}%`,
105 label: id === 'fiveHour' ? '5 hours' : 'week',
106 tone: limitTone(limit.percentUsed),
107 ringPercent: clamp(limit.percentUsed),
108 alt: `${name} limit ${p}% used` + (when ? `, renews ${when}` : ''),
109 }
110}
111
112/** The marks under the sentence, in order. Limit marks are omitted when the limit is null. */
113export function marks(s: Snapshot, opts: FormatOptions = {}): Mark[] {
114 const q = s.quality
115 const quality: Mark = q
116 ? {
117 id: 'quality',
118 icon: q.score < QUALITY_FLOOR ? 'slip' : 'check',
119 value: String(Math.round(q.score)),
120 badge: gradeOf(q.score),
121 label: 'quality',
122 tone: qualityTone(q.score),
123 alt: `Quality grade ${gradeOf(q.score)}, score ${Math.round(q.score)} of 100`,
124 }
125 : { id: 'quality', icon: 'check', value: '--', badge: '--', label: 'quality', tone: 'none', alt: 'Quality not measured yet' }
126
127 const p = s.contextPercent
128 const context: Mark =
129 p != null
130 ? { id: 'context', icon: 'gauge', value: `${Math.round(p)}%`, label: 'context', tone: fillTone(p), ringPercent: clamp(p), alt: `Context ${Math.round(p)}% full` }
131 : { id: 'context', icon: 'gauge', value: '--', label: 'context', tone: 'none', ringPercent: 0, alt: 'Context fill not reported yet' }
132
133 const out = [quality, context, cacheMark(s.cache)]
134 if (s.fiveHour) out.push(limitMark('fiveHour', s.fiveHour, s.now, opts))
135 if (s.week) out.push(limitMark('week', s.week, s.now, opts))
136 return out
137}
138
139function cacheCard(c: CacheView): Card {
140 const est = ` The clock is ${estimateNote(c)}.`
141 // Offered only while a press can still run: not in the last seconds before the cache drops.
142 const warm: Action[] = c.measured && (c.secondsLeft ?? 0) > KEEP_WARM_MARGIN_S ? [{ id: 'warm', label: 'Keep warm' }] : []
143 const stake = c.tokensAtStake
144 switch (c.state) {
145 case 'warm':
146 return {
147 id: 'cache',
148 title: `Warm for ${minutes(c.secondsLeft ?? 0)}`,
149 body: [plain(stake != null ? `Messages re-read ${tokens(stake)} tokens at a tenth of the price.` : 'Messages re-read the context at a tenth of the price.'), plain(est)],
150 actions: warm,
151 }
152 case 'warning':
153 return {
154 id: 'cache',
155 title: c.secondsLeft != null ? `Drops in ${minutes(c.secondsLeft)}` : 'Drops soon',
156 body: [
157 ...(stake != null ? [plain('Then the next message re-reads '), loseRun(stake), plain(' at full price.')] : [plain('Then the next message re-reads the whole context at full price.')]),
158 plain(est),
159 ],
160 actions: warm,
161 }
162 case 'cold':
163 return {
164 id: 'cache',
165 title: 'Cold',
166 body: [
167 ...(stake != null ? [plain('Next message re-reads '), loseRun(stake), plain(' at full price.')] : [plain('Next message re-reads the whole context at full price.')]),
168 plain(' Cleaning up first re-reads it once and makes later messages cheaper.'),
169 ],
170 actions: [{ id: 'clean-first', label: 'Clean up first' }],
171 }
172 case 'refreshing':
173 return { id: 'cache', title: 'Refreshing', body: [plain('Every request in this turn keeps the cache warm.')], actions: [] }
174 case 'warming':
175 return { id: 'cache', title: 'Keeping warm', body: [plain('A one-line warm-up is running.')], actions: [] }
176 default:
177 return { id: 'cache', title: 'Not started', body: [plain('The clock starts after the first reply.')], actions: [] }
178 }
179}
180
181function limitCard(id: 'fiveHour' | 'week', limit: Limit, now: number, opts: FormatOptions): Card {
182 const when = renewal(limit.resetsAt, now, opts)
183 const at = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
184 if (!when || !Number.isFinite(at)) {
185 const name = id === 'fiveHour' ? '5-hour' : 'Weekly'
186 return { id, title: `${name} limit ${Math.round(limit.percentUsed)}% used`, body: [plain('Renewal time not reported.')], actions: [] }
187 }
188 return { id, title: `Renews ${when}`, body: [plain(`That is ${relative(at, now)}.`)], actions: [] }
189}
190
191/** Hover card bodies, one per mark, same order and omissions as `marks`. */
192export function cards(s: Snapshot, opts: FormatOptions = {}): Card[] {
193 const q = s.quality
194 const qualityActions: Action[] = [
195 { id: 'clean', label: 'Clean up' },
196 { id: 'fresh', label: s.freshArmed ? 'Click again to clear' : 'Start fresh' },
197 ]
198 const quality: Card = q
199 ? {
200 id: 'quality',
201 title: `Quality ${gradeOf(q.score)} ${Math.round(q.score)}`,
202 body: [plain(q.drag ? `Biggest drag: ${q.drag}.` : 'Nothing is dragging quality down.')],
203 actions: qualityActions,
204 }
205 : { id: 'quality', title: 'Quality --', body: [plain('The score appears after the first tool call.')], actions: qualityActions }
206
207 const context: Card =
208 s.contextTokens != null && s.contextWindow != null
209 ? { id: 'context', title: `${tokens(s.contextTokens)} of ${tokens(s.contextWindow)} tokens`, body: [plain('Quality holds best under half full.')], actions: [] }
210 : { id: 'context', title: 'Context --', body: [plain('The fill appears after the first reply.')], actions: [] }
211
212 const out = [quality, context, cacheCard(s.cache)]
213 if (s.fiveHour) out.push(limitCard('fiveHour', s.fiveHour, s.now, opts))
214 if (s.week) out.push(limitCard('week', s.week, s.now, opts))
215 return out
216}
217
218function savingsBlock(s: Snapshot): SavingsBlock {
219 const sv = s.savings
220 if (!sv) {
221 return {
222 state: s.savingsLoading ? 'loading' : 'unavailable',
223 sessionTokens: null,
224 last30Tokens: null,
225 sessionText: '--',
226 last30Text: '--',
227 daily: [],
228 bars: [],
229 reason: s.savingsLoading ? null : 'Savings appear once Token Optimizer has measured some.',
230 }
231 }
232 const top = Math.max(1, ...sv.daily)
233 return {
234 state: s.savingsLoading ? 'loading' : 'ready',
235 sessionTokens: sv.sessionTokens,
236 last30Tokens: sv.last30Tokens,
237 sessionText: sv.sessionTokens != null ? tokens(sv.sessionTokens) : '--',
238 last30Text: sv.last30Tokens != null ? tokens(sv.last30Tokens) : '--',
239 daily: sv.daily,
240 bars: sv.daily.map((v) => Math.max(8, Math.round((Math.max(0, v) / top) * 100))),
241 reason: null,
242 }
243}
244
245/**
246 * The detail row under Clawd: only what the bar does not show.
247 * `s.now` is epoch milliseconds; the quality cache's epochs are seconds.
248 */
249/** Longer branch names are cut so the unfolded row stays on one line. */
250const BRANCH_MAX = 20
251
252export function row(s: Snapshot, _opts: FormatOptions = {}): Row {
253 const q = s.quality
254 const facts: Fact[] = []
255 // One line in the band: every fact in its shortest plain form.
256 if (s.branch) facts.push({ icon: 'branch', runs: [strong(s.branch.length > BRANCH_MAX ? `${s.branch.slice(0, BRANCH_MAX - 1)}…` : s.branch)] })
257 // The band's own sightings fill in until Token Optimizer's quality file exists (a new session).
258 const startS = q?.sessionStartEpoch ?? (s.startedAtMs != null ? s.startedAtMs / 1000 : null)
259 if (startS != null) facts.push({ icon: 'clock', runs: [strong(duration(s.now / 1000 - startS))] })
260 const tools = Math.max(q?.toolCalls ?? 0, s.toolCallsSeen ?? 0)
261 if (q?.toolCalls != null || (s.toolCallsSeen ?? 0) > 0) facts.push({ icon: 'tool', runs: [strong(String(tools)), plain(tools === 1 ? ' tool' : ' tools')] })
262 const compacted = Math.max(q?.compactions ?? 0, s.compactionsSeen ?? 0)
263 if (compacted > 0) facts.push({ icon: 'compact', runs: [strong(`${compacted}×`), plain(' compacted')] })
264 facts.push({
265 icon: 'bookmark',
266 runs: (s.checkpointEpoch ?? q?.checkpointEpoch) != null
267 ? [plain('Checkpoint '), strong(ago((s.checkpointEpoch ?? q!.checkpointEpoch!) * 1000, s.now))]
268 : s.earlierCheckpoint
269 ? [plain('Earlier checkpoint '), strong(ago(s.earlierCheckpoint.epoch * 1000, s.now))]
270 : [plain('No checkpoint yet')],
271 })
272 return { facts, savings: savingsBlock(s) }
273}
274desktop/token-optimizer-desktop/src/pose.ts 229 lines1// Clawd's pose as a pure reducer. A base pose derived from what is
2// going on, plus short transient poses with holds. No I/O, no 'claude-code':
3// register.tsx maps engine events onto PoseEvent and passes the time in.
4import type { Pose } from './contracts.ts'
5
6/** Working sub-pose changes wait this long to settle, so read/type bursts do not strobe. */
7export const DEBOUNCE_MS = 900
8/** A working sub-pose stays on screen at least this long before the next one replaces it. */
9export const MIN_DWELL_MS = 1600
10/** Idle this long (no events) and Clawd naps. Agent default (plan Assumptions). */
11export const NAP_AFTER_MS = 10 * 60_000
12/** How long each transient pose holds. */
13export const HOLD_MS = { wake: 2000, done: 2500, stop: 2500, error: 4000 } as const
14
15type Transient = keyof typeof HOLD_MS
16type SubPose = 'think' | 'read' | 'type' | 'write' | 'lift'
17
18export type PoseEvent =
19 | { type: 'session-start' }
20 | { type: 'turn-start' }
21 | { type: 'working-changed'; working: boolean }
22 | { type: 'thinking' }
23 | { type: 'text' }
24 | { type: 'tool-call'; tool: string; agentId?: string }
25 | { type: 'tool-done'; agentId?: string }
26 | { type: 'agent-spawn'; agentId: string; background: boolean }
27 | { type: 'agent-done'; agentId: string }
28 | { type: 'permission-open' }
29 | { type: 'permission-closed' }
30 | { type: 'question-open' }
31 | { type: 'question-closed' }
32 | { type: 'compact-start' }
33 | { type: 'compact-end' }
34 | { type: 'turn-complete'; reason: 'answer' | 'aborted' | 'refusal' | 'error'; agentId?: string }
35 | { type: 'cache-cold-changed'; cold: boolean }
36 | { type: 'tick'; now: number }
37
38export type PoseState = {
39 /** The pose on screen. */
40 pose: Pose
41 /** When the pose on screen last changed (ms). */
42 since: number
43 /** Last time the reducer saw (ms). */
44 now: number
45 working: boolean
46 /** Latest main-thread working signal, before the debounce. */
47 rawSub: SubPose | null
48 /** When rawSub last changed. */
49 rawSince: number
50 /** The working sub-pose that has settled and may show. */
51 sub: SubPose | null
52 /** Running subagents by id; true = background (outlives the main turn). */
53 agents: Readonly<Record<string, boolean>>
54 permissions: number
55 questions: number
56 compacting: boolean
57 cold: boolean
58 /** Last non-tick, non-cache event (ms); napping counts from here. */
59 lastActivity: number
60 /** Transient hold deadlines (ms); 0 = not playing. */
61 until: Readonly<Record<Transient, number>>
62}
63
64const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'NotebookRead', 'LS'])
65const LIFT_TOOLS = new Set(['Task', 'Agent'])
66
67/** Which pose a main-thread tool call shows. Unknown tools type. */
68export function toolPose(tool: string): 'read' | 'type' | 'lift' {
69 if (READ_TOOLS.has(tool)) return 'read'
70 if (LIFT_TOOLS.has(tool)) return 'lift'
71 return 'type'
72}
73
74const NO_TRANSIENTS: Record<Transient, number> = { wake: 0, done: 0, stop: 0, error: 0 }
75
76export function initialPose(now: number): PoseState {
77 return {
78 pose: 'idle',
79 since: now,
80 now,
81 working: false,
82 rawSub: null,
83 rawSince: now,
84 sub: null,
85 agents: {},
86 permissions: 0,
87 questions: 0,
88 compacting: false,
89 cold: false,
90 lastActivity: now,
91 until: NO_TRANSIENTS,
92 }
93}
94
95/**
96 * One step. `now` defaults to the tick's own time, or the last time seen for
97 * other events; pass it explicitly when the caller knows when the event landed.
98 */
99export function reducePose(state: PoseState, event: PoseEvent, now?: number): PoseState {
100 const t = now ?? (event.type === 'tick' ? event.now : state.now)
101 let s: PoseState = { ...state, now: t }
102 if (event.type !== 'tick' && event.type !== 'cache-cold-changed') s.lastActivity = t
103
104 const setRaw = (sub: SubPose | null): void => {
105 if (s.rawSub === sub) return
106 s.rawSub = sub
107 s.rawSince = t
108 }
109 const play = (tr: Transient): void => {
110 // Dropped, not queued, while Clawd is asking for you.
111 if (needsYou(s)) return
112 s.until = { ...s.until, [tr]: t + HOLD_MS[tr] }
113 }
114 const dropAgent = (id: string): void => {
115 if (!(id in s.agents)) return
116 const rest = { ...s.agents }
117 delete rest[id]
118 s.agents = rest
119 }
120
121 switch (event.type) {
122 case 'session-start':
123 s = { ...initialPose(t), pose: s.pose, since: s.since, cold: s.cold }
124 play('wake')
125 break
126 case 'turn-start':
127 setRaw('think')
128 break
129 case 'working-changed':
130 s.working = event.working
131 if (event.working && s.rawSub === null) setRaw('think')
132 if (!event.working) {
133 setRaw(null)
134 s.sub = null
135 }
136 break
137 case 'thinking':
138 setRaw('think')
139 break
140 case 'text':
141 setRaw('write')
142 break
143 case 'tool-call':
144 // Subagent events only feed heavy lifting, through spawn/done.
145 if (event.agentId === undefined) setRaw(toolPose(event.tool))
146 break
147 case 'tool-done':
148 if (event.agentId === undefined && s.rawSub === 'lift') setRaw('think')
149 break
150 case 'agent-spawn':
151 s.agents = { ...s.agents, [event.agentId]: event.background }
152 break
153 case 'agent-done':
154 dropAgent(event.agentId)
155 break
156 case 'permission-open':
157 s.permissions += 1
158 break
159 case 'permission-closed':
160 s.permissions = Math.max(0, s.permissions - 1)
161 break
162 case 'question-open':
163 s.questions += 1
164 break
165 case 'question-closed':
166 s.questions = Math.max(0, s.questions - 1)
167 break
168 case 'compact-start':
169 s.compacting = true
170 break
171 case 'compact-end':
172 s.compacting = false
173 break
174 case 'turn-complete':
175 if (event.agentId !== undefined) {
176 dropAgent(event.agentId)
177 break
178 }
179 // Foreground agents end with the main turn; background ones keep lifting.
180 s.agents = Object.fromEntries(Object.entries(s.agents).filter(([, bg]) => bg))
181 setRaw(null)
182 s.sub = null
183 play(event.reason === 'error' ? 'error' : event.reason === 'aborted' ? 'stop' : 'done')
184 break
185 case 'cache-cold-changed':
186 s.cold = event.cold
187 break
188 case 'tick':
189 break
190 }
191
192 settle(s, t)
193 const pose = derive(s, t)
194 if (pose !== s.pose) {
195 s.pose = pose
196 s.since = t
197 }
198 return s
199}
200
201function needsYou(s: PoseState): boolean {
202 return s.permissions > 0 || s.questions > 0
203}
204
205/** Debounce: the first sub-pose of a turn shows at once; later changes wait to settle. */
206function settle(s: PoseState, t: number): void {
207 if (s.rawSub === s.sub) return
208 if (s.sub === null || (t - s.rawSince >= DEBOUNCE_MS && t - s.since >= MIN_DWELL_MS)) s.sub = s.rawSub
209}
210
211function playing(s: PoseState, tr: Transient, t: number): boolean {
212 return s.until[tr] > t
213}
214
215/** Precedence: needs-you > dizzy > stopped > compacting > heavy lifting > working > done > wake > cold > napping > watching. */
216function derive(s: PoseState, t: number): Pose {
217 if (needsYou(s)) return 'ask'
218 if (playing(s, 'error', t)) return 'error'
219 if (playing(s, 'stop', t)) return 'stop'
220 if (s.compacting) return 'compact'
221 if (Object.keys(s.agents).length > 0) return 'lift'
222 if (s.working) return s.sub ?? 'think'
223 if (playing(s, 'done', t)) return 'done'
224 if (playing(s, 'wake', t)) return 'wake'
225 if (s.cold) return 'cold'
226 if (t - s.lastActivity >= NAP_AFTER_MS) return 'sleep'
227 return 'idle'
228}
229desktop/token-optimizer-desktop/hooks/data.ts 474 lines1// Data gathering and re-keying for the band.
2//
3// The engine refuses `$` passed across an import ("$ is followed only into a
4// function declared in this same file"), and a plugin has exactly one hooks
5// module. So this file never sees `$`: it works over `DataIo`, a port that
6// register.tsx builds from `$` in one top-level function (`dataIo($)`), each
7// member spelled `$.noun.method(...)` there. That keeps every noun call where
8// the engine's scan looks for it, and lets this logic run under plain Node.
9//
10// Every lookup is best effort: a figure that cannot be read is left null and
11// the rest still fill. Nothing here registers a hook; register.tsx wires the
12// events and the cadence below.
13import type { TokenOptimizerDesktopSession } from '../types/index.d.ts'
14import type { Limit, Quality } from '../src/contracts.ts'
15import {
16 parseQualityCache,
17 parseStatusBar,
18 parseUsage,
19 resolveTokenOptimizerRoot,
20 type StatusBar,
21 type TokenOptimizerRoot,
22} from '../src/parse.ts'
23
24/** Redraw once a second, but only inside the cache warning window. */
25export const TICK_WARNING_MS = 1_000
26/** Redraw otherwise, beside the event-driven redraws. */
27export const TICK_IDLE_MS = 60_000
28/** Re-read the quality cache this often, and after each turn. */
29export const QUALITY_REFRESH_MS = 60_000
30/** Run the status command this long after a turn ends. */
31export const STATUS_AFTER_TURN_MS = 5_000
32/** The status command answers from its cache in well under a second warm. */
33export const STATUS_TIMEOUT_MS = 4_000
34/** `git branch --show-current` is local and instant; anything slower is skipped. */
35export const GIT_TIMEOUT_MS = 1_000
36
37/** Shown as the savings reason when no Token Optimizer install is found. */
38export const NOT_FOUND = 'Token Optimizer not found'
39/** No Python 3 launcher starts: Token Optimizer's scripts cannot run. */
40export const NO_PYTHON = 'Token Optimizer needs Python 3 to measure savings.'
41/** The installed Token Optimizer predates the status command this band reads. */
42export const OUTDATED = 'Update Token Optimizer to see savings.'
43
44/**
45 * Python launchers in the order tried. The mod cannot see the host's OS, so a
46 * launcher that cannot start (or Windows' Store stub, exit 9009) moves on to
47 * the next: python3 on macOS and Linux, python or the `py -3` launcher on
48 * Windows. The first that starts is remembered for the module's life.
49 */
50export const PYTHON_LAUNCHERS: readonly (readonly string[])[] = [['python3'], ['python'], ['py', '-3']]
51
52/** Windows' "app execution alias" stub for a missing python exits with this. */
53const WINDOWS_NOT_FOUND = 9009
54
55let launcherIndex = 0
56
57/** Forgets which launcher worked (tests, and nothing else). */
58export function resetLauncher(): void {
59 launcherIndex = 0
60}
61
62/**
63 * What the gatherer needs from the engine. register.tsx answers each member
64 * with the `$` call named beside it; any member may reject.
65 */
66export type DataIo = {
67 /** `$.clock.now()` (ms) */
68 now: () => Promise<number>
69 /** `$.session.id()` */
70 sessionId: () => Promise<string>
71 /** `$.session.cwd()` */
72 cwd: () => Promise<string>
73 /** `$.env.get('HOME')` (the validator wants a literal variable name) */
74 envHome: () => Promise<string | undefined>
75 /** `$.env.get('CLAUDE_CONFIG_DIR')`: a relocated Claude folder, as Token Optimizer honours it */
76 envConfigDir?: () => Promise<string | undefined>
77 /** `$.env.get('USERPROFILE')`, the Windows home */
78 envUserProfile: () => Promise<string | undefined>
79 /** `$.session.usage()` */
80 usage: () => Promise<unknown>
81 /** `$.fs.list(path)` */
82 list: (path: string) => Promise<readonly { name: string }[]>
83 /** `$.fs.stat(path)` */
84 stat: (path: string) => Promise<{ kind: string; mtimeMs: number }>
85 /** `$.fs.read(path)` */
86 read: (path: string) => Promise<string>
87 /** `$.plugin.root`: this plugin's own folder */
88 pluginRoot?: () => string
89 /** `$.process.run(argv, init)` */
90 run: (argv: string[], init: { cwd?: string; timeoutMs: number; stdin?: string }) => Promise<{ exitCode: number; stdout: string; stderr?: string }>
91 /** `$.ui.log(text, { to: 'debug' })`: a line in Claude Code's debug log */
92 log?: (text: string) => Promise<void>
93}
94
95async function attempt<T>(work: () => Promise<T>, fallback: T): Promise<T> {
96 try {
97 return await work()
98 } catch {
99 return fallback
100 }
101}
102
103/** Session ids become file names; keep only what Token Optimizer's own sanitizer keeps. */
104export function cleanId(id: string): string {
105 const clean = id.replace(/[^a-zA-Z0-9_-]/g, '')
106 // Token Optimizer files an id shorter than 6 under "unknown": read nothing for it either.
107 return clean.length >= 6 ? clean : ''
108}
109
110/** True when the stored atom belongs to another session (or to none) and must be reset. */
111export function shouldReset(stored: TokenOptimizerDesktopSession | null, liveSessionId: string): boolean {
112 return stored === null || stored.sessionId !== liveSessionId
113}
114
115/** The user's home, as the engine's process sees it. */
116export async function readHome(io: DataIo): Promise<string> {
117 return (await attempt(() => io.envHome(), undefined)) || (await attempt(() => io.envUserProfile(), undefined)) || ''
118}
119
120/** The Claude folder: CLAUDE_CONFIG_DIR when set (as Token Optimizer's claude_home()), else ~/.claude. */
121export async function claudeDir(io: DataIo, home: string): Promise<string> {
122 const set = io.envConfigDir ? await attempt(() => io.envConfigDir!(), undefined) : undefined
123 const dir = set ? set.trim().replace(/[\\/]+$/, '') : ''
124 // Absolute only, as Token Optimizer requires: a relative one would point the band elsewhere.
125 const absolute = dir.startsWith('/') || /^[A-Za-z]:[\\/]/.test(dir)
126 return absolute ? dir : home ? `${home}/.claude` : ''
127}
128
129/**
130 * The freshest `quality-cache-<sid>.json` across Token Optimizer's storage
131 * directories: each plugin install's data dir, then the legacy
132 * `~/.claude/token-optimizer`. Newest modification time wins.
133 */
134export async function readQuality(io: DataIo, home: string, sid: string): Promise<Quality | null> {
135 if (!home || !sid) {
136 return null
137 }
138
139 const claude = await claudeDir(io, home)
140 const dataRoot = `${claude}/plugins/data`
141 const entries = await attempt(() => io.list(dataRoot), [])
142 const dirs = entries
143 .filter(entry => entry.name.includes('token-optimizer'))
144 .map(entry => `${dataRoot}/${entry.name}/token-optimizer`)
145 dirs.push(`${claude}/token-optimizer`)
146
147 let freshest: { path: string; mtimeMs: number } | null = null
148
149 for (const dir of dirs) {
150 const path = `${dir}/quality-cache-${sid}.json`
151 const stat = await attempt(() => io.stat(path), null)
152
153 if (stat && stat.kind === 'file' && (!freshest || stat.mtimeMs > freshest.mtimeMs)) {
154 freshest = { path, mtimeMs: stat.mtimeMs }
155 }
156 }
157
158 if (!freshest) {
159 return null
160 }
161
162 const { path } = freshest
163 const raw = await attempt(() => io.read(path), null)
164 const nowMs = await attempt(() => io.now(), Date.now())
165
166 return raw === null ? null : parseQualityCache(raw, nowMs / 1000)
167}
168
169/**
170 * Token Optimizer's scripts: the installed-plugins registry first, then
171 * the skill install; the first whose measure.py exists. null when neither does.
172 */
173export async function findTokenOptimizerRoot(io: DataIo, home: string): Promise<TokenOptimizerRoot | null> {
174 const claude = await claudeDir(io, home)
175 const registry = claude ? await attempt(() => io.read(`${claude}/plugins/installed_plugins.json`), null) : null
176 // Shipped inside Token Optimizer, the plugin root is Token Optimizer itself; loaded on its
177 // own from a checkout (desktop/<this plugin>), the scripts two folders up are the matching version.
178 const root = io.pluginRoot ? await attempt(async () => io.pluginRoot!(), '') : ''
179 const sibling = [root.replace(/[\\/]+$/, ''), trimTwo(root)]
180 .filter(dir => dir !== '')
181 .map(dir => ({ scriptsDir: `${dir}/skills/token-optimizer/scripts`, runner: `${dir}/hooks/module_runner.py` }))
182
183 // Only scripts inside the Claude folder run (or beside this plugin in a checkout): a
184 // tampered or stale registry entry pointing elsewhere is skipped, never executed.
185 // Compared with one separator and one case of drive letter, so a Windows home
186 // (C:\\Users\\me) and a registry path (C:\\Users\\me\\.claude\\...) agree.
187 const norm = (p: string) => p.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, (_m: string, d: string) => `${d.toLowerCase()}:`)
188 // Windows paths compare without case, as the filesystem does.
189 const fold = (p: string) => (/^[a-z]:\//.test(norm(claude)) ? norm(p).toLowerCase() : norm(p))
190 const base = fold(claude)
191 const inside = (p: string) => base !== '' && (fold(p) === base || fold(p).startsWith(`${base}/`))
192 const listed = resolveTokenOptimizerRoot(registry, claude).filter(r => inside(r.scriptsDir))
193 for (const root of [...sibling, ...listed]) {
194 if (!(await attempt(() => io.stat(`${root.scriptsDir}/measure.py`), null))) {
195 continue
196 }
197
198 const { runner } = root
199 const hasRunner = runner !== null && (await attempt(() => io.stat(runner), null)) !== null
200
201 return { scriptsDir: root.scriptsDir, runner: hasRunner ? runner : null }
202 }
203
204 return null
205}
206
207function newer(a: number | null, b: number | null): number | null {
208 return a === null ? b : b === null ? a : Math.max(a, b)
209}
210
211/** A path two folders up, or '' when it has fewer. */
212function trimTwo(path: string): string {
213 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
214 return parts.length > 2 ? parts.slice(0, -2).join('/') : ''
215}
216
217/** The argv for `measure.py <args>`, through module_runner when the install has it (bytecode reuse). */
218export function measureArgv(root: TokenOptimizerRoot, args: readonly string[], python: readonly string[] = PYTHON_LAUNCHERS[0] ?? ['python3']): string[] {
219 const launch = root.runner ? [...python, root.runner, root.scriptsDir, 'measure'] : [...python, `${root.scriptsDir}/measure.py`]
220
221 return [...launch, ...args]
222}
223
224/** The arguments of `measure.py status-bar` for one session. */
225function statusBarArgs(sid: string, transcript?: string): string[] {
226 return ['status-bar', '--session', sid, '--json', ...(transcript ? ['--transcript', transcript] : [])]
227}
228
229function isTimeout(error: unknown): boolean {
230 const name = error instanceof Error ? error.name : ''
231 return name === 'TimeoutError' || /timed? ?out|deadline/i.test(error instanceof Error ? error.message : String(error))
232}
233
234/** No Python launcher could start at all (every one was tried). */
235function noLauncher(error: unknown): boolean {
236 return /no python launcher|python launcher not found|ENOENT|not found/i.test(error instanceof Error ? error.message : String(error))
237}
238
239/**
240 * Runs `measure.py <args>` with the first Python launcher that starts. A
241 * timeout is the command's own answer and is never retried, so a slow read
242 * costs one timeout, not three. Rejects as the last attempt did.
243 */
244export async function runMeasure(
245 io: DataIo,
246 root: TokenOptimizerRoot,
247 args: readonly string[],
248 init: { timeoutMs: number; stdin?: string },
249): Promise<{ exitCode: number; stdout: string; stderr?: string }> {
250 let lastError: unknown = new Error('no python launcher')
251
252 for (let i = launcherIndex; i < PYTHON_LAUNCHERS.length; i++) {
253 try {
254 const result = await io.run(measureArgv(root, args, PYTHON_LAUNCHERS[i]), init)
255
256 if (result.exitCode === WINDOWS_NOT_FOUND && i < PYTHON_LAUNCHERS.length - 1) {
257 lastError = new Error('python launcher not found')
258 continue
259 }
260
261 launcherIndex = i
262 return result
263 } catch (error) {
264 if (isTimeout(error)) {
265 throw error
266 }
267
268 lastError = error
269 }
270 }
271
272 throw lastError
273}
274
275/**
276 * `measure.py status-bar --json`; 'outdated' when the install has no
277 * such command (it prints usage, not JSON), null when it fails, times out or
278 * prints something else.
279 */
280export async function readStatusBar(
281 io: DataIo,
282 root: TokenOptimizerRoot,
283 sid: string,
284 transcript?: string,
285): Promise<StatusBar | 'outdated' | 'nopython' | null> {
286 const args = statusBarArgs(sid, transcript)
287 let result: { exitCode: number; stdout: string; stderr?: string } | 'nopython' | null = null
288 try {
289 result = await runMeasure(io, root, args, { timeoutMs: STATUS_TIMEOUT_MS })
290 } catch (error) {
291 result = noLauncher(error) ? 'nopython' : null
292 }
293
294 if (result === 'nopython') return 'nopython'
295 if (!result) return null
296 if (result.exitCode !== 0 && io.log) {
297 // Why the status read failed, where `claude --debug` shows it.
298 void attempt(() => io.log!(`token-optimizer status bar: status-bar exited ${result.exitCode}: ${(result.stderr ?? '').trim().slice(0, 300)}`), undefined)
299 }
300 // The JSON is the last line that is one (a stray line printed before it is not a reason to fail).
301 const json = result.stdout.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('{')).pop()
302 // An install without the command prints its usage instead of JSON (or exits 2).
303 // Usage text instead of JSON is an older install; empty output is just a failed read.
304 if (!json) return (result.exitCode === 0 || result.exitCode === 2) && result.stdout.trim() !== '' ? 'outdated' : null
305 return result.exitCode === 0 ? parseStatusBar(json) : null
306}
307
308/** The current git branch; null outside a repository, on a detached HEAD, or without git. */
309export async function readBranch(io: DataIo, cwd: string): Promise<string | null> {
310 const init = cwd ? { cwd, timeoutMs: GIT_TIMEOUT_MS } : { timeoutMs: GIT_TIMEOUT_MS }
311 const result = await attempt(() => io.run(['git', 'branch', '--show-current'], init), null)
312 const branch = result && result.exitCode === 0 ? result.stdout.trim() : ''
313
314 return branch === '' ? null : branch
315}
316
317export type GatherOptions = {
318 /** The live session id when the caller knows it better than `$.session.id()` (a classic SessionStart's `session_id`). */
319 sessionId?: string
320 /** Start over even when the id matches (a clear). */
321 reset?: boolean
322 /** Run the status command (start, 5 s after a turn, row opened). */
323 savings?: boolean
324 /** The session's transcript, so the status command need not look it up. */
325 transcript?: string
326}
327
328/**
329 * The per-session part of a Snapshot. Starts from `previous` only when it
330 * belongs to this session, so nothing carries over a clear. Savings and
331 * the clock facts come from the status command when asked for; when it is not
332 * asked, fails or times out, the last known values stay.
333 */
334export async function gather(
335 io: DataIo,
336 previous: TokenOptimizerDesktopSession | null,
337 options: GatherOptions = {},
338): Promise<TokenOptimizerDesktopSession> {
339 const now = await attempt(() => io.now(), Date.now())
340 const sid = cleanId(options.sessionId ?? (await attempt(() => io.sessionId(), '')))
341 const base = options.reset || shouldReset(previous, sid) ? null : previous
342 const cwd = await attempt(() => io.cwd(), '')
343 const home = await readHome(io)
344 const reported = parseUsage(await attempt(() => io.usage(), null))
345 // A limit does not vanish mid-session: a refresh that comes back without one (right
346 // after a compact, say) keeps the last known value; once that window has renewed, 0%.
347 const keep = (fresh: Limit | null, last: Limit | null | undefined): Limit | null => {
348 if (fresh) return fresh
349 // Kept only until its own renewal: after that the old figure is wrong, and an unreadable
350 // renewal time is no renewal time (either could otherwise stay on screen for good).
351 if (!last || last.resetsAt === null) return null
352 const renews = Date.parse(last.resetsAt)
353 return Number.isFinite(renews) && renews > now ? last : null
354 }
355 const usage = { ...reported, fiveHour: keep(reported.fiveHour, base?.fiveHour), week: keep(reported.week, base?.week) }
356 const sawLimits = Boolean(base?.sawLimits || reported.fiveHour || reported.week)
357 const branch = await readBranch(io, cwd)
358 const quality = await readQuality(io, home, sid)
359
360 let status: StatusBar | 'outdated' | 'nopython' | null = null
361 let notFound = false
362
363 if (options.savings && sid) {
364 const root = await findTokenOptimizerRoot(io, home)
365 notFound = root === null
366 status = root ? await readStatusBar(io, root, sid, options.transcript) : null
367 }
368
369 const kept = {
370 savings: base?.savings ?? null,
371 savingsState: base?.savingsState ?? ('unavailable' as const),
372 savingsReason: base?.savingsReason ?? null,
373 lastRequestEpoch: base?.lastRequestEpoch ?? null,
374 cacheLifetime: base?.cacheLifetime ?? null,
375 checkpointEpoch: base?.checkpointEpoch ?? null,
376 earlierCheckpoint: base?.earlierCheckpoint ?? null,
377 compactions: base?.compactions ?? null,
378 }
379 const seen = { toolCallsSeen: base?.toolCallsSeen ?? 0, compactionsSeen: base?.compactionsSeen ?? 0 }
380
381 const facts = notFound
382 ? { ...kept, savings: null, savingsState: 'unavailable' as const, savingsReason: NOT_FOUND, checkpointEpoch: null }
383 : status === 'outdated'
384 ? { ...kept, savings: null, savingsState: 'unavailable' as const, savingsReason: OUTDATED }
385 : status === 'nopython'
386 ? { ...kept, savings: null, savingsState: 'unavailable' as const, savingsReason: NO_PYTHON }
387 : status
388 ? {
389 // While savings load, the last known figures stay on show.
390 savings: status.savings ?? (status.savingsState === 'loading' ? kept.savings : null),
391 savingsState: status.savingsState,
392 savingsReason: status.savings ? null : status.savingsReason,
393 lastRequestEpoch: status.lastRequestEpoch ?? kept.lastRequestEpoch,
394 cacheLifetime: status.cacheLifetime ?? kept.cacheLifetime,
395 checkpointEpoch: status.checkpointEpoch,
396 earlierCheckpoint: status.earlierCheckpoint,
397 compactions: status.compactions ?? kept.compactions,
398 }
399 : kept
400
401 // The higher count wins: the quality cache can lag a compaction that just landed.
402 // Never lower than the band already knew this session (it may have seen the compaction itself).
403 const known = Math.max(
404 'compactions' in facts && facts.compactions != null ? facts.compactions : 0,
405 base?.quality?.compactions ?? 0,
406 )
407 const counted = quality && known > quality.compactions ? { ...quality, compactions: known } : quality
408 // A status answer has already checked the checkpoint file is still on disk, so it wins
409 // over the quality cache, which keeps naming a save after retention removes it.
410 const answered = status !== null && typeof status === 'object'
411 const checkpointEpoch = notFound ? null : answered ? facts.checkpointEpoch : newer(quality?.checkpointEpoch ?? null, facts.checkpointEpoch)
412 const merged = counted && answered ? { ...counted, checkpointEpoch } : counted
413
414 return {
415 sessionId: sid,
416 gatheredAt: now,
417 quality: merged,
418 ...usage,
419 branch,
420 ...facts,
421 // Without a status answer, the newer of the two: the quality cache knows quality saves
422 // the moment they land, the status command also knows stop and compaction saves.
423 ...seen,
424 sawLimits,
425 checkpointEpoch,
426 sheetOpen: base?.sheetOpen ?? false,
427 }
428}
429
430/**
431 * What to store when `fresh` lands on top of `current` (read inside
432 * `update`): keeps a row toggle made while gathering, never across sessions
433 * or a forced reset.
434 */
435export function mergeStored(
436 current: TokenOptimizerDesktopSession | null,
437 fresh: TokenOptimizerDesktopSession,
438 reset = false,
439 savingsRead = true,
440): TokenOptimizerDesktopSession {
441 const sameSession = !reset && current !== null && current.sessionId === fresh.sessionId
442
443 // A refresh that began before a compaction landed must not write its count back down.
444 const counted = sameSession ? Math.max(current.quality?.compactions ?? 0, fresh.quality?.compactions ?? 0) : null
445 const quality = fresh.quality && counted !== null && counted > fresh.quality.compactions ? { ...fresh.quality, compactions: counted } : fresh.quality
446
447 // The band's own counts only grow; a refresh that read them earlier cannot undo a later one.
448 const seen = sameSession
449 ? {
450 toolCallsSeen: Math.max(current.toolCallsSeen ?? 0, fresh.toolCallsSeen ?? 0),
451 compactionsSeen: Math.max(current.compactionsSeen ?? 0, fresh.compactionsSeen ?? 0),
452 }
453 : {}
454
455 // A refresh that did not run the status command only carried its older copy of what that
456 // command reports: a slower savings refresh that landed meanwhile keeps its figures.
457 const statusFacts =
458 sameSession && !savingsRead
459 ? {
460 savings: current.savings,
461 savingsState: current.savingsState,
462 savingsReason: current.savingsReason,
463 earlierCheckpoint: current.earlierCheckpoint,
464 cacheLifetime: current.cacheLifetime ?? fresh.cacheLifetime,
465 lastRequestEpoch: newer(current.lastRequestEpoch, fresh.lastRequestEpoch),
466 checkpointEpoch: newer(current.checkpointEpoch, fresh.checkpointEpoch),
467 compactions:
468 current.compactions == null ? fresh.compactions : fresh.compactions == null ? current.compactions : Math.max(current.compactions, fresh.compactions),
469 }
470 : {}
471
472 return { ...fresh, ...statusFacts, ...seen, quality, sheetOpen: sameSession ? current.sheetOpen : fresh.sheetOpen }
473}
474desktop/token-optimizer-desktop/src/format.ts 116 lines1// Pure formatting for the band: clocks, token counts, relative and local times,
2// grades. No I/O and nothing from 'claude-code'.
3
4/** Injectable zone and locale so tests do not depend on the machine's clock settings. */
5export type FormatOptions = { timeZone?: string; locale?: string }
6
7const MINUTE = 60_000
8const DAY_MIN = 24 * 60
9
10/** Seconds as m:ss, clamped to 0:00..60:00 (the longest cache lifetime is an hour). */
11export function clock(sec: number): string {
12 const s = Math.min(3600, Math.max(0, Math.floor(Number.isFinite(sec) ? sec : 0)))
13 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
14}
15
16/** Minutes left as 52m; a countdown that only needs to move once a minute. */
17export function minutes(sec: number): string {
18 const s = Math.min(3600, Math.max(0, Number.isFinite(sec) ? sec : 0))
19 return `${Math.ceil(s / 60)}m`
20}
21
22/** Token counts as 940, 340k, 1.2M, 50M. */
23export function tokens(n: number): string {
24 if (!Number.isFinite(n) || n < 0) return '--'
25 if (n < 1000) return String(Math.round(n))
26 if (n < 999_500) return `${Math.round(n / 1000)}k`
27 const scale = n < 999_500_000 ? { d: 1e6, u: 'M' } : { d: 1e9, u: 'B' }
28 const v = n / scale.d
29 const text = v < 9.95 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v))
30 return text + scale.u
31}
32
33/** Time until `targetMs` from `nowMs`: "in 48 minutes", "in 2h 14m", "in 6 days". Past targets read as `ago`. */
34export function relative(targetMs: number, nowMs: number): string {
35 const diff = targetMs - nowMs
36 if (diff < 0) return ago(targetMs, nowMs)
37 const mins = Math.round(diff / MINUTE)
38 if (mins < 1) return 'in under a minute'
39 if (mins < 60) return mins === 1 ? 'in 1 minute' : `in ${mins} minutes`
40 if (mins < DAY_MIN) {
41 const m = mins % 60
42 return m === 0 ? `in ${Math.floor(mins / 60)}h` : `in ${Math.floor(mins / 60)}h ${String(m).padStart(2, '0')}m`
43 }
44 const days = Math.floor(mins / DAY_MIN)
45 if (days < 2) {
46 const h = Math.floor((mins % DAY_MIN) / 60)
47 return h === 0 ? 'in 1 day' : `in 1 day ${h}h`
48 }
49 return `in ${days} days`
50}
51
52/** Time since `thenMs`: "just now", "3 min ago", "2h 5m ago", "3 days ago". */
53export function ago(thenMs: number, nowMs: number): string {
54 const mins = Math.floor(Math.max(0, nowMs - thenMs) / MINUTE)
55 if (mins < 1) return 'just now'
56 if (mins < 60) return `${mins}m ago`
57 if (mins < DAY_MIN) {
58 const m = mins % 60
59 return m === 0 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 60)}h ${m}m ago`
60 }
61 const days = Math.floor(mins / DAY_MIN)
62 return days === 1 ? '1 day ago' : `${days} days ago`
63}
64
65/** A span in seconds: "48m", "1h 5m", "under 1m". */
66export function duration(sec: number): string {
67 const s = Math.max(0, Math.floor(sec))
68 if (s < 60) return 'under 1m'
69 const mins = Math.floor(s / 60)
70 if (mins < 60) return `${mins}m`
71 return `${Math.floor(mins / 60)}h ${mins % 60}m`
72}
73
74function clean(text: string): string {
75 // Newer ICU puts a narrow no-break space before AM/PM.
76 return text.replace(/[ ]/g, ' ')
77}
78
79function dayNumber(ms: number, timeZone: string | undefined): number {
80 const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(ms)
81 const get = (t: string) => Number(parts.find((p) => p.type === t)?.value)
82 return Date.UTC(get('year'), get('month') - 1, get('day')) / 86_400_000
83}
84
85/** Local renewal time from an ISO reset: "today at 3:20 PM", "tomorrow at 9:00 AM", "Thursday, Oct 8 at 9:00 AM". Null when unparseable. */
86export function renewal(iso: string | null, nowMs: number, opts: FormatOptions = {}): string | null {
87 if (!iso) return null
88 const at = Date.parse(iso)
89 if (!Number.isFinite(at)) return null
90 const { timeZone, locale } = opts
91 const time = clean(new Intl.DateTimeFormat(locale, { timeZone, hour: 'numeric', minute: '2-digit' }).format(at))
92 const diff = dayNumber(at, timeZone) - dayNumber(nowMs, timeZone)
93 if (diff === 0) return `today at ${time}`
94 if (diff === 1) return `tomorrow at ${time}`
95 const weekday = new Intl.DateTimeFormat(locale, { timeZone, weekday: 'long' }).format(at)
96 const month = new Intl.DateTimeFormat(locale, { timeZone, month: 'short' }).format(at)
97 const day = new Intl.DateTimeFormat(locale, { timeZone, day: 'numeric' }).format(at)
98 return `${weekday}, ${month} ${day} at ${time}`
99}
100
101/** Renewal for use after "Renews": "at 3:20 PM" today, otherwise as `renewal`. */
102export function renewalPhrase(iso: string | null, nowMs: number, opts: FormatOptions = {}): string | null {
103 const r = renewal(iso, nowMs, opts)
104 return r && r.startsWith('today ') ? r.slice('today '.length) : r
105}
106
107/** Letter grade with the same boundaries as score_to_grade in measure.py. */
108export function gradeOf(score: number): 'S' | 'A' | 'B' | 'C' | 'D' | 'F' {
109 if (score >= 90) return 'S'
110 if (score >= 80) return 'A'
111 if (score >= 70) return 'B'
112 if (score >= 55) return 'C'
113 if (score >= 40) return 'D'
114 return 'F'
115}
116