SLOPSHOPPER

time-left

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

newpaneguardcommandtimeragents
v0.1.0MITupdated 2026-10-24DJsluxx/claude-time-left
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-left
│ ┃ Time left ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing running. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /time-left │ ⎿ time-left: Time-left pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Time left
Nothing running.
README

time-left

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 ….

Install

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

Use

The pane opens on its own once a window is attached; /time-left opens it again.

License

MIT

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