Pins the Vercel deploy queue of the linked project under the prompt: every deploy queued, building or just finished, with its phase and elapsed time

Pins the Vercel deploy queue of the linked project under the prompt. Every deploy that is queued, building or just finished gets one line, with its phase, its target, its branch, how long it has run, and the commit subject.
Turn function hooks on first. This is a Claude Code function hook, the early-access feature proposed in anthropics/claude-code#91870. It is off by default, and this plugin does nothing until you turn it on. Add
"env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }to~/.claude/settings.json, or start one session withCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
It never starts a turn and never writes to the transcript. It polls the vercel CLI on your machine and draws the answer. You keep working, and the deploy sits in the corner of your eye.
The block lives in the band above the prompt. It sits under the turn narrator's line and above the prompt itself. The header is dim. A deploy in flight is yellow, Ready is green, Error and Canceled are red.
You run git push. Vercel has not seen it yet:
▲ my-site
◌ waiting for a deploy 12s
❯ █
The deploy shows up and builds:
▲ my-site · 1 building
◐ Building Production · main 1m 20s Drop the review card's dead avatar column (#1238)
❯ █
A busy afternoon, three deploys on the board at once:
▲ my-site · 1 building · 1 queued · 1 finished
◐ Building Production · main 1m 20s Drop the review card's dead avatar column (#1238)
○ Queued Preview · fix/nav 4s Stop the nav from wrapping on mobile
● Ready Production · main took 2m 3s · 4m ago Rename the pricing page
❯ █
One went wrong:
▲ my-site · 1 finished
✗ Error Preview · feat/checkout took 48s · 1m 10s ago Add the team checkout
❯ █
The band clears on its own. A finished deploy stays for 5 minutes, then its line goes. When the queue is empty and no push is waiting, the block is not drawn at all, so an idle session looks like an idle session.
| Dot | Phase |
|---|---|
○ | Queued |
◐ | Building |
◑ | Initializing |
● | Ready |
✗ | Error |
⊘ | Canceled |
◌ | Waiting: you pushed, and Vercel has not listed the deploy yet |
A poll every 60 seconds keeps the block honest while nothing is happening. Three shell lines make it look now, and then every 15 seconds until the deploy is done:
git push (with any flags, from any directory in the line)gh pr mergevercel deploy or vercel --prodA --dry-run on any of them does not count. The hook waits for the command to finish before it looks, because Vercel has nothing to report until the push lands. The "waiting for a deploy" line then holds for up to 6 minutes. If no deploy appears in that window, the block clears and the poll goes back to the idle rate.
The three commands are matched in the Bash tool call's command string. A push you type into a different terminal is not seen. The next idle poll picks that deploy up anyway, up to 60 seconds later.
| Event | What it does |
|---|---|
session.start | Finds the .vercel/project.json link, reads the project name, and starts the poll loop. If there is no link file, it logs one line and goes quiet for the session |
tool.call on Bash | Waits for the command to finish. If the command was a push, a merge or a deploy, it wakes the poll loop |
ui.render on AbovePrompt | Draws the header and the rows from the last poll. A 1-second tick keeps the clocks moving between polls |
The poll runs vercel ls --format json --non-interactive from the linked directory. It looks for the link in the session's directory first, then the repo root, then a short find of the repo for a linked package in a monorepo. The first hit wins.
vercel CLI on your PATH, logged in. Check with vercel whoami..vercel/project.json in the repo. vercel link writes one.If either is missing, the plugin logs one line to the debug log and does nothing else. An error from the CLI is logged once per outage, not once per poll.
Every option has a default that works. Options are read from your user settings only. A pluginConfigs block in a project's .claude/settings.json is not read.
{
"pluginConfigs": {
"vercel-deploy-status@awesome-claude-code-function-hooks": {
"options": {
"activePollSeconds": 10,
"holdFinishedMinutes": 10
}
}
}
}
| Option | Default | What it does |
|---|---|---|
vercelBin | vercel | The executable to run. Set a full path if your shell's PATH and Claude Code's differ |
activePollSeconds | 15 | How often to ask Vercel while a deploy is in flight, or a push is waiting on one |
idlePollSeconds | 60 | How often to ask Vercel while nothing is in flight |
holdFinishedMinutes | 5 | How long a finished deploy keeps its line |
watchAfterPushMinutes | 6 | How long a push keeps the active rate while no deploy has appeared |
maxRows | 8 | How many deploys to list before folding the rest into "… and N more" |
When you run it with --plugin-dir instead of installing it, the key is vercel-deploy-status or vercel-deploy-status@inline.
vercel ls returns the newest deploys, not all of them. A deploy that was in flight and then fell off that list is shown as Ready, because that is the usual way a deploy leaves the list.hooks/deploy-status.tsx the three hooks and the JSX; imports ./queue.ts
hooks/queue.ts everything with no UI in it: types, the wake regex,
the formatters, and the merge of one poll into the
next queue. Imports nothing
test/queue.test.mts 52 checks on queue.ts, on bare Node
Three rules of this runtime, learned the hard way and marked in the code: a .map() array is not a valid Box child, so wrap it in a Fragment; key is a Button prop only, so a Box with a key fails validation and the whole tree falls back in silence; and nothing inside a hook may be named next. next is the continuation each hook is handed, and a local of that name shadows it, which the loader refuses. The module then registers no hook at all, so the plugin is silent rather than wrong. claude plugin validate catches it, and --debug-file names it.
Step 1. Get the types. In Claude Code, from the root of this repo, run /plugin-types. It writes types/claude-code.d.ts, which is not in git.
Step 2. Run the checks:
node --experimental-strip-types plugins/vercel-deploy-status/test/queue.test.mts
claude plugin validate plugins/vercel-deploy-status
npx tsc -p plugins/vercel-deploy-status/tsconfig.json
Step 3. See it live. From a repo with a .vercel/project.json:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/plugins/vercel-deploy-status
Then push a commit, or run vercel deploy, and watch the band. To see the loader's own view, add --debug-file load.log and look for the line hooks module vercel-deploy-status loaded. Debug output does not go to stderr under -p, so the file is the only place to read it.
hooks/deploy-status.tsx 212 lines1/** @jsx h */
2import type { Register, EngineInterface, Timer } from 'claude-code'
3import {
4 type Deployment,
5 type Row,
6 IN_FLIGHT,
7 LABEL,
8 DOT,
9 COLOR,
10 LABEL_WIDTH,
11 num,
12 elapsed,
13 cut,
14 plural,
15 targetOf,
16 parseList,
17 header,
18 clock,
19 merge,
20 isDeployCommand,
21} from './queue.ts'
22
23// The Vercel deploy queue of the linked project, drawn as a block in the band
24// above the prompt: a header with the counts, then one line per deploy that is
25// queued, building, or just finished. It polls `vercel ls` on the host and
26// never starts a turn.
27//
28// A push or a PR merge wakes it: the Bash call that ran `git push`,
29// `gh pr merge` or `vercel deploy` puts the block into "waiting" and polls at
30// the active rate until a deploy shows up.
31//
32// Everything with no UI in it lives in ./queue.ts, where a bare Node test
33// can reach it.
34
35// The directory `vercel` must run from: the nearest one holding a link file.
36// The session's directory first, then the repo root, then a short search for
37// a linked package inside a monorepo.
38async function findLinkedDir($: EngineInterface, cwd: string): Promise<string | null> {
39 if (await $.fs.exists(`${cwd}/.vercel/project.json`)) return cwd
40
41 const root = (await $.session.repo())?.root ?? cwd
42 if (root !== cwd && (await $.fs.exists(`${root}/.vercel/project.json`))) return root
43
44 try {
45 const r = await $.process.run(
46 ['find', root, '-maxdepth', '4', '-type', 'd', '-name', 'node_modules', '-prune', '-o',
47 '-type', 'f', '-path', '*/.vercel/project.json', '-print'],
48 { timeoutMs: 10_000 },
49 )
50 const hit = r.stdout.split('\n').map((l) => l.trim()).filter(Boolean)[0]
51 if (hit) return hit.slice(0, -'/.vercel/project.json'.length)
52 } catch {
53 // No `find`, or it timed out. The plugin stays quiet, which is the point.
54 }
55 return null
56}
57
58export const register: Register = (on, options) => {
59 const vercelBin = String(options.vercelBin || 'vercel')
60 const activeMs = num(options.activePollSeconds, 15) * 1000
61 const idleMs = num(options.idlePollSeconds, 60) * 1000
62 const holdMs = num(options.holdFinishedMinutes, 5) * 60 * 1000
63 const watchMs = num(options.watchAfterPushMinutes, 6) * 60 * 1000
64 const maxRows = Math.floor(num(options.maxRows, 8))
65
66 // The band reads these; session.start's poll loop writes them. They live
67 // out here because the two hooks are separate closures.
68 let project = 'vercel'
69 let rows: Row[] = [] // the queue on the band, newest first
70 let pushedAt: number | null = null // when a push last asked us to look
71
72 // Set by session.start once the project is linked; until then a push has
73 // nothing to wake.
74 let wake: (() => void) | null = null
75
76 const waiting = (now: number): boolean => pushedAt !== null && now - pushedAt < watchMs
77 const inFlightRows = (): Row[] => rows.filter((r) => IN_FLIGHT.has(r.d.state))
78
79 on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
80 // A survey owns the band while it is up, and there is nothing to draw
81 // when the queue is empty and no push is waiting on a deploy.
82 if (e.props.hasSurvey) return next(e)
83 const now = $.clock.now()
84 if (rows.length === 0 && !waiting(now)) return next(e)
85
86 const { Box, Text } = await $.ui.resolve(e)
87
88 const shown = rows.slice(0, maxRows)
89 const hidden = rows.length - shown.length
90
91 // Two JSX rules of this runtime, learned the hard way: a `.map()` array
92 // is not a valid Box child, so it is wrapped in a Fragment; and `key` is
93 // a Button prop only, so no row carries one.
94 return (
95 <Box flexDirection="column">
96 <Text dimColor>{header(project, rows)}</Text>
97 {waiting(now) && inFlightRows().length === 0 ? (
98 <Text dimColor>{`◌ waiting for a deploy ${elapsed(now - pushedAt!)}`}</Text>
99 ) : (
100 <Text>{''}</Text>
101 )}
102 <>
103 {shown.map((r) => {
104 const d = r.d
105 const ref = d.meta?.githubCommitRef ?? ''
106 const where = [targetOf(d), ref].filter(Boolean).join(' · ')
107 const subject = d.meta?.githubCommitMessage ? cut(d.meta.githubCommitMessage, 72) : ''
108 return (
109 <Box gap={2}>
110 <Text color={COLOR[d.state]}>{`${DOT[d.state]} ${LABEL[d.state].padEnd(LABEL_WIDTH)}`}</Text>
111 <Text dimColor>{where}</Text>
112 <Text>{clock(r, now)}</Text>
113 {subject ? <Text dimColor wrap="truncate-end">{subject}</Text> : <Text>{''}</Text>}
114 </Box>
115 )
116 })}
117 </>
118 {hidden > 0 ? <Text dimColor>{` … and ${plural(hidden, 'more')}`}</Text> : <Text>{''}</Text>}
119 </Box>
120 )
121 })
122
123 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
124 const command = typeof e.command === 'string' ? e.command : ''
125 if (!isDeployCommand(command)) return next(e)
126
127 // Let the push finish first: Vercel has nothing to report until it lands.
128 const result = await next(e)
129 wake?.()
130 return result
131 })
132
133 on('session.start', async ($, e, next) => {
134 if (!e.interactive || e.surface === null) return next(e)
135
136 const dir = await findLinkedDir($, e.cwd)
137 if (!dir) {
138 $.ui.log('vercel-deploy-status: no .vercel/project.json in this repo, staying quiet')
139 return next(e)
140 }
141
142 try {
143 const link = JSON.parse(await $.fs.readFile(`${dir}/.vercel/project.json`)) as { projectName?: string }
144 if (link.projectName) project = link.projectName
145 } catch {
146 // The label falls back to "vercel"; the CLI still resolves the link.
147 }
148
149 let errorShown = false
150
151 // True while the loop should keep the active rate: a deploy is in flight,
152 // or a push is still inside its watch window.
153 const poll = async (): Promise<boolean> => {
154 let list: Deployment[]
155 try {
156 const r = await $.process.run([vercelBin, 'ls', '--format', 'json', '--non-interactive'], {
157 cwd: dir,
158 timeoutMs: 25_000,
159 })
160 if (r.exitCode !== 0) throw new Error(cut(r.stderr || r.stdout || `exit ${r.exitCode}`, 120))
161 list = parseList(r.stdout)
162 } catch (err) {
163 // One line per outage, not one per poll.
164 if (!errorShown) {
165 errorShown = true
166 $.ui.log(`vercel-deploy-status: ${err instanceof Error ? err.message : String(err)}`)
167 }
168 return waiting($.clock.now())
169 }
170 errorShown = false
171
172 const now = $.clock.now()
173 const m = merge(rows, list, now, holdMs)
174 for (const d of m.started) {
175 $.ui.toast(`▲ ${project}: deploy started (${targetOf(d)})`)
176 pushedAt = null // the deploy we were waiting for is here
177 }
178 for (const d of m.finished) {
179 $.ui.toast(`▲ ${project}: ${LABEL[d.state]} after ${elapsed((d.ready ?? now) - d.createdAt)}`, {
180 timeoutMs: 8000,
181 })
182 }
183 rows = m.rows
184 return inFlightRows().length > 0 || waiting(now)
185 }
186
187 let timer: Timer | null = null
188 const loop = async () => {
189 const active = await poll()
190 $.ui.invalidate('ui.render')
191 timer = $.clock.after(active ? activeMs : idleMs, loop)
192 }
193
194 // A push cancels the pending idle wait and looks now.
195 wake = () => {
196 pushedAt = $.clock.now()
197 $.ui.invalidate('ui.render')
198 timer?.cancel()
199 timer = null
200 void loop()
201 }
202
203 // The clocks on the band tick between polls.
204 $.clock.every(1000, () => {
205 if (rows.length > 0 || waiting($.clock.now())) $.ui.invalidate('ui.render')
206 })
207 void loop()
208
209 return next(e)
210 })
211}
212hooks/queue.ts 167 lines1// The part of vercel-deploy-status with no UI in it: the shapes `vercel ls`
2// returns, the shell lines that mean "a deploy is coming", the merge that
3// turns one poll into the next queue, and the formatters the band draws
4// with. It imports nothing, so `test/queue.test.mts` loads it on bare Node.
5
6export type State = 'QUEUED' | 'BUILDING' | 'INITIALIZING' | 'READY' | 'ERROR' | 'CANCELED'
7
8export type Deployment = {
9 url: string
10 name: string
11 state: State
12 target: string | null
13 createdAt: number
14 ready?: number
15 meta?: {
16 githubCommitRef?: string
17 githubCommitMessage?: string
18 }
19}
20
21// One line of the queue: the deploy, and when it left flight (null while it
22// is still queued or building).
23export type Row = { d: Deployment; finishedAt: number | null }
24
25export const IN_FLIGHT: ReadonlySet<State> = new Set(['QUEUED', 'BUILDING', 'INITIALIZING'])
26
27// A shell line that ends in a Vercel deploy. `git push` carries its own flags,
28// so the alternation walks them; a dry run deploys nothing.
29const DEPLOYS =
30 /(^|[^\w./-])(git(\s+-\S+(\s+\S+)?)*\s+push(\s|$)|gh\s+pr\s+merge(\s|$)|vercel\s+(deploy|--prod)(\s|$))/
31const DRY_RUN = /--dry-run/
32
33export function isDeployCommand(command: string): boolean {
34 return DEPLOYS.test(command) && !DRY_RUN.test(command)
35}
36
37export const LABEL: Record<State, string> = {
38 QUEUED: 'Queued',
39 BUILDING: 'Building',
40 INITIALIZING: 'Initializing',
41 READY: 'Ready',
42 ERROR: 'Error',
43 CANCELED: 'Canceled',
44}
45
46export const DOT: Record<State, string> = {
47 QUEUED: '○',
48 BUILDING: '◐',
49 INITIALIZING: '◑',
50 READY: '●',
51 ERROR: '✗',
52 CANCELED: '⊘',
53}
54
55export const COLOR: Record<State, string> = {
56 QUEUED: 'yellow',
57 BUILDING: 'yellow',
58 INITIALIZING: 'yellow',
59 READY: 'green',
60 ERROR: 'red',
61 CANCELED: 'red',
62}
63
64// The widest label, so the columns line up down the queue.
65export const LABEL_WIDTH = Math.max(...Object.values(LABEL).map((l) => l.length))
66
67export function num(v: unknown, fallback: number): number {
68 const n = Number(v)
69 return Number.isFinite(n) && n > 0 ? n : fallback
70}
71
72export function elapsed(ms: number): string {
73 const s = Math.max(0, Math.floor(ms / 1000))
74 const m = Math.floor(s / 60)
75 const h = Math.floor(m / 60)
76 if (h > 0) return `${h}h ${m % 60}m`
77 if (m > 0) return `${m}m ${s % 60}s`
78 return `${s}s`
79}
80
81export function cut(text: string, max: number): string {
82 const one = text.split('\n')[0].trim()
83 return one.length > max ? `${one.slice(0, max - 1)}…` : one
84}
85
86export function plural(n: number, word: string): string {
87 return `${n} ${word}${n === 1 ? '' : 's'}`
88}
89
90export function targetOf(d: Deployment): string {
91 return d.target === 'production' ? 'Production' : 'Preview'
92}
93
94// `vercel ls --format json` prints a line or two of chatter before the JSON
95// on some versions, so the parse starts at the first brace.
96export function parseList(stdout: string): Deployment[] {
97 const start = stdout.indexOf('{')
98 if (start < 0) throw new Error('no JSON in vercel ls output')
99 const body = JSON.parse(stdout.slice(start)) as { deployments?: Deployment[] }
100 return (body.deployments ?? []).slice().sort((a, b) => b.createdAt - a.createdAt)
101}
102
103// The header line: the project, then the counts that are not zero.
104export function header(project: string, rows: Row[]): string {
105 const queued = rows.filter((r) => r.d.state === 'QUEUED').length
106 const building = rows.filter((r) => r.d.state === 'BUILDING' || r.d.state === 'INITIALIZING').length
107 const done = rows.length - queued - building
108 const counts = [
109 building > 0 ? `${building} building` : '',
110 queued > 0 ? `${queued} queued` : '',
111 done > 0 ? `${done} finished` : '',
112 ].filter(Boolean)
113 return [`▲ ${project}`, ...counts].join(' · ')
114}
115
116// The time column of one row: how long a deploy has been in flight, or how
117// long it took and how long ago it finished.
118export function clock(r: Row, now: number): string {
119 const d = r.d
120 if (IN_FLIGHT.has(d.state)) return elapsed(now - d.createdAt)
121 const took = `took ${elapsed((d.ready ?? r.finishedAt ?? now) - d.createdAt)}`
122 return r.finishedAt ? `${took} · ${elapsed(now - r.finishedAt)} ago` : took
123}
124
125export type Merge = {
126 rows: Row[] // the next queue, newest first
127 started: Deployment[] // in flight now, and not on the last queue
128 finished: Deployment[] // on the last queue in flight, and out of flight now
129}
130
131// One poll in, the next queue out. Every deploy in flight goes on the queue.
132// A deploy that was in flight and is not any more keeps its line for holdMs
133// with its final state. One that fell off the list has finished, and Ready is
134// the usual way, so it is shown as Ready.
135//
136// The local is `nextRows`, not `next`, on purpose. This function is pure and
137// could live in the hook module, and there `next` is the continuation every
138// hook is handed. A local of that name shadows it and the whole module is
139// refused at load, with no hook registered. Keep the name free everywhere.
140export function merge(rows: Row[], list: Deployment[], now: number, holdMs: number): Merge {
141 const known = new Set(rows.map((r) => r.d.url))
142 const nextRows: Row[] = []
143 const started: Deployment[] = []
144 const finished: Deployment[] = []
145
146 for (const d of list) {
147 if (!IN_FLIGHT.has(d.state)) continue
148 if (!known.has(d.url)) started.push(d)
149 nextRows.push({ d, finishedAt: null })
150 }
151
152 for (const r of rows) {
153 if (nextRows.some((n) => n.d.url === r.d.url)) continue
154 if (IN_FLIGHT.has(r.d.state)) {
155 const final = list.find((d) => d.url === r.d.url)
156 const d: Deployment = final && !IN_FLIGHT.has(final.state) ? final : { ...r.d, state: 'READY' }
157 finished.push(d)
158 nextRows.push({ d, finishedAt: now })
159 } else if (r.finishedAt !== null && now - r.finishedAt <= holdMs) {
160 nextRows.push(r)
161 }
162 }
163
164 nextRows.sort((a, b) => b.d.createdAt - a.d.createdAt)
165 return { rows: nextRows, started, finished }
166}
167