SLOPSHOPPER

corgi-band

A band above the prompt: corgi sessions waiting on you elsewhere (with Focus), and what this turn posted, moved or pushed - each with a tick or the error

newpanebandguardcommandtoast
★ 37v0.1.1MITupdated 2026-10-06Andriiklymiuk/corgi/plugins/corgi-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · corgi-band
│ ┃ corgi ✕ › fix the failing auth test and add an audit log call │ ┃ Sessions Refresh │ ┃ none running ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Tickets ⏺ Update(src/auth.ts) │ ┃ nothing waiting ⎿ 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 │ │ › /corgi │ ⎿ corgi-band: corgi board opened. │ │ this turn: ✓ git push Hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
this turn: ✓ git push Hide
Pane · corgi
Sessions Refresh none running Tickets nothing waiting
README

corgi-band

A Claude Code mod for people who run corgi.

  • Band above the prompt. Sessions elsewhere that stopped on a question or a permission, each with a Focus button (corgi agent focus). Under them, what this turn said to other people - Slack posts, ticket moves, comments, PR verbs, pushes - each with a tick, or a cross and the error (a revoked Slack token shows here, not in a report you have to read). Hide puts it away until something changes.
  • Toast the moment one of those fails.
  • Status line corgi · 2 waiting on you · 5h 91% - only when something waits or the 5-hour window is past 80%.
  • /corgi opens a pane: every live session (waiting first, with Focus), the tickets that want a person (Blocked, Review, Inbox, with Work on it → corgi agent watch work), the stack of this folder (corgi status), and the budget while its window is current.

It reads corgi --json agent sessions, corgi --json agent kanban and corgi --json status every 20 seconds; without corgi on PATH it stays silent.

Install

/plugin install corgi-band --marketplace Andriiklymiuk/corgi

Answer y to add the marketplace, then pick the user scope.

Source 3 files
hooks/register.tsx 208 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Action, Snapshot, Waiting } from '../types'
5import {
6  actionsIn, budgetOf, cardsOf, failureOf, fiveHourOf, glyph, readBoard, sessionsOf, settle, signature, stackOf, statusText, waitingOf,
7} from './board'
8
9const waiting = atom({ plugin: 'corgi-band', key: 'waiting' } as const, [] as Waiting[])
10const actions = atom({ plugin: 'corgi-band', key: 'actions' } as const, [] as Action[])
11const hidden = atom({ plugin: 'corgi-band', key: 'hidden' } as const, '')
12const snapshot = atom({ plugin: 'corgi-band', key: 'snapshot' } as const, null as Snapshot | null)
13
14const PANE = 'corgi-board'
15
16const POLL_MS = 20_000
17
18const me = { id: '', configDir: '', wantsPane: false }
19
20async function runJSON($: EngineInterface, argv: string[]): Promise<string | undefined> {
21  try {
22    const out = await $.process.run(argv, { timeoutMs: 15_000 })
23    return out.exitCode === 0 ? out.stdout : undefined
24  } catch {
25    return undefined
26  }
27}
28
29async function refreshPane($: EngineInterface): Promise<void> {
30  const [boardOut, kanbanOut, stackOut] = await Promise.all([
31    runJSON($, ['corgi', '--json', 'agent', 'sessions']),
32    runJSON($, ['corgi', '--json', 'agent', 'kanban']),
33    runJSON($, ['corgi', '--json', 'status']),
34  ])
35  const board = boardOut === undefined ? undefined : readBoard(boardOut)
36  const now = await $.clock.now()
37  const next: Snapshot = {
38    sessions: board ? sessionsOf(board) : [],
39    cards: kanbanOut ? cardsOf(kanbanOut).slice(0, 8) : [],
40    stack: stackOut ? stackOf(stackOut) : [],
41    budget: board ? budgetOf(board, me.configDir, now) : undefined,
42    at: now,
43  }
44  await update($, snapshot, () => next)
45}
46
47async function refresh($: EngineInterface): Promise<boolean> {
48  let out
49  try {
50    out = await $.process.run(['corgi', '--json', 'agent', 'sessions'], { timeoutMs: 10_000 })
51  } catch {
52    return false
53  }
54  const board = out.exitCode === 0 ? readBoard(out.stdout) : undefined
55  if (board === undefined) return true
56  const now = await $.clock.now()
57  const list = waitingOf(board, me.id)
58  await update($, waiting, () => list)
59  $.ui.status(statusText(list.length, fiveHourOf(board, me.configDir, now)))
60  if (me.wantsPane) await refreshPane($)
61  return true
62}
63
64export const register: Register = on => {
65  on('session.start', async ($, e, next) => {
66    me.id = await $.session.id()
67    me.configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? ''
68    if (!e.isInteractive) return next(e)
69    await $.command.register({ name: 'corgi', description: 'corgi board: sessions waiting on you, tickets, the stack, the budget' })
70    void refresh($).then(found => {
71      if (found) $.clock.every(POLL_MS, () => void refresh($))
72    })
73    return next(e)
74  })
75
76  on('command.run', { command: 'corgi' }, async $ => {
77    me.wantsPane = true
78    await $.ui.open({ id: PANE, title: 'corgi' })
79    await refreshPane($)
80    return { text: 'corgi board opened.' }
81  })
82
83  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
84    const { Box, Button, Text } = $.ui.resolve(e)
85    const snap = await read($, snapshot)
86    if (snap === null) return <Text dimColor>Reading corgi…</Text>
87    const down = snap.stack.filter(s => !s.isHealthy)
88
89    return (
90      <Box flexDirection="column">
91        <Box>
92          <Text bold>Sessions </Text>
93          <Button key="refresh" label="Refresh" plain dimColor onPress={() => refreshPane($)} />
94        </Box>
95        {snap.sessions.length === 0 && <Text dimColor>  none running</Text>}
96        {snap.sessions.map(s => (
97          <Box key={`s-${s.id}`}>
98            <Text color={s.status === 'needs_input' ? 'yellow' : s.status === 'working' ? 'green' : undefined}>{glyph(s.status)} </Text>
99            <Text wrap="truncate-end" dimColor={s.id === me.id}>
100              {s.name}
101              {s.what ? ` · ${s.what}` : ''}
102              {s.context !== undefined ? ` · ${s.context}%` : ''}
103              {s.id === me.id ? ' (here)' : ''}{' '}
104            </Text>
105            {s.id !== me.id && (
106              <Button key={`focus-${s.id}`} label="Focus" onPress={() => void runJSON($, ['corgi', 'agent', 'focus', s.id])} />
107            )}
108          </Box>
109        ))}
110        <Text> </Text>
111        <Text bold>Tickets</Text>
112        {snap.cards.length === 0 && <Text dimColor>  nothing waiting</Text>}
113        {snap.cards.map(c => (
114          <Box key={`c-${c.ref}`}>
115            <Text dimColor>{c.column.padEnd(8)}</Text>
116            <Text wrap="truncate-end">
117              {c.ref} {c.title}{' '}
118            </Text>
119            {c.column !== 'Running' && (
120              <Button key={`work-${c.ref}`} label="Work on it" onPress={() => void runJSON($, ['corgi', 'agent', 'watch', 'work', c.ref])} />
121            )}
122          </Box>
123        ))}
124        {snap.stack.length > 0 && (
125          <Box flexDirection="column">
126            <Text> </Text>
127            <Text bold>Stack</Text>
128            <Text wrap="wrap">
129              {snap.stack.map(s => `${s.isHealthy ? '✓' : '✗'} ${s.label}${s.port ? ` :${s.port}` : ''}`).join('   ')}
130            </Text>
131            {down.length > 0 && <Text color="red">{down.length} down - ask me to debug it, or run corgi run</Text>}
132          </Box>
133        )}
134        {snap.budget !== undefined && (
135          <Box flexDirection="column">
136            <Text> </Text>
137            <Text dimColor>budget {snap.budget}</Text>
138          </Box>
139        )}
140      </Box>
141    )
142  })
143
144  on('prompt.submit', async ($, e, next) => {
145    await update($, actions, () => [])
146    return next(e)
147  }).catch(($, e, next) => next(e))
148
149  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
150    const labels = actionsIn(e.command)
151    if (labels.length === 0) return next(e)
152    const ran = await next(e)
153    const why = ran.deny ?? failureOf(ran.isError === true, ran.text ?? '')
154    await update($, actions, list => [...list, ...settle(labels, why)].slice(-12))
155    if (why !== undefined) {
156      $.ui.toast(`corgi: ${labels.join(', ')} failed - ${why}`, { timeoutMs: 8000 })
157    }
158    return ran
159  }).catch(($, e, next) => next(e))
160
161  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162    const list = await read($, waiting)
163    const done = e.props.isWorking ? [] : await read($, actions)
164    const isHidden = (await read($, hidden)) === signature(list, done)
165    if (e.props.hasSurvey || isHidden || (list.length === 0 && done.length === 0)) {
166      return next(e)
167    }
168    const { Box, Button, Text } = $.ui.resolve(e)
169    const shown = list.slice(0, 3)
170
171    return (
172      <Box flexDirection="column">
173        {shown.map(w => (
174          <Box key={`w-${w.id}`}>
175            <Text color="yellow">◐ </Text>
176            <Text wrap="truncate-end">
177              {w.name} waits on you: {w.what}{' '}
178            </Text>
179            <Button
180              key={`focus-${w.id}`}
181              label="Focus"
182              onPress={() => void $.process.run(['corgi', 'agent', 'focus', w.id]).catch(() => undefined)}
183            />
184          </Box>
185        ))}
186        {list.length > shown.length && <Text dimColor>+{list.length - shown.length} more waiting</Text>}
187        {done.length > 0 && (
188          <Box>
189            <Text dimColor>this turn: </Text>
190            <Text wrap="truncate-end">
191              {done.map(a => (a.isDone ? `✓ ${a.label}` : `✗ ${a.label} (${a.why})`)).join(' · ')}{' '}
192            </Text>
193          </Box>
194        )}
195        <Box>
196          <Button
197            key="hide"
198            label="Hide"
199            plain
200            dimColor
201            onPress={() => update($, hidden, () => signature(list, done))}
202          />
203        </Box>
204      </Box>
205    )
206  })
207}
208
hooks/board.ts 162 lines
1import type { Action, PaneCard, PaneService, PaneSession, Waiting } from '../types'
2
3type BoardSession = {
4  id: string
5  status?: string
6  title?: string
7  display?: string
8  label?: string
9  detail?: string
10  configDir?: string
11  pending?: { tool?: string; subject?: string } | null
12}
13type Window = { percent?: number; resetsAt?: string }
14type Board = {
15  sessions?: BoardSession[]
16  accounts?: { configDir?: string; limits?: { fiveHour?: Window; sevenDay?: Window } }[]
17}
18
19export function readBoard(stdout: string): Board | undefined {
20  try {
21    return JSON.parse(stdout) as Board
22  } catch {
23    return undefined
24  }
25}
26
27export function waitingOf(board: Board, me: string): Waiting[] {
28  return (board.sessions ?? [])
29    .filter(s => s.status === 'needs_input' && s.id !== me)
30    .map(s => {
31      const pending = s.pending?.tool ? [s.pending.tool, s.pending.subject].filter(Boolean).join(' ') : ''
32      return {
33        id: s.id,
34        name: s.title || s.display || s.label || s.id.slice(0, 8),
35        what: clip(pending || s.detail || 'a question', 60),
36      }
37    })
38}
39
40export function fiveHourOf(board: Board, configDir: string, now: number): number | undefined {
41  const account = (board.accounts ?? []).find(a => (a.configDir ?? '') === configDir)
42  const w = account?.limits?.fiveHour
43  if (w?.percent === undefined || (w.resetsAt !== undefined && Date.parse(w.resetsAt) < now)) {
44    return undefined
45  }
46  return w.percent
47}
48
49export function statusText(waiting: number, fiveHour: number | undefined): string | undefined {
50  const parts: string[] = []
51  if (waiting > 0) parts.push(`${waiting} waiting on you`)
52  if (fiveHour !== undefined && fiveHour >= 80) parts.push(`5h ${fiveHour}%`)
53  return parts.length ? `corgi · ${parts.join(' · ')}` : undefined
54}
55
56const flags = String.raw`(?:\s+--?\S+)*`
57const corgiAgent = String.raw`\bcorgi${flags}\s+agent${flags}`
58const arg = String.raw`("[^"]*"|'[^']*'|\S+)`
59const patterns: [RegExp, (m: RegExpExecArray) => string][] = [
60  [new RegExp(`${corgiAgent}\\s+chat\\s+(announce|post)\\b`, 'g'), () => 'Slack post'],
61  [new RegExp(`${corgiAgent}\\s+watch\\s+move\\s+${arg}\\s+${arg}`, 'g'), m => `moved ${bare(m[1])} → ${bare(m[2])}`],
62  [new RegExp(`${corgiAgent}\\s+watch\\s+(comment|assign|workpad)\\s+${arg}`, 'g'), m => `${m[1]} ${bare(m[2])}`],
63  [new RegExp(`${corgiAgent}\\s+watch\\s+pr\\s+(ready|merge|close|approve|request|comment)\\s+${arg}`, 'g'), m => `PR ${m[1]} ${short(bare(m[2]))}`],
64  [/\bgh\s+pr\s+(create|ready|merge|comment|review)\b/g, m => `gh pr ${m[1]}`],
65  [/\bglab\s+mr\s+(create|merge|note|update|approve)\b/g, m => `glab mr ${m[1]}`],
66  [/\bgit(?:\s+-C\s+\S+)?\s+push\b(?![^;&|]*--dry-run)/g, () => 'git push'],
67]
68
69export function actionsIn(command: string): string[] {
70  const found: string[] = []
71  for (const [re, label] of patterns) {
72    re.lastIndex = 0
73    for (let m = re.exec(command); m; m = re.exec(command)) {
74      found.push(label(m))
75    }
76  }
77  return found
78}
79
80export function failureOf(isError: boolean, text: string): string | undefined {
81  const line = text.split('\n').map(l => l.trim()).find(l => /^(error|fatal|refused|rejected|denied)\b|token_revoked|invalid_auth|! \[rejected\]/i.test(l))
82  if (line !== undefined) return clip(line.replace(/^Exit code \d+\s*/, ''), 80)
83  if (!isError) return undefined
84  const first = text.split('\n').map(l => l.replace(/^Exit code \d+\s*/, '').trim()).find(Boolean)
85  return clip(first ?? 'failed', 80)
86}
87
88export function settle(labels: string[], why: string | undefined): Action[] {
89  return labels.map(label => (why === undefined ? { label, isDone: true } : { label, isDone: false, why }))
90}
91
92export function signature(waiting: Waiting[], actions: Action[]): string {
93  return JSON.stringify([waiting.map(w => w.id + w.what), actions])
94}
95
96function bare(s: string | undefined): string {
97  return (s ?? '').replace(/^["']|["']$/g, '')
98}
99
100function short(s: string): string {
101  const m = /\/([^/]+)\/(?:pull|-\/merge_requests)\/(\d+)/.exec(s)
102  return m ? `${m[1]}#${m[2]}` : s
103}
104
105function clip(s: string, n: number): string {
106  return s.length > n ? s.slice(0, n - 1) + '…' : s
107}
108
109const statusOrder: Record<string, number> = { needs_input: 0, limited: 1, working: 2, done: 3, unknown: 4 }
110
111export function sessionsOf(board: Board): PaneSession[] {
112  return (board.sessions ?? [])
113    .filter(s => s.status !== 'gone' && s.status !== 'stale')
114    .map(s => ({
115      id: s.id,
116      name: clip(s.title || s.display || s.label || s.id.slice(0, 8), 40),
117      status: s.status ?? 'unknown',
118      what: clip((s.pending?.tool ? [s.pending.tool, s.pending.subject].filter(Boolean).join(' ') : s.detail) || '', 40),
119      context: (s as { context?: { percent?: number } }).context?.percent,
120    }))
121    .sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9))
122}
123
124const columnOrder: Record<string, number> = { Blocked: 0, Review: 1, Inbox: 2, Ready: 3, Running: 4 }
125
126export function cardsOf(stdout: string): PaneCard[] {
127  let parsed: { cards?: { ref?: string; title?: string; column?: string; workspace?: string; why?: string }[] }
128  try {
129    parsed = JSON.parse(stdout)
130  } catch {
131    return []
132  }
133  return (parsed.cards ?? [])
134    .filter(c => c.ref && c.column && c.column !== 'Done' && !c.ref.startsWith('slack-'))
135    .map(c => ({ ref: c.ref!, title: clip(c.title ?? '', 50), column: c.column!, workspace: c.workspace ?? '', why: c.why }))
136    .sort((a, b) => (columnOrder[a.column] ?? 9) - (columnOrder[b.column] ?? 9))
137}
138
139export function stackOf(stdout: string): PaneService[] {
140  let parsed: { label?: string; port?: number; healthy?: boolean }[]
141  try {
142    parsed = JSON.parse(stdout)
143  } catch {
144    return []
145  }
146  if (!Array.isArray(parsed)) return []
147  return parsed.map(s => ({ label: (s.label ?? '?').replace(/^(services|db_services)\./, ''), port: s.port, isHealthy: s.healthy === true }))
148}
149
150export function budgetOf(board: Board, configDir: string, now: number): string | undefined {
151  const account = (board.accounts ?? []).find(a => (a.configDir ?? '') === configDir)
152  const limits = account?.limits as { fiveHour?: Window; sevenDay?: Window } | undefined
153  const part = (name: string, w?: Window) =>
154    w?.percent === undefined || (w.resetsAt !== undefined && Date.parse(w.resetsAt) < now) ? undefined : `${name} ${w.percent}%`
155  const parts = [part('5h', limits?.fiveHour), part('week', limits?.sevenDay)].filter(Boolean)
156  return parts.length ? parts.join(' · ') : undefined
157}
158
159export function glyph(status: string): string {
160  return { needs_input: '◐', working: '●', limited: '⏸', done: '✓' }[status] ?? '◌'
161}
162
types/index.d.ts 19 lines
1export type Waiting = { id: string; name: string; what: string }
2export type Action = { label: string; isDone: boolean; why?: string }
3export type PaneSession = { id: string; name: string; status: string; what: string; context?: number }
4export type PaneCard = { ref: string; title: string; column: string; workspace: string; why?: string }
5export type PaneService = { label: string; port?: number; isHealthy: boolean }
6export type Snapshot = {
7  sessions: PaneSession[]
8  cards: PaneCard[]
9  stack: PaneService[]
10  budget?: string
11  at: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'corgi-band': { waiting: Waiting[]; actions: Action[]; hidden: string; snapshot: Snapshot | null }
17  }
18}
19