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.
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.
/plugin install vercel-deploy-status --marketplace ray-amjad/claude-code-mods
Answer y to add the marketplace, then pick a scope.
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 walk of the repo ($.fs.list, four levels, skipping node_modules and dot folders) 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@claude-code-mods": {
"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 58 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.
Run the checks from the root of the repo:
node --experimental-strip-types plugins/vercel-deploy-status/test/queue.test.mts
claude plugin validate plugins/vercel-deploy-status
See it live. From a repo with a .vercel/project.json:
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.
hooks/deploy-status.tsx 224 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 findLinkedBelow,
22} from './queue.ts'
23
24// The Vercel deploy queue of the linked project, drawn as a block in the band
25// above the prompt: a header with the counts, then one line per deploy that is
26// queued, building, or just finished. It polls `vercel ls` on the host and
27// never starts a turn.
28//
29// A push or a PR merge wakes it: the Bash call that ran `git push`,
30// `gh pr merge` or `vercel deploy` puts the block into "waiting" and polls at
31// the active rate until a deploy shows up.
32//
33// Everything with no UI in it lives in ./queue.ts, where a bare Node test
34// can reach it.
35
36// The directory `vercel` must run from: the nearest one holding a link file.
37// The session's directory first, then the repo root, then a short search for
38// a linked package inside a monorepo.
39async function findLinkedDir($: EngineInterface, cwd: string): Promise<string | null> {
40 if (await $.fs.exists(`${cwd}/.vercel/project.json`)) return cwd
41
42 const root = (await $.session.repo())?.root ?? cwd
43 if (root !== cwd && (await $.fs.exists(`${root}/.vercel/project.json`))) return root
44
45 // A walk with $.fs, not `find`: on Windows `find` is find.exe, which searches text.
46 return findLinkedBelow(root, (dir) => $.fs.list(dir), (path) => $.fs.exists(path))
47}
48
49export const register: Register = (on, options) => {
50 const vercelBin = String(options.vercelBin || 'vercel')
51 const activeMs = num(options.activePollSeconds, 15) * 1000
52 const idleMs = num(options.idlePollSeconds, 60) * 1000
53 const holdMs = num(options.holdFinishedMinutes, 5) * 60 * 1000
54 const watchMs = num(options.watchAfterPushMinutes, 6) * 60 * 1000
55 const maxRows = Math.floor(num(options.maxRows, 8))
56
57 // The band reads these; session.start's poll loop writes them. They live
58 // out here because the two hooks are separate closures.
59 let project = 'vercel'
60 let rows: Row[] = [] // the queue on the band, newest first
61 let pushedAt: number | null = null // when a push last asked us to look
62
63 // Set by session.start once the project is linked; until then a push has
64 // nothing to wake.
65 let wake: (() => Promise<void>) | null = null
66
67 const waiting = (now: number): boolean => pushedAt !== null && now - pushedAt < watchMs
68 const inFlightRows = (): Row[] => rows.filter((r) => IN_FLIGHT.has(r.d.state))
69
70 on('ui.render', { component: 'AbovePrompt', surface: 'terminal' }, async ($, e, next) => {
71 // A survey owns the band while it is up, and there is nothing to draw
72 // when the queue is empty and no push is waiting on a deploy.
73 if (e.props.hasSurvey) return next(e)
74 const now = await $.clock.now()
75 if (rows.length === 0 && !waiting(now)) return next(e)
76
77 const { Box, Text } = await $.ui.resolve(e)
78
79 const shown = rows.slice(0, maxRows)
80 const hidden = rows.length - shown.length
81
82 // Two JSX rules of this runtime, learned the hard way: a `.map()` array
83 // is not a valid Box child, so it is wrapped in a Fragment; and `key` is
84 // a Button prop only, so no row carries one.
85 return (
86 <Box flexDirection="column">
87 <Text dimColor>{header(project, rows)}</Text>
88 {waiting(now) && inFlightRows().length === 0 ? (
89 <Text dimColor>{`◌ waiting for a deploy ${elapsed(now - pushedAt!)}`}</Text>
90 ) : (
91 <Text>{''}</Text>
92 )}
93 <>
94 {shown.map((r) => {
95 const d = r.d
96 const ref = d.meta?.githubCommitRef ?? ''
97 const where = [targetOf(d), ref].filter(Boolean).join(' · ')
98 const subject = d.meta?.githubCommitMessage ? cut(d.meta.githubCommitMessage, 72) : ''
99 return (
100 <Box gap={2}>
101 <Text color={COLOR[d.state]}>{`${DOT[d.state]} ${LABEL[d.state].padEnd(LABEL_WIDTH)}`}</Text>
102 <Text dimColor>{where}</Text>
103 <Text>{clock(r, now)}</Text>
104 {subject ? <Text dimColor wrap="truncate-end">{subject}</Text> : <Text>{''}</Text>}
105 </Box>
106 )
107 })}
108 </>
109 {hidden > 0 ? <Text dimColor>{` … and ${plural(hidden, 'more')}`}</Text> : <Text>{''}</Text>}
110 </Box>
111 )
112 })
113
114 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
115 const command = typeof e.command === 'string' ? e.command : ''
116 if (!isDeployCommand(command)) return next(e)
117
118 // Let the push finish first: Vercel has nothing to report until it lands.
119 const result = await next(e)
120 await wake?.()
121 return result
122 })
123
124 on('session.start', async ($, e, next) => {
125 // Only where a person is at the prompt: a -p run or the SDK has no band.
126 // (The field is `isInteractive`. The old check read `e.interactive`,
127 // which does not exist, so it kept the mod off everywhere.)
128 if (!e.isInteractive || e.surface == null) return next(e)
129
130 const dir = await findLinkedDir($, e.cwd)
131 if (!dir) {
132 $.ui.log('vercel-deploy-status: no .vercel/project.json in this repo, staying quiet')
133 return next(e)
134 }
135
136 try {
137 const link = JSON.parse(await $.fs.read(`${dir}/.vercel/project.json`)) as { projectName?: string }
138 if (link.projectName) project = link.projectName
139 } catch {
140 // The label falls back to "vercel"; the CLI still resolves the link.
141 }
142
143 let errorShown = false
144
145 // True while the loop should keep the active rate: a deploy is in flight,
146 // or a push is still inside its watch window.
147 const poll = async (): Promise<boolean> => {
148 let list: Deployment[]
149 try {
150 const r = await $.process.run([vercelBin, 'ls', '--format', 'json', '--non-interactive'], {
151 cwd: dir,
152 timeoutMs: 25_000,
153 })
154 if (r.exitCode !== 0) throw new Error(cut(r.stderr || r.stdout || `exit ${r.exitCode}`, 120))
155 list = parseList(r.stdout)
156 } catch (err) {
157 // One line per outage, not one per poll.
158 if (!errorShown) {
159 errorShown = true
160 $.ui.log(`vercel-deploy-status: ${err instanceof Error ? err.message : String(err)}`)
161 }
162 return waiting(await $.clock.now())
163 }
164 errorShown = false
165
166 const now = await $.clock.now()
167 const m = merge(rows, list, now, holdMs)
168 for (const d of m.started) {
169 $.ui.toast(`▲ ${project}: deploy started (${targetOf(d)})`)
170 pushedAt = null // the deploy we were waiting for is here
171 }
172 for (const d of m.finished) {
173 $.ui.toast(`▲ ${project}: ${LABEL[d.state]} after ${elapsed((d.ready ?? now) - d.createdAt)}`, {
174 timeoutMs: 8000,
175 })
176 }
177 rows = m.rows
178 return inFlightRows().length > 0 || waiting(now)
179 }
180
181 // One loop at a time. A push that lands while a poll runs asks for one
182 // more poll right after it, rather than start a second loop beside it.
183 let timer: Timer | null = null
184 let polling = false
185 let again = false
186 const loop = async () => {
187 if (polling) {
188 again = true
189 return
190 }
191 polling = true
192 let active = false
193 try {
194 do {
195 again = false
196 active = await poll()
197 $.ui.invalidate('ui.render')
198 } while (again)
199 } finally {
200 polling = false
201 }
202 timer = $.clock.after(active ? activeMs : idleMs, loop)
203 }
204
205 // A push cancels the pending idle wait and looks now.
206 // (`$.clock.now()` is a promise, so each read of it is awaited.)
207 wake = async () => {
208 pushedAt = await $.clock.now()
209 $.ui.invalidate('ui.render')
210 timer?.cancel()
211 timer = null
212 void loop()
213 }
214
215 // The clocks on the band tick between polls.
216 $.clock.every(1000, async () => {
217 if (rows.length > 0 || waiting(await $.clock.now())) $.ui.invalidate('ui.render')
218 })
219 void loop()
220
221 return next(e)
222 })
223}
224hooks/queue.ts 198 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
168// --- the monorepo search ----------------------------------------------------
169
170// One entry of a folder listing, as `$.fs.list` gives it.
171export type Entry = { name: string; kind: string }
172
173// The first folder under `root` that holds .vercel/project.json, shallowest
174// first. It used to be `find root -maxdepth 4 ... -path */.vercel/project.json`,
175// which a native Windows terminal does not have, so it walks `list` instead:
176// the link file at most four levels down (the linked folder at most two),
177// skipping node_modules and dot folders other than .vercel. A folder that
178// cannot be listed is skipped.
179export async function findLinkedBelow(
180 root: string,
181 list: (dir: string) => Promise<Entry[]>,
182 exists: (path: string) => Promise<boolean>,
183): Promise<string | null> {
184 let level = [root]
185 for (let depth = 0; depth <= 2 && level.length > 0; depth++) {
186 // Each level is listed at once, not one folder after another
187 const listed = await Promise.all(level.map(async (dir) => {
188 const dirs = (await list(dir).catch(() => [] as Entry[])).filter((e) => e.kind === 'dir')
189 const linked = dirs.some((e) => e.name === '.vercel') && (await exists(`${dir}/.vercel/project.json`).catch(() => false))
190 return { dir, dirs, linked }
191 }))
192 const hit = listed.find((l) => l.linked)
193 if (hit) return hit.dir
194 level = listed.flatMap((l) => l.dirs.filter((e) => e.name !== 'node_modules' && !e.name.startsWith('.')).map((e) => `${l.dir}/${e.name}`))
195 }
196 return null
197}
198