SLOPSHOPPER

clocks-band

A band above the prompt showing every Bash command, MCP call and subagent that has run for a while, with elapsed time against its limit.

newbandguardtimeragents
A shopper browsing a rack in a slop shop
README

clocks-band: a Claude Code mod by Naren

A band above the prompt showing every Bash command, MCP call and subagent that has run for a while, with elapsed time against its limit.

Part of Naren's Claude Toolkit.

What it shows

Once something has run for 5 seconds (you can change that), it gets a row in a band above the prompt. The row goes away when the call ends. With nothing long-running, the band is not there at all.

subagent  Explore: scan the repo      3m42s / 10m00s warn
Bash      pytest -q tests/            2m10s / 10m00s limit
mcp       github.search_code          1m05s
subagent  Explore: audit all tests    12m10s / 10m00s warn  OVER
  • kind and label: Bash with the command, mcp with the server and tool, subagent with its type and description. A subagent row can end with the Bash or MCP call it is running now: Explore: scan → pytest 12s.
  • elapsed: how long the mod has seen it running.
  • limit is a time the engine states: a Bash call's own timeout. warn is an advisory time you set for subagents and MCP calls; the mod stops nothing at that time, it only marks the row OVER.
  • Up to 4 rows (longest first) and then +N more.

Settings

All optional; set them in /config.

SettingDefaultMeaning
Show after (seconds)5a row appears once the call has run this long
Most rows4rows before +N more
Subagent warn time (minutes)10a subagent row is marked OVER past this
MCP call warn time (seconds)120an MCP row is marked OVER past this
Limit shown for a Bash call with no timeout (seconds)00 shows no limit for a Bash call that has none of its own

What it does not do

Version 1 only watches. It cannot stop a command, a subagent or an MCP call, and it has no kill key and no enforced limits. Those need facts about what a mod is allowed to stop; a short live probe is planned first, then a separate v1.1. The band never changes, delays or blocks a call, and a failure inside it never reaches a call.

Limits

  • Elapsed time starts when the mod sees the call start; time before that is not counted, and calls shorter than the "show after" time never appear.
  • Only a Bash call that carries a timeout shows an engine limit. Everything else shows an advisory warn time, or none.
  • A Bash command started in the background is not shown (it returns at once).
  • If the mod reloads (for example after you change a setting in /config) while something runs, the rows for those calls are dropped.
  • A Bash call is ended by the engine at its own timeout, so a limit row is rarely seen past OVER; OVER mostly shows on warn rows (subagents, MCP calls) and on the Bash limit you set in settings.
  • Not checked live yet: how the band looks on your screen, and the subagent rows.

Try it

This is a mod, so it loads from a folder rather than from the skills directory:

claude --plugin-dir plugins/clocks-band

Then ask Claude to run something slow (for example sleep 30), and look above the prompt. Tests for the mod run with claude plugin test plugins/clocks-band.

