SLOPSHOPPER

task-timer

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-22Jmezas/claude-mods/task-timer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-timer
│ ┃ Timer ✕ › fix the failing auth test and add an audit log call │ ┃ idle │ ┃ session: 1 turns · 0:42 · 9 tools · 1480 out ⏺ Read(src/auth.ts) │ ┃ tokens ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ # time tools model prompt ⎿ Added 2 lines, removed 1 line │ ┃ 1 0:42 9 opus-5-5 fix the fa… ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /timer │ ⎿ task-timer: Timer pane opened. │ │ ⏱ last 0:42 · 9 tools · opus-5-5 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ task-timer: ⏱ last 0:42 · 9 tools · opus-5-5

Draws

Band
⏱ last 0:42 · 9 tools · opus-5-5
Pane · Timer
idle session: 1 turns · 0:42 · 9 tools · 1480 out tokens # time tools model prompt 1 0:42 9 opus-5-5 fix the failing auth …
README

task-timer

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.

Instalación

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.

Comandos

  • /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.
Source 2 files
hooks/register.tsx 323 lines
1import { 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}
323
types/index.d.ts 40 lines
1export 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