SLOPSHOPPER

session-meter

Context, cost and rate-limit meter beside the chat; warns when the session gets too expensive and hands it over to a fresh one.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-meter
│ ┃ Session meter ✕ › fix the failing auth test and add an audit log call │ ┃ ○ Healthy │ ┃ claude-opus-5-5 · 1 turns · 30m ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Context 97K / 200K (49%) ⏺ Update(src/auth.ts) │ ┃ Spent $0.42 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ AGAINST YOUR LIMITS ⎿ 3 pass, 1 fail │ ┃ Context 97K of 250K │ ┃ ███████████░░░░░░░░░░░░░░░░░ 39% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Cost per turn $0.00 of $3.00 │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ✻ Worked for 42s · done 4:20 PM │ ┃ Session total $0.42 of $60.00 │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1% › /meter │ ┃ ⎿ session-meter: ``` │ ┃ PLAN LIMITS ⎿ session-meter: 🟢 Healthy · claude-opus-5-5 · 1 turns · 30m │ ┃ 5-hour · ⎿ session-meter: │ ┃ █████████░░░░░░░░░░░░░░░░░░░ 31% ⎿ session-meter: Context window 97K of 200K (49%) │ ┃ ⎿ session-meter: Spent so far $0.42 │ ┃ [ Continue in a new session ] ⎿ session-meter: │ ┃ │ ┃ Limits: 250K context · $3.00/turn · $60.00 │ ┃ total. Change with /meter limits. │ ○ context 97K (49%) · spent $0.42 · 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ context 97K (49%) · spent $0.42 · 5h 31%
Pane · Session meter
○ Healthy claude-opus-5-5 · 1 turns · 30m Context 97K / 200K (49%) Spent $0.42 AGAINST YOUR LIMITS Context 97K of 250K ███████████░░░░░░░░░░░░░░░░░ 39% Cost per turn $0.00 of $3.00 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% Session total $0.42 of $60.00 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 1% PLAN LIMITS 5-hour · █████████░░░░░░░░░░░░░░░░░░░ 31% [ Continue in a new session ] Limits: 250K context · $3.00/turn · $60.00 total. Change with /meter limits.
README

session-meter

A Claude Code mod that shows how heavy the session has become, and moves the work to a fresh session when it gets too expensive to keep going.

Where it shows. The claude.ai web and mobile views of a cloud session attach no surface for mods, so no pane or band can draw there (checked 22 Oct: $.session.surfaces() was empty). There:

  • /meter prints the whole meter as text in the chat;
  • the alert comes through the conversation: when a limit is crossed, Claude says so in one line at the start of its next reply and points to /meter handover.

In the Claude Code desktop app, the terminal or an IDE, it also draws a pane beside the chat and a summary line above the prompt.

  • Context: tokens re-read on every turn, against the model's window (ring gauge on desktop and mobile, text in the terminal).
  • Spend: session total, cost of each turn (sparkline), and the average of the last three turns.
  • Plan limits: the 5-hour and weekly windows with their reset times, when the plan reports them.
  • Limits: context 250K tokens, $3 per turn and $60 per session by default. At 70% of a limit it shows a toast; at 100% it sends a notification, adds a band above the prompt and puts the handover button first. Change the limits with /meter limits <contextK> <turnUsd> <totalUsd>; they are remembered across sessions.
  • Continue in a new session (also /meter handover): asks the model to write a handover (the repo's own convention if it has one), commit and push it, then open a new cloud session that starts from it. The pane then links to the new session. Without the cloud session tools, it tells you to /clear and paste one line.

Dollar figures are the session ledger's list-price estimates; on a subscription they measure how fast the limits go.

Install

This repo is a plugin marketplace (.claude-plugin/marketplace.json), and .claude/settings.json declares it and turns the mod on for anyone who opens the project (Claude Code asks once to trust the marketplace).

At the prompt of a terminal session:

/plugin install session-meter --marketplace DastanZar/Autonomous_Game_Studio_Agent

For every cloud session, add these lines to the cloud environment's setup script (environment menu in the session's title bar → Edit → Setup script):

claude plugin marketplace add DastanZar/Autonomous_Game_Studio_Agent
claude plugin install session-meter@studio-mods
claude plugin marketplace update studio-mods
claude plugin update session-meter@studio-mods

The environment caches the setup result, so a new plugin version reaches sessions only after the setup script is edited (any edit forces a fresh run).

While developing it, run claude --plugin-dir .claude/mods/session-meter. Check it with claude plugin validate and claude plugin test in this folder.

Source 2 files
hooks/register.tsx 545 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Handover, Level, Limits, Orchestration, Snapshot } from '../types'
5
6const PANE = 'session-meter'
7const TITLE = 'Session meter'
8const DEFAULT_LIMITS: Limits = { contextK: 250, turnUsd: 3, totalUsd: 60 }
9
10const snap = atom({ plugin: 'session-meter', key: 'snap' } as const, null)
11const turnCosts = atom({ plugin: 'session-meter', key: 'turnCosts' } as const, [])
12const limits = atom({ plugin: 'session-meter', key: 'limits' } as const, DEFAULT_LIMITS)
13const alerted = atom({ plugin: 'session-meter', key: 'alerted' } as const, 'calm')
14const handover = atom({ plugin: 'session-meter', key: 'handover' } as const, { status: 'idle' })
15const lastUsd = atom({ plugin: 'session-meter', key: 'lastUsd' } as const, -1)
16const announced = atom({ plugin: 'session-meter', key: 'announced' } as const, 'calm')
17const panelVersion = atom({ plugin: 'session-meter', key: 'panelVersion' } as const, 0)
18const handledRequest = atom({ plugin: 'session-meter', key: 'handledRequest' } as const, 0)
19const sessionTitle = atom({ plugin: 'session-meter', key: 'sessionTitle' } as const, '')
20const isBandHidden = atom({ plugin: 'session-meter', key: 'isBandHidden' } as const, false)
21
22const NO_ORCH: Orchestration = { reads: 0, denied: 0, agents: {}, edited: [] }
23const orch = atom({ plugin: 'session-meter', key: 'orchestration' } as const, NO_ORCH)
24
25// Reading the main thread may do per user turn before it must hand the rest to the Explore subagent.
26const READS_PER_TURN = 3
27const EXPLORE_DENY = 'Delegate this reading to the Explore subagent (Haiku) and keep only its summary; see docs/orchestration.md'
28const WEB_DENY = 'Send web lookups to the researcher subagent (Haiku)'
29const AGENT_MODEL: Record<string, string> = { Explore: 'haiku', researcher: 'haiku', worker: 'sonnet' }
30const READ_ONLY_BASH = /^\s*(cat|grep|rg|find|ls|head|tail|sed\s+-n|wc|tree|stat|file)\b(?!.*(?:>|\|\s*(?:tee|sh|bash)\b|-exec|-delete))/
31const ALWAYS_OK = /(^|\/)\.claude\/|(^|\/)docs\/orchestration\.md$/
32
33const COLORS = { calm: '#3fb68b', warm: '#e0a03a', over: '#e5534b', track: '#8b8b8b33', ink: '#9aa0a6' }
34
35const HANDOVER_PROMPT = `This session has become expensive to keep running (its context is re-read on every turn). Hand it over to a fresh session:
36
371. Write a handover note: the goal, what is done (with commit hashes and file paths), decisions made and why, what is in progress, the exact next step, open questions, and anything the next session must not redo. Keep it under 150 lines. If the repository already has a handover file or convention (for example docs/HANDOVER.md, or a closeout step in AGENTS.md), use it; otherwise write docs/handover/<YYYY-MM-DD>-<topic>.md (or a scratchpad file if there is no repository). Follow the repository's own rules (logs, decision records) as usual.
382. Commit and push it, with any finished work, to the current branch.
393. If the create_session tool (claude-code-remote) is available, create a new session on the same repository and branch, with the same model, whose prompt is: "Read <handover path> on branch <branch> and continue from its next step." Reply with the new session's id and its link (https://claude.ai/code/<session id>).
40   If it is not available, tell me to run /clear and then send: "Read <handover path> and continue from its next step."
414. Then stop: do no further work in this session.`
42
43const fmtK = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}K` : `${n}`)
44const fmtUsd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : `$${n.toFixed(2)}`)
45const worstOf = <T extends { ratio: number }>(ps: T[]) => ps.reduce((a, b) => (b.ratio > a.ratio ? b : a))
46const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0)
47
48function fmtAge(ms: number) {
49  const m = Math.max(0, Math.round(ms / 60000))
50  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
51}
52
53function fmtReset(iso: string | undefined, now: number) {
54  if (!iso) return ''
55  const left = Date.parse(iso) - now
56  return Number.isFinite(left) ? `resets in ${fmtAge(left)}` : ''
57}
58
59// The three pressures, each as a fraction of its limit. The worst one sets the level.
60function pressures(s: Snapshot, costs: number[], l: Limits) {
61  const recent = avg(costs.slice(-3))
62  return [
63    { id: 'context', label: 'Context', ratio: s.tokens / (l.contextK * 1000), detail: `${fmtK(s.tokens)} of ${l.contextK}K` },
64    { id: 'turn', label: 'Cost per turn', ratio: costs.length ? recent / l.turnUsd : 0, detail: `${fmtUsd(recent)} of ${fmtUsd(l.turnUsd)}` },
65    { id: 'total', label: 'Session total', ratio: s.usd / l.totalUsd, detail: `${fmtUsd(s.usd)} of ${fmtUsd(l.totalUsd)}` },
66  ]
67}
68
69function levelOf(ratio: number): Level {
70  return ratio >= 1 ? 'over' : ratio >= 0.7 ? 'warm' : 'calm'
71}
72
73function bar(ratio: number, width: number) {
74  const filled = Math.max(0, Math.min(width, Math.round(Math.min(1, ratio) * width)))
75  return '█'.repeat(filled) + '░'.repeat(width - filled)
76}
77
78function ringSvg(percent: number, level: Level, center: string, sub: string) {
79  const r = 46
80  const c = 2 * Math.PI * r
81  const dash = (Math.min(100, percent) / 100) * c
82  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
83<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS.track}" stroke-width="10"/>
84<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS[level]}" stroke-width="10" stroke-linecap="round"
85 stroke-dasharray="${dash.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 60 60)"/>
86<text x="60" y="60" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="600" fill="${COLORS[level]}">${center}</text>
87<text x="60" y="80" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="${COLORS.ink}">${sub}</text>
88</svg>`
89}
90
91function sparkSvg(costs: number[], cap: number) {
92  const w = 240
93  const h = 44
94  const xs = costs.slice(-24)
95  if (xs.length < 2) return ''
96  const top = Math.max(cap, ...xs) * 1.1
97  const pts = xs.map((v, i) => `${((i / (xs.length - 1)) * (w - 4) + 2).toFixed(1)},${(h - 2 - (v / top) * (h - 6)).toFixed(1)}`)
98  const capY = (h - 2 - (cap / top) * (h - 6)).toFixed(1)
99  const last = xs[xs.length - 1] ?? 0
100  const [lx, ly] = (pts[pts.length - 1] ?? '0,0').split(',')
101  const color = COLORS[levelOf(avg(xs.slice(-3)) / cap)]
102  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
103<line x1="0" x2="${w}" y1="${capY}" y2="${capY}" stroke="${COLORS.over}" stroke-opacity="0.45" stroke-dasharray="3 3"/>
104<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
105<circle cx="${lx}" cy="${ly}" r="3" fill="${color}"><title>${fmtUsd(last)}</title></circle>
106</svg>`
107}
108
109async function measure($: EngineInterface) {
110  const u = await $.session.usage()
111  const next: Snapshot = {
112    tokens: u.context.tokens ?? 0,
113    window: u.context.window,
114    percent: u.context.percent ?? 0,
115    usd: u.cost?.usd ?? 0,
116    rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
117    startedAt: u.startedAt,
118    model: await $.session.model(),
119    turns: await $.session.turns(),
120  }
121  await update($, snap, () => next)
122  return next
123}
124
125async function judge($: EngineInterface, s: Snapshot) {
126  const l = await read($, limits)
127  const costs = await read($, turnCosts)
128  const worst = worstOf(pressures(s, costs, l))
129  const level = levelOf(worst.ratio)
130  const before = await read($, alerted)
131  if (level === before) return
132  await update($, alerted, () => level)
133  $.ui.status(level === 'calm' ? undefined : `${level === 'over' ? '●' : '◐'} ${worst.label} ${Math.round(worst.ratio * 100)}%`)
134  if (level === 'over' && before !== 'over') {
135    await update($, isBandHidden, () => false)
136    void $.ui.notify(`${worst.label} is past its limit (${worst.detail}). Hand over to a fresh session to keep turns cheap.`, {
137      title: 'Session getting expensive',
138    }).catch(() => undefined)
139  } else if (level === 'warm' && before === 'calm') {
140    $.ui.toast(`Session meter: ${worst.label.toLowerCase()} at ${Math.round(worst.ratio * 100)}% of its limit`)
141  }
142}
143
144async function startHandover($: EngineInterface) {
145  if ((await read($, handover)).status === 'writing') return
146  await update($, handover, (): Handover => ({ status: 'writing' }))
147  $.ui.toast('Writing the handover; a new session follows')
148  await $.prompt.submit({ text: HANDOVER_PROMPT })
149}
150
151// The meter as text: what /meter prints, for clients that draw no pane (the claude.ai web and mobile views).
152async function report($: EngineInterface) {
153  const s = await read($, snap)
154  if (!s) return 'Session meter: no response measured yet.'
155  const l = await read($, limits)
156  const costs = await read($, turnCosts)
157  const now = await $.clock.now()
158  const ps = pressures(s, costs, l)
159  const level = levelOf(worstOf(ps).ratio)
160  const icon = (lv: Level) => (lv === 'over' ? '🔴' : lv === 'warm' ? '🟡' : '🟢')
161  const verdict = level === 'over' ? 'Too expensive to keep going' : level === 'warm' ? 'Getting heavy' : 'Healthy'
162  const row = (label: string, ratio: number, detail: string) =>
163    `${icon(levelOf(ratio))} ${label.padEnd(14)} ${bar(ratio, 20)} ${String(Math.round(ratio * 100)).padStart(3)}%  ${detail}`
164  const lines = [
165    `${icon(level)} ${verdict}  ·  ${s.model} · ${s.turns} turns · ${fmtAge(now - s.startedAt)}`,
166    '',
167    `Context window   ${fmtK(s.tokens)} of ${fmtK(s.window)} (${s.percent}%)`,
168    `Spent so far     ${fmtUsd(s.usd)}${costs.length ? `  ·  last turn ${fmtUsd(costs[costs.length - 1] ?? 0)}  ·  average ${fmtUsd(avg(costs))}` : ''}`,
169    costs.length >= 2
170      ? `Cost per turn    ${costs.slice(-30).map(c => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((c / Math.max(l.turnUsd, ...costs)) * 8))]).join('')}`
171      : '',
172    '',
173    'Against your limits',
174    ...ps.map(p => row(p.label, p.ratio, p.detail)),
175    ...(s.rateLimits.length
176      ? ['', 'Plan limits', ...s.rateLimits.map(r =>
177          row(r.kind === 'five_hour' ? '5-hour' : r.kind === 'seven_day' ? 'Weekly' : r.kind, r.percentUsed / 100, fmtReset(r.resetsAt, now)))]
178      : []),
179    '',
180    level === 'over'
181      ? 'Run /meter handover to write the handover and continue in a new session.'
182      : 'Commands: /meter handover · /meter limits <contextK> <turnUsd> <totalUsd>',
183  ].filter((x, i, a) => !(x === '' && a[i - 1] === ''))
184  return '```\n' + lines.join('\n') + '\n```'
185}
186
187const PANEL = 'https://claude.ai/artifact/DJi4GZJYZrKox92i9cqdCx'
188const PANEL_LOG = '/tmp/claude-0/-home-user/a0c53fe4-4e70-5b3c-a4fb-23fcabbfce94/scratchpad/meter-diag/panel.json'
189
190// Reports this session's figures to the Session Meter page (its "sessions" collection), which claude.ai shows beside the chat.
191async function report2panel($: EngineInterface, s: Snapshot) {
192  const cse = await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID')
193  const id = cse ? cse.replace(/^cse_/, 'session_') : `local_${await $.session.id()}`
194  const five = s.rateLimits.find(r => r.kind === 'five_hour') ?? s.rateLimits[0]
195  if (!(await read($, sessionTitle))) {
196    // The session's own title, as the claude.ai sidebar shows it.
197    const got = await $.mcp.call('claude-code-remote', 'get_session', {}).catch(() => null)
198    const title = JSON.stringify(got ?? '').match(/\\?"title\\?"\s*:\s*\\?"((?:[^"\\]|\\.)*?)\\?"/)?.[1]
199    if (title) await update($, sessionTitle, () => title.replace(/\\(.)/g, '$1'))
200  }
201  const data = {
202    title: (await read($, sessionTitle)) || (await $.session.cwd()).split('/').pop() || 'Claude Code session',
203    model: s.model, tokens: s.tokens, window: s.window, usd: s.usd, turns: s.turns,
204    updated: await $.clock.now(), startedAt: s.startedAt,
205    rate: five ? { kind: five.kind, percentUsed: five.percentUsed, status: five.percentUsed >= 100 ? 'limited' : 'allowed', resetsAt: five.resetsAt ? Date.parse(five.resetsAt) / 1000 : 0 } : null,
206  }
207  const send = (version: number) =>
208    $.tool.call({ tool: 'ArtifactData', action: 'set', url: PANEL, collection: 'sessions', doc_id: id, data, ...(version > 0 ? { if_version: version } : {}) } as never)
209  const versionIn = (r: { text?: string; result?: unknown }) => Number((r.text ?? JSON.stringify(r.result ?? '')).match(/version\\?"?\s*:?\s*(\d+)/)?.[1] ?? 0)
210  let ran = await send(await read($, panelVersion))
211  if (ran.deny || ran.isError) {
212    // Unknown or stale version: read the document's current one and try once more.
213    const got = await $.tool.call({ tool: 'ArtifactData', action: 'get', url: PANEL, collection: 'sessions', doc_id: id } as never)
214    ran = await send(versionIn(got))
215  }
216  const version = versionIn(ran)
217  if (version) await update($, panelVersion, () => version)
218  await $.fs.write(PANEL_LOG, JSON.stringify({ at: data.updated, id, ok: !ran.deny && !ran.isError, deny: ran.deny, text: ran.text?.slice(0, 400) }, null, 2)).catch(() => undefined)
219}
220
221// The page's "Continue in a new session" button leaves {handover: <ms>} in requests/<session id>; a session picks it up here.
222async function checkHandover($: EngineInterface) {
223  const cse = await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID')
224  if (!cse) return
225  const doc_id = cse.replace(/^cse_/, 'session_')
226  const got = await $.tool.call({ tool: 'ArtifactData', action: 'get', url: PANEL, collection: 'requests', doc_id } as never)
227  if (got.deny || got.isError) return
228  const body = got.text ?? JSON.stringify(got.result ?? '')
229  const asked = Number(body.match(/handover\\?"?\s*:\s*(\d+)/)?.[1] ?? 0)
230  if (!asked) return
231  if (asked > (await read($, handledRequest))) {
232    await update($, handledRequest, () => asked)
233    // Consume the request so a later session start does not repeat it; if_version keeps a newer request safe.
234    const version = Number(body.match(/version\\?"?\s*:?\s*(\d+)/)?.[1] ?? 0)
235    await $.tool.call({ tool: 'ArtifactData', action: 'delete', url: PANEL, collection: 'requests', doc_id, ...(version > 0 ? { if_version: version } : {}) } as never).catch(() => undefined)
236    await startHandover($)
237  }
238}
239
240// Counts for /meter orchestration live in $.store, keyed by session, so the command reads them whichever loop wrote them.
241async function bump($: EngineInterface, field: 'denied' | 'agents', key?: string) {
242  const k = `orch:${await $.session.id()}`
243  const cur = ((await $.store.get(k)) as { denied: number; agents: Record<string, number> } | undefined) ?? { denied: 0, agents: {} }
244  if (field === 'denied') cur.denied += 1
245  else if (key) cur.agents[key] = (cur.agents[key] ?? 0) + 1
246  await $.store.set(k, cur)
247}
248
249async function orchestrationText($: EngineInterface) {
250  const o = ((await $.store.get(`orch:${await $.session.id()}`)) as { denied: number; agents: Record<string, number> } | undefined) ?? { denied: 0, agents: {} }
251  const by = Object.entries(o.agents).map(([m, n]) => `${m} ${n}`).join(', ') || 'none'
252  return `Subagent calls this session: ${by}. Main-thread reads denied (sent to Haiku): ${o.denied}.`
253}
254
255const isCostlyMain = (model: string) => /opus|fable/i.test(model)
256
257export const register: Register = on => {
258  on('session.start', async ($, e, next) => {
259    const stored = (await $.store.get('limits')) as Limits | undefined
260    if (stored) await update($, limits, () => ({ ...DEFAULT_LIMITS, ...stored }))
261    await $.command.register({
262      name: 'meter',
263      description: 'Session meter: show the figures, set limits (/meter limits <contextK> <turnUsd> <totalUsd>), or hand over now (/meter handover)',
264    })
265    await $.command.register({ name: 'orchestration', description: 'Delegation check: subagent calls by model and calls denied this session' })
266    const first = await measure($)
267    if ((await read($, lastUsd)) < 0) await update($, lastUsd, () => first.usd)
268    void $.ui.open({ id: PANE, title: TITLE })
269    void checkHandover($).catch(() => undefined)
270    $.clock.every(60_000, () => void checkHandover($).catch(() => undefined))
271    return next(e)
272  })
273
274  // A surface that attaches later (desktop app, terminal, IDE) gets the pane.
275  on('session.attach', async ($, e, next) => {
276    const done = await next(e)
277    void $.ui.open({ id: PANE, title: TITLE })
278    return done
279  })
280
281  // No pane reaches the web and mobile views, so the alert goes through the conversation: once per level, Claude says it.
282  on('prompt.submit', async ($, e, next) => {
283    if (e.origin.kind === 'plugin') return next(e)
284    await update($, orch, o => ({ ...o, reads: 0 }))
285    void checkHandover($).catch(() => undefined)
286    const level = await read($, alerted)
287    const said = await read($, announced)
288    if (level === 'calm' || level === said || (level === 'warm' && said === 'over')) return next(e)
289    await update($, announced, () => level)
290    const s = await read($, snap)
291    const note =
292      level === 'over'
293        ? `[session-meter] This session is past its cost limits (${s ? `${fmtK(s.tokens)} context, ${fmtUsd(s.usd)} spent` : ''}). Before anything else in your reply, tell the user in one line that the session has become expensive to continue, and that /meter shows the figures and /meter handover moves the work to a fresh session. Then answer their message as usual.`
294        : `[session-meter] This session is at 70% of a cost limit. At the end of your reply, add one short line telling the user, and that /meter shows the figures.`
295    return next({ ...e, context: [...(e.context ?? []), note] })
296  })
297
298  on('session.measure', async ($, e, next) => {
299    const s = await measure($)
300    await judge($, s)
301    return next(e)
302  })
303
304  on('turn.complete', async ($, e, next) => {
305    const done = await next(e)
306    const s = await measure($)
307    const before = await read($, lastUsd)
308    if (before >= 0 && s.usd > before) await update($, turnCosts, xs => [...xs, s.usd - before].slice(-48))
309    await update($, lastUsd, () => s.usd)
310    await judge($, s)
311    await report2panel($, s).catch(() => undefined)
312    return done
313  })
314
315  on('tool.call', { tool: 'mcp__claude-code-remote__create_session' }, async ($, e, next) => {
316    const ran = await next(e)
317    if ((await read($, handover)).status === 'writing' && !ran.isError && !ran.deny) {
318      const id = JSON.stringify(ran.result ?? ran.text ?? '').match(/session_[A-Za-z0-9]+/)?.[0]
319      if (id) {
320        await update($, handover, (): Handover => ({ status: 'ready', sessionId: id }))
321        void $.ui.notify('The new session is ready with the handover. Open it from the session meter.', { title: 'Handover done' }).catch(() => undefined)
322      }
323    }
324    return ran
325  })
326
327  // Delegation, enforced: on an Opus or Fable main thread, web lookups and reading past the third call of a turn go to Haiku.
328  on('tool.call', async ($, e, next) => {
329    if (e.agentId) return next(e)
330    const tool = e.tool
331    if (tool === 'Agent') {
332      const key = e.model ?? AGENT_MODEL[e.subagent_type ?? ''] ?? 'inherit'
333      await bump($, 'agents', key)
334      return next(e)
335    }
336    if (tool === 'Edit' || tool === 'Write') {
337      await update($, orch, o => ({ ...o, edited: [...o.edited, e.file_path].slice(-50) }))
338      return next(e)
339    }
340    const isWeb = tool === 'WebSearch' || tool === 'WebFetch'
341    const isRead =
342      tool === 'Read' || tool === 'Grep' || tool === 'Glob' || (tool === 'Bash' && READ_ONLY_BASH.test(e.command))
343    if (!isWeb && !isRead) return next(e)
344    if (!isCostlyMain(await $.session.model())) return next(e)
345    const deny = async (reason: string) => {
346      await bump($, 'denied')
347      return { deny: reason }
348    }
349    if (isWeb) return deny(WEB_DENY)
350    const path = tool === 'Read' ? e.file_path : tool === 'Bash' ? e.command : (e.path ?? '')
351    const state = await read($, orch)
352    if (ALWAYS_OK.test(path) || state.edited.includes(path)) return next(e)
353    if (state.reads >= READS_PER_TURN) return deny(EXPLORE_DENY)
354    await update($, orch, o => ({ ...o, reads: o.reads + 1 }))
355    return next(e)
356  })
357
358  on('command.run', { command: 'orchestration' }, async $ => ({ text: await orchestrationText($) }))
359
360  on('command.run', { command: 'meter' }, async ($, e) => {
361    const [verb, ...rest] = (e.args ?? '').trim().split(/\s+/)
362    if (verb === 'orchestration') return { text: await orchestrationText($) }
363    if (verb === 'handover') {
364      await startHandover($)
365      return { text: 'Handover started.' }
366    }
367    if (verb === 'limits') {
368      const [contextK = NaN, turnUsd = NaN, totalUsd = NaN] = rest.map(Number)
369      if (![contextK, turnUsd, totalUsd].every(n => Number.isFinite(n) && n > 0)) {
370        return { text: 'Usage: /meter limits <contextK> <turnUsd> <totalUsd>, e.g. /meter limits 250 3 60' }
371      }
372      const l = { contextK, turnUsd, totalUsd }
373      await update($, limits, () => l)
374      await $.store.set('limits', l)
375      await judge($, await measure($))
376      return { text: `Limits set: context ${contextK}K tokens, ${fmtUsd(turnUsd)} per turn, ${fmtUsd(totalUsd)} per session.` }
377    }
378    void $.ui.open({ id: PANE, title: TITLE, focus: true })
379    return { text: await report($) }
380  })
381
382  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
383    const { Box, Text, Button } = $.ui.resolve(e)
384    const s = await read($, snap)
385    if (!s) return <Text dimColor>Waiting for the first response…</Text>
386    const l = await read($, limits)
387    const costs = await read($, turnCosts)
388    const ho = await read($, handover)
389    const now = await $.clock.now()
390    const ps = pressures(s, costs, l)
391    const worst = worstOf(ps)
392    const level = levelOf(worst.ratio)
393    const cols = Math.max(24, e.props.bodyColumns ?? 40)
394    const barW = Math.max(10, Math.min(28, cols - 18))
395    const isRich = e.surface !== 'terminal'
396    const els = $.ui.resolve(e)
397    const Svg = 'Svg' in els ? els.Svg : null
398    const Link = els.Link
399    const verdict = level === 'over' ? 'Too expensive to keep going' : level === 'warm' ? 'Getting heavy' : 'Healthy'
400
401    return (
402      <Box flexDirection="column" gap={1} paddingX={1}>
403        <Box flexDirection="column">
404          <Text size="title" weight="semibold" bold color={COLORS[level]}>
405            {level === 'over' ? '●' : level === 'warm' ? '◐' : '○'} {verdict}
406          </Text>
407          <Text dimColor size="footnote">
408            {s.model} · {s.turns} turns · {fmtAge(now - s.startedAt)}
409          </Text>
410        </Box>
411
412        {isRich && Svg ? (
413          <Box flexDirection="row" gap={2} alignItems="center">
414            <Svg
415              source={ringSvg(s.percent, levelOf(s.tokens / (l.contextK * 1000)), `${s.percent}%`, `of ${fmtK(s.window)}`)}
416              alt={`Context window ${s.percent}% full`}
417              width={120}
418              height={120}
419            />
420            <Box flexDirection="column">
421              <Text size="caption" dimColor>CONTEXT</Text>
422              <Text size="heading" weight="semibold" tabular>{fmtK(s.tokens)}</Text>
423              <Text size="caption" dimColor>SPENT</Text>
424              <Text size="heading" weight="semibold" tabular>{fmtUsd(s.usd)}</Text>
425            </Box>
426          </Box>
427        ) : (
428          <Box flexDirection="column">
429            <Text>
430              <Text dimColor>Context </Text>
431              <Text bold>{fmtK(s.tokens)}</Text>
432              <Text dimColor> / {fmtK(s.window)} ({s.percent}%)</Text>
433            </Text>
434            <Text>
435              <Text dimColor>Spent   </Text>
436              <Text bold>{fmtUsd(s.usd)}</Text>
437            </Text>
438          </Box>
439        )}
440
441        <Box flexDirection="column">
442          <Text size="caption" dimColor>AGAINST YOUR LIMITS</Text>
443          {ps.map(p => (
444            <Box key={p.id} flexDirection="column">
445              <Text>
446                <Text>{p.label.padEnd(14)}</Text>
447                <Text dimColor tabular>{p.detail}</Text>
448              </Text>
449              <Text color={COLORS[levelOf(p.ratio)]} mono>
450                {bar(p.ratio, barW)} {Math.round(p.ratio * 100)}%
451              </Text>
452            </Box>
453          ))}
454        </Box>
455
456        {costs.length >= 2 && (
457          <Box flexDirection="column">
458            <Text size="caption" dimColor>
459              COST PER TURN · last {fmtUsd(costs[costs.length - 1] ?? 0)} · avg {fmtUsd(avg(costs))}
460            </Text>
461            {isRich && Svg ? (
462              <Svg source={sparkSvg(costs, l.turnUsd)} alt="Cost of each recent turn" width={240} height={44} />
463            ) : (
464              <Text color={COLORS[levelOf(avg(costs.slice(-3)) / l.turnUsd)]} mono>
465                {costs
466                  .slice(-barW)
467                  .map(c => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((c / Math.max(l.turnUsd, ...costs)) * 8))])
468                  .join('')}
469              </Text>
470            )}
471          </Box>
472        )}
473
474        {s.rateLimits.length > 0 && (
475          <Box flexDirection="column">
476            <Text size="caption" dimColor>PLAN LIMITS</Text>
477            {s.rateLimits.map(r => (
478              <Box key={r.kind} flexDirection="column">
479                <Text>
480                  <Text>{r.kind === 'five_hour' ? '5-hour' : r.kind === 'seven_day' ? 'Weekly' : r.kind}</Text>
481                  <Text dimColor> · {fmtReset(r.resetsAt, now)}</Text>
482                </Text>
483                <Text color={COLORS[levelOf(r.percentUsed / 100)]} mono>
484                  {bar(r.percentUsed / 100, barW)} {r.percentUsed}%
485                </Text>
486              </Box>
487            ))}
488          </Box>
489        )}
490
491        <Box flexDirection="column" gap={1}>
492          {ho.status === 'ready' && ho.sessionId ? (
493            <Box flexDirection="column">
494              <Text color={COLORS.calm}>Handover done. The new session has the notes.</Text>
495              <Link href={`https://claude.ai/code/${ho.sessionId}`} label="Open the new session →" />
496            </Box>
497          ) : ho.status === 'writing' ? (
498            <Text dimColor>Writing the handover…</Text>
499          ) : (
500            <Button
501              key="handover"
502              label="Continue in a new session"
503              variant={level === 'over' ? 'primary' : 'secondary'}
504              onPress={() => startHandover($)}
505            />
506          )}
507          <Text dimColor size="footnote">
508            Limits: {l.contextK}K context · {fmtUsd(l.turnUsd)}/turn · {fmtUsd(l.totalUsd)} total. Change with /meter limits.
509          </Text>
510        </Box>
511      </Box>
512    )
513  })
514
515  // A one-line summary that is always on, for surfaces that seat no pane; the alert and its buttons join it past a limit.
516  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
517    const s = await read($, snap)
518    if (e.props.hasSurvey || !s) return next(e)
519    const l = await read($, limits)
520    const costs = await read($, turnCosts)
521    const ho = await read($, handover)
522    const level = levelOf(worstOf(pressures(s, costs, l)).ratio)
523    const isAlerting = level === 'over' && ho.status === 'idle' && !(await read($, isBandHidden))
524    const five = s.rateLimits.find(r => r.kind === 'five_hour')
525    const { Box, Text, Button } = $.ui.resolve(e)
526    return (
527      <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
528        <Text color={COLORS[level]}>{level === 'over' ? '●' : level === 'warm' ? '◐' : '○'}</Text>
529        <Text>
530          <Text dimColor>context </Text>
531          <Text bold>{fmtK(s.tokens)}</Text>
532          <Text dimColor> ({s.percent}%) · spent </Text>
533          <Text bold>{fmtUsd(s.usd)}</Text>
534          {costs.length > 0 && <Text dimColor> · {fmtUsd(avg(costs.slice(-3)))}/turn</Text>}
535          {five && <Text dimColor> · 5h {five.percentUsed}%</Text>}
536        </Text>
537        {isAlerting && <Text color={COLORS.over}>Too expensive to keep going.</Text>}
538        {isAlerting && <Button key="go" label="Continue in a new session" variant="primary" onPress={() => startHandover($)} />}
539        {isAlerting && <Button key="later" label="Later" onPress={() => update($, isBandHidden, () => true)} />}
540        {ho.status === 'writing' && <Text dimColor>Writing the handover…</Text>}
541      </Box>
542    )
543  })
544}
545
types/index.d.ts 43 lines
1export type Limits = { contextK: number; turnUsd: number; totalUsd: number }
2
3export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
4
5export type Snapshot = {
6  tokens: number
7  window: number
8  percent: number
9  usd: number
10  rateLimits: RateWindow[]
11  startedAt: number
12  model: string
13  turns: number
14}
15
16export type Orchestration = { reads: number; denied: number; agents: Record<string, number>; edited: string[] }
17
18export type Level = 'calm' | 'warm' | 'over'
19
20export type Handover = {
21  status: 'idle' | 'writing' | 'ready'
22  sessionId?: string
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    'session-meter': {
28      snap: Snapshot | null
29      turnCosts: number[]
30      limits: Limits
31      alerted: Level
32      handover: Handover
33      isBandHidden: boolean
34      lastUsd: number
35      announced: Level
36      panelVersion: number
37      handledRequest: number
38      sessionTitle: string
39      orchestration: Orchestration
40    }
41  }
42}
43