SLOPSHOPPER

run-status

Shows the long tool runs' progress files (.progress/): a status line, a pane with a bar per process, and toasts; /runs, /runs clear

newpanecommandtoaststatusprocess
v0.1.0no licenseupdated 2026-10-1522nmcdon/Riftrite/.claude/skills/run-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-status
│ ┃ Runs ✕ › fix the failing auth test and add an audit log call │ ┃ No runs. The long tools write to .progress/ │ ┃ while they go. ⏺ 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 │ │ › /runs │ ⎿ run-status: No runs. The long tools write to .progress/ while th │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Runs
No runs. The long tools write to .progress/ while they go.
Source 3 files
hooks/register.tsx 186 lines
1// Run status: shows the long tools' progress files (tools/progress.gd writes
2// <repo>/.progress/<id>.json) while they run, as a status line, a pane of one
3// card per group (a bar per process), and a toast when a group ends.
4// /runs opens the pane; /runs clear deletes the files of finished, failed,
5// and dead jobs. No model calls. The logic is in model.ts.
6
7import { atom, read, update } from 'claude-code'
8import type { Engine, Register } from 'claude-code'
9
10import type { GroupView, JobState } from '../types'
11import {
12  bar,
13  buildGroups,
14  duration,
15  endToast,
16  groupLine,
17  historyKey,
18  isGoing,
19  parseProgress,
20  percent,
21  quarter,
22  statusLine,
23  statusText,
24  type History,
25  type Seen,
26} from './model'
27
28const PANE = 'run-status'
29const POLL_MS = 2000
30const HISTORY = 'history'
31const groupsAtom = atom({ plugin: 'run-status', key: 'groups' } as const, [] as GroupView[])
32
33const STATE_COLOR: Record<string, string> = {
34  running: 'suggestion',
35  stale: 'warning',
36  dead: 'error',
37  failed: 'error',
38  done: 'success',
39}
40
41// The module's own memory (a reload starts it over): the states last seen
42// (for toasts), and whether a poll ran.
43const lastState = new Map<string, JobState>()
44let hasPolled = false
45let isPolling = false
46const lastQuarter = new Map<string, number>()
47
48// A line in the conversation every client shows (the remote ones draw no
49// pane or status line), never read by the model.
50async function announce($: Engine, text: string): Promise<void> {
51  await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `run-status: ${text}` }] } }).catch(() => undefined)
52}
53
54async function progressDir($: Engine): Promise<string> {
55  const custom = await $.env.get('RIFTRITE_PROGRESS_DIR')
56  return custom || `${await $.session.root()}/.progress`
57}
58
59// Alive unless ps finds no such process or a zombie (one that exited and
60// hasn't been reaped, which `kill -0` would call alive).
61async function isAlive($: Engine, pid: number): Promise<boolean> {
62  try {
63    const { exitCode, stdout } = await $.process.run(['ps', '-o', 'stat=', '-p', String(pid)], { timeoutMs: 5000 })
64    return exitCode === 0 && !stdout.trim().startsWith('Z')
65  } catch {
66    return true // couldn't ask: not called dead
67  }
68}
69
70async function readFiles($: Engine): Promise<Seen[]> {
71  const dir = await progressDir($)
72  if (!(await $.fs.exists(dir))) return []
73  const seen: Seen[] = []
74  for (const entry of await $.fs.list(dir)) {
75    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
76    const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => undefined)
77    const file = text === undefined ? undefined : parseProgress(entry.name, text)
78    if (file === undefined) continue
79    seen.push({ file, isAlive: file.status === 'running' ? await isAlive($, file.pid) : true })
80  }
81  return seen
82}
83
84async function poll($: Engine): Promise<GroupView[]> {
85  if (isPolling) return read($, groupsAtom)
86  isPolling = true
87  try {
88    const now = await $.clock.now()
89    const history = ((await $.store.get(HISTORY)) ?? {}) as History
90    const seen = await readFiles($)
91    const groups = buildGroups(seen, now, history)
92    let learned = false
93    for (const group of groups) {
94      const toast = hasPolled ? endToast(group, lastState.get(group.id)) : undefined
95      const reached = quarter(group)
96      const before = lastQuarter.get(group.id)
97      if (hasPolled && toast === undefined && isGoing(group) && (before === undefined || reached > before)) {
98        await announce($, groupLine(group))
99      }
100      lastQuarter.set(group.id, reached)
101      if (toast !== undefined) {
102        $.ui.toast(toast, { timeoutMs: 8000 })
103        await announce($, toast)
104        if (group.state === 'done' && group.total > 0) {
105          history[historyKey(group.kind, group.label, group.parts.length)] = group.elapsedMs / group.total
106          learned = true
107        }
108      }
109      lastState.set(group.id, group.state)
110    }
111    if (learned) await $.store.set(HISTORY, history)
112    hasPolled = true
113    await update($, groupsAtom, () => groups)
114    $.ui.status(statusLine(groups))
115    return groups
116  } finally {
117    isPolling = false
118  }
119}
120
121export const register: Register = on => {
122  on('session.start', async ($, e, next) => {
123    await $.command.register({
124      name: 'runs',
125      description: 'Show the long tool runs (run_runner, sim_runner, placement) and their progress',
126      argumentHint: '[clear]',
127    })
128    $.clock.every(POLL_MS, () => {
129      void poll($).catch(() => undefined)
130    })
131    const groups = await poll($).catch(() => [] as GroupView[])
132    await announce($, `watching .progress/: ${statusLine(groups) ?? 'nothing running'}`)
133    return next(e)
134  })
135
136  on('command.run', { command: 'runs' }, async ($, e) => {
137    if ((e.args ?? '').trim() === 'clear') {
138      const dir = await progressDir($)
139      const groups = await poll($)
140      const gone = groups.flatMap(g => g.parts.filter(p => p.state === 'done' || p.state === 'failed' || p.state === 'dead'))
141      for (const part of gone) {
142        await $.process.run(['rm', '-f', `${dir}/${part.id}.json`]).catch(() => undefined)
143      }
144      await poll($)
145      return { text: gone.length === 0 ? 'No finished, failed, or dead runs to clear.' : `Cleared ${gone.length} progress file${gone.length === 1 ? '' : 's'}.` }
146    }
147    await poll($)
148    const groups = await poll($).catch(() => [] as GroupView[])
149    await $.ui.open({ id: PANE, title: 'Runs' })
150    return { text: statusText(groups) }
151  })
152
153  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154    const { Box, Text } = $.ui.resolve(e)
155    const groups = await read($, groupsAtom)
156    const width = Math.max(10, Math.min(48, (e.viewport?.columns ?? 60) - 14))
157    return (
158      <Box flexDirection="column" gap={1}>
159        {groups.length === 0 && <Text dimColor>No runs. The long tools write to .progress/ while they go.</Text>}
160        {groups.map(group => (
161          <Box key={group.id} flexDirection="column">
162            <Box flexDirection="row" justifyContent="space-between">
163              <Text bold>{group.label}</Text>
164              <Text color={STATE_COLOR[group.state]}>{group.state}</Text>
165            </Box>
166            <Text>
167              {bar(group.done, group.total, width)} {percent(group.done, group.total)}%
168            </Text>
169            <Text dimColor>
170              {group.done}/{group.total} {group.kind} · {duration(group.elapsedMs)} elapsed
171              {group.etaMs !== null ? ` · ~${duration(group.etaMs)} left` : group.state === 'running' ? ' · ETA unknown' : ''}
172            </Text>
173            {group.parts.length > 1 &&
174              group.parts.map(part => (
175                <Text key={part.id} color={part.state === 'running' || part.state === 'done' ? undefined : STATE_COLOR[part.state]} dimColor={part.state === 'done'}>
176                  {(part.part || part.id).padStart(7)} {bar(part.done, part.total, Math.max(6, Math.floor(width / 2)))} {part.done}/{part.total}
177                  {part.state === 'running' ? '' : ` ${part.state}`}
178                </Text>
179              ))}
180          </Box>
181        ))}
182      </Box>
183    )
184  })
185}
186
hooks/model.ts 271 lines
1// The run-status mod's logic, with no `$`: reading progress files, each
2// part's and group's state, the ETA, and the text the status line, the pane,
3// /runs, and the notices show. register.tsx does the reading, the timer and the drawing.
4
5import type { GroupView, JobState, PartView, ProgressFile } from '../types'
6
7/** A part with no progress for this long is stale (longer when its units are slow). */
8export const STALE_MS = 60_000
9/**
10 * No progress for this many units' worth of its pace before a part is stale:
11 * one run can take several times the average, and a dead process is caught
12 * by its pid, so stale is only for a process that's alive and stuck.
13 */
14export const STALE_UNITS = 10
15/** With no pace to go on yet (no unit done anywhere, no history), stale after this long. */
16export const STALE_UNKNOWN_MS = 30 * 60_000
17/**
18 * How many of the group's units a process's own count as before its own pace
19 * outweighs the group's: a few finished units are noise, and the slowest of
20 * eight noisy guesses ran hours long early in a run.
21 */
22export const PACE_PRIOR_UNITS = 6
23
24/** Wall ms per unit for a whole group, by historyKey. */
25export type History = Record<string, number>
26
27/** A file as read, with whether its process still runs (true when unknown). */
28export type Seen = { file: ProgressFile; isAlive: boolean }
29
30const STATUSES = ['running', 'done', 'failed'] as const
31
32/** A progress file's contents, or undefined if it isn't one. */
33export function parseProgress(name: string, text: string): ProgressFile | undefined {
34  let data: unknown
35  try {
36    data = JSON.parse(text)
37  } catch {
38    return undefined
39  }
40  if (typeof data !== 'object' || data === null) return undefined
41  const d = data as Record<string, unknown>
42  const num = (key: string): number | undefined => (typeof d[key] === 'number' ? (d[key] as number) : undefined)
43  const str = (key: string): string => (typeof d[key] === 'string' ? (d[key] as string) : '')
44  const total = num('total')
45  const done = num('done')
46  const started = num('started_ms')
47  const updated = num('updated_ms')
48  const pid = num('pid')
49  const status = STATUSES.find(s => s === d['status'])
50  if (total === undefined || done === undefined || started === undefined || updated === undefined || pid === undefined || status === undefined) {
51    return undefined
52  }
53  const id = name.replace(/\.json$/, '')
54  return {
55    id,
56    label: str('label') || id,
57    kind: str('kind') || 'units',
58    total,
59    done,
60    started_ms: started,
61    updated_ms: updated,
62    group: str('group') || id,
63    part: str('part'),
64    pid,
65    status,
66  }
67}
68
69export function historyKey(kind: string, label: string, parts: number): string {
70  return `${kind}|${label}|${parts}`
71}
72
73/** "k/N" as "k+1 of N"; "" stays "". */
74export function partName(part: string): string {
75  const m = /^(\d+)\/(\d+)$/.exec(part)
76  return m ? `${Number(m[1]) + 1} of ${m[2]}` : ''
77}
78
79/**
80 * A part's state. Done and failed come from the file; a running file whose
81 * process is gone is dead; one with no progress for longer than STALE_UNITS
82 * units at its pace (at least STALE_MS; STALE_UNKNOWN_MS with no pace) is
83 * stale.
84 */
85export function partState(file: ProgressFile, isAlive: boolean, now: number, paceMs: number | undefined): JobState {
86  if (file.status !== 'running') return file.status
87  if (!isAlive) return 'dead'
88  const limit = paceMs === undefined ? STALE_UNKNOWN_MS : Math.max(STALE_MS, STALE_UNITS * paceMs)
89  return now - file.updated_ms > limit ? 'stale' : 'running'
90}
91
92/** A group's state from its parts': dead, stale, running, failed, then done. */
93export function groupState(states: JobState[]): JobState {
94  for (const state of ['dead', 'stale', 'running', 'failed'] as const) {
95    if (states.includes(state)) return state
96  }
97  return 'done'
98}
99
100export function isActive(state: JobState): boolean {
101  return state === 'running' || state === 'stale'
102}
103
104/** Whether any of a group's processes still runs (a group with a dead part may). */
105export function isGoing(group: GroupView): boolean {
106  return group.parts.some(p => isActive(p.state))
107}
108
109/**
110 * One process's ms per unit: its finished units' time blended with the
111 * group's pace (`groupMs`, one process's ms per unit over the group, or
112 * history's), the group's counting as PACE_PRIOR_UNITS units; undefined with
113 * neither.
114 */
115export function pace(file: ProgressFile, groupMs: number | undefined): number | undefined {
116  const own = file.done > 0 ? file.updated_ms - file.started_ms : 0
117  if (groupMs === undefined) return file.done > 0 ? own / file.done : undefined
118  return (own + PACE_PRIOR_UNITS * groupMs) / (file.done + PACE_PRIOR_UNITS)
119}
120
121/** One process's ms left at `paceMs`, less what its unit under way has already taken. */
122export function eta(file: ProgressFile, now: number, paceMs: number | undefined): number | null {
123  const left = file.total - file.done
124  if (left <= 0) return 0
125  if (paceMs === undefined) return null
126  return Math.max(0, left * paceMs - Math.min(now - file.updated_ms, paceMs))
127}
128
129/**
130 * The files grouped and measured, newest group first. A group's ETA is its
131 * slowest running process's, since the last units wait on the slowest while
132 * the rest stand done.
133 */
134export function buildGroups(seen: Seen[], now: number, history: History): GroupView[] {
135  const byGroup = new Map<string, Seen[]>()
136  for (const one of seen) {
137    const list = byGroup.get(one.file.group) ?? []
138    list.push(one)
139    byGroup.set(one.file.group, list)
140  }
141  const groups: GroupView[] = []
142  for (const [id, list] of byGroup) {
143    list.sort((a, b) => a.file.part.localeCompare(b.file.part, undefined, { numeric: true }) || a.file.id.localeCompare(b.file.id))
144    const first = list[0]!.file
145    const partCount = list.length
146    const groupHistory = history[historyKey(first.kind, first.label, partCount)]
147    // One process's ms per unit over the group's finished units, else history's.
148    const doneSoFar = list.reduce((sum, one) => sum + one.file.done, 0)
149    const groupMs = doneSoFar > 0
150      ? list.reduce((sum, one) => sum + (one.file.done > 0 ? one.file.updated_ms - one.file.started_ms : 0), 0) / doneSoFar
151      : groupHistory === undefined ? undefined : groupHistory * partCount
152    const paces = list.map(({ file }) => pace(file, groupMs))
153    const parts: PartView[] = list.map(({ file, isAlive }, i) => ({
154      id: file.id,
155      part: partName(file.part),
156      total: file.total,
157      done: file.done,
158      state: partState(file, isAlive, now, paces[i]),
159    }))
160    const total = list.reduce((sum, one) => sum + one.file.total, 0)
161    const done = list.reduce((sum, one) => sum + one.file.done, 0)
162    const state = groupState(parts.map(p => p.state))
163    const isGoing = parts.some(p => isActive(p.state))
164    const startedMs = Math.min(...list.map(one => one.file.started_ms))
165    const endedMs = isGoing ? now : Math.max(...list.map(one => one.file.updated_ms))
166    const elapsedMs = Math.max(0, endedMs - startedMs)
167    let etaMs: number | null = isGoing ? 0 : null
168    list.forEach(({ file }, i) => {
169      if (etaMs === null || !isActive(parts[i]!.state)) return
170      const left = eta(file, now, paces[i])
171      etaMs = left === null ? null : Math.max(etaMs, left)
172    })
173    groups.push({
174      id,
175      label: first.label,
176      kind: first.kind,
177      total,
178      done,
179      state,
180      startedMs,
181      elapsedMs,
182      etaMs,
183      parts,
184    })
185  }
186  return groups.sort((a, b) => b.startedMs - a.startedMs)
187}
188
189export function percent(done: number, total: number): number {
190  return total <= 0 ? 100 : Math.floor((done * 100) / total)
191}
192
193/** "1h12m", "4m", "45s". */
194export function duration(ms: number): string {
195  const s = Math.max(0, Math.round(ms / 1000))
196  const h = Math.floor(s / 3600)
197  const m = Math.floor((s % 3600) / 60)
198  if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
199  if (m > 0) return `${m}m`
200  return `${s}s`
201}
202
203/** "2 runs · 47% · ~1h12m left", or undefined when nothing runs. */
204export function statusLine(groups: GroupView[]): string | undefined {
205  const active = groups.filter(isGoing)
206  if (active.length === 0) return undefined
207  const total = active.reduce((sum, g) => sum + g.total, 0)
208  const done = active.reduce((sum, g) => sum + g.done, 0)
209  const etas = active.map(g => g.etaMs)
210  const left = etas.some(e => e === null) ? 'ETA unknown' : `~${duration(Math.max(...(etas as number[])))} left`
211  const stale = active.filter(g => g.state === 'stale').length
212  const dead = active.filter(g => g.state === 'dead').length
213  const words = [`${active.length} ${active.length === 1 ? 'run' : 'runs'}`, `${percent(done, total)}%`, left]
214  if (stale > 0) words.push(`${stale} stale`)
215  if (dead > 0) words.push(`${dead} dead`)
216  return words.join(' · ')
217}
218
219/** The toast for a group that has just ended, or undefined if it hasn't. */
220export function endToast(group: GroupView, previous: JobState | undefined): string | undefined {
221  if (isActive(group.state) || (previous !== undefined && !isActive(previous))) return undefined
222  switch (group.state) {
223    case 'done':
224      return `${group.label}: done, ${group.total} ${group.kind} in ${duration(group.elapsedMs)}`
225    case 'failed':
226      return `${group.label}: failed (${group.done} of ${group.total} ${group.kind})`
227    case 'dead': {
228      const dead = group.parts.filter(p => p.state === 'dead').map(p => p.part || p.id)
229      return `${group.label}: a process died (${dead.join(', ')})`
230    }
231  }
232  return undefined
233}
234
235/** A text bar `width` cells wide. */
236export function bar(done: number, total: number, width: number): string {
237  const filled = total <= 0 ? width : Math.min(width, Math.floor((done * width) / total))
238  return '█'.repeat(filled) + '░'.repeat(width - filled)
239}
240
241/** One line of a group: label, percent, done of total, elapsed, ETA, and state. */
242export function groupLine(group: GroupView): string {
243  const left = group.etaMs !== null ? ` · ~${duration(group.etaMs)} left` : isGoing(group) ? ' · ETA unknown' : ''
244  return `${group.label}: ${percent(group.done, group.total)}% · ${group.done}/${group.total} ${group.kind} · ${duration(group.elapsedMs)} elapsed${left} · ${group.state}`
245}
246
247/** The whole status as text (what /runs answers, for surfaces that draw no pane). */
248export function statusText(groups: GroupView[]): string {
249  if (groups.length === 0) return 'No runs. The long tools write to .progress/ while they go.'
250  const lines: string[] = []
251  for (const group of groups) {
252    lines.push(groupLine(group))
253    lines.push(`  ${bar(group.done, group.total, 30)}`)
254    if (group.parts.length > 1) {
255      for (const part of group.parts) {
256        lines.push(`  ${part.part || part.id}: ${part.done}/${part.total}${part.state === 'running' ? '' : ` ${part.state}`}`)
257      }
258    }
259  }
260  return lines.join('\n')
261}
262
263/**
264 * The quarter a group has reached (0 to 4), for the notices: one at each
265 * 25% it passes, so a long run reports a few times, not every poll.
266 */
267export function quarter(group: GroupView): number {
268  return group.total <= 0 ? 4 : Math.min(4, Math.floor((group.done * 4) / group.total))
269}
270
271
types/index.d.ts 55 lines
1// The run-status mod's contract: the progress files tools/progress.gd writes,
2// and the groups the mod's pane and status line show.
3
4/** One <repo>/.progress/<id>.json, as tools/progress.gd writes it. */
5export type ProgressFile = {
6  /** The file's name without `.json`: the tool and its process id. */
7  id: string
8  label: string
9  /** What a unit is: runs, encounters, teams, builds. */
10  kind: string
11  total: number
12  done: number
13  /** Wall-clock ms since the epoch. */
14  started_ms: number
15  updated_ms: number
16  /** The processes splitting one job share it (a --jobs parent's id). */
17  group: string
18  /** "k/N" (k from 0), or "" for a job of one process. */
19  part: string
20  pid: number
21  status: 'running' | 'done' | 'failed'
22}
23
24/** running; stale (no progress for too long); dead (its process is gone); done; failed. */
25export type JobState = 'running' | 'stale' | 'dead' | 'done' | 'failed'
26
27export type PartView = {
28  id: string
29  /** "1 of 4", or "" for a job of one process. */
30  part: string
31  total: number
32  done: number
33  state: JobState
34}
35
36export type GroupView = {
37  id: string
38  label: string
39  kind: string
40  total: number
41  done: number
42  state: JobState
43  startedMs: number
44  elapsedMs: number
45  /** Ms left, or null while there's nothing to go on. */
46  etaMs: number | null
47  parts: PartView[]
48}
49
50declare module 'claude-code' {
51  interface PluginState {
52    'run-status': { groups: GroupView[] }
53  }
54}
55