Claude usage pace (5-hour and weekly) above the prompt, drawn from the ctempo CLI

Draws ctempo's pace line above the prompt, for the Claude Desktop Code tab where a custom statusLine does not render. All the math is in the Python CLI (../../ctempo); this mod only:
session.measure (when rateLimits changed) and every 60 s, turns $.session.usage().rateLimits into the statusLine payload shape and runs python -I -S bin/ctempo statusline --source mod --format json through $.process.run (the interpreter, its flags and the entry come from runtime.json in the ctempo home, ~/.claude-tempo or CTEMPO_HOME, written by ctempo install: -I -S -X utf8 on Windows, -I -S when the file lists none; a ~ or relative CTEMPO_HOME is taken from your home directory, HOME, or USERPROFILE on Windows);$.state;AbovePrompt band for the surfaces listed in the CLI config (mod.surfaces, default ["desktop"]), keeping other mods' rows below its own.Each call also records a sample, so the Desktop app is a collector too.
On Windows the mod finds your home directory as Python does: USERPROFILE, else HOMEDRIVE plus HOMEPATH, never HOME (Git Bash sets one that Python ignores). CTEMPO_HOME may be C:\..., C:/..., ~\..., ~/... or Git Bash's /c/..., and \ctempo is on your home's drive. C:ctempo (relative to a drive's current folder) is refused, and so is any other path that starts with a single / (/srv/ctempo): Git Bash, which runs the terminal status line, would rewrite it to a folder under its own install. The CLI refuses both with the same messages, and the one for /srv/ctempo names the same path on your home's drive, where an older version kept it. Keep the home on a local drive: the mod usually cannot read a network (\\server\share) folder, and says so. When the pinned python.exe is gone, the band says "Python not found at <path>: run ctempo install --apply", and the mod reads runtime.json again at its next refresh. The band draws in the Desktop app's local sessions, not in WSL sessions, where plugins are not available.
Install it with the rest of ctempo (ctempo install --apply), or by hand:
claude plugin marketplace add /path/to/claude-tempo
claude plugin install ctempo@claude-tempo
On Windows: claude plugin marketplace add C:/path/to/claude-tempo; if claude rejects the path, run claude plugin marketplace add ./ from the repo folder.
Then /reload-plugins in an open session, or start a new one.
ctempo uninstall --apply removes it again, including when ctempo install only printed these commands because claude was not on PATH. It also removes a mod added without ctempo install (it finds it in your user settings); or remove that with claude plugin uninstall ctempo@claude-tempo and claude plugin marketplace remove claude-tempo.
Develop: claude plugin validate . and claude plugin test . from this folder.
hooks/register.tsx 293 lines1// ctempo band: the same pace line as the terminal statusLine, drawn above the
2// prompt where no statusLine renders (the Desktop Code tab).
3//
4// Thin on purpose: all the math lives in the Python CLI. The mod hands it the
5// session's rate limits in the statusLine payload shape and draws the
6// tone-tagged segments it prints. It is also the collector where no
7// statusLine runs: each call records a sample (source "mod").
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
11
12import type { Tone, View } from '../types'
13
14const view = atom({ plugin: 'ctempo', key: 'view' } as const, null)
15
16const RUN_TIMEOUT_MS = 10_000
17const REFRESH_MS = 60_000
18// The interpreter flags of a runtime.json written before it listed them.
19const DEFAULT_ARGS: readonly string[] = ['-I', '-S']
20
21type Runtime = { python: string; args: readonly string[]; entry: string; home: string }
22
23// Desktop paints theme keys; the terminal maps them to its palette.
24const TONE: Record<Tone, { dimColor?: boolean; color?: string }> = {
25 dim: { dimColor: true },
26 plain: {},
27 opportunity: { color: 'success' },
28 warn: { color: 'warning' },
29 alert: { color: 'error' },
30}
31
32// The statusLine stdin shape: windows keyed by kind, resets_at in epoch seconds.
33export function toPayload(sessionId: string, limits: readonly SessionRateLimit[]) {
34 const rateLimits: Record<string, { used_percentage: number; resets_at?: number }> = {}
35 for (const limit of limits) {
36 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
37 rateLimits[limit.kind] = {
38 used_percentage: limit.percentUsed,
39 ...(Number.isFinite(resetsAt) ? { resets_at: Math.floor(resetsAt / 1000) } : {}),
40 }
41 }
42 return {
43 session_id: sessionId,
44 ...(limits.length > 0 ? { rate_limits: rateLimits } : {}),
45 kinds: limits.map((limit) => limit.kind),
46 }
47}
48
49// Module state: a reload starts it over, which is what we want.
50let runtime: Runtime | null = null
51let running = false
52let timer: Timer | null = null
53let periodMs = REFRESH_MS
54// A refresh asked for while one runs: the newest limits win, null = re-read.
55let queued: readonly SessionRateLimit[] | null | undefined
56
57// The user's home as Python's os.path.expanduser finds it, and whether
58// Windows path rules apply.
59export type UserHome = { windows: boolean; home: string | undefined }
60
61// No $ API names the OS; Windows sets OS=Windows_NT system-wide. There Python
62// ignores HOME (Git Bash sets one of its own) and takes USERPROFILE, else
63// HOMEDRIVE+HOMEPATH; an empty value counts as unset.
64async function userHome($: EngineInterface): Promise<UserHome> {
65 if ((await $.env.get('OS')) !== 'Windows_NT') return { windows: false, home: await $.env.get('HOME') }
66 const profile = await $.env.get('USERPROFILE')
67 if (profile) return { windows: true, home: profile }
68 const homePath = await $.env.get('HOMEPATH')
69 return { windows: true, home: homePath ? `${(await $.env.get('HOMEDRIVE')) ?? ''}${homePath}` : undefined }
70}
71
72// One separator between the two, as os.path.join puts it.
73function join(base: string, name: string, windows: boolean): string {
74 const ends = windows ? /[\\/]$/ : /\/$/
75 return ends.test(base) ? `${base}${name}` : `${base}${windows ? '\\' : '/'}${name}`
76}
77
78// A UNC path (\\server\share): $.fs.read refuses most of them.
79function isNetwork(path: string, windows: boolean): boolean {
80 return windows && /^[\\/]{2}/.test(path)
81}
82
83// The drive of a Windows path as ntpath.splitdrive reads it: `C:`, or
84// `\\server\share`; '' when it has none.
85function driveOf(path: string | undefined): string {
86 return path?.match(/^(?:[A-Za-z]:|[\\/]{2}[^\\/]+[\\/][^\\/]+)/)?.[0] ?? ''
87}
88
89// Git Bash's spelling of a drive path: `/c/Users/x` is `C:/Users/x`, and `/d`
90// is `D:/` (a slash, one ASCII letter, then a slash or the end), as the CLI's
91// config.windows_home reads it. Anything else is returned as given.
92export function fromGitBash(path: string): string {
93 const drive = /^\/([A-Za-z])(?:\/|$)/.exec(path)?.[1]
94 return drive ? `${drive.toUpperCase()}:/${path.slice(3)}` : path
95}
96
97// The same rules as the CLI: an empty CTEMPO_HOME counts as unset, a leading
98// `~` is the user's home, and a relative path is taken from it, never from the
99// session's cwd (Claude Code copies settings.json env values as written). On
100// Windows `~\` works like `~/`, Git Bash's `/c/Users/x` is `C:/Users/x`,
101// `C:\`, `C:/` and `\\server\share` are absolute, a path rooted with a
102// backslash and no drive (`\x`) takes the drive of the user's home, as
103// Python's ntpath.join does, and `C:x` (relative to that drive's current
104// folder) is refused. So is any other path rooted with a slash and no drive
105// (`/x`): Git Bash, which runs the statusLine, rewrites it to a folder under
106// its own install before Python sees it, so the band and the line would use
107// different folders. Its message names `/x` on the home's drive, the folder
108// older versions used. The CLI refuses both with the same messages
109// (config.windows_home).
110export function resolveHome(value: string | undefined, user: UserHome): string {
111 const { windows, home } = user
112 const given = windows && value ? fromGitBash(value) : value
113 if (given && (windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2})/ : /^\//).test(given)) return given
114 if (windows && given && /^[A-Za-z]:/.test(given)) {
115 throw new Error(`CTEMPO_HOME ${given} is relative to a drive's current folder; use an absolute path`)
116 }
117 if (windows && given?.startsWith('/')) {
118 throw new Error(
119 `CTEMPO_HOME ${given} has no drive; Git Bash rewrites such a path to a folder under its own install. ` +
120 `Write it with a drive, e.g. ${driveOf(home) || 'C:'}${given}, or as ~/...`,
121 )
122 }
123 if (windows && given && /^\\/.test(given)) return `${driveOf(home)}${given}`
124 const tilde = windows ? /^~(?:[\\/]|$)/ : /^~(?:\/|$)/
125 if (given?.startsWith('~') && !tilde.test(given)) {
126 const forms = windows ? '~, ~/ and ~\\' : '~ and ~/'
127 throw new Error(`CTEMPO_HOME ${given}: only ${forms} are expanded; use an absolute path`)
128 }
129 if (!home) {
130 const name = windows ? 'USERPROFILE' : 'HOME'
131 throw new Error(`${name} is unset: set CTEMPO_HOME to an absolute path, or run \`ctempo install --apply\``)
132 }
133 if (!given) return join(home, '.claude-tempo', windows)
134 const rest = given.replace(windows ? /^~[\\/]?/ : /^~\/?/, '')
135 return rest ? join(home, rest, windows) : home
136}
137
138async function loadRuntime($: EngineInterface): Promise<Runtime> {
139 if (runtime !== null) return runtime
140 const user = await userHome($)
141 const home = resolveHome(await $.env.get('CTEMPO_HOME'), user)
142 let text: string
143 try {
144 text = await $.fs.read(join(home, 'runtime.json', user.windows))
145 } catch (error) {
146 if (!isNetwork(home, user.windows)) throw error
147 const why = error instanceof Error ? error.message : String(error)
148 throw new Error(`cannot read runtime.json from network path ${home}: set CTEMPO_HOME to a local folder (${why})`)
149 }
150 // A byte order mark (a Windows editor's) would make JSON.parse throw.
151 const parsed = JSON.parse(text.replace(/^\uFEFF/, '')) as { python?: unknown; args?: unknown; entry?: unknown }
152 if (typeof parsed.python !== 'string' || typeof parsed.entry !== 'string') {
153 throw new Error('runtime.json has no python/entry; run `ctempo install --apply`')
154 }
155 const args = parsed.args === undefined ? DEFAULT_ARGS : parsed.args
156 if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) {
157 throw new Error('runtime.json args is not a list of strings; run `ctempo install --apply`')
158 }
159 runtime = { python: parsed.python, args, entry: parsed.entry, home }
160 return runtime
161}
162
163// The CLI's mod.refresh_s, clamped; re-arms the timer when it changes.
164function rearm($: EngineInterface, seconds: unknown) {
165 const s = typeof seconds === 'number' && Number.isFinite(seconds) ? seconds : 60
166 const ms = Math.min(3600, Math.max(15, s)) * 1000
167 if (ms === periodMs || timer === null) return
168 timer.cancel()
169 periodMs = ms
170 timer = $.clock.every(periodMs, () => {
171 void refresh($)
172 })
173}
174
175async function runOnce($: EngineInterface, limits: readonly SessionRateLimit[] | null) {
176 const rt = await loadRuntime($)
177 const current = limits ?? (await $.session.usage()).rateLimits
178 const payload = toPayload(await $.session.id(), current)
179 const result = await $.process.run(
180 [rt.python, ...rt.args, rt.entry, 'statusline', '--source', 'mod', '--format', 'json'],
181 { stdin: JSON.stringify(payload), timeoutMs: RUN_TIMEOUT_MS, env: { CTEMPO_HOME: rt.home } },
182 )
183 if (result.exitCode !== 0 || result.stdout.trim() === '') {
184 // On Windows a missing interpreter does not fail to start: the spawn falls
185 // back to cmd.exe, which says only "...operable program or batch file."
186 // (exit 9009) or "The system cannot find the path specified.". Asked only
187 // after a failed run, so a Store alias exists() cannot see is not
188 // misreported while it works.
189 if (result.exitCode !== 0 && !(await $.fs.exists(rt.python).catch(() => true))) {
190 runtime = null // read runtime.json again on the next refresh, after a reinstall
191 throw new Error(`Python not found at ${rt.python}: run \`ctempo install --apply\``)
192 }
193 const lines = result.stderr.trim().split(/\r?\n/)
194 // cmd.exe's own message spans 2 lines.
195 const why = result.exitCode === 9009 ? lines.join(' ') : lines.pop()
196 throw new Error(why || `ctempo exited ${result.exitCode}`)
197 }
198 const parsed = JSON.parse(result.stdout) as Partial<View>
199 if (!Array.isArray(parsed.segments)) {
200 throw new Error(typeof parsed.line === 'string' ? parsed.line : 'unexpected output from ctempo')
201 }
202 const segments = parsed.segments
203 await update($, view, () => ({
204 line: typeof parsed.line === 'string' ? parsed.line : '',
205 segments,
206 surfaces: Array.isArray(parsed.surfaces) ? parsed.surfaces : ['desktop'],
207 refresh_s: typeof parsed.refresh_s === 'number' ? parsed.refresh_s : 60,
208 }))
209 rearm($, parsed.refresh_s)
210}
211
212async function refresh($: EngineInterface, limits: readonly SessionRateLimit[] | null = null) {
213 if (running) {
214 queued = limits ?? queued ?? null
215 return
216 }
217 running = true
218 let pending: readonly SessionRateLimit[] | null | undefined = limits
219 try {
220 while (pending !== undefined) {
221 queued = undefined
222 try {
223 await runOnce($, pending)
224 } catch (error) {
225 const message = error instanceof Error ? error.message : String(error)
226 await update($, view, (old) => ({
227 line: old?.line ?? '',
228 segments: old?.segments ?? [],
229 surfaces: old?.surfaces ?? ['desktop'],
230 refresh_s: old?.refresh_s ?? 60,
231 error: message.slice(0, 200),
232 }))
233 }
234 pending = queued
235 }
236 } finally {
237 running = false
238 }
239}
240
241export const register: Register = (on) => {
242 on('session.start', async ($, e, next) => {
243 const started = await next(e)
244 // Off the start path: the first prompt never waits for Python.
245 $.clock.after(0, () => {
246 void refresh($)
247 })
248 timer = $.clock.every(periodMs, () => {
249 void refresh($)
250 })
251 return started
252 })
253
254 on('session.measure', async ($, e, next) => {
255 const measured = await next(e)
256 if (e.changed.includes('rateLimits')) {
257 void refresh($, e.rateLimits)
258 }
259 return measured
260 })
261
262 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
263 const current = await read($, view)
264 if (current === null || e.props.hasSurvey || !current.surfaces.includes(e.surface)) {
265 return next(e)
266 }
267 const { Box, Text } = $.ui.resolve(e)
268 const ours = current.error ? (
269 <Text dimColor wrap="truncate-end">
270 ctempo: {current.error}
271 </Text>
272 ) : (
273 <Box flexDirection="row">
274 {current.segments.map((segment, index) => (
275 <Text key={`s${index}`} wrap="truncate-end" {...TONE[segment.tone]}>
276 {segment.text}
277 </Text>
278 ))}
279 </Box>
280 )
281 // Keep what other mods draw in the band below ours.
282 const below = await next(e)
283 return below ? (
284 <Box flexDirection="column">
285 {ours}
286 {below}
287 </Box>
288 ) : (
289 ours
290 )
291 })
292}
293types/index.d.ts 19 lines1export type Tone = 'dim' | 'plain' | 'opportunity' | 'warn' | 'alert'
2
3export type Segment = { text: string; tone: Tone }
4
5// What `ctempo statusline --format json` prints, plus the mod's own error.
6export type View = {
7 line: string
8 segments: Segment[]
9 surfaces: string[]
10 refresh_s: number
11 error?: string
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 ctempo: { view: View | null }
17 }
18}
19