A pane showing how long running subagents, tool calls and turns have left, estimated from past runs

A Claude Code mod that adds a Time left pane beside the chat. It shows each running subagent, long tool call and the current turn with elapsed time, an estimate of the time remaining and a progress bar, plus the last few finished items and how long they took.
Estimates are learned: every finished run is recorded per kind (for example agent:Explore or tool:Bash), and the estimate is the median of the last 20 runs. Until a kind has a run of its own, a built-in default is used and the row is marked (guess). Runs past their estimate show over by ….
At the prompt of a Claude Code terminal session:
/plugin install time-left --marketplace DJsluxx/claude-time-left
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install time-left --marketplace DJsluxx/claude-time-left
The pane opens on its own once a window is attached; /time-left opens it again.
MIT
hooks/register.tsx 222 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { History, Job } from '../types'
5
6const PANE = 'time-left'
7const STORE_KEY = 'history'
8const KEEP_RUNS = 20
9const KEEP_DONE = 8
10const SHOW_TOOL_AFTER_MS = 2000
11const BAR_WIDTH = 14
12
13const jobs = atom({ plugin: 'time-left', key: 'jobs' } as const, [])
14const history = atom({ plugin: 'time-left', key: 'history' } as const, {})
15const now = atom({ plugin: 'time-left', key: 'now' } as const, 0)
16
17// Used until a bucket has a run of its own to learn from.
18const DEFAULT_MS: Record<string, number> = {
19 'agent:Explore': 90_000,
20 'agent:Plan': 120_000,
21 agent: 180_000,
22 'tool:Bash': 20_000,
23 'tool:Workflow': 600_000,
24 tool: 10_000,
25 turn: 60_000,
26}
27
28const median = (xs: number[]): number => {
29 const s = [...xs].sort((a, b) => a - b)
30 const mid = Math.floor(s.length / 2)
31 const hi = s[mid] ?? 0
32 return s.length % 2 ? hi : ((s[mid - 1] ?? hi) + hi) / 2
33}
34
35const estimate = (key: string, past: History): { ms: number; isGuess: boolean } => {
36 const runs = past[key]
37 if (runs && runs.length > 0) return { ms: median(runs), isGuess: false }
38 const kind = key.split(':')[0] ?? key
39 return { ms: DEFAULT_MS[key] ?? DEFAULT_MS[kind] ?? 60_000, isGuess: true }
40}
41
42const fmt = (ms: number): string => {
43 const s = Math.max(0, Math.round(ms / 1000))
44 if (s < 60) return `${s}s`
45 const m = Math.floor(s / 60)
46 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
47 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
48}
49
50const bar = (fraction: number): string => {
51 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_WIDTH)
52 return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
53}
54
55const trim = (list: Job[]): Job[] => {
56 const running = list.filter(j => j.endedAt === undefined)
57 const done = list.filter(j => j.endedAt !== undefined).slice(-KEEP_DONE)
58 return [...running, ...done].sort((a, b) => a.startedAt - b.startedAt)
59}
60
61async function start($: EngineInterface, job: Omit<Job, 'startedAt'>): Promise<void> {
62 const t = await $.clock.now()
63 await update($, now, () => t)
64 await update($, jobs, list =>
65 trim([...list.filter(j => j.id !== job.id), { ...job, startedAt: t }]),
66 )
67}
68
69async function finish($: EngineInterface, id: string, isAborted = false): Promise<void> {
70 const t = await $.clock.now()
71 const list = await read($, jobs)
72 const job = list.find(j => j.id === id && j.endedAt === undefined)
73 if (!job) return
74 await update($, jobs, l => trim(l.map(j => (j.id === id && j.endedAt === undefined ? { ...j, endedAt: t } : j))))
75 if (isAborted) return
76 const past = await update($, history, h => ({
77 ...h,
78 [job.key]: [...(h[job.key] ?? []), t - job.startedAt].slice(-KEEP_RUNS),
79 }))
80 await $.store.set(STORE_KEY, past)
81}
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 const saved = (await $.store.get(STORE_KEY)) as History | undefined
86 if (saved) await update($, history, h => ({ ...saved, ...h }))
87 await $.command.register({
88 name: 'time-left',
89 description: 'Show the time-left pane for running agents and tasks',
90 })
91 $.clock.every(1000, () => {
92 void tick($)
93 })
94 void openPane($)
95 return next(e)
96 })
97
98 // The desktop window can attach after the session starts: open then.
99 on('session.attach', async ($, e, next) => {
100 const attached = await next(e)
101 void openPane($)
102 return attached
103 })
104
105 on('command.run', { command: 'time-left' }, async $ => {
106 await $.ui.open({ id: PANE, title: 'Time left' })
107 return { text: 'Time-left pane opened.' }
108 })
109
110 on('agent.spawn', async ($, e, next) => {
111 const spawned = await next(e)
112 if ('agentId' in spawned && spawned.agentId) {
113 await start($, {
114 id: spawned.agentId,
115 kind: 'agent',
116 label: `${e.subagentType}: ${e.description}`,
117 key: `agent:${e.subagentType}`,
118 })
119 }
120 return spawned
121 })
122
123 on('turn.start', async ($, e, next) => {
124 if (!e.agentId) void openPane($)
125 if (e.agentId) await resumeAgent($, e.agentId)
126 else await start($, { id: 'turn', kind: 'turn', label: 'This turn', key: 'turn' })
127 return next(e)
128 })
129
130 on('turn.complete', async ($, e, next) => {
131 await finish($, e.agentId ?? 'turn', e.isAborted)
132 return next(e)
133 })
134
135 on('tool.call', async ($, e, next) => {
136 if (e.tool === 'Agent' || e.agentId) return next(e)
137 await start($, { id: e.tool_use_id, kind: 'tool', label: e.tool, key: `tool:${e.tool}` })
138 try {
139 return await next(e)
140 } finally {
141 await finish($, e.tool_use_id)
142 }
143 })
144
145 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146 const { Box, Text } = $.ui.resolve(e)
147 const t = await read($, now)
148 const past = await read($, history)
149 const visible = (await read($, jobs)).filter(
150 j => j.kind !== 'tool' || (j.endedAt ?? t) - j.startedAt >= SHOW_TOOL_AFTER_MS,
151 )
152 const running = visible.filter(j => j.endedAt === undefined)
153 const done = visible.filter(j => j.endedAt !== undefined).reverse()
154
155 return (
156 <Box flexDirection="column">
157 {running.length === 0 && <Text dimColor>Nothing running.</Text>}
158 {running.map(job => {
159 const est = estimate(job.key, past)
160 const elapsed = Math.max(0, t - job.startedAt)
161 const left = est.ms - elapsed
162 const guess = est.isGuess ? ' (guess)' : ''
163 return (
164 <Box flexDirection="column">
165 <Text bold>{job.label}</Text>
166 <Text>
167 {bar(elapsed / est.ms)} {fmt(elapsed)} /{' '}
168 {left >= 0 ? `~${fmt(left)} left${guess}` : `over by ${fmt(-left)}`}
169 </Text>
170 </Box>
171 )
172 })}
173 {done.length > 0 && <Text dimColor>── finished ──</Text>}
174 {done.map(job => (
175 <Text dimColor>
176 ✓ {job.label} in {fmt((job.endedAt ?? 0) - job.startedAt)}
177 </Text>
178 ))}
179 </Box>
180 )
181 })
182}
183
184let isPaneOpened = false
185
186// Opens the pane once a surface can draw it; a session with none yet waits.
187async function openPane($: EngineInterface): Promise<void> {
188 if (isPaneOpened) return
189 try {
190 const surfaces = await $.session.surfaces()
191 if (surfaces.length === 0) return
192 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Time left' })
193 isPaneOpened = isPlaced
194 } catch {
195 // No surface yet: the next attach or turn tries again.
196 }
197}
198
199// Bumps the clock value the pane reads, only while something runs.
200async function tick($: EngineInterface): Promise<void> {
201 const list = await read($, jobs)
202 if (!list.some(j => j.endedAt === undefined)) return
203 const t = await $.clock.now()
204 await update($, now, () => t)
205}
206
207// A subagent's loop can start a run without a spawn this mod saw
208// (a resume, or a reload mid-run): track it from the agent list.
209async function resumeAgent($: EngineInterface, agentId: string): Promise<void> {
210 const list = await read($, jobs)
211 const job = list.find(j => j.id === agentId)
212 if (job && job.endedAt === undefined) return
213 const info = (await $.agent.list()).find(a => a.id === agentId)
214 const type = info?.type ?? 'agent'
215 await start($, {
216 id: agentId,
217 kind: 'agent',
218 label: job?.label ?? `${type}: ${info?.description ?? agentId}`,
219 key: job?.key ?? `agent:${type}`,
220 })
221}
222types/index.d.ts 19 lines1export type Job = {
2 id: string
3 kind: 'agent' | 'tool' | 'turn'
4 label: string
5 /** History bucket the estimate is drawn from, e.g. `agent:Explore`. */
6 key: string
7 startedAt: number
8 endedAt?: number
9}
10
11/** Past durations in milliseconds, newest last, per history bucket. */
12export type History = Record<string, number[]>
13
14declare module 'claude-code' {
15 interface PluginState {
16 'time-left': { jobs: Job[]; history: History; now: number }
17 }
18}
19