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.

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.
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
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.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.+N more.All optional; set them in /config.
| Setting | Default | Meaning |
|---|---|---|
| Show after (seconds) | 5 | a row appears once the call has run this long |
| Most rows | 4 | rows before +N more |
| Subagent warn time (minutes) | 10 | a subagent row is marked OVER past this |
| MCP call warn time (seconds) | 120 | an MCP row is marked OVER past this |
| Limit shown for a Bash call with no timeout (seconds) | 0 | 0 shows no limit for a Bash call that has none of its own |
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.
timeout shows an engine limit. Everything else shows an advisory warn time, or none./config) while something runs, the rows for those calls are dropped.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.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.
hooks/register.tsx 212 lines1import { 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}
212hooks/clocks.ts 184 lines1import 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}
184types/index.d.ts 26 lines1export 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