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

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
h:mm). Sin título se muestra sin título; sin proyecto, se omite.|. 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.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.
bita ls --json al arrancar la sesión y después cada 30 segundos.bita (bita start, cd repo && bita stop, ~/.local/bin/bita ls, etc.).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).
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
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.
hooks/register.tsx 162 lines1import { 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}
162hooks/timers.ts 142 lines1import 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}
142types/index.d.ts 13 lines1export 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