SLOPSHOPPER

stark-progress

Live progress bars above the prompt for Gru, Minion and Agnes runs, read from ~/.cache/stark-progress/

newbandtimer
v0.1.0no licenseupdated 2026-10-1021StarkCom/bifrost/mods/stark-progress
A shopper browsing a rack in a slop shop
README

stark-progress

A Claude Code mod that draws live progress bars above the prompt for Gru, Minion and Agnes runs: one bar per ticket, filled by stage, and an n/m closed total in Gru's tab. It polls the state files below once a second.

Install it on its own; the seven skill plugins do not need it:

/plugin install stark-progress@bifrost

The state contract

The skills write these files; the mod only reads them. They are runtime-neutral: a Codex worker writes them too, and only a Claude Code session draws them. Every file is whole JSON, written atomically: a writer writes <name>.json.tmp beside it and renames it over <name>.json, so a poll never reads a half-written file (the worker spine's command does this for a ticket's file). A field of the wrong type reads as absent: a stage that is not a string draws as ticket.

~/.cache/stark-progress/<STARK-n>.json, one per ticket:

{ "id": "STARK-n", "title": "<ticket title>", "stage": "pr", "pr": "<PR url or null>", "updated": "<ISO 8601>" }

stage is one of ticket, pr, review, merged, closed (the bar fills in that order) or blocked (drawn empty and red).

~/.cache/stark-progress/run-<id>.json, one per Gru run, <id> being Gru's launch id (the epic's, or GRU-n):

{ "epic": "STARK-n", "session": "<Gru's session id>", "tickets": ["STARK-a", "STARK-b"] }

session is Gru's own session id ($CLAUDE_CODE_SESSION_ID, or $CODEX_THREAD_ID on Codex). A ticket with no file yet draws as ticket.

Which tab shows what

  • A session that a run file names as its session shows that run: Gru's tab, even though it stands in a worktree named for its launch id (the epic's own STARK-n, or GRU-n).
  • Any other session whose project root (where it started, or the worktree it moved to; a shell cd does not move it) is a folder named STARK-n, a worker's worktree, shows that ticket's bar, once its file exists.
  • Nothing matching draws nothing.

Each poll lists the directory and reads the run files, then only the ticket files the tab draws, so old ticket files cost a listing and nothing more.

Develop

