SLOPSHOPPER

athena-progress

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

newpanecommandtimer
v0.1.0no licenseupdated 2026-10-16itamartz/ClaudeCode-Mods/progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · athena-progress
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ No progress files │ ┃ C:\Users\Itamartz\AthenaDev\progress ⏺ 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 │ │ › /progress │ ⎿ athena-progress: Progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Progress
No progress files C:\Users\Itamartz\AthenaDev\progress
README

athena-progress

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.

Install

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

Configure

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" }
}

Commands

CommandWhat it does
/progressToggle the pane
/progress refreshRe-read the folder now
/progress clearHide finished files until they change

Progress file format

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.

Tests

From the repository root:

claude plugin test progress
Source 3 files
hooks/register.js 125 lines
1import { 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}
125
hooks/progress.js 92 lines
1// 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}
92
types/index.d.ts 9 lines
1export 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