Stopwatch for turns and named tasks: live elapsed time above the prompt, a /timer pane with per-turn duration, tool count, model and tokens.

Cronómetro para turnos y tareas con nombre en Claude Code.
Mientras un turno se ejecuta, la banda encima del prompt y la línea de estado muestran el tiempo transcurrido y el número de herramientas usadas. Al terminar el turno, la banda muestra su duración, el número de herramientas y el modelo que respondió. El panel /timer lista cada turno de la sesión con duración, herramientas, modelo y el prompt al que respondió, además de los totales de la sesión.
Las tareas con nombre abarcan varios turnos: /timer start <nombre> abre una tarea, cada turno suma su tiempo activo, y /timer stop la cierra e informa del tiempo de reloj, el tiempo activo y el número de turnos. Las tareas terminadas persisten entre sesiones.
En el prompt de una sesión de terminal:
/plugin install task-timer --marketplace Jmezas/claude-mods
Desde una shell: claude plugin install task-timer --marketplace Jmezas/claude-mods. Responde y a Add marketplace? y elige el ámbito de usuario.
/timer abre el panel./timer start <nombre> inicia una tarea con nombre./timer stop termina la tarea en curso./timer hide y /timer show alternan la banda encima del prompt./timer reset borra el historial de la sesión y las tareas guardadas.hooks/register.tsx 323 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Running, Task, TurnRecord } from '../types'
5
6const PANE = 'task-timer'
7const MAX_TURNS = 200
8const MAX_TASKS = 50
9
10const turns = atom({ plugin: 'task-timer', key: 'turns' } as const, [])
11const running = atom({ plugin: 'task-timer', key: 'running' } as const, null)
12const task = atom({ plugin: 'task-timer', key: 'task' } as const, null)
13const tasks = atom({ plugin: 'task-timer', key: 'tasks' } as const, [])
14const tick = atom({ plugin: 'task-timer', key: 'tick' } as const, 0)
15const isHidden = atom({ plugin: 'task-timer', key: 'isHidden' } as const, false)
16
17const pad = (n: number) => String(n).padStart(2, '0')
18
19const fmt = (ms: number) => {
20 const total = Math.max(0, Math.round(ms / 1000))
21 const h = Math.floor(total / 3600)
22 const m = Math.floor((total % 3600) / 60)
23 const s = total % 60
24
25 return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
26}
27
28const short = (model: string) =>
29 model.replace(/^claude-/, '').replace(/-\d{8}$/, '').slice(0, 14)
30
31const snippet = (text: string, max: number) => {
32 const flat = text.replace(/\s+/g, ' ').trim()
33
34 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
35}
36
37const isTaskList = (value: unknown): value is Task[] =>
38 Array.isArray(value) &&
39 value.every(
40 one =>
41 typeof one === 'object' &&
42 one !== null &&
43 typeof (one as Task).name === 'string' &&
44 typeof (one as Task).startedAt === 'number',
45 )
46
47export const register: Register = on => {
48 let nextPrompt = ''
49 let ticker: { cancel: () => void } | null = null
50
51 const stopTicker = () => {
52 ticker?.cancel()
53 ticker = null
54 }
55
56 on('session.start', async ($, e, next) => {
57 await $.command.register({
58 name: 'timer',
59 description: 'Task stopwatch: pane | start <name> | stop | reset | hide | show',
60 argumentHint: '[start <name>|stop|reset|hide|show]',
61 })
62 const stored = await $.store.get('tasks')
63 if (isTaskList(stored)) await update($, tasks, () => stored)
64 const open = await $.store.get('task')
65 if (isTaskList([open])) await update($, task, () => open as Task)
66
67 return next(e)
68 })
69
70 on('command.run', { command: 'timer' }, async ($, e) => {
71 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
72 const name = rest.join(' ').trim()
73 const now = await $.clock.now()
74
75 if (verb === 'start') {
76 const current = await read($, task)
77 if (current !== null) {
78 return { text: `timer: task "${current.name}" is still running, /timer stop first` }
79 }
80 const started: Task = {
81 name: name || `task ${(await read($, tasks)).length + 1}`,
82 startedAt: now,
83 endedAt: null,
84 turns: 0,
85 activeMs: 0,
86 }
87 await update($, task, () => started)
88 await $.store.set('task', started)
89 await $.ui.open({ id: PANE, title: 'Timer' })
90
91 return { text: `timer: started "${started.name}"` }
92 }
93
94 if (verb === 'stop') {
95 const current = await read($, task)
96 if (current === null) return { text: 'timer: no task running' }
97 const done: Task = { ...current, endedAt: now }
98 const list = [...(await read($, tasks)), done].slice(-MAX_TASKS)
99 await update($, tasks, () => list)
100 await update($, task, () => null)
101 await $.store.set('tasks', list)
102 await $.store.delete('task')
103
104 return {
105 text: `timer: "${done.name}" took ${fmt(now - done.startedAt)} wall, ${fmt(done.activeMs)} active over ${done.turns} turns`,
106 }
107 }
108
109 if (verb === 'reset') {
110 await update($, turns, () => [])
111 await update($, tasks, () => [])
112 await update($, task, () => null)
113 await $.store.delete('tasks')
114 await $.store.delete('task')
115
116 return { text: 'timer: history cleared' }
117 }
118
119 if (verb === 'hide' || verb === 'show') {
120 await update($, isHidden, () => verb === 'hide')
121
122 return { text: `timer: band ${verb === 'hide' ? 'hidden' : 'shown'}` }
123 }
124
125 const opened = await $.ui.open({ id: PANE, title: 'Timer' })
126
127 return {
128 text: opened.isPlaced
129 ? 'Timer pane opened.'
130 : `Timer pane waits for a wider terminal (${opened.reason}).`,
131 }
132 })
133
134 on('prompt.submit', ($, e, next) => {
135 if (e.command === undefined && e.isMeta === undefined && e.turnId === undefined) {
136 nextPrompt = e.text
137 }
138
139 return next(e)
140 })
141
142 on('turn.start', async ($, e, next) => {
143 if (e.agentId !== undefined) return next(e)
144 const startedAt = await $.clock.now()
145 const run: Running = {
146 turnId: e.turnId,
147 prompt: snippet(nextPrompt, 60),
148 startedAt,
149 tools: 0,
150 }
151 nextPrompt = ''
152 await update($, running, () => run)
153
154 stopTicker()
155 ticker = $.clock.every(1000, () => {
156 void (async () => {
157 const now = await $.clock.now()
158 const current = await read($, running)
159 if (current === null) return
160 const open = await read($, task)
161 $.ui.status(
162 `⏱ ${fmt(now - current.startedAt)} · ${current.tools} tools${open ? ` · ${open.name}` : ''}`,
163 )
164 await update($, tick, () => now)
165 })()
166 })
167
168 return next(e)
169 })
170
171 on('tool.call', async ($, e, next) => {
172 if (e.agentId === undefined) {
173 await update($, running, current =>
174 current === null ? null : { ...current, tools: current.tools + 1 },
175 )
176 }
177
178 return next(e)
179 })
180
181 on('turn.complete', async ($, e, next) => {
182 if (e.agentId !== undefined) return next(e)
183 stopTicker()
184 const current = await read($, running)
185 const startedAt = current?.startedAt ?? (await $.clock.now()) - e.durationMs
186 const record: TurnRecord = {
187 id: e.turnId,
188 prompt: current?.prompt ?? '',
189 startedAt,
190 durationMs: e.durationMs,
191 tools: current?.tools ?? 0,
192 model: e.usage?.model ?? '',
193 inputTokens:
194 (e.usage?.input_tokens ?? 0) + (e.usage?.cache_read_input_tokens ?? 0),
195 outputTokens: e.usage?.output_tokens ?? 0,
196 reason: e.reason,
197 }
198 await update($, turns, list => [...list, record].slice(-MAX_TURNS))
199 await update($, running, () => null)
200
201 const open = await read($, task)
202 if (open !== null) {
203 const grown: Task = {
204 ...open,
205 turns: open.turns + 1,
206 activeMs: open.activeMs + e.durationMs,
207 }
208 await update($, task, () => grown)
209 await $.store.set('task', grown)
210 }
211
212 $.ui.status(
213 `⏱ last ${fmt(e.durationMs)} · ${record.tools} tools${record.model ? ` · ${short(record.model)}` : ''}`,
214 )
215 if (e.durationMs > 120_000) $.ui.toast(`Turn took ${fmt(e.durationMs)}`)
216
217 return next(e)
218 })
219
220 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221 const hidden = await read($, isHidden)
222 if (hidden || e.props.hasSurvey) return next(e)
223 const current = await read($, running)
224 const list = await read($, turns)
225 const open = await read($, task)
226 const now = await read($, tick)
227 const { Box, Text } = $.ui.resolve(e)
228
229 if (current !== null) {
230 return (
231 <Box>
232 <Text>
233 ⏱ {fmt(Math.max(now, current.startedAt) - current.startedAt)}
234 </Text>
235 <Text dimColor>
236 {' '}· {current.tools} tools
237 {open ? ` · ${open.name} (${open.turns + 1}ª vuelta)` : ''}
238 </Text>
239 </Box>
240 )
241 }
242
243 const lastTurn = list[list.length - 1]
244 if (lastTurn === undefined && open === null) return next(e)
245
246 return (
247 <Box>
248 {lastTurn && (
249 <Text dimColor>
250 ⏱ last {fmt(lastTurn.durationMs)} · {lastTurn.tools} tools
251 {lastTurn.model ? ` · ${short(lastTurn.model)}` : ''}
252 </Text>
253 )}
254 {open && (
255 <Text dimColor>
256 {lastTurn ? ' · ' : ''}task {open.name}: {open.turns} turns,{' '}
257 {fmt(open.activeMs)} active
258 </Text>
259 )}
260 </Box>
261 )
262 })
263
264 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265 const { Box, Text } = $.ui.resolve(e)
266 const list = await read($, turns)
267 const current = await read($, running)
268 const open = await read($, task)
269 const done = await read($, tasks)
270 const now = await read($, tick)
271 const width = e.props.bodyColumns
272 const rows = Math.max(3, e.props.scroll.bodyRows - 8)
273
274 const totalMs = list.reduce((sum, one) => sum + one.durationMs, 0)
275 const totalTools = list.reduce((sum, one) => sum + one.tools, 0)
276 const totalOut = list.reduce((sum, one) => sum + one.outputTokens, 0)
277 const promptWidth = Math.max(8, width - 34)
278
279 return (
280 <Box flexDirection="column">
281 {current !== null ? (
282 <Text>
283 ⏱ running {fmt(Math.max(now, current.startedAt) - current.startedAt)} ·{' '}
284 {current.tools} tools · {current.prompt}
285 </Text>
286 ) : (
287 <Text dimColor>idle</Text>
288 )}
289 {open !== null && (
290 <Text>
291 task {open.name}: {open.turns} turns · {fmt(open.activeMs)} active ·{' '}
292 {fmt(Math.max(now, open.startedAt) - open.startedAt)} wall
293 </Text>
294 )}
295 <Text dimColor>
296 session: {list.length} turns · {fmt(totalMs)} · {totalTools} tools ·{' '}
297 {totalOut} out tokens
298 </Text>
299 <Text> </Text>
300 <Text dimColor>
301 {'#'.padStart(3)} {'time'.padStart(7)} {'tools'.padStart(5)}{' '}
302 {'model'.padEnd(14)} prompt
303 </Text>
304 {list.slice(-rows).map((one, i) => (
305 <Text key={one.id} wrap="truncate-end">
306 {String(list.length - Math.min(rows, list.length) + i + 1).padStart(3)}{' '}
307 {fmt(one.durationMs).padStart(7)} {String(one.tools).padStart(5)}{' '}
308 {short(one.model).padEnd(14)} {snippet(one.prompt, promptWidth)}
309 </Text>
310 ))}
311 {done.length > 0 && <Text> </Text>}
312 {done.length > 0 && <Text dimColor>finished tasks</Text>}
313 {done.slice(-5).map(one => (
314 <Text key={`${one.name}-${one.startedAt}`} dimColor wrap="truncate-end">
315 {one.name}: {fmt((one.endedAt ?? one.startedAt) - one.startedAt)} wall ·{' '}
316 {fmt(one.activeMs)} active · {one.turns} turns
317 </Text>
318 ))}
319 </Box>
320 )
321 })
322}
323types/index.d.ts 40 lines1export type TurnRecord = {
2 id: string
3 prompt: string
4 startedAt: number
5 durationMs: number
6 tools: number
7 model: string
8 inputTokens: number
9 outputTokens: number
10 reason: string
11}
12
13export type Running = {
14 turnId: string
15 prompt: string
16 startedAt: number
17 tools: number
18}
19
20export type Task = {
21 name: string
22 startedAt: number
23 endedAt: number | null
24 turns: number
25 activeMs: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'task-timer': {
31 turns: TurnRecord[]
32 running: Running | null
33 task: Task | null
34 tasks: Task[]
35 tick: number
36 isHidden: boolean
37 }
38 }
39}
40