claude plugin validate mods/stark-progress
claude plugin test mods/stark-progress
claude --plugin-dir mods/stark-progress
Source 2 files
hooks/register.tsx 155 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Row, RunFile, Stage, TicketFile, View } from '../types'
5
6// The state contract is in ../README.md.
7const STAGES: Stage[] = ['ticket', 'pr', 'review', 'merged', 'closed']
8const WIDTH = 20
9const POLL_MS = 1000
10const TICKET_ID = /^STARK-\d+$/
11
12const view = atom({ plugin: 'stark-progress', key: 'view' } as const, null)
13
14/** A bar filled by stage; `blocked` and anything unknown draw empty. */
15export function bar(stage: string): string {
16  const at = STAGES.indexOf(stage as Stage)
17  const filled = at < 0 ? 0 : Math.round(((at + 1) / STAGES.length) * WIDTH)
18
19  return '█'.repeat(filled) + '░'.repeat(WIDTH - filled)
20}
21
22function parse<T>(text: string | undefined): Partial<T> | null {
23  if (!text) return null
24  try {
25    const value: unknown = JSON.parse(text)
26
27    return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Partial<T>) : null
28  } catch {
29    return null
30  }
31}
32
33/** A ticket's row; a field of the wrong type reads as absent, so drawing never throws. */
34function row(id: string, text: string | undefined): Row {
35  const t = parse<TicketFile>(text)
36
37  return {
38    id,
39    title: typeof t?.title === 'string' ? t.title : '',
40    stage: typeof t?.stage === 'string' ? t.stage : 'ticket',
41  }
42}
43
44const isRunName = (name: string) => name.startsWith('run-') && name.endsWith('.json')
45
46/** The first run file, by name, whose `session` is this session's: Gru's run. */
47export function ownRun(session: string, files: Record<string, string>): RunFile | null {
48  for (const name of Object.keys(files).filter(isRunName).sort()) {
49    const run = parse<RunFile>(files[name])
50    if (run?.session !== session || !Array.isArray(run.tickets)) continue
51    const tickets = run.tickets.filter((id): id is string => typeof id === 'string')
52
53    return { epic: typeof run.epic === 'string' ? run.epic : '', session, tickets: [...new Set(tickets)] }
54  }
55
56  return null
57}
58
59/** The ticket a worker's worktree is named for, from the session's project root. */
60export function ticketOf(root: string): string | null {
61  const base = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
62
63  return TICKET_ID.test(base) ? base : null
64}
65
66/**
67 * What this tab shows, from the session's project root and id and the contract
68 * directory's files by name. A run file whose `session` is this session's wins
69 * (Gru's tab, which stands in a worktree named for its launch id, the epic's
70 * own `STARK-n` included); otherwise a worker's worktree is named for its
71 * ticket, so its tab shows that ticket's file.
72 */
73export function buildView(root: string, session: string, files: Record<string, string>): View | null {
74  const run = ownRun(session, files)
75  if (run !== null) {
76    const rows = run.tickets.map(id => row(id, files[`${id}.json`]))
77    const closed = rows.filter(r => r.stage === 'closed').length
78
79    return { heading: `Gru · ${run.epic} · ${closed}/${rows.length} closed`, rows }
80  }
81
82  const id = ticketOf(root)
83  const text = id === null ? undefined : files[`${id}.json`]
84
85  return id === null || text === undefined ? null : { heading: null, rows: [row(id, text)] }
86}
87
88export const register: Register = on => {
89  let last = ''
90  let isPolling = false
91
92  on('session.start', async ($, e, next) => {
93    const home = await $.env.get('HOME')
94    if (!home) return next(e)
95    const dir = `${home}/.cache/stark-progress`
96
97    $.clock.every(POLL_MS, async () => {
98      // A slow tick must not overlap the next one and write an older view last.
99      if (isPolling) return
100      isPolling = true
101      try {
102        const listed = await $.fs.list(dir).catch(() => [])
103        const names = new Set(listed.filter(f => f.kind === 'file' && f.name.endsWith('.json')).map(f => f.name))
104        const files: Record<string, string> = {}
105        const load = async (name: string) => {
106          if (!names.has(name)) return
107          const text = await $.fs.read(`${dir}/${name}`).catch(() => undefined)
108          if (typeof text === 'string') files[name] = text
109        }
110
111        // The run files first; then only the ticket files this tab draws, so a
112        // directory of old tickets costs a listing, not a read of each.
113        const [root, session] = await Promise.all([$.session.root(), $.session.id()])
114        await Promise.all([...names].filter(isRunName).map(load))
115        const run = ownRun(session, files)
116        const ticket = ticketOf(root)
117        const wanted = run !== null ? run.tickets : ticket === null ? [] : [ticket]
118        await Promise.all(wanted.map(id => load(`${id}.json`)))
119
120        const fresh = buildView(root, session, files)
121        const key = JSON.stringify(fresh)
122        if (key === last) return
123        last = key
124        await update($, view, () => fresh)
125      } finally {
126        isPolling = false
127      }
128    })
129
130    return next(e)
131  })
132
133  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134    const current = await read($, view)
135    if (e.props.hasSurvey || current === null || current.rows.length === 0) {
136      return next(e)
137    }
138
139    const { Box, Text } = $.ui.resolve(e)
140    const color = (stage: Stage) => (stage === 'closed' ? 'green' : stage === 'blocked' ? 'red' : 'cyan')
141
142    return (
143      <Box flexDirection="column">
144        {current.heading === null ? null : <Text bold>{current.heading}</Text>}
145        {current.rows.map(r => (
146          <Text key={r.id}>
147            <Text color={color(r.stage)}>{bar(r.stage)}</Text> {r.id.padEnd(11)} {r.stage.padEnd(7)}{' '}
148            <Text dimColor>{r.title}</Text>
149          </Text>
150        ))}
151      </Box>
152    )
153  })
154}
155
types/index.d.ts 29 lines
1export type Stage = 'ticket' | 'pr' | 'review' | 'merged' | 'closed' | 'blocked'
2
3/** `~/.cache/stark-progress/<STARK-n>.json`, one per ticket. */
4export type TicketFile = {
5  id: string
6  title: string
7  stage: Stage
8  pr: string | null
9  updated: string
10}
11
12/** `~/.cache/stark-progress/run-<id>.json`, one per Gru run. */
13export type RunFile = {
14  epic: string
15  session: string
16  tickets: string[]
17}
18
19export type Row = { id: string; title: string; stage: Stage }
20
21/** What the band draws: a Gru run's rows, or a worker's one row. */
22export type View = { heading: string | null; rows: Row[] }
23
24declare module 'claude-code' {
25  interface PluginState {
26    'stark-progress': { view: View | null }
27  }
28}
29