SLOPSHOPPER

vercel-deploy-status

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

newbandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
README

vercel-deploy-status

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.

Install

/plugin install vercel-deploy-status --marketplace ray-amjad/claude-code-mods

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

What it looks like

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.

DotPhase
○Queued
◐Building
◑Initializing
●Ready
✗Error
⊘Canceled
◌Waiting: you pushed, and Vercel has not listed the deploy yet

What wakes it

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 merge
  • vercel deploy or vercel --prod

A --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.

The three hooks

EventWhat it does
session.startFinds 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 BashWaits for the command to finish. If the command was a push, a merge or a deploy, it wakes the poll loop
ui.render on AbovePromptDraws 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.

Requirements

  • The vercel CLI on your PATH, logged in. Check with vercel whoami.
  • A .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.

Options

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
      }
    }
  }
}
OptionDefaultWhat it does
vercelBinvercelThe executable to run. Set a full path if your shell's PATH and Claude Code's differ
activePollSeconds15How often to ask Vercel while a deploy is in flight, or a push is waiting on one
idlePollSeconds60How often to ask Vercel while nothing is in flight
holdFinishedMinutes5How long a finished deploy keeps its line
watchAfterPushMinutes6How long a push keeps the active rate while no deploy has appeared
maxRows8How 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.

Known limits

  • 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.
  • One project per session. The first link file found is the one polled.
  • The toast on start and on finish uses the terminal's toast slot, so a toast from another plugin can push it out.

Layout

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.

Test 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.

Source 2 files
hooks/deploy-status.tsx 224 lines
1/** @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}
224
hooks/queue.ts 198 lines
1// 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