Live pane tracking the context window: colored category breakdown, a timeline of growth spikes, and per-skill growth attribution.

<img src="quoin/docs/images/quoin-hero.png" alt="Quoin" width="420">
Workflow state for stateless coding agents.
Quoin is a workflow-memory toolkit for coding agents. Its core value is the artifact-centric workflow: plans, architecture notes, review artifacts, gates, session handoff, lessons learned, and a cost ledger stored in predictable files under .workflow_artifacts/.
Quoin now has two runtime paths:
~/.claude.AGENTS.md and points Codex at portable workflow contracts. It uses native Codex planning, approvals, sandboxing, repo-scoped instructions, and model/reasoning controls.Quoin does not install global Codex commands, plugins, hooks, command files, or runtime extensions.
quoin/core/, while Claude and Codex behavior is isolated in adapter directories..workflow_artifacts/memory/sessions/.not_available.Install the package:
pip install quoin
Install or update the Claude Code adapter:
quoin install --runtime claude
quoin doctor --runtime claude
When --scope is not specified, the installer prompts interactively to ask whether to install globally (~/.claude/) or project-level (./.claude/). In non-interactive environments (CI, pipes), it defaults to global without prompting.
quoin install and bare quoin remain backward-compatible aliases for the Claude install path.
bash quoin/install.sh needs a Python that meets requires-python in pyproject.toml (3.10 or newer). It looks at QUOIN_PYTHON first, then every directory on PATH in order (any python3.N), then the usual Homebrew, python.org, pyenv, uv, asdf, mise, conda (root and named environments, including /opt and ~/opt installs and the Homebrew cask), Linuxbrew and MacPorts locations, and finally uv python find. bash quoin/install.sh --print-python prints the interpreter it would use without installing; set QUOIN_PYTHON=/path/to/python3 to choose one yourself. A .venv synced from another computer usually has a dangling interpreter link: recreate it on this machine, or point QUOIN_PYTHON at a local interpreter. The affected-area test runner reports such a venv by name.
quoin install records the interpreter and source tree it ran from (quoin-runtime.json, next to the installed .claude/ tree), so an autonomous run's detached hand-off can relaunch the exact CLI you meant to run instead of guessing via PATH. Run quoin install with whichever interpreter or tool you actually use day to day — for a uv tool install or pipx install setup, that means running that tool's own quoin install, not a system Python's. Re-run quoin install after every upgrade, recreated virtualenv, or Python version change; a stale record makes hand-offs refuse rather than run the wrong code. quoin doctor checks the record and names the exact fix when it's missing or stale.
The Claude adapter can be installed at two scopes:
User scope (global) — installs to ~/.claude/. Skills, hooks, and workflow rules are available in every Claude Code session on the machine.
quoin install --runtime claude --scope user # explicit global, no prompt
Project scope — installs to <project>/.claude/. Skills and hooks activate only when Claude Code is opened in that directory. Hooks register in <project>/.claude/settings.json instead of your personal settings file.
quoin install --runtime claude --scope project # explicit project, no prompt
quoin install --runtime claude --scope project:/path # explicit project root
Pass --scope explicitly to skip the interactive prompt (useful in scripts and CI).
Note: Claude Code resolves personal scope (
~/.claude/skills/) before project scope. If you have a user-scope install, it will shadow a project-scope install for skills. Runquoin doctor --scope projectto detect conflicts.
Generate the Codex repo-local scaffold:
quoin install --runtime codex --project-root .
quoin install --runtime codex --project-root . --check
quoin doctor --runtime codex --project-root . --smoke
The Codex install path writes AGENTS.md in the selected project root. The --check form verifies that AGENTS.md is up to date without writing files. The doctor command verifies repo-local readiness; --smoke also runs the deterministic Codex workflow smoke test.
Equivalent Codex setup command:
quoin codex init --project-root .
quoin codex init --project-root . --check
Legacy source install for Claude remains supported (prompts for scope interactively):
git clone https://github.com/FourthWiz/quoin
cd quoin
bash quoin/install.sh # prompts: global or project?
bash quoin/install.sh --scope user # global, no prompt
bash quoin/install.sh --scope project # project, no prompt
GitHub redirects the old FourthWiz/claude_dev_workflow URL to this repository.
| Runtime | Setup command | Scope | Implemented behavior |
|---|---|---|---|
| Claude Code | quoin install --runtime claude | Global adapter under ~/.claude | Skills, scripts, hooks, memory files, CLAUDE.md workflow rules, slash-command workflow |
| Codex | quoin install --runtime codex --project-root . | Repo-local AGENTS.md | Portable artifact workflow, generated root instructions, readiness/smoke checks, handoff validation, cost event writer |
The portable core is shared. Runtime-specific mechanics are adapter-owned:
quoin/adapters/claude/ and install to ~/.claude.quoin/adapters/codex/ and are used from the repository. They are documentation, generated instructions, and validation scripts, not Codex command packages.In the project where you want Codex to use Quoin:
pip install quoin
quoin install --runtime codex --project-root .
quoin doctor --runtime codex --project-root . --smoke
Then ask Codex for Quoin phases in natural language:
.workflow_artifacts/."The practical Codex workflow loop is documented as:
discover -> plan -> implement -> review -> gate
Codex procedure docs live in quoin/adapters/codex/procedures/ and link back to portable contracts in quoin/core/skills/.
Codex readiness and smoke:
quoin doctor --runtime codex --project-root .
quoin doctor --runtime codex --project-root . --smoke
Handoff validation:
python3 quoin/adapters/codex/validate_codex_handoff.py --self-test
python3 quoin/adapters/codex/validate_codex_handoff.py --project-root . --file .workflow_artifacts/memory/sessions/<date>-<task>-codex.md
Cost event writing and validation:
python3 quoin/adapters/codex/cost_event.py --self-test
python3 quoin/adapters/codex/cost_event.py write --project-root . --task <task> --phase <phase> --effort <low|medium|high|max|unknown>
python3 quoin/adapters/codex/cost_event.py validate --project-root . --task <task> --expect-codex
Codex cost rows use the portable cost-ledger shape. They record known local values such as runtime, task, phase, timestamp, session id when supplied, and effort. Token counts, dollar cost, and telemetry source are recorded as not_available because this repository has no verified Codex local telemetry interface.
After installing the Claude adapter:
/init_workflow one-time project bootstrap
/architect design the solution
/thorough_plan converge on a plan with critic review
/implement write code from the plan
/review verify implementation against the plan
/gate stop for an explicit quality checkpoint
/end_of_task finalize, push, and capture lessons
Claude-specific command behavior, model assignments, hook behavior, and cost-capture plumbing are documented in quoin/CLAUDE.md.
These slash commands are Claude adapter commands. Codex uses the same portable workflow intent through natural-language phase requests and repo-local docs.
| Command | Model | What it does |
|---|---|---|
/architect | Opus | Deep architectural analysis; internal critic loop |
/plan | Opus | Detailed implementation plan |
/thorough_plan | Opus | Triages task size; runs plan -> critic -> revise convergence |
/critic | Opus | Reviews a plan for gaps, risks, and integration issues |
/revise | Opus | Revises plan from critic feedback in strict/large mode |
/revise-fast | Sonnet | Revises plan from critic feedback in medium mode |
| Command | Model | What it does |
|---|---|---|
/implement | Sonnet | Writes code from the plan |
/review | Opus | Verifies implementation against the plan |
/gate | Sonnet | Automated quality checkpoint between phases; requires approval |
/rollback | Sonnet | Safely undoes an implementation phase or specific tasks |
/pr | Sonnet | Full pull-request lifecycle: optional version bump, push, create PR, wait for merge, switch branch |
| Command | Model | What it does |
|---|---|---|
/init_workflow | Opus | One-time project bootstrap; creates .workflow_artifacts/ and runs discovery |
/discover | Opus | Scans repos; maps architecture, dependencies, and git history |
/start_of_day | Haiku | Morning briefing from daily/session memory |
/end_of_day | Sonnet | Saves session state, dedupes and promotes insights; auto-invokes /sleep |
/end_of_task | Sonnet | Pushes branch, captures lessons, and finalizes task state |
/checkpoint | Sonnet | Save/restore session context mid-session; writes pending-restore sentinel |
/continue_work | Sonnet | Resume context from a prior session using the recent-sessions index |
/sleep | Sonnet | Memory consolidation: promotes insights to lessons-learned, archives stale entries |
| Command | Model | What it does |
|---|---|---|
/run | Opus | End-to-end pipeline orchestrator; pauses at gates |
/cost_snapshot | Haiku | Live Claude cost: today, lifetime, and per-task breakdown |
/triage | Haiku | Routes the request to the right workflow phase |
/weekly_review | Haiku | Aggregates weekly progress into a structured review |
/capture_insight | Haiku | Logs a pattern or discovery to daily insights |
/expand <path> | Sonnet | Re-renders a terse workflow artifact in readable English |
/status | Haiku | Renders the workflow pipeline graph with the active phase marked (read-only) |
/cleanup | Sonnet | Trash-moves stale sentinels and old checkpoints into recoverable archive; standalone runs also offer a confirmed task-folder bookkeeping pass |
/next-steps | Haiku | Append-only queue for future work items (add / list / done N) |
Quoin can route Claude Code through claude-code-router to use open models from OpenRouter (DeepSeek V4.1, GLM-5.3, and others) when your Anthropic quota is low. This is fully opt-in — quoin install is unchanged.
export OPENROUTER_API_KEY=sk-or-... # your OpenRouter key
quoin router setup # install CCR + scaffold config
This installs claude-code-router globally (requires Node 22 or newer) and scaffolds ~/.claude-code-router/config.json with an OpenRouter provider and a tier routing map. Any existing config is backed up with a timestamp before changes are applied.
Check the result:
quoin router status # installed? config present? proxy running? key set?
quoin doctor # includes a one-line CCR probe
| Mode | Command | When to use |
|---|---|---|
| Open models via CCR (v2) | ccr code | Quota exhausted, cost-sensitive work |
| Open models via CCR (v3) | ccr default-claude-code | Same, on CCR v3 — profile exists but routes nothing until configured in ccr ui |
| Native Anthropic | claude | Normal use, full quoin skill fidelity |
On CCR v2, ccr code auto-starts the local proxy and launches the real claude binary in your terminal — quoin's slash commands and skills work normally. CCR v3 has no code subcommand; ccr default-claude-code selects its profile, but that profile routes nothing until you configure its models in ccr ui.
Sanity-check: inside an open-model session, type /help. The quoin skill list should resolve. If it does not, run quoin doctor and file an issue.
Note: the model shown in the Claude Code header will still say "Sonnet 4.6" (or whatever your default Claude model is). This is expected — CCR routes requests transparently at the HTTP layer, below what the Claude Code UI can see. The actual model invoked is the one CCR maps to (e.g. DeepSeek V4 for default requests).
Run claude directly (not ccr code / ccr default-claude-code) to use native Anthropic models. No config changes needed — the CCR config is left intact so you can flip back by running ccr code (v2) or ccr default-claude-code (v3) again.
The tier → model mapping is seeded to ~/.config/quoin/models.json on first setup. Edit it any time, or use the quoin models command (see below). Re-running quoin router setup re-applies the current models.json to the CCR Router config — it does not reset it.
View and manage the tier → open-model mapping:
quoin models # show current mapping and active launch mode
quoin models set <tier> <slug> # update one tier's slug
quoin models preset open # restore all three defaults at once
quoin models reset # document native-launch; back up CCR config (non-destructive)
quoin models reset --native # identical to reset (explicit-intent spelling)
Tiers: haiku, sonnet, opus
Friendly aliases (expand to the corresponding default slug):
| Alias | Slug |
|---|---|
flash | z-ai/glm-5.3-flash |
pro | deepseek/deepseek-v4.1-flash |
glm | z-ai/glm-5.3 |
Examples:
quoin models set opus glm # set opus → z-ai/glm-5.3 (alias)
quoin models set sonnet pro # set sonnet → deepseek/deepseek-v4.1-flash (alias)
quoin models set haiku anthropic/claude-3-haiku # any OpenRouter slug
Slug validation is advisory: known slugs are accepted silently; unknown-but-plausible slugs (containing a /) are accepted with a warning; only structurally malformed input is rejected. You can always edit ~/.config/quoin/models.json directly.
Secret rule: quoin models never reads or writes OPENROUTER_API_KEY. The key lives only in the CCR config authored by quoin router setup. set and preset update the provider's models list in-place, leaving api_key byte-unchanged.
reset is non-destructive: quoin models reset backs up the CCR config and prints native-launch instructions, but leaves the Router keys, provider block, and models.json intact. Run ccr code (v2) or ccr default-claude-code (v3) again to switch back to open models with no re-setup required.
Agentdesk is a Zellij terminal layout launcher bundled with quoin. It opens a named Zellij session with panes pre-configured for agent-assisted development — Claude Code, Codex, and a plain shell — in a single command.
Agentdesk is deployed automatically when you install the Claude adapter at user scope:
quoin install --runtime claude # or: quoin install --runtime claude --scope user
This copies agentdesk.zsh and setup-agentdesk.sh to ~/.config/agentdesk/. Running the setup (Zellij, lazygit and fzf via Homebrew, plus a line in your .zshrc) changes your system, so it only happens with your consent:
~/.zshrc (backup kept). It is not asked when ~/.zshrc already sources the helper.quoin install --setup-agentdesk or bash quoin/install.sh --setup-agentdesk.The setup runs as the last install step. If it fails, you get a warning and quoin stays installed; the deployed script is idempotent, so re-run the install with --setup-agentdesk to retry. After that, agentdesk is available in every new shell session.
Agentdesk is user-scope only — it is not deployed in project-scope installs.
agentdesk # interactive picker (when run in a TTY)
agentdesk <session-name> # named session with fixed layout
agentdesk --mode solo|duo|trio # preset layout
agentdesk --mode trio --name my-sess # named preset-layout session
agentdesk claude [codex] [shell] [...] # custom layout from window-type tokens
Preset modes:
| Mode | Panes |
|---|---|
solo | One Claude Code pane, full-width |
duo | Claude Code + Shell, side-by-side |
trio | Claude Code + Codex + Shell, side-by-side |
Window-type tokens (positional, mutually exclusive with --mode):
| Token | Pane contents |
|---|---|
claude | Claude Code |
codex | Codex |
shell | Plain zsh shell |
Examples:
agentdesk # picker — choose a preset or enter custom tokens
agentdesk pricing # named session, fixed layout
agentdesk --mode trio # Claude + Codex + Shell
agentdesk claude codex # custom two-pane layout
agentdesk claude claude # two Claude panes side-by-side
If Zellij is not running, agentdesk starts a new session. agentdesk never re-attaches: if a session with the chosen name already exists, it starts a fresh session with a numeric suffix (foo_1, foo_2, …). To resume an existing session, use agentdesk-attach SESSION-NAME.
![]()
Quoin separates portable workflow contracts from runtime adapters:
quoin/core/workflow/ defines shared rules, task layout, session state, cost ledger shape, and skill metadata.quoin/core/skills/ defines runtime-neutral skill intent.quoin/core/scripts/ contains portable helper implementations.quoin/adapters/claude/ contains Claude-specific skill bodies and runtime assumptions.quoin/adapters/codex/ contains Codex-facing repo-local instructions, procedures, readiness checks, handoff validation, and cost-event tooling.The project root containing AGENTS.md is the Quoin project root for Codex. Codex writes .workflow_artifacts/ there even when the code being edited lives in a nested subdirectory.
Claude and Codex expose different runtime mechanics, so Quoin keeps cost and effort handling honest:
ccusage output.low, medium, high, max, and unknown.not_available.For runtime-neutral effort vocabulary, see quoin/docs/effort-levels.md.
quoin/QUICKSTART.md - Claude command reference tablequoin/CLAUDE.md - Claude workflow rules, model assignments, artifacts, and cost conventionsquoin/adapters/codex/setup.md - Codex repo-local setup and readinessquoin/adapters/codex/workflow.md - Codex workflow execution guidequoin/adapters/codex/handoff.md - Codex session handoff contractquoin/adapters/codex/cost.md - Codex cost event behaviorquoin/docs/runtime-portability.md - portable core and runtime adapter boundaryquoin/docs/runtime-portability-status.md - current migration status by runtimequoin/docs/runtime-parity-matrix.md - evidence-based runtime parity matrixquoin/docs/effort-levels.md - runtime-neutral effort vocabularyQuoin intentionally does not:
Future runtime integrations should add explicit adapter contracts instead of copying Claude-specific behavior into the portable core.
Bug reports and PRs welcome. Quoin development uses Quoin itself: keep changes artifact-aware, runtime boundaries explicit, and documentation honest about what is implemented versus planned.
PolyForm Noncommercial 1.0.0 — source-available, noncommercial use only.
hooks/register.tsx 452 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3
4import type { Breakdown, Sample, SampleKind, SkillEvent, View } from '../types'
5
6
7const PANE = 'context-tracker'
8const MAX_SAMPLES = 600
9const MAX_SKILL_EVENTS = 300
10
11const samples = atom({ plugin: 'context-tracker', key: 'samples' } as const, [] as Sample[])
12const breakdown = atom({ plugin: 'context-tracker', key: 'breakdown' } as const, null as Breakdown | null)
13const skillEvents = atom({ plugin: 'context-tracker', key: 'skillEvents' } as const, [] as SkillEvent[])
14const view = atom({ plugin: 'context-tracker', key: 'view' } as const, 'categories' as View)
15
16// ---------------------------------------------------------------- palette
17
18const CATEGORY_COLORS: Record<string, string> = {
19 'System prompt': '#7aa2f7',
20 'System tools': '#bb9af7',
21 'MCP tools': '#ff9e64',
22 'Custom agents': '#e0af68',
23 'Memory files': '#9ece6a',
24 'Skills': '#2ac3de',
25 'Messages': '#f7768e',
26 'Free space': '#3b4261',
27 'Autocompact buffer': '#565f89',
28}
29const KIND_COLORS = { used: '#c0caf5', free: '#3b4261', buffer: '#565f89', deferred: '#414868' }
30const SPIKE_RED = '#f7768e'
31const SPIKE_YELLOW = '#e0af68'
32const CALM_GREEN = '#9ece6a'
33const COMPACT_BLUE = '#7dcfff'
34
35const colorOf = (name: string, kind: keyof typeof KIND_COLORS) =>
36 CATEGORY_COLORS[name] ?? KIND_COLORS[kind]
37
38const fillColor = (percent: number) =>
39 percent >= 80 ? SPIKE_RED : percent >= 50 ? SPIKE_YELLOW : CALM_GREEN
40
41/** Red for a big jump, yellow for a notable one, dim otherwise. */
42const deltaColor = (delta: number, window: number) => {
43 if (delta >= Math.max(10_000, window * 0.05)) return SPIKE_RED
44 if (delta >= 3_000) return SPIKE_YELLOW
45 if (delta < 0) return COMPACT_BLUE
46 return undefined
47}
48
49// ---------------------------------------------------------------- formatting
50
51const fmtK = (n: number) => {
52 const abs = Math.abs(n)
53 if (abs < 1_000) return `${Math.round(n)}`
54 if (abs < 1_000_000) return `${(n / 1_000).toFixed(1)}k`
55 return `${(n / 1_000_000).toFixed(2)}M`
56}
57const fmtDelta = (n: number) => (n >= 0 ? `+${fmtK(n)}` : `-${fmtK(-n)}`)
58const pad2 = (n: number) => String(n).padStart(2, '0')
59const fmtTime = (t: number) => {
60 const d = new Date(t)
61 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
62}
63const fmtMs = (ms: number) => (ms >= 60_000 ? `${(ms / 60_000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`)
64const padEnd = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
65const padStart = (s: string, n: number) => (s.length >= n ? s : ' '.repeat(n - s.length) + s)
66
67const bar = (ratio: number, width: number) => {
68 const r = Math.max(0, Math.min(1, ratio))
69 const filled = Math.round(r * width)
70 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
71}
72
73const SPARK = '▁▂▃▄▅▆▇█'
74const sparkline = (values: number[], max: number) =>
75 values
76 .map(v => SPARK[Math.min(SPARK.length - 1, Math.max(0, Math.round((v / Math.max(1, max)) * (SPARK.length - 1))))])
77 .join('')
78
79const toolsLabel = (tools: Record<string, number>, limit = 3) =>
80 Object.entries(tools)
81 .sort((a, b) => b[1] - a[1])
82 .slice(0, limit)
83 .map(([name, n]) => (n > 1 ? `${name}×${n}` : name))
84 .join(' ')
85
86// ---------------------------------------------------------------- recording
87
88type Recorder = {
89 /** The current main-thread turn's tool calls, by name. */
90 tools: Record<string, number>
91 /** Skills invoked in the current main-thread turn. */
92 skills: string[]
93 /** Main-thread turns seen since load. */
94 turn: number
95 /** Last context size recorded, to compute deltas without a read. */
96 lastTokens: number
97 window: number
98}
99
100async function addSample(
101 $: EngineInterface,
102 rec: Recorder,
103 input: { tokens: number; percent: number; kind: SampleKind; durationMs?: number; fromTokens?: number },
104) {
105 const t = await $.clock.now()
106 let isNew = false
107 await update($, samples, list => {
108 const last = list[list.length - 1]
109 // A measurement right after a turn repeats the turn's own figure: keep one point.
110 if (last && input.kind === 'measure' && last.tokens === input.tokens) return list
111 isNew = true
112 const sample: Sample = {
113 t,
114 tokens: input.tokens,
115 percent: input.percent,
116 kind: input.kind,
117 turn: rec.turn,
118 tools: input.kind === 'turn' ? { ...rec.tools } : {},
119 skills: input.kind === 'turn' ? [...rec.skills] : [],
120 durationMs: input.durationMs,
121 fromTokens: input.fromTokens,
122 }
123 return [...list, sample].slice(-MAX_SAMPLES)
124 })
125 if (isNew) {
126 await update($, skillEvents, list =>
127 list.map(ev => (ev.tokensAfter === null ? { ...ev, tokensAfter: input.tokens } : ev)),
128 )
129 }
130 rec.lastTokens = input.tokens
131}
132
133async function refreshBreakdown($: EngineInterface) {
134 try {
135 const usage = await $.session.usage({ breakdown: 'summary', columns: 80 })
136 const b = usage.context.breakdown
137 if (!b) return
138 const snapshot: Breakdown = {
139 at: await $.clock.now(),
140 total: b.totalTokens,
141 max: b.rawMaxTokens,
142 percent: b.percentage,
143 categories: b.categories.map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
144 }
145 await update($, breakdown, () => snapshot)
146 } catch {
147 // No session bound (a -p run, a test without the op answered): keep what we had.
148 }
149}
150
151// ---------------------------------------------------------------- register
152
153export const register: Register = on => {
154 const rec: Recorder = { tools: {}, skills: [], turn: 0, lastTokens: 0, window: 0 }
155
156 on('session.start', async ($, e, next) => {
157 await $.command.register({
158 name: 'ctx',
159 description: 'Open the context tracker pane (categories, timeline, skills)',
160 })
161 void $.ui.open({ id: PANE, title: 'Context' })
162 void refreshBreakdown($)
163 return next(e)
164 })
165
166 on('command.run', { command: 'ctx' }, async $ => {
167 await $.ui.open({ id: PANE, title: 'Context', focus: true })
168 await refreshBreakdown($)
169 return { text: 'Context tracker pane opened. Hotkeys 1/2/3 switch views, r refreshes.' }
170 })
171
172 on('prompt.submit', ($, e, next) => {
173 rec.tools = {}
174 rec.skills = []
175 return next(e)
176 })
177
178 on('tool.call', ($, e, next) => {
179 if (!e.agentId) rec.tools[e.tool] = (rec.tools[e.tool] ?? 0) + 1
180 return next(e)
181 })
182
183 on('skill.prompt', async ($, e, next) => {
184 rec.skills.push(e.skill)
185 const ev: SkillEvent = {
186 t: await $.clock.now(),
187 skill: e.skill,
188 turn: rec.turn + 1,
189 promptTokens: Math.round(e.text.length / 4),
190 tokensBefore: rec.lastTokens,
191 tokensAfter: null,
192 }
193 await update($, skillEvents, list => [...list, ev].slice(-MAX_SKILL_EVENTS))
194 return next(e)
195 })
196
197 on('turn.complete', async ($, e, next) => {
198 if (!e.agentId) {
199 rec.turn += 1
200 const u = e.usage
201 if (u) {
202 const tokens = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
203 const percent = rec.window > 0 ? Math.round((tokens / rec.window) * 100) : 0
204 await addSample($, rec, { tokens, percent, kind: 'turn', durationMs: e.durationMs })
205 }
206 rec.tools = {}
207 rec.skills = []
208 }
209 return next(e)
210 })
211
212 on('session.measure', async ($, e, next) => {
213 rec.window = e.context.window
214 if (e.changed.includes('context') && e.context.tokens !== undefined) {
215 const tokens = e.context.tokens
216 const percent = e.context.percent ?? Math.round((tokens / e.context.window) * 100)
217 const delta = tokens - rec.lastTokens
218 await addSample($, rec, { tokens, percent, kind: 'measure' })
219 await refreshBreakdown($)
220 const tag = delta === 0 || rec.lastTokens === tokens ? '' : ` ${fmtDelta(delta)}`
221 $.ui.status(`ctx ${percent}%${tag}`)
222 }
223 return next(e)
224 })
225
226 on('session.compact', async ($, e, next) => {
227 const result = await next(e)
228 if (!e.agentId && e.trigger !== 'precompute' && 'messages' in result && result.messages) {
229 const from = result.tokensBefore ?? rec.lastTokens
230 const to = result.tokensAfter ?? 0
231 const percent = rec.window > 0 ? Math.round((to / rec.window) * 100) : 0
232 await addSample($, rec, { tokens: to, percent, kind: 'compact', fromTokens: from })
233 await refreshBreakdown($)
234 }
235 return result
236 })
237
238 // ---------------------------------------------------------------- drawing
239
240 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
241 const { Box, Text, Button } = $.ui.resolve(e)
242 const width = Math.max(40, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
243 const [list, snap, events, current] = await Promise.all([
244 read($, samples),
245 read($, breakdown),
246 read($, skillEvents),
247 read($, view),
248 ])
249 const last = list[list.length - 1]
250 const prev = list[list.length - 2]
251 const window = snap?.max ?? rec.window
252 const tokensNow = last?.tokens ?? snap?.total ?? 0
253 const pctNow = last?.percent ?? snap?.percent ?? 0
254 const lastDelta = last && prev ? last.tokens - prev.tokens : 0
255
256 const tab = (key: View, hotkey: string, label: string) => (
257 <Button
258 key={`view-${key}`}
259 hotkey={hotkey}
260 plain
261 label={label}
262 dimColor={current !== key}
263 onPress={() => update($, view, () => key)}
264 />
265 )
266
267 const header = (
268 <Box flexDirection="column">
269 <Box>
270 <Text bold>Context </Text>
271 <Text color={fillColor(pctNow)} bold>{fmtK(tokensNow)}</Text>
272 <Text dimColor> / {fmtK(window)} ({pctNow}%)</Text>
273 {last && prev && (
274 <Text color={deltaColor(lastDelta, window)}> last {fmtDelta(lastDelta)}</Text>
275 )}
276 <Text dimColor> · {list.length} pts · {events.length} skill runs</Text>
277 </Box>
278 <Box gap={2}>
279 {tab('categories', '1', 'Categories')}
280 {tab('timeline', '2', 'Timeline')}
281 {tab('skills', '3', 'Skills')}
282 <Button key="refresh" hotkey="r" plain dimColor label="Refresh" onPress={() => refreshBreakdown($)} />
283 </Box>
284 </Box>
285 )
286
287 let body
288 if (current === 'categories') body = drawCategories()
289 else if (current === 'timeline') body = drawTimeline()
290 else body = drawSkills()
291
292 return (
293 <Box flexDirection="column">
294 {header}
295 <Text> </Text>
296 {body}
297 </Box>
298 )
299
300 // ------------------------------------------------ categories view
301 function drawCategories() {
302 if (!snap) return <Text dimColor>No breakdown yet. Press r after the first response.</Text>
303 const inWindow = snap.categories.filter(c => c.kind !== 'deferred')
304 const deferred = snap.categories.filter(c => c.kind === 'deferred')
305 const stackWidth = width - 2
306 const stack = inWindow
307 .map(c => ({ c, cells: Math.round((c.tokens / Math.max(1, snap.max)) * stackWidth) }))
308 .filter(x => x.cells > 0)
309 const nameW = Math.min(20, Math.max(...inWindow.map(c => c.name.length), 8))
310 const barW = Math.max(10, width - nameW - 20)
311 return (
312 <Box flexDirection="column">
313 <Box>
314 {stack.map(({ c, cells }) => (
315 <Text color={colorOf(c.name, c.kind)}>{'█'.repeat(cells)}</Text>
316 ))}
317 </Box>
318 <Text> </Text>
319 {inWindow.map(c => {
320 const share = c.tokens / Math.max(1, snap.max)
321 return (
322 <Box>
323 <Text color={colorOf(c.name, c.kind)}>■ </Text>
324 <Text dimColor={c.kind !== 'used'}>{padEnd(c.name, nameW)} </Text>
325 <Text>{padStart(fmtK(c.tokens), 7)} </Text>
326 <Text dimColor>{padStart(`${Math.round(share * 100)}%`, 4)} </Text>
327 <Text color={colorOf(c.name, c.kind)}>{bar(share, barW)}</Text>
328 </Box>
329 )
330 })}
331 {deferred.length > 0 && (
332 <Box flexDirection="column" marginTop={1}>
333 {deferred.map(c => (
334 <Text dimColor>
335 ◌ {padEnd(c.name, nameW)} {padStart(fmtK(c.tokens), 7)} (outside the window, loads on demand)
336 </Text>
337 ))}
338 </Box>
339 )}
340 <Text dimColor>
341 {'\n'}measured {fmtTime(snap.at)} · {fmtK(snap.total)} of {fmtK(snap.max)} ({snap.percent}%)
342 </Text>
343 </Box>
344 )
345 }
346
347 // ------------------------------------------------ timeline view
348 function drawTimeline() {
349 if (list.length === 0) return <Text dimColor>No measurements yet. Points appear after each turn.</Text>
350 const maxTokens = Math.max(window, ...list.map(s => s.tokens))
351 const sparkWidth = width - 2
352 const sparkPoints = list.slice(-sparkWidth)
353 const barW = Math.max(8, Math.min(24, Math.floor(width * 0.25)))
354 const rowsRoom = Math.max(10, (e.viewport?.rows ?? 40) - 8)
355 const shown = list.slice(-rowsRoom)
356 const firstIndex = list.length - shown.length
357 const labelW = Math.max(10, width - 8 - 1 - barW - 1 - 7 - 1 - 7 - 1 - 4 - 2)
358 return (
359 <Box flexDirection="column">
360 <Text color={fillColor(pctNow)}>{sparkline(sparkPoints.map(s => s.tokens), maxTokens)}</Text>
361 <Text dimColor>
362 {padEnd('time', 8)} {padEnd('fill', barW)} {padStart('tokens', 7)} {padStart('delta', 7)} {padEnd('turn', 4)} what happened
363 </Text>
364 {shown.map((s, i) => {
365 const before = list[firstIndex + i - 1]
366 const delta = before ? s.tokens - before.tokens : 0
367 const dc = deltaColor(delta, window)
368 const isSpike = dc === SPIKE_RED
369 const parts: string[] = []
370 if (s.kind === 'compact') parts.push(`compacted from ${fmtK(s.fromTokens ?? 0)}`)
371 if (s.skills.length) parts.push(`/${s.skills.join(' /')}`)
372 if (Object.keys(s.tools).length) parts.push(toolsLabel(s.tools))
373 if (s.durationMs !== undefined) parts.push(fmtMs(s.durationMs))
374 const label = parts.join(' · ')
375 return (
376 <Box>
377 <Text dimColor>{fmtTime(s.t)} </Text>
378 <Text color={fillColor(s.percent)}>{bar(s.tokens / Math.max(1, maxTokens), barW)} </Text>
379 <Text bold={isSpike}>{padStart(fmtK(s.tokens), 7)} </Text>
380 <Text color={dc} dimColor={dc === undefined} bold={isSpike}>
381 {padStart(before ? fmtDelta(delta) : '', 7)}{' '}
382 </Text>
383 <Text dimColor>{padEnd(s.kind === 'compact' ? 'cmp' : `T${s.turn}`, 4)} </Text>
384 <Text color={s.kind === 'compact' ? COMPACT_BLUE : isSpike ? SPIKE_RED : undefined} wrap="truncate-end">
385 {isSpike ? '▲ ' : ''}{label.slice(0, labelW)}
386 </Text>
387 </Box>
388 )
389 })}
390 </Box>
391 )
392 }
393
394 // ------------------------------------------------ skills view
395 function drawSkills() {
396 if (events.length === 0) return <Text dimColor>No skill invoked yet.</Text>
397 type Agg = { skill: string; runs: number; prompt: number; growth: number; max: number; last: number }
398 const byName = new Map<string, Agg>()
399 for (const ev of events) {
400 const growth = ev.tokensAfter === null ? 0 : ev.tokensAfter - ev.tokensBefore
401 const a = byName.get(ev.skill) ?? { skill: ev.skill, runs: 0, prompt: 0, growth: 0, max: 0, last: 0 }
402 a.runs += 1
403 a.prompt += ev.promptTokens
404 a.growth += growth
405 a.max = Math.max(a.max, growth)
406 a.last = Math.max(a.last, ev.t)
407 byName.set(ev.skill, a)
408 }
409 const aggs = [...byName.values()].sort((a, b) => b.growth - a.growth)
410 const nameW = Math.min(24, Math.max(6, ...aggs.map(a => a.skill.length)))
411 const maxGrowth = Math.max(1, ...aggs.map(a => a.growth))
412 const barW = Math.max(8, Math.min(20, width - nameW - 40))
413 const recent = events.slice(-Math.max(5, (e.viewport?.rows ?? 40) - aggs.length - 12)).reverse()
414 return (
415 <Box flexDirection="column">
416 <Text dimColor>
417 {padEnd('skill', nameW)} {padStart('runs', 4)} {padStart('prompt≈', 8)} {padStart('growth', 8)} {padStart('max', 7)} share of growth
418 </Text>
419 {aggs.map(a => (
420 <Box>
421 <Text bold>{padEnd(a.skill, nameW)} </Text>
422 <Text>{padStart(String(a.runs), 4)} </Text>
423 <Text dimColor>{padStart(fmtK(a.prompt), 8)} </Text>
424 <Text color={deltaColor(a.max, window)}>{padStart(fmtDelta(a.growth), 8)} </Text>
425 <Text color={deltaColor(a.max, window)}>{padStart(fmtDelta(a.max), 7)} </Text>
426 <Text color={deltaColor(a.max, window) ?? CALM_GREEN}>{bar(a.growth / maxGrowth, barW)}</Text>
427 </Box>
428 ))}
429 <Text> </Text>
430 <Text dimColor>recent invocations (turn growth = context after the turn minus before the skill ran)</Text>
431 {recent.map(ev => {
432 const growth = ev.tokensAfter === null ? null : ev.tokensAfter - ev.tokensBefore
433 const dc = growth === null ? undefined : deltaColor(growth, window)
434 return (
435 <Box>
436 <Text dimColor>{fmtTime(ev.t)} </Text>
437 <Text dimColor>T{String(ev.turn).padEnd(3)} </Text>
438 <Text bold>{padEnd('/' + ev.skill, nameW + 1)} </Text>
439 <Text dimColor>prompt≈{padStart(fmtK(ev.promptTokens), 6)} </Text>
440 <Text color={dc} dimColor={dc === undefined}>
441 {growth === null ? 'pending' : `turn ${fmtDelta(growth)}`}
442 </Text>
443 <Text dimColor> ({fmtK(ev.tokensBefore)} → {ev.tokensAfter === null ? '…' : fmtK(ev.tokensAfter)})</Text>
444 </Box>
445 )
446 })}
447 </Box>
448 )
449 }
450 })
451}
452types/index.d.ts 63 lines1export type SampleKind = 'turn' | 'measure' | 'compact'
2
3/** One point on the context timeline. */
4export type Sample = {
5 /** When it was taken, ms since the epoch. */
6 t: number
7 /** Context tokens the next request re-sends (input + cache read + cache write). */
8 tokens: number
9 /** `tokens` over the model's window, whole percent. */
10 percent: number
11 kind: SampleKind
12 /** Main-thread turn number the sample belongs to (0 before the first). */
13 turn: number
14 /** Tool calls made in that turn, by tool name. */
15 tools: Record<string, number>
16 /** Skills invoked in that turn. */
17 skills: string[]
18 /** Wall-clock length of the turn, when known. */
19 durationMs?: number
20 /** For a compaction: the size it came down from. */
21 fromTokens?: number
22}
23
24export type Category = {
25 name: string
26 tokens: number
27 kind: 'used' | 'free' | 'buffer' | 'deferred'
28}
29
30export type Breakdown = {
31 at: number
32 total: number
33 max: number
34 percent: number
35 categories: Category[]
36}
37
38/** One skill invocation and the growth attributed to it. */
39export type SkillEvent = {
40 t: number
41 skill: string
42 turn: number
43 /** Estimated tokens of the skill's own prompt text. */
44 promptTokens: number
45 /** Context size at the last sample before the skill ran. */
46 tokensBefore: number
47 /** Context size at the first sample after it; null until measured. */
48 tokensAfter: number | null
49}
50
51export type View = 'categories' | 'timeline' | 'skills'
52
53declare module 'claude-code' {
54 interface PluginState {
55 'context-tracker': {
56 samples: Sample[]
57 breakdown: Breakdown | null
58 skillEvents: SkillEvent[]
59 view: View
60 }
61 }
62}
63