Source 3 files
hooks/register.tsx 212 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ClockState, Item } from '../types'
5import {
6  addItem,
7  clearDoing,
8  endAgent,
9  formatRows,
10  kindOfTool,
11  labelFor,
12  limitFor,
13  readOptions,
14  removeItem,
15  setDoing,
16  visibleRows,
17} from './clocks'
18
19const EMPTY: ClockState = { items: [], tick: 0 }
20
21export const clocksAtom = atom({ plugin: 'clocks-band', key: 'clocks' } as const, EMPTY)
22
23type Hooked = Parameters<Parameters<Parameters<Register>[0]>[2]>[0]
24
25// Ids this load of the module has added. State survives a hot reload but this module's variables do
26// not, so on the first event after a load, rows it did not add are left over from an earlier load.
27const mine = new Set<string>()
28let fresh = false
29let counter = 0
30let timer: { cancel: () => void } | null = null
31
32// The mod only watches: everything below is fire-and-forget and swallows its own failures, so a
33// slow, failing or hung state write can never hold up or break a tool call.
34const quietly = (work: Promise<unknown>): void => {
35  work.catch(() => {})
36}
37
38const stopTimer = (): void => {
39  timer?.cancel()
40  timer = null
41}
42
43// One tick: drop subagents the engine no longer reports as running, and bump the tick so the band redraws.
44const sweep = async ($: Hooked): Promise<void> => {
45  let alive: Set<string> | null = null
46  let known: Set<string> | null = null
47  try {
48    const agents = await $.agent.list()
49    known = new Set(agents.map(agent => agent.id))
50    alive = new Set(agents.filter(agent => agent.status === 'running').map(agent => agent.id))
51  } catch {
52    // keep what we have
53  }
54  const next = await update($, clocksAtom, state => ({
55    items: state.items.filter(
56      item =>
57        item.kind !== 'subagent' ||
58        item.agentId === undefined ||
59        known === null ||
60        !known.has(item.agentId) ||
61        (alive !== null && alive.has(item.agentId)),
62    ),
63    tick: state.tick + 1,
64  }))
65  if (next.items.length === 0) stopTimer()
66}
67
68const startTimer = ($: Hooked): void => {
69  if (timer !== null) return
70  timer = $.clock.every(1000, () => {
71    quietly(sweep($))
72  })
73}
74
75const freshen = async ($: Hooked): Promise<void> => {
76  if (fresh) return
77  fresh = true
78  await update($, clocksAtom, state => ({ ...state, items: state.items.filter(item => mine.has(item.id)) }))
79}
80
81// The start time is asked for at once but only awaited off the call's path.
82const record = async ($: Hooked, started: Promise<number>, make: (startedAt: number) => Item, then?: (item: Item) => Promise<unknown>): Promise<void> => {
83  const item = make(await started)
84  await add($, item)
85  if (then) await then(item)
86}
87
88const add = async ($: Hooked, item: Item): Promise<void> => {
89  await update($, clocksAtom, state => ({ ...state, items: addItem(state.items, item) }))
90  startTimer($)
91}
92
93const change = async ($: Hooked, edit: (items: readonly Item[]) => Item[]): Promise<void> => {
94  const next = await update($, clocksAtom, state => ({ ...state, items: edit(state.items) }))
95  if (next.items.length === 0) stopTimer()
96}
97
98export const register: Register = (on, rawOptions) => {
99  const options = readOptions(rawOptions)
100  on('tool.call', async ($, e, next) => {
101    quietly(freshen($))
102    const kind = kindOfTool(e.tool)
103    const input = e as unknown as { command?: unknown; timeout?: unknown; run_in_background?: unknown }
104    if (kind === null || input.run_in_background === true) {
105      return next(e)
106    }
107    const id = e.tool_use_id
108    const agentId = (e as { agentId?: string }).agentId
109    mine.add(id)
110    const label = labelFor(kind, { command: input.command, tool: e.tool })
111    const limit = limitFor(kind, input, options)
112    let added: Promise<unknown> = Promise.resolve()
113    try {
114      const started = $.clock.now()
115      started.catch(() => {})
116      added = record(
117        $,
118        started,
119        startedAt => ({ id, kind, label, startedAt, ...limit }),
120        item => (agentId === undefined ? undefined : change($, items => setDoing(items, agentId, { id, label, startedAt: item.startedAt }))),
121      ).catch(() => {})
122    } catch {
123      // not recorded; the call proceeds as if the mod were not there
124    }
125    const remove = (): void => {
126      quietly(added.then(() => change($, items => clearDoing(removeItem(items, id), agentId ?? '', id))))
127    }
128    try {
129      next.signal.addEventListener('abort', remove, { once: true })
130    } catch {
131      // no signal to listen to; the finally below still covers calls that settle
132    }
133    try {
134      return await next(e)
135    } finally {
136      remove()
137    }
138  })
139
140  on('agent.spawn', async ($, e, next) => {
141    quietly(freshen($))
142    const result = await next(e)
143    try {
144      const agentId = result.agentId
145      if (agentId !== undefined) {
146        const id = `agent:${agentId}`
147        mine.add(id)
148        const started = $.clock.now()
149        started.catch(() => {})
150        const label = labelFor('subagent', { subagentType: e.subagentType, description: e.description })
151        const limit = limitFor('subagent', {}, options)
152        quietly(record($, started, startedAt => ({ id, kind: 'subagent', label, startedAt, ...limit, agentId })))
153      }
154    } catch {
155      // the spawn is untouched
156    }
157    return result
158  })
159
160  on('turn.complete', async ($, e, next) => {
161    quietly(freshen($))
162    const result = await next(e)
163    try {
164      const agentId = (e as { agentId?: string }).agentId
165      if (agentId !== undefined) {
166        quietly(change($, items => endAgent(items, agentId)))
167      }
168    } catch {
169      // the turn is untouched
170    }
171    return result
172  })
173
174  on('session.end', async ($, e, next) => {
175    // /clear and /resume end the conversation while the process goes on under another session.
176    if (e.reason === 'clear' || e.reason === 'resume') {
177      try {
178        stopTimer()
179        mine.clear()
180        quietly(update($, clocksAtom, () => EMPTY))
181      } catch {
182        // never break the session
183      }
184    }
185    return next(e)
186  })
187
188  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189    try {
190      const props = e.props as { hasSurvey?: boolean; bodyColumns?: number }
191      if (props.hasSurvey) return next(e)
192      const state = await read($, clocksAtom)
193      const now = await $.clock.now()
194      const { rows, more } = visibleRows(state.items, now, options)
195      if (rows.length === 0) return next(e)
196      const lines = formatRows(rows, more, now, typeof props.bodyColumns === 'number' ? props.bodyColumns : 100)
197      const { Box, Text } = $.ui.resolve(e)
198      return (
199        <Box flexDirection="column">
200          {lines.map((line, index) => (
201            <Text key={index} wrap="truncate" color={line.includes('  OVER') ? 'red' : undefined} dimColor={!line.includes('  OVER')}>
202              {line}
203            </Text>
204          ))}
205        </Box>
206      )
207    } catch {
208      return next(e)
209    }
210  })
211}
212
hooks/clocks.ts 184 lines
1import type { Item, ItemKind } from '../types'
2
3export type Options = {
4  minSeconds: number
5  maxRows: number
6  subagentWarnMinutes: number
7  mcpWarnSeconds: number
8  bashDefaultLimitSeconds: number
9}
10
11export const DEFAULTS: Options = {
12  minSeconds: 5,
13  maxRows: 4,
14  subagentWarnMinutes: 10,
15  mcpWarnSeconds: 120,
16  bashDefaultLimitSeconds: 0,
17}
18
19const RANGES: Record<keyof Options, readonly [number, number]> = {
20  minSeconds: [1, 600],
21  maxRows: [1, 20],
22  subagentWarnMinutes: [1, 1440],
23  mcpWarnSeconds: [1, 86400],
24  bashDefaultLimitSeconds: [0, 3600],
25}
26
27/** The settings, each kept only when it is a finite number in its range; anything else is the default. */
28export const readOptions = (raw: Readonly<Record<string, unknown>>): Options => {
29  const out: Options = { ...DEFAULTS }
30  for (const key of Object.keys(RANGES) as (keyof Options)[]) {
31    const value = raw[key]
32    const [low, high] = RANGES[key]
33    if (typeof value === 'number' && Number.isFinite(value) && value >= low && value <= high) {
34      out[key] = value
35    }
36  }
37  out.maxRows = Math.floor(out.maxRows)
38  return out
39}
40
41/**
42 * Characters that are invisible or reorder what follows them: zero-width
43 * (U+200B-U+200D), the bidi marks and embeddings (U+200E-U+200F, U+202A-U+202E)
44 * and the isolates (U+2066-U+2069). A command carrying a right-to-left override
45 * would otherwise be drawn reversed in the band, so a label could not be
46 * trusted to show what is about to run.
47 */
48const INVISIBLE_OR_BIDI = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g
49
50/** One line of safe text: control, zero-width and bidi characters become visible escapes, whitespace collapses, long text is cut. */
51export const escapeText = (text: string, max: number): string => {
52  const escape = (char: string): string => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
53  const flat = String(text)
54    .replace(/[\r\n\t]+/g, ' ')
55    .replace(/[\u0000-\u001f\u007f-\u009f]/g, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
56    .replace(INVISIBLE_OR_BIDI, escape)
57    .replace(/ {2,}/g, ' ')
58    .trim()
59  const chars = Array.from(flat)
60  return chars.length <= max ? flat : chars.slice(0, Math.max(0, max - 1)).join('') + '…'
61}
62
63export const formatDuration = (ms: number): string => {
64  const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1000))
65  if (total < 60) return `${total}s`
66  const minutes = Math.floor(total / 60)
67  if (minutes < 60) return `${minutes}m${String(total % 60).padStart(2, '0')}s`
68  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
69}
70
71export const kindOfTool = (tool: string): 'bash' | 'mcp' | null =>
72  tool === 'Bash' ? 'bash' : tool.startsWith('mcp__') ? 'mcp' : null
73
74const LABEL_MAX = 60
75
76export const labelFor = (
77  kind: ItemKind,
78  input: { command?: unknown; tool?: unknown; subagentType?: unknown; description?: unknown },
79): string => {
80  if (kind === 'bash') {
81    const text = typeof input.command === 'string' ? escapeText(input.command, LABEL_MAX) : ''
82    return text === '' ? 'Bash' : text
83  }
84  if (kind === 'mcp') {
85    const tool = typeof input.tool === 'string' ? input.tool : ''
86    const parts = tool.startsWith('mcp__') ? tool.slice(5).split('__') : []
87    const server = parts.shift() ?? ''
88    const name = parts.join('__')
89    const label = name === '' ? server : `${server}.${name}`
90    return label === '' ? 'mcp' : escapeText(label, LABEL_MAX)
91  }
92  const type = typeof input.subagentType === 'string' && input.subagentType !== '' ? input.subagentType : 'agent'
93  const description = typeof input.description === 'string' ? input.description.trim() : ''
94  return escapeText(description === '' ? type : `${type}: ${description}`, LABEL_MAX)
95}
96
97/** The time to compare with: a Bash timeout (milliseconds) is a limit; subagent and MCP times are advisory warns. */
98export const limitFor = (
99  kind: ItemKind,
100  input: { timeout?: unknown },
101  options: Options,
102): { limitMs: number | null; limitKind: 'limit' | 'warn' | null } => {
103  if (kind === 'bash') {
104    const timeout = input.timeout
105    if (typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0) {
106      return { limitMs: timeout, limitKind: 'limit' }
107    }
108    if (options.bashDefaultLimitSeconds > 0) {
109      return { limitMs: options.bashDefaultLimitSeconds * 1000, limitKind: 'limit' }
110    }
111    return { limitMs: null, limitKind: null }
112  }
113  if (kind === 'mcp') {
114    return { limitMs: options.mcpWarnSeconds * 1000, limitKind: 'warn' }
115  }
116  return { limitMs: options.subagentWarnMinutes * 60_000, limitKind: 'warn' }
117}
118
119export const addItem = (items: readonly Item[], item: Item): Item[] =>
120  items.some(existing => existing.id === item.id)
121    ? items.map(existing => (existing.id === item.id ? item : existing))
122    : [...items, item]
123
124export const removeItem = (items: readonly Item[], id: string): Item[] => items.filter(item => item.id !== id)
125
126export const endAgent = (items: readonly Item[], agentId: string): Item[] =>
127  items.filter(item => !(item.kind === 'subagent' && item.agentId === agentId))
128
129export const setDoing = (items: readonly Item[], agentId: string, doing: NonNullable<Item['doing']>): Item[] =>
130  items.map(item => (item.kind === 'subagent' && item.agentId === agentId ? { ...item, doing } : item))
131
132export const clearDoing = (items: readonly Item[], agentId: string, doingId: string): Item[] =>
133  items.map(item => {
134    if (item.kind === 'subagent' && item.agentId === agentId && item.doing?.id === doingId) {
135      const { doing: _gone, ...rest } = item
136      return rest
137    }
138    return item
139  })
140
141export type Row = Item & { elapsedMs: number; over: boolean }
142
143export const visibleRows = (
144  items: readonly Item[],
145  now: number,
146  options: Options,
147): { rows: Row[]; more: number } => {
148  const qualifying = items
149    .map(item => {
150      const elapsedMs = Math.max(0, now - item.startedAt)
151      return { ...item, elapsedMs, over: item.limitMs !== null && elapsedMs >= item.limitMs }
152    })
153    .filter(row => row.elapsedMs >= options.minSeconds * 1000)
154    .sort((a, b) => b.elapsedMs - a.elapsedMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
155  return { rows: qualifying.slice(0, options.maxRows), more: Math.max(0, qualifying.length - options.maxRows) }
156}
157
158const TAG: Record<ItemKind, string> = { bash: 'Bash', mcp: 'mcp', subagent: 'subagent' }
159const TAG_WIDTH = 9
160const MIN_LABEL = 8
161
162const timeText = (row: Row): string =>
163  formatDuration(row.elapsedMs) +
164  (row.limitMs !== null ? ` / ${formatDuration(row.limitMs)} ${row.limitKind}` : '') +
165  (row.over ? '  OVER' : '')
166
167/** The band's lines. The label gives way first on a narrow width; the times are never cut. */
168export const formatRows = (rows: readonly Row[], more: number, now: number, width: number): string[] => {
169  if (rows.length === 0 && more === 0) return []
170  const times = rows.map(timeText)
171  const timeWidth = Math.max(0, ...times.map(text => text.length))
172  const labels = rows.map(row =>
173    row.doing ? `${row.label} → ${row.doing.label} ${formatDuration(now - row.doing.startedAt)}` : row.label,
174  )
175  const room = Math.max(MIN_LABEL, width - TAG_WIDTH - 2 - timeWidth)
176  const labelWidth = Math.min(room, Math.max(0, ...labels.map(label => Array.from(label).length)))
177  const lines = rows.map((row, index) => {
178    const label = escapeText(labels[index], labelWidth)
179    return `${TAG[row.kind].padEnd(TAG_WIDTH)}${label.padEnd(labelWidth)}  ${times[index]}`
180  })
181  if (more > 0) lines.push(`+${more} more`)
182  return lines
183}
184
types/index.d.ts 26 lines
1export type ItemKind = 'bash' | 'mcp' | 'subagent'
2
3export type Item = {
4  id: string
5  kind: ItemKind
6  label: string
7  /** milliseconds on $.clock.now()'s clock when the mod saw the call start */
8  startedAt: number
9  /** the time to compare the elapsed time with, or null when there is none */
10  limitMs: number | null
11  /** 'limit' when the engine states it (a Bash timeout), 'warn' when it is our advisory time */
12  limitKind: 'limit' | 'warn' | null
13  /** a subagent's own id (kind 'subagent') */
14  agentId?: string
15  /** what a subagent is doing now: its latest tool call */
16  doing?: { id: string; label: string; startedAt: number }
17}
18
19export type ClockState = { items: Item[]; tick: number }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'clocks-band': { clocks: ClockState }
24  }
25}
26