SLOPSHOPPER

task-progress

Progress bar, elapsed and remaining time for the running agent turn

newbandguardprompttimeragents
v0.1.0no licenseupdated 2026-10-14felixchen-tw/claude-config/mods/task-progress
A shopper browsing a rack in a slop shop
README

~/.claude 設定同步

新裝置:

git clone git@github.com:felixchen-tw/claude-config.git ~/.claude   # ~/.claude 已存在就先移開,再把憑證搬回來
git clone https://github.com/Raymondhou0917/ai-level-check ~/ai-level-check && ln -s ~/ai-level-check ~/.claude/skills/ai-level-check

啟動 Claude Code 後,settings.json 裡的 plugin 會自動安裝。 felix-mods 的 marketplace 用的是絕對路徑 /home/felix/.claude/mods,家目錄不同(例如 macOS 的 /Users/felix)要改 settings.json。 skills/ 裡的 caveman 系列與 find-skills 是從 npx skills 複製進來的實體檔(不再 symlink 到 ~/.agents),要更新就重跑 npx skills add JuliusBrussee/caveman -g 後再複製進來。 claude.ai 內建的 skills(skills/synced/)跟著帳號同步,不用處理。

同步:git -C ~/.claude pull / git -C ~/.claude commit -am ... && git push

Source 2 files
hooks/register.tsx 208 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Run, SubAgent } from '../types'
5
6const run = atom({ plugin: 'task-progress', key: 'run' } as const, null)
7const agents = atom({ plugin: 'task-progress', key: 'agents' } as const, [])
8const clock = atom({ plugin: 'task-progress', key: 'now' } as const, 0)
9
10const LIVE = ['pending', 'running', 'waiting']
11
12const bar = (frac: number, width = 20) => {
13  const n = Math.round(Math.min(Math.max(frac, 0), 1) * width)
14  return '█'.repeat(n) + '░'.repeat(width - n)
15}
16
17const fmt = (ms: number) => {
18  const s = Math.max(0, Math.round(ms / 1000))
19  return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`
20}
21
22const MIN_TURN_MS = 15_000
23
24const ema = (avg: number | undefined, ms: number) => (avg ? avg * 0.7 + ms * 0.3 : ms)
25
26// Active time: wall clock minus time spent waiting on the user.
27export const active = (r: Run, now: number) =>
28  now - r.startedAt - (r.pausedMs ?? 0) - (r.pausedAt === undefined ? 0 : now - r.pausedAt)
29
30// Todo progress when the agent keeps a list; else elapsed against the average length.
31// Step pace is measured from when the list appeared, so exploration before it isn't spread over the steps.
32// ponytail: linear pace per step, weight later steps if the tail still runs long
33export const estimate = (r: Run, now: number) => {
34  const elapsed = active(r, now)
35  if (r.total > 0 && r.done > 0) {
36    const start = r.todoAt ?? 0
37    const perStep = (elapsed - start) / r.done
38    return { frac: r.done / r.total, elapsed, totalMs: elapsed + perStep * (r.total - r.done), isTodo: true }
39  }
40  if (r.total > 0) return { frac: 0, elapsed, totalMs: undefined, isTodo: true }
41  if (r.avgMs) return { frac: Math.min(elapsed / r.avgMs, 0.95), elapsed, totalMs: r.avgMs, isTodo: false }
42  return { frac: 0, elapsed, totalMs: undefined, isTodo: false }
43}
44
45const line = (r: Run, now: number) => {
46  const { frac, elapsed, totalMs, isTodo } = estimate(r, now)
47  const steps = isTodo ? `  ${r.done}/${r.total} 步` : ''
48  const time = totalMs === undefined
49    ? `已用 ${fmt(elapsed)} · 預估中…`
50    : elapsed > totalMs
51      ? `已用 ${fmt(elapsed)} · 已超出平均 ${fmt(elapsed - totalMs)}`
52      : `已用 ${fmt(elapsed)} · 剩餘 ~${fmt(totalMs - elapsed)} / 預估 ${fmt(totalMs)}`
53  return `${bar(frac)} ${Math.round(frac * 100)}%${steps}  ${time}`
54}
55
56// Applies a todo change to the main run, or to the subagent whose loop made the call.
57// The first change that gives the list steps stamps todoAt.
58const onTodos = async ($: EngineInterface, agentId: string | undefined, fn: (r: Run) => Partial<Run>) => {
59  const now = await $.clock.now()
60  const apply = <T extends Run>(r: T): T => {
61    const next = { ...r, ...fn(r) }
62    return next.todoAt === undefined && next.total > 0 ? { ...next, todoAt: active(next, now) } : next
63  }
64  return agentId === undefined
65    ? update($, run, r => (r ? apply(r) : r))
66    : update($, agents, list => list.map(a => (a.id === agentId ? apply(a) : a)))
67}
68
69// Tools that always wait on the user; any other main-loop call waits when its check says ask.
70const ASKS_USER = ['AskUserQuestion', 'ExitPlanMode']
71
72const pause = async ($: EngineInterface, id: string | undefined) => {
73  const now = await $.clock.now()
74  await update($, run, r => (r && r.pausedAt === undefined ? { ...r, pausedAt: now, pauseId: id } : r))
75}
76
77const resume = async ($: EngineInterface, id: string | undefined) => {
78  const now = await $.clock.now()
79  await update($, run, r =>
80    r && r.pausedAt !== undefined && r.pauseId === id
81      ? { ...r, pausedMs: (r.pausedMs ?? 0) + now - r.pausedAt, pausedAt: undefined, pauseId: undefined }
82      : r,
83  )
84}
85
86let tick: Timer | undefined
87
88function ensureTick($: EngineInterface) {
89  tick ??= $.clock.every(1000, async () => {
90    const now = await $.clock.now()
91    await update($, clock, () => now)
92
93    const tracked = await read($, agents)
94    if (tracked.length > 0) {
95      const live = new Set((await $.agent.list()).filter(a => LIVE.includes(a.status)).map(a => a.id))
96      const ended = tracked.filter(a => !live.has(a.id))
97      for (const a of ended) {
98        const key = `avgAgentMs:${a.type}`
99        await $.store.set(key, ema((await $.store.get(key)) as number | undefined, now - a.startedAt))
100      }
101      if (ended.length > 0) await update($, agents, list => list.filter(a => live.has(a.id)))
102    }
103
104    if ((await read($, run)) === null && (await read($, agents)).length === 0) {
105      tick?.cancel()
106      tick = undefined
107    }
108  })
109}
110
111export const register: Register = on => {
112  on('prompt.submit', async ($, e, next) => {
113    const now = await $.clock.now()
114    const avgKey = `avgTurnMs:${await $.session.root()}`
115    const avgMs = ((await $.store.get(avgKey)) ?? (await $.store.get('avgTurnMs'))) as number | undefined
116    await update($, clock, () => now)
117    await update($, run, () => ({ startedAt: now, done: 0, total: 0, avgMs, avgKey }))
118    ensureTick($)
119    return next(e)
120  }).catch(($, e, next) => next(e))
121
122  on('agent.spawn', async ($, e, next) => {
123    const res = await next(e)
124    if (res.deny === undefined && res.agentId) {
125      const id = res.agentId
126      const now = await $.clock.now()
127      const avgMs = (await $.store.get(`avgAgentMs:${e.subagentType}`)) as number | undefined
128      const label = e.description.length > 24 ? `${e.description.slice(0, 23)}…` : e.description
129      const a: SubAgent = { id, type: e.subagentType, label, startedAt: now, done: 0, total: 0, avgMs }
130      await update($, agents, list => [...list.filter(x => x.id !== id), a])
131      ensureTick($)
132    }
133    return res
134  }).catch(($, e, next) => next(e))
135
136  // ponytail: an asked call's pause also covers the tool's own run after approval; fine unless approved calls run long
137  on('tool.check', async ($, e, next) => {
138    const res = await next(e)
139    if (e.agentId === undefined && e.tool_use_id && res.decision === 'ask') await pause($, e.tool_use_id)
140    return res
141  }).catch(($, e, next) => next(e))
142
143  on('tool.call', async ($, e, next) => {
144    if (e.agentId === undefined && ASKS_USER.includes(e.tool)) await pause($, e.tool_use_id)
145    try {
146      return await next(e)
147    } finally {
148      if (e.agentId === undefined) await resume($, e.tool_use_id).catch(() => {})
149    }
150  }).catch(($, e, next) => next(e))
151
152  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
153    const done = e.todos.filter(t => t.status === 'completed').length
154    await onTodos($, e.agentId, () => ({ done, total: e.todos.length }))
155    return next(e)
156  }).catch(($, e, next) => next(e))
157
158  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
159    await onTodos($, e.agentId, r => ({ total: r.total + 1 }))
160    return next(e)
161  }).catch(($, e, next) => next(e))
162
163  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
164    if (e.status === 'completed') await onTodos($, e.agentId, r => ({ done: r.done + 1 }))
165    if (e.status === 'deleted') await onTodos($, e.agentId, r => ({ total: r.total - 1 }))
166    return next(e)
167  }).catch(($, e, next) => next(e))
168
169  // Subagent loops raise turn.complete too; only the main loop ends the run.
170  // Short or interrupted turns would drag the average away from real tasks, so they don't count.
171  on('turn.complete', async ($, e, next) => {
172    if (e.agentId !== undefined) return next(e)
173    const r = await read($, run)
174    if (r && !e.isAborted) {
175      const ms = active(r, await $.clock.now())
176      if (ms >= MIN_TURN_MS) {
177        if (r.avgKey) await $.store.set(r.avgKey, ema(r.avgMs, ms))
178        await $.store.set('avgTurnMs', ema((await $.store.get('avgTurnMs')) as number | undefined, ms))
179      }
180    }
181    await update($, run, () => null)
182    return next(e)
183  })
184
185  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186    const r = await read($, run)
187    const subs = await read($, agents)
188    const showMain = e.props.isWorking && r !== null
189    if (e.props.hasSurvey || (!showMain && subs.length === 0)) return next(e)
190
191    const below = await next(e)
192    const now = await read($, clock)
193    const { Box, Text } = $.ui.resolve(e)
194
195    return (
196      <Box flexDirection="column">
197        {showMain && r ? <Text color="cyan">任務 {line(r, now)}</Text> : null}
198        {subs.map(a => (
199          <Text key={a.id} color="magenta">
200            {'  ↳ '}{a.label} <Text dimColor>({a.type})</Text> {line(a, now)}
201          </Text>
202        ))}
203        {below}
204      </Box>
205    )
206  })
207}
208
types/index.d.ts 22 lines
1export type Run = {
2  startedAt: number
3  done: number
4  total: number
5  avgMs?: number
6  // Time spent waiting on the user (permission dialogs, questions), excluded from estimates.
7  pausedMs?: number
8  pausedAt?: number
9  pauseId?: string
10  // Active time when the todo list first appeared; step pace is measured from here.
11  todoAt?: number
12  avgKey?: string
13}
14
15export type SubAgent = Run & { id: string; type: string; label: string }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'task-progress': { run: Run | null; agents: SubAgent[]; now: number }
20  }
21}
22