SLOPSHOPPER

stack-health

Status dots above the prompt for your Docker Compose services and any local ports you name.

newbandprocessnetworktimer
v0.2.1no licenseupdated 2026-10-16Arkboi-GG/claude-mods/mods/stack-health
A shopper browsing a rack in a slop shop
README

claude-mods

A small collection of Claude Code mods.

Install

/plugin install <mod> --marketplace Arkboi-GG/claude-mods

The bars stack above the prompt, so you can install any combination.

Mods

  • task-progress: the band above the prompt. While Claude works on a multi-step task it shows one block per step: the running step fills as "In Progress", finished steps turn green "Completed", and a turn that stops with steps still open turns amber "Paused". When Claude is idle it shows usage rings (context, 5-hour and 7-day limits) with a Compact button. It reads the task list Claude keeps while working, so it appears for multi-step tasks. If you also use the skins mod, run /skin band off once so its own usage band doesn't show twice.
  • repo-status: branch, ahead/behind, changed-file count and the open PR's check state (needs git; gh is optional).
  • stack-health: status dots for your Docker Compose services and any local ports you list (web=5173,api=3000).
  • preflight: a live pre-commit checklist. After Claude edits files it re-runs your typecheck/lint (auto-detected, or set checks) and shows pass/fail with an error count.
  • env-guard: a green LOCAL badge that turns red PROD when a tool call matches your production patterns, and toasts when it happens.
  • agent-crabs: a pixel-art crab for every running subagent, dressed for its role (beret, hard hat, postman cap and more), with the agent's name, what it is working on and a timer. Finished crabs cheer and leave after a few seconds; failed ones turn grey. Name an agent by starting the Agent call's description with Name: , e.g. Olivia: rewrite welcome email; map names to outfits with the outfits option.
Source 2 files
hooks/register.tsx 115 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Dot } from '../types'
5
6const dotsAtom = atom({ plugin: 'stack-health', key: 'dots' } as const, [] as Dot[])
7
8const WIDE = 110
9
10// Shared "dev row" protocol (the same helper sits in every mod of this repo):
11// each mod adds a keyed zone to one row, so the mods fill a single line together
12// and still work alone. Zones sort by their key, `zone-<order>`.
13function addZone(below: any, zone: any, order: number): any {
14  const tagged = { ...zone, props: { ...zone.props, key: `zone-${order}`, flexShrink: 0 } }
15  const byKey = (a: any, b: any) => String(a?.props?.key).localeCompare(String(b?.props?.key))
16  const merge = (node: any): any => {
17    if (!node || typeof node !== 'object') return null
18    if (node.props?.key === 'devrow') return { ...node, children: [...(node.children ?? []), tagged].sort(byKey) }
19    const kids: any[] = node.children ?? []
20    for (let i = 0; i < kids.length; i++) {
21      const hit = merge(kids[i])
22      if (hit) return { ...node, children: kids.map((k, j) => (j === i ? hit : k)) }
23    }
24    return null
25  }
26  const merged = merge(below)
27  if (merged) return merged
28  const row = {
29    type: 'Box',
30    props: { key: 'devrow', paddingX: 1, justifyContent: 'space-between', flexWrap: 'wrap', columnGap: 3 },
31    children: [tagged],
32  }
33  return {
34    type: 'Box',
35    props: { flexDirection: 'column' },
36    children: below ? [below, row] : [row],
37  }
38}
39
40const COLORS = { up: '#22c55e', wait: '#f59e0b', down: '#ef4444' } as const
41
42export const register: Register = (on, options) => {
43  const wantCompose = options.compose !== false
44  const ports = String(options.ports ?? '')
45    .split(',')
46    .map(s => s.trim())
47    .filter(Boolean)
48    .map(s => {
49      const [a, b] = s.split('=')
50      return b ? { name: a.trim(), port: b.trim() } : { name: `:${a}`, port: a }
51    })
52
53  on('session.start', async ($, e, next) => {
54    const poll = async () => {
55      const dots: Dot[] = []
56
57      if (wantCompose) {
58        try {
59          const r = await $.process.run(['docker', 'compose', 'ps', '--all', '--format', 'json'], { timeoutMs: 10000 })
60          if (r.exitCode === 0) {
61            const text = r.stdout.trim()
62            const rows: Array<{ Service?: string; State?: string; Health?: string; Status?: string }> = text.startsWith('[')
63              ? JSON.parse(text)
64              : text.split('\n').filter(Boolean).map(l => JSON.parse(l))
65            for (const row of rows) {
66              const state =
67                row.State === 'running' ? (row.Health === 'starting' ? 'wait' : row.Health === 'unhealthy' ? 'down' : 'up')
68                : row.State === 'restarting' || row.State === 'created' ? 'wait'
69                : 'down'
70              dots.push({ name: row.Service ?? '?', state, detail: (row.Status ?? '').replace(/^Up /i, '').replace(/ \(.*\)/, '').toLowerCase() })
71            }
72          }
73        } catch {
74          // docker not installed or daemon down: no compose dots
75        }
76      }
77
78      for (const p of ports) {
79        try {
80          await $.http.fetch(`http://localhost:${p.port}/`)
81          dots.push({ name: p.name, state: 'up', detail: `:${p.port}` })
82        } catch {
83          dots.push({ name: p.name, state: 'down', detail: `:${p.port}` })
84        }
85      }
86
87      await update($, dotsAtom, () => dots)
88    }
89    await poll()
90    $.clock.every(10000, poll)
91    return next(e)
92  })
93
94  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
95    const below = await next(e)
96    const dots = await read($, dotsAtom)
97    if (e.props.hasSurvey || dots.length === 0) return below
98
99    const { Box, Text } = $.ui.resolve(e)
100    const isWide = e.props.bodyColumns >= WIDE
101    const zone = (
102      <Box gap={2}>
103        {dots.map(d => (
104          <Box key={d.name} gap={1}>
105            <Text color={COLORS[d.state]}>●</Text>
106            <Text dimColor={d.state === 'up'}>{d.name}</Text>
107            {isWide && d.detail ? <Text dimColor>{d.detail}</Text> : null}
108          </Box>
109        ))}
110      </Box>
111    )
112    return addZone(below, zone, 2)
113  })
114}
115
types/index.d.ts 8 lines
1export type Dot = { name: string; state: 'up' | 'down' | 'wait'; detail: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'stack-health': { dots: Dot[] }
6  }
7}
8