SLOPSHOPPER

ctempo

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

newbandprocesstimer
v0.1.0MITupdated 2026-10-15PershinIlya/Claude-Code-Mods/mods/ctempo
A shopper browsing a rack in a slop shop
README

ctempo mod

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:

  1. on 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);
  2. stores the tone-tagged segments it prints in $.state;
  3. draws them on the 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.

Source 2 files
hooks/register.tsx 293 lines
1// 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}
293
types/index.d.ts 19 lines
1export 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