SLOPSHOPPER

bita-timer

Shows bita's running timers in a band above the prompt, without spending tokens.

bandguardprocesstimer
★ 2v0.1.0MITupdated 2026-10-23KikeDeAlba/bita-cli/plugins/bita-timer
A shopper browsing a rack in a slop shop
README

bita-timer

Mod de Claude Code que dibuja una banda sobre el prompt con los cronómetros de bita que están corriendo:

⏱ Revisar MR · bita · 1:25
  • Un cronómetro: título · proyecto · tiempo transcurrido (h:mm). Sin título se muestra sin título; sin proyecto, se omite.
  • Varios: se listan todos, del más reciente al más antiguo, separados por |. Si no caben en el ancho de la banda, se condensan en el más reciente más un resumen: Revisar MR · bita · 1:25 +2 más · 3:10 en total.
  • Sin cronómetros corriendo, o si bita no está instalado, la banda no aparece.

Costo

Cero tokens: el mod no llama al modelo ni agrega texto a la conversación; solo dibuja. Requiere Claude Code 2.1.287 o posterior y no aplica en claude -p, Codex, opencode ni Gemini.

Cómo se actualiza

  • Lee bita ls --json al arrancar la sesión y después cada 30 segundos.
  • También relee poco después de cada llamada a Bash cuyo comando ejecute bita (bita start, cd repo && bita stop, ~/.local/bin/bita ls, etc.).
  • El tiempo transcurrido avanza con el reloj local (se revisa cada 15 segundos), sin lanzar procesos.
  • Si bita falla un momento (por ejemplo, la base está bloqueada), la banda conserva lo último que leyó. Si bita no está instalado, deja de buscarlo durante 5 minutos, salvo que se ejecute un comando bita.

El binario se busca primero en el registro de kit (la línea de comando que bita setup deja en tools.d/bita.json), luego en el PATH y, si no está ahí, en las carpetas de pnpm, nvm, Volta, npm, Bun y ~/.local/bin (en Windows, %APPDATA%\npm y %LOCALAPPDATA%\pnpm).

Instalación

Es opcional y va aparte del plugin bita; ambos se publican en el marketplace del repositorio (.claude-plugin/marketplace.json). Desde el prompt de Claude Code:

/plugin marketplace add KikeDeAlba/bita-cli
/plugin install bita-timer@bita

O desde la terminal, junto con el plugin bita:

bita setup --mod

Para probarlo desde una copia local del repositorio, sin instalarlo:

claude --plugin-dir plugins/bita-timer

Desarrollo

claude plugin validate plugins/bita-timer
claude plugin test plugins/bita-timer
node --test test/bita-timer.test.ts

La lógica de lectura y formato vive en hooks/timers.ts (sin dependencias) y la cubren las pruebas de test/bita-timer.test.ts; hooks/register.tsx solo conecta los eventos de Claude Code con esa lógica, y tests/band.test.ts lo prueba contra el motor de Claude Code.

