Status light for Render deploys in the status line, with a toast when one starts, goes live, fails or is canceled.

Small Claude Code mods from Stylus Nexus: a progress row above the prompt, a usage meter, a Render deploy light, and a checker for team conventions. Each mod is a few hundred lines of TypeScript with tests, so you can read one in a sitting before you install it.
A mod is a Claude Code plugin with a hooks module: it can add status lines, rows above the prompt, toasts, tools and commands.
Read before you install. A mod runs inside your Claude Code session and sees your prompts, tool calls and answers. Review a mod's source first. The table says which mods touch the network or can block a command.
| Mod | What it does | Network | Can block a command? |
|---|---|---|---|
task-progress | One progress row per plan the model reports, plus a row per running subagent. | No | Only its own tool, on bad input |
limits-meter | 5-hour and weekly usage in the status line, plus running subagents. Toasts at 90% and 95%. | No | No |
deploy-watch | A light per Render service in the status line, with a toast when a deploy starts, goes live, fails or is canceled. | Render API, read-only | No |
house-rules | Warns when a goal-checklist strip breaks its rules. Optional: status-line check and a commit and PR title rule. | No | Only the title rule, and only if you turn it on |
/plugin marketplace add stylusnexus/claude-mods
/plugin install task-progress@stylusnexus-mods
Replace task-progress with any mod name above. Run /plugin to enable, disable or configure it later.
Requires Claude Code 2.1.286 or newer, the first build that loads hooks modules (a similar mod, zycck/claude-mods, documents the same minimum). These mods were built and tested on 2.1.296; older builds have not been tried. Check yours with claude --version.
A progress row above the prompt for each plan the model reports:
Ship login · Build · 2/6 steps · ▓▓▓░░░░░░░ 33% · running
↳ explorer · sonnet · Grep · 0:42
{id, next:true}, {id, done:[…], active:…}, {id, failed:…, note}, {id, state:…}. An unknown step title is refused with the list of valid ones.doneBarSeconds (default 30)./progress [show|hide] and /progress-clear.The bar shows what the model reports. A full bar is not proof the work is done; check the work.
Not yet verified: it has not run in a long live session, and a subagent's row leaves on that subagent's first finished turn, so a subagent with several turns may drop off early.
Status line such as 5h 42% · wk 18% · 2 agents. Toasts once when a window passes 90% and again at 95%, with the time until it resets. No options, no network.
Status line such as deploy: api 🟢 · web 🟡. A toast when a service's newest deploy starts, goes live, fails or is canceled.
| Option | Meaning |
|---|---|
renderApiKey | A Render API key. The mod only lists deploys. Stored as a sensitive option. |
serviceIds | Comma-separated Render service ids; label one with name=id, for example api=srv-abc123,web=srv-def456. |
pollSeconds | How often to check, 10 to 600. Default 30. |
The only network calls are GET https://api.render.com/v1/services/<id>/deploys?limit=1 with your key.
Built for one team's goal-checklist convention: rows of ⬜ 🔄 🟦 🚀 ✅ with a tally line, directly above a 🟢/🟡/🔴 status line. By default it only shows a toast when a printed strip breaks the rules (tally mismatch, skipped stage, merged item with no SHA, strip with nothing changed, strip when no checklist is active). Read strip.ts before relying on it.
| Option | Default | Meaning |
|---|---|---|
checkStatusLine | off | Warn when a reply does not end on a 🟢/🟡/🔴 line of 100 characters or fewer. |
enforceCommitRule | off | Blocks git commit and gh pr create unless the subject is type(scope): summary, 50 characters or fewer, lowercase after the colon, no trailing period. |
Each mod lives in plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and hooks/*.ts. Pure logic sits in its own file with unit tests beside it; register.ts holds the hooks.
cd plugins/<name>
claude plugin test .
claude plugin validate .
There is no CI yet: the checks above need the Claude Code CLI and have not been wired into GitHub Actions.
Issues and pull requests are welcome. Keep a mod small, keep its tests passing, and say in the README table whether it uses the network or can block a command. Use type(scope): summary commit titles of 50 characters or fewer.
MIT © 2026 Stylus Nexus
hooks/register.ts 67 lines1import type { EngineInterface, Register } from 'claude-code'
2import { parseDeploy, parseServices, statusLine, transition, type Deploy, type Reading, type Service } from './watch'
3
4export type Watched = { seen: Map<string, Deploy>; isAuthReported: boolean }
5
6async function read($: EngineInterface, key: string, service: Service): Promise<Reading> {
7 const url = `https://api.render.com/v1/services/${encodeURIComponent(service.id)}/deploys?limit=1`
8 let fetched: { ok: boolean; status: number; text: string }
9 try {
10 fetched = await $.http.fetch(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' } })
11 } catch (err) {
12 return { service, problem: 'error', detail: `no reply (${String(err instanceof Error ? err.message : err).slice(0, 40)})` }
13 }
14 if (!fetched.ok) {
15 const auth = fetched.status === 401 || fetched.status === 403
16 return { service, problem: auth ? 'auth' : 'error', detail: `Render ${fetched.status}` }
17 }
18 try {
19 const body: unknown = JSON.parse(fetched.text)
20 if (Array.isArray(body) && body.length === 0) return { service }
21 const deploy = parseDeploy(body)
22 return deploy ? { service, deploy } : { service, problem: 'error', detail: 'unrecognised reply' }
23 } catch {
24 return { service, problem: 'error', detail: 'reply is not JSON' }
25 }
26}
27
28// One round: read each service, say what moved, redraw the status line. Never throws.
29export async function poll($: EngineInterface, key: string, services: Service[], watched: Watched) {
30 try {
31 const readings = await Promise.all(services.map(s => read($, key, s)))
32 for (const r of readings) {
33 if (r.problem === 'auth' && !watched.isAuthReported) {
34 watched.isAuthReported = true
35 $.ui.toast(`${$.plugin.name}: Render refused the API key; check renderApiKey.`)
36 }
37 if (!r.deploy) continue
38 const said = transition(r.service.name, watched.seen.get(r.service.id), r.deploy)
39 if (said) $.ui.toast(`${$.plugin.name}: ${said}`)
40 watched.seen.set(r.service.id, r.deploy)
41 }
42 $.ui.status(statusLine(readings))
43 } catch {
44 $.ui.status(undefined)
45 }
46}
47
48export const register: Register = (on, options) => {
49 const key = typeof options.renderApiKey === 'string' ? options.renderApiKey.trim() : ''
50 const services = parseServices(typeof options.serviceIds === 'string' ? options.serviceIds : '')
51 const periodMs = Math.max(10, Number(options.pollSeconds) || 30) * 1000
52 const watched: Watched = { seen: new Map(), isAuthReported: false }
53
54 on('session.start', async ($, e, next) => {
55 const result = await next(e)
56 if (key === '' || services.length === 0) {
57 $.ui.toast(`${$.plugin.name}: set renderApiKey and serviceIds to watch deploys.`)
58 return result
59 }
60 await poll($, key, services, watched)
61 $.clock.every(periodMs, () => {
62 void poll($, key, services, watched)
63 })
64 return result
65 })
66}
67hooks/watch.ts 58 lines1export type Deploy = { id: string; status: string; message?: string }
2export type Service = { id: string; name: string }
3export type Reading = { service: Service; deploy?: Deploy; problem?: 'auth' | 'error'; detail?: string }
4
5const BUILDING = ['created', 'build_in_progress', 'update_in_progress', 'pre_deploy_in_progress']
6const FAILED = ['build_failed', 'update_failed', 'pre_deploy_failed']
7
8export const light = (status: string): string =>
9 status === 'live' ? '🟢' : BUILDING.includes(status) ? '🟡' : FAILED.includes(status) ? '🔴' : '⚪'
10
11// "srv-abc" or "api=srv-abc", separated by commas or spaces.
12export const parseServices = (text: string): Service[] =>
13 text
14 .split(/[\s,]+/)
15 .filter(Boolean)
16 .map(item => {
17 const [name, id] = item.includes('=') ? item.split('=') : [item, item]
18 return { id: id!, name: name! }
19 })
20
21// The newest deploy from Render's list-deploys body, wrapped as { deploy } or flat.
22export const parseDeploy = (body: unknown): Deploy | undefined => {
23 const first = Array.isArray(body) ? (body[0] as Record<string, unknown> | undefined) : undefined
24 const d = (first?.deploy ?? first) as { id?: unknown; status?: unknown; commit?: { message?: unknown } } | undefined
25 if (typeof d?.id !== 'string' || typeof d.status !== 'string') return undefined
26 const message = typeof d.commit?.message === 'string' ? d.commit.message.split('\n')[0]!.slice(0, 40) : undefined
27 return { id: d.id, status: d.status, message }
28}
29
30export const statusLine = (readings: Reading[]): string => {
31 const details = new Set(readings.map(r => (r.problem ? r.detail ?? '' : undefined)))
32 if (details.size === 1 && [...details][0]) return `deploy: ❔ ${[...details][0]}`
33 return (
34 'deploy: ' +
35 readings
36 .map(r => `${r.service.name} ${r.problem ? `❔${r.detail ? ` ${r.detail}` : ''}` : r.deploy ? light(r.deploy.status) : '⚪'}`)
37 .join(' · ')
38 )
39}
40
41// What to tell the person when a service's newest deploy moved; nothing on the first reading.
42export const transition = (name: string, prev: Deploy | undefined, next: Deploy): string | undefined => {
43 if (prev === undefined) return undefined
44 const what = next.message ? ` (${next.message})` : ''
45 const l = light(next.status)
46 if (next.id !== prev.id) {
47 if (l === '🟡') return `${name} deploy started${what}`
48 if (l === '🟢') return `${name} deploy is live${what}`
49 if (l === '🔴') return `${name} deploy failed${what}`
50 return undefined
51 }
52 if (next.status === prev.status) return undefined
53 if (l === '🟢') return `${name} deploy is live${what}`
54 if (l === '🔴') return `${name} deploy failed${what}`
55 if (next.status === 'canceled') return `${name} deploy was canceled${what}`
56 return undefined
57}
58