Pane listing task progress read from AthenaDev/progress/*.json; /progress toggles it

A Claude Code mod that adds a /progress pane. The pane shows the progress JSON files that Claude subagents write to a folder. Running work is listed first, with its tasks; finished work collapses to one green 😊 line.
Push this repository to GitHub as itamartz/ClaudeCode-Mods. Then, on any computer, run this in a Claude Code terminal session:
/plugin install athena-progress --marketplace itamartz/ClaudeCode-Mods
Answer y to add the marketplace, then pick a scope. The owner/repo in that line must match the GitHub repository name exactly.
For local development, run from the folder instead:
claude --plugin-dir <path>\progress
The mod reads C:\Users\Itamartz\AthenaDev\progress by default. If the folder is elsewhere on a machine, set the progressDir option, either in the config menu (/config) or in settings under pluginConfigs:
"pluginConfigs": {
"athena-progress": { "progressDir": "D:\work\progress" }
}
| Command | What it does |
|---|---|
/progress | Toggle the pane |
/progress refresh | Re-read the folder now |
/progress clear | Hide finished files until they change |
One *.json file per piece of work:
{
"title": "BIOS phase 2",
"updated": "yyyy/MM/dd HH:mm:ss",
"now": { "action": "Running E2E", "since": "yyyy/MM/dd HH:mm:ss" },
"tasks": [
{ "name": "Catalog", "status": "done|running|pending|failed", "detail": "42 attributes" }
]
}
Loose formats are tolerated: status aliases (ok, pass, complete, completed, fail, error) and extra words, tasks given as plain strings, and missing fields all still render.
From the repository root:
claude plugin test progresshooks/register.js 125 lines1import { atom, read, update } from 'claude-code'
2import { MARK, dismissKey, elapsed, isFinished, parseProgress, summary, visible } from './progress.js'
3
4// Read-only: the mod lists and reads this one folder, nothing else.
5// Set by register from the progressDir option (see plugin.json userConfig).
6let DIR = ''
7const PANE = 'athena-progress'
8const REFRESH_MS = 10000
9
10const files = atom({ plugin: 'athena-progress', key: 'files' }, [])
11const readAt = atom({ plugin: 'athena-progress', key: 'readAt' }, '')
12// Mirrors $.store 'dismissed' (kept across sessions) so the pane redraws on change.
13const dismissed = atom({ plugin: 'athena-progress', key: 'dismissed' }, [])
14
15const pad = n => String(n).padStart(2, '0')
16const stamp = d => `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
17
18async function load($) {
19 const entries = await $.fs.list(DIR).catch(() => [])
20 const json = entries.filter(e => e.kind === 'file' && e.name.toLowerCase().endsWith('.json'))
21 json.sort((a, b) => b.mtimeMs - a.mtimeMs)
22 const parsed = await Promise.all(
23 json.map(async e => {
24 const text = await $.fs.read(`${DIR}\\${e.name}`).catch(() => undefined)
25 return text === undefined
26 ? { file: e.name, title: e.name, updated: '', tasks: [], error: 'unreadable' }
27 : parseProgress(e.name, text)
28 }),
29 )
30 await update($, files, () => parsed)
31 const kept = await $.store.get('dismissed')
32 await update($, dismissed, () => (Array.isArray(kept) ? kept : []))
33 const now = await $.clock.now()
34 await update($, readAt, () => stamp(new Date(now)))
35}
36
37const isOpen = async $ => (await $.ui.panes()).some(p => p.id === PANE)
38
39/** @type {import('claude-code').Register} */
40export const register = (on, options) => {
41 DIR = String(options.progressDir)
42 on('session.start', async ($, e, next) => {
43 await $.command.register({
44 name: 'progress',
45 description: 'Toggle the Athena progress pane (refresh re-reads now, clear hides finished work)',
46 argumentHint: '[refresh|clear]',
47 immediate: true,
48 })
49 // Cheap: one directory listing every 20 s, and only while the pane is open.
50 // Always re-read on the tick (one listing + one small read per file): gating on
51 // $.ui.panes() left the pane frozen when the lookup did not match.
52 $.clock.every(REFRESH_MS, async () => {
53 try {
54 await load($)
55 } catch (err) {
56 await $.ui.log(`athena-progress: refresh failed: ${err}`, { to: 'debug' })
57 }
58 })
59 return next(e)
60 })
61
62 on('command.run', { command: 'progress' }, async ($, e) => {
63 if (e.args.trim() === 'refresh') {
64 await load($)
65 return { text: 'Progress re-read.' }
66 }
67 if (e.args.trim() === 'clear') {
68 await load($)
69 const keys = (await read($, files)).filter(f => isFinished(f)).map(dismissKey)
70 const old = await read($, dismissed)
71 const fresh = keys.filter(k => !old.includes(k))
72 // Keep only keys that still match a file, so the store never grows unbounded.
73 const next = [...old.filter(k => keys.includes(k)), ...fresh]
74 await update($, dismissed, () => next)
75 await $.store.set('dismissed', next)
76 return { text: `Cleared ${fresh.length} finished.` }
77 }
78 if (await isOpen($)) {
79 await $.ui.close({ id: PANE })
80 return { text: 'Progress pane closed.' }
81 }
82 await load($)
83 await $.ui.open({ id: PANE, title: 'Progress' })
84 return { text: 'Progress pane opened.' }
85 })
86
87 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88 const { Box, Text } = $.ui.resolve(e)
89 const all = await read($, files)
90 const list = visible(all, await read($, dismissed))
91 const at = await read($, readAt)
92 if (all.length > 0 && list.length === 0) return h(Text, { dimColor: true }, 'No running work')
93 if (list.length === 0) {
94 return h(Box, { flexDirection: 'column' },
95 h(Text, { dimColor: true }, 'No progress files'),
96 h(Text, { dimColor: true, wrap: 'truncate-middle' }, DIR))
97 }
98 return h(Box, { flexDirection: 'column' },
99 ...list.map(f => isFinished(f)
100 ? h(Text, { key: f.file, color: 'success', wrap: 'truncate-end' },
101 `😊 ${f.title} — done · ${f.tasks.length} tasks${f.updated ? ' · updated ' + f.updated.slice(-8) : ''}`)
102 : h(Box, { key: f.file, flexDirection: 'column', marginBottom: 1 },
103 h(Text, { bold: true, wrap: 'truncate-end' }, f.title),
104 f.error
105 ? h(Text, { color: 'error' }, `${f.file}: ${f.error}`)
106 : h(Text, { dimColor: true }, `${summary(f.tasks)}${f.updated ? ' · updated ' + f.updated.slice(-8) : ''}${at ? ' · checked ' + at.slice(-8) : ''}`),
107 f.now
108 ? h(Text, { wrap: 'truncate-end' },
109 h(Text, { color: 'warning' }, '▶ '),
110 f.now.action,
111 h(Text, { dimColor: true }, elapsed(f.now.since, at) ? ` · ${elapsed(f.now.since, at)}` : ''))
112 : '',
113 ...f.tasks.map(t =>
114 h(Text, { wrap: 'truncate-end' },
115 h(Text, { color: MARK[t.status].color }, MARK[t.status].glyph),
116 ` ${t.name}`,
117 t.detail ? h(Text, { dimColor: true }, ` — ${t.detail}`) : ''),
118 ),
119 ),
120 ),
121 h(Text, { dimColor: true }, `read ${at}`),
122 )
123 })
124}
125hooks/progress.js 92 lines1// Pure helpers: parse one progress file and summarise it. No $ here, so tests call them directly.
2
3const STATUSES = ['done', 'running', 'pending', 'failed']
4const ALIAS = { ok: 'done', pass: 'done', complete: 'done', completed: 'done', fail: 'failed', error: 'failed' }
5
6// "done: 11402 pass" -> status by its first word (case-insensitive), the rest becomes detail.
7function normStatus(raw) {
8 const [, word = '', rest = ''] = /^\s*(\S*)\s*(.*)$/s.exec(String(raw ?? '')) ?? []
9 const w = word.toLowerCase().replace(/[^a-z]+$/, '')
10 return { status: STATUSES.includes(w) ? w : ALIAS[w] ?? 'pending', rest: rest.trim() }
11}
12
13function toTask(t) {
14 if (typeof t === 'string') {
15 const m = /\b(done|running|pending|failed)\b/i.exec(t)
16 return { name: t, status: m ? m[1].toLowerCase() : 'pending', detail: '' }
17 }
18 const { status, rest } = normStatus(t.status)
19 const detail = t.detail == null ? '' : String(t.detail)
20 return { name: String(t.name ?? '(unnamed)'), status, detail: detail === '' ? rest : detail }
21}
22
23export const MARK = {
24 done: { glyph: '✓', color: 'success' },
25 running: { glyph: '●', color: 'warning' },
26 pending: { glyph: '○', color: 'inactive' },
27 failed: { glyph: '✗', color: 'error' },
28}
29
30/** @returns {import('../types').ProgressFile} */
31export function parseProgress(file, text) {
32 let data
33 try {
34 data = JSON.parse(text)
35 } catch {
36 return { file, title: file, updated: '', tasks: [], error: 'invalid JSON' }
37 }
38 if (data === null || typeof data !== 'object' || !Array.isArray(data.tasks)) {
39 return { file, title: file, updated: '', tasks: [], error: 'no "tasks" array' }
40 }
41 const tasks = data.tasks
42 .filter(t => typeof t === 'string' || (t !== null && typeof t === 'object'))
43 .map(toTask)
44 // now.action "done" marks the whole file finished, whatever its tasks say; it then has no "now" line.
45 const finished = typeof data.now?.action === 'string' && data.now.action.trim().toLowerCase() === 'done'
46 const now = !finished && data.now !== null && typeof data.now === 'object' && typeof data.now.action === 'string' && data.now.action !== ''
47 ? { action: data.now.action, since: typeof data.now.since === 'string' ? data.now.since : '' }
48 : undefined
49 return {
50 file,
51 title: typeof data.title === 'string' && data.title !== '' ? data.title : file,
52 updated: typeof data.updated === 'string' ? data.updated : '',
53 tasks,
54 ...(finished ? { finished } : {}),
55 ...(now ? { now } : {}),
56 }
57}
58
59// "yyyy/MM/dd HH:mm:ss" (local) -> epoch ms, or NaN.
60export function parseStamp(s) {
61 const m = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(s ?? '')
62 return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() : NaN
63}
64
65// Elapsed between two stamps as "4m 12s" / "1h 03m"; '' when either is unknown.
66export function elapsed(since, at) {
67 const ms = parseStamp(at) - parseStamp(since)
68 if (!Number.isFinite(ms) || ms < 0) return ''
69 const s = Math.floor(ms / 1000)
70 if (s >= 3600) return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
71 return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
72}
73
74// Marked done, or every task done: the file collapses to one smiley line.
75export const isFinished = f => !f.error && (f.finished === true || (f.tasks.length > 0 && f.tasks.every(t => t.status === 'done')))
76
77export function summary(tasks) {
78 const n = s => tasks.filter(t => t.status === s).length
79 const extra = [n('running') && `${n('running')} running`, n('failed') && `${n('failed')} failed`].filter(Boolean)
80 return `${n('done')}/${tasks.length} done${extra.length ? ' · ' + extra.join(' · ') : ''}`
81}
82
83// A dismissal is keyed on file + updated, so a file that changes later shows again.
84export const dismissKey = f => `${f.file}|${f.updated}`
85
86// Not dismissed, running first, finished after; each group keeps its newest-first order.
87export function visible(list, dismissed) {
88 const shown = list.filter(f => !dismissed.includes(dismissKey(f)))
89 const done = isFinished
90 return [...shown.filter(f => !done(f)), ...shown.filter(done)]
91}
92types/index.d.ts 9 lines1export type ProgressTask = { name: string; status: 'done' | 'running' | 'pending' | 'failed'; detail: string }
2export type ProgressFile = { file: string; title: string; updated: string; tasks: ProgressTask[]; error?: string; finished?: boolean; now?: { action: string; since: string } }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'athena-progress': { files: ProgressFile[]; readAt: string; dismissed: string[] }
7 }
8}
9