Source 3 files
hooks/register.tsx 162 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { RunningTimer } from '../types'
5import { BAND_PREFIX, bandText, binaryCandidates, isBitaCommand, manifestCommand, parseEnvelope, registryManifestPath, type CandidateEnv } from './timers.ts'
6
7const timers = atom({ plugin: 'bita-timer', key: 'timers' } as const, [] as RunningTimer[])
8const now = atom({ plugin: 'bita-timer', key: 'now' } as const, 0)
9
10const REFRESH_MS = 30_000
11const TICK_MS = 15_000
12const MISSING_BACKOFF_MS = 300_000
13const AFTER_COMMAND_MS = 250
14
15let binary: string[] | null = null
16let missingUntil = 0
17let inFlight: Promise<void> | null = null
18let isQueued = false
19let loops: Timer[] = []
20
21async function candidateEnv($: EngineInterface): Promise<CandidateEnv> {
22  const env: CandidateEnv = {}
23  const set = (key: keyof CandidateEnv, value: string | undefined) => {
24    if (value) env[key] = value
25  }
26  set('HOME', await $.env.get('HOME'))
27  set('USERPROFILE', await $.env.get('USERPROFILE'))
28  set('APPDATA', await $.env.get('APPDATA'))
29  set('LOCALAPPDATA', await $.env.get('LOCALAPPDATA'))
30  set('XDG_CONFIG_HOME', await $.env.get('XDG_CONFIG_HOME'))
31  set('KIT_REGISTRY_DIR', await $.env.get('KIT_REGISTRY_DIR'))
32  set('PNPM_HOME', await $.env.get('PNPM_HOME'))
33  set('NVM_BIN', await $.env.get('NVM_BIN'))
34  set('VOLTA_HOME', await $.env.get('VOLTA_HOME'))
35  set('BUN_INSTALL', await $.env.get('BUN_INSTALL'))
36  set('NPM_CONFIG_PREFIX', await $.env.get('NPM_CONFIG_PREFIX'))
37  return env
38}
39
40async function registered($: EngineInterface, env: CandidateEnv): Promise<string[] | null> {
41  const file = registryManifestPath(env)
42  if (file === null) return null
43  try {
44    return manifestCommand(await $.fs.read(file))
45  } catch {
46    return null
47  }
48}
49
50async function candidates($: EngineInterface): Promise<string[][]> {
51  const env = await candidateEnv($)
52  const fromRegistry = await registered($, env)
53  const list = [...(fromRegistry ? [fromRegistry] : []), ...binaryCandidates(env)]
54  const same = (a: string[], b: string[]) => a.join('\0') === b.join('\0')
55  return binary === null ? list : [binary, ...list.filter(item => !same(item, binary as string[]))]
56}
57
58async function runBita($: EngineInterface, candidate: string[]): Promise<{ stdout: string; exitCode: number } | null> {
59  try {
60    return await $.process.run([...candidate, 'ls', '--json'], { timeoutMs: 10_000 })
61  } catch {
62    return null
63  }
64}
65
66async function fetchTimers($: EngineInterface): Promise<RunningTimer[] | null> {
67  for (const candidate of await candidates($)) {
68    const ran = await runBita($, candidate)
69    if (ran === null) continue
70    binary = candidate
71    if (ran.exitCode !== 0) return null
72    return parseEnvelope(ran.stdout, await $.clock.now())
73  }
74  binary = null
75  return []
76}
77
78async function load($: EngineInterface, isForced: boolean): Promise<void> {
79  try {
80    const at = await $.clock.now()
81    if (!isForced && at < missingUntil) return
82    const found = await fetchTimers($)
83    if (found === null) return
84    missingUntil = binary === null ? at + MISSING_BACKOFF_MS : 0
85    await update($, now, () => at)
86    await update($, timers, () => found)
87  } catch {
88    return
89  }
90}
91
92function refresh($: EngineInterface, isForced = false): Promise<void> {
93  if (inFlight !== null) {
94    if (isForced) isQueued = true
95    return inFlight
96  }
97  inFlight = (async () => {
98    await load($, isForced)
99    while (isQueued) {
100      isQueued = false
101      await load($, true)
102    }
103    inFlight = null
104  })()
105  return inFlight
106}
107
108async function tick($: EngineInterface): Promise<void> {
109  try {
110    const at = await $.clock.now()
111    await update($, now, () => at)
112  } catch {
113    return
114  }
115}
116
117function startLoops($: EngineInterface): void {
118  for (const loop of loops) loop.cancel()
119  loops = [
120    $.clock.after(0, () => void refresh($, true)),
121    $.clock.every(REFRESH_MS, () => void refresh($)),
122    $.clock.every(TICK_MS, () => void tick($)),
123  ]
124}
125
126export const register: Register = on => {
127  on('session.start', async ($, e, next) => {
128    const result = await next(e)
129    try {
130      startLoops($)
131    } catch {
132      return result
133    }
134    return result
135  })
136
137  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
138    const result = await next(e)
139    try {
140      if (isBitaCommand(e.command)) $.clock.after(AFTER_COMMAND_MS, () => void refresh($, true))
141    } catch {
142      return result
143    }
144    return result
145  }).catch(($, e, next) => next(e))
146
147  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
148    if (e.props.hasSurvey) return next(e)
149    const text = bandText(await read($, timers), await read($, now), e.props.bodyColumns)
150    if (text === null) return next(e)
151    const { Box, Text } = $.ui.resolve(e)
152    return (
153      <Box>
154        <Text dimColor wrap="truncate-end">
155          {BAND_PREFIX}
156          {text}
157        </Text>
158      </Box>
159    )
160  })
161}
162
hooks/timers.ts 142 lines
1import type { RunningTimer } from '../types/index.d.ts'
2
3export const UNTITLED = 'sin título'
4
5export const BAND_PREFIX = '⏱ '
6
7export function parseEnvelope(text: string, now: number): RunningTimer[] | null {
8  let parsed: unknown
9  try {
10    parsed = JSON.parse(text)
11  } catch {
12    return null
13  }
14  if (!isRecord(parsed) || parsed['ok'] !== true || !Array.isArray(parsed['data'])) return null
15  const timers: RunningTimer[] = []
16  for (const item of parsed['data']) {
17    const timer = toTimer(item, now)
18    if (timer) timers.push(timer)
19  }
20  return timers
21}
22
23function toTimer(item: unknown, now: number): RunningTimer | null {
24  if (!isRecord(item) || item['running'] === false) return null
25  const id = item['id']
26  if (typeof id !== 'number') return null
27  const description = typeof item['description'] === 'string' ? item['description'].trim() : ''
28  const projectName = typeof item['projectName'] === 'string' && item['projectName'].trim() !== '' ? item['projectName'].trim() : null
29  const startedAt = startOf(item, now)
30  if (startedAt === null) return null
31  return { id, title: description === '' ? UNTITLED : description, project: projectName, startedAt }
32}
33
34function startOf(item: Record<string, unknown>, now: number): number | null {
35  const start = item['start']
36  if (typeof start === 'string') {
37    const ms = Date.parse(start)
38    if (Number.isFinite(ms)) return ms
39  }
40  const seconds = item['durationSeconds']
41  if (typeof seconds === 'number' && Number.isFinite(seconds)) return now - seconds * 1000
42  return null
43}
44
45function isRecord(value: unknown): value is Record<string, unknown> {
46  return typeof value === 'object' && value !== null && !Array.isArray(value)
47}
48
49export function formatElapsed(seconds: number): string {
50  const minutes = Math.max(0, Math.floor(seconds / 60))
51  const hours = Math.floor(minutes / 60)
52  return `${hours}:${String(minutes % 60).padStart(2, '0')}`
53}
54
55export function elapsedSeconds(timer: RunningTimer, now: number): number {
56  return Math.max(0, Math.floor((now - timer.startedAt) / 1000))
57}
58
59export function describeTimer(timer: RunningTimer, now: number): string {
60  const parts = [timer.title]
61  if (timer.project !== null) parts.push(timer.project)
62  parts.push(formatElapsed(elapsedSeconds(timer, now)))
63  return parts.join(' · ')
64}
65
66export function bandText(timers: readonly RunningTimer[], now: number, columns: number): string | null {
67  if (timers.length === 0) return null
68  const ordered = [...timers].sort((a, b) => b.startedAt - a.startedAt)
69  const full = ordered.map((timer) => describeTimer(timer, now)).join('  |  ')
70  if (ordered.length === 1 || full.length + BAND_PREFIX.length + 1 <= columns) return full
71  const total = ordered.reduce((sum, timer) => sum + elapsedSeconds(timer, now), 0)
72  const suffix = `  +${ordered.length - 1} más · ${formatElapsed(total)} en total`
73  const [first] = ordered
74  return `${describeTimer(first!, now)}${suffix}`
75}
76
77const BITA_COMMAND = /(?:^|[;&|(`\n]|\$\()\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:(?:time|exec|command|nohup)\s+)*(?:\S*\/)?bita(?=\s|$|[;&|)`])/
78
79export function isBitaCommand(command: unknown): boolean {
80  return typeof command === 'string' && BITA_COMMAND.test(command)
81}
82
83export type CandidateEnv = Partial<
84  Record<
85    'HOME' | 'USERPROFILE' | 'APPDATA' | 'LOCALAPPDATA' | 'XDG_CONFIG_HOME' | 'KIT_REGISTRY_DIR' | 'PNPM_HOME' | 'NVM_BIN' | 'VOLTA_HOME' | 'BUN_INSTALL' | 'NPM_CONFIG_PREFIX',
86    string
87  >
88>
89
90function joinPath(windows: boolean, ...parts: string[]): string {
91  return parts.join(windows ? '\\' : '/')
92}
93
94function isWindows(env: CandidateEnv): boolean {
95  return env.HOME === undefined && (env.USERPROFILE !== undefined || env.APPDATA !== undefined)
96}
97
98export function registryManifestPath(env: CandidateEnv): string | null {
99  const windows = isWindows(env)
100  if (env.KIT_REGISTRY_DIR) return joinPath(windows, env.KIT_REGISTRY_DIR, 'bita.json')
101  const config = windows ? env.APPDATA : (env.XDG_CONFIG_HOME ?? (env.HOME ? joinPath(false, env.HOME, '.config') : undefined))
102  return config ? joinPath(windows, config, 'kikedealba', 'tools.d', 'bita.json') : null
103}
104
105export function manifestCommand(text: string): string[] | null {
106  try {
107    const parsed: unknown = JSON.parse(text)
108    const bin = typeof parsed === 'object' && parsed !== null ? (parsed as { bin?: unknown }).bin : undefined
109    if (!Array.isArray(bin) || bin.length === 0 || !bin.every(part => typeof part === 'string' && part.length > 0)) return null
110    return bin as string[]
111  } catch {
112    return null
113  }
114}
115
116export function binaryCandidates(env: CandidateEnv): string[][] {
117  const windows = isWindows(env)
118  const home = windows ? (env.USERPROFILE ?? env.HOME) : env.HOME
119  const name = windows ? 'bita.cmd' : 'bita'
120  const dirs: string[] = []
121  const add = (dir: string | undefined) => {
122    if (dir && !dirs.includes(dir)) dirs.push(dir)
123  }
124  add(env.PNPM_HOME)
125  add(env.NVM_BIN)
126  if (env.VOLTA_HOME) add(joinPath(windows, env.VOLTA_HOME, 'bin'))
127  if (env.NPM_CONFIG_PREFIX) add(windows ? env.NPM_CONFIG_PREFIX : joinPath(false, env.NPM_CONFIG_PREFIX, 'bin'))
128  if (env.BUN_INSTALL) add(joinPath(windows, env.BUN_INSTALL, 'bin'))
129  if (windows) {
130    if (env.APPDATA) add(joinPath(true, env.APPDATA, 'npm'))
131    if (env.LOCALAPPDATA) add(joinPath(true, env.LOCALAPPDATA, 'pnpm'))
132  } else if (home) {
133    add(joinPath(false, home, '.local', 'share', 'pnpm'))
134    add(joinPath(false, home, 'Library', 'pnpm'))
135    add(joinPath(false, home, '.npm-global', 'bin'))
136    add(joinPath(false, home, '.volta', 'bin'))
137    add(joinPath(false, home, '.bun', 'bin'))
138    add(joinPath(false, home, '.local', 'bin'))
139  }
140  return [['bita'], ...dirs.map(dir => [joinPath(windows, dir, name)])]
141}
142
types/index.d.ts 13 lines
1export type RunningTimer = {
2  id: number
3  title: string
4  project: string | null
5  startedAt: number
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'bita-timer': { timers: RunningTimer[]; now: number }
11  }
12}
13