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

A Claude Code mod for people who run corgi.
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.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.
/plugin install corgi-band --marketplace Andriiklymiuk/corgi
Answer y to add the marketplace, then pick the user scope.
hooks/register.tsx 208 lines1import { 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}
208hooks/board.ts 162 lines1import 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}
162types/index.d.ts 19 lines1export 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