Merged is not deployed: follow a merged PR or commit through its workflow runs, deployments and an optional live URL with gh, on a pixel-art level map, and…

▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▀█
█ █▀▄ █▀█ █▄▄ ██▄ █▀▄ ★ COURSE CLEAR!

The problem: a PR merges, the session moves on, and an hour later someone asks why the change is not there. The merge was only the first stage: the build on the merge commit, each deploy and the rollout still had to happen, and one of them failed or never ran. The study behind this library found "merged but I still can't see it" in 34 prompts across 14 sessions.
tracer follows a merged commit on a timer outside the model, with gh: its workflow runs, its GitHub deployments and, optionally, a URL that shows the running version. It draws the chain as a level map, MERGED ▸ BUILD ▸ DEPLOY:<env> ▸ LIVE, and wakes the session once, when the chain reaches its last stage or a stage fails.
/plugin marketplace add pourya7/claude-code-mods
/plugin install tracer@claude-code-mods
It needs the GitHub CLI on PATH, signed in (gh auth login).
| Command | What it does |
|---|---|
/trace 42 | Follow PR #42 in the session's repo (found with gh repo view). The PR must be merged; tracer reads its merge commit with gh pr view 42 --json mergeCommit. |
/trace owner/repo#42, /trace https://github.com/owner/repo/pull/42 | Follow a PR in a named repo. |
/trace abc1234, /trace owner/repo@abc1234, a commit URL | Follow a commit directly (7 to 40 hex characters, resolved to the full SHA). A bare number is always a PR. |
/trace | Open the tracer pane. |
/trace stop, /trace stop 42 | Stop every trace, or one (also [ STOP ] / [ CLEAR ] in the pane). |
Every intervalSeconds, each trace still on the road is polled:
gh api repos/<repo>/actions/runs?head_sha=<sha>. No runs yet, or any run queued or in progress, is pending. It is done when every run finished as success, neutral or skipped. A run waiting for approval (action_required) keeps it pending. Any other conclusion (failure, timed_out, cancelled, startup_failure, ...) fails it, naming up to three runs.gh api repos/<repo>/deployments?sha=<sha>, one stage per environment. The environments in environments come first, in that order, and wait even before a deployment exists. Any other environment joins in the order it first appeared. For the newest deployment of each environment, tracer reads its newest status (deployments/<id>/statuses?per_page=1): success or inactive is done, failure or error fails, anything else (or no status yet) is pending.liveUrl is set). Once every earlier stage is done, tracer fetches liveUrl with {sha} and {short} filled in. It is live when the response is 2xx and the body holds the full SHA or its 7-character short form, or the liveMatch text when that is set. A failed fetch or a non-2xx status stays pending, and tracer tries again on the next poll.No deployments configured → BUILD is the final stage. With environments empty, if no deployment of the commit exists by the time its runs pass, the chain ends at BUILD and the wake says No deployments to follow. Set environments when your deploys appear later than the build (for example from an outside deployer), so tracer waits for them.
Waking ($.prompt.submit, once per trace):
| When | Prompt |
|---|---|
| LIVE reached | TRACER: PR #42 (abc1234) is LIVE — MERGED ▸ BUILD ▸ DEPLOY:PRODUCTION ▸ LIVE in 14m. |
| Last stage reached, no LIVE | TRACER: PR #42 (abc1234) reached DEPLOY:PRODUCTION — MERGED ▸ BUILD ▸ DEPLOY:PRODUCTION in 9m. |
| BUILD is the last stage | TRACER: PR #42 (abc1234) reached BUILD — MERGED ▸ BUILD in 6m. No deployments to follow. |
| A stage failed | TRACER: PR #42 (abc1234) FAILED at BUILD — FAILED: "ci". Investigate. |
Run and environment names come from whoever wrote the workflow, so before they reach a prompt each is cut to plain characters (letters, digits, space . : / ( ) _ -) and 40 columns.
Stopping. Polling for a trace stops when it is done or failed, or timeoutMinutes after /trace, which toasts TRACER #42 TIME UP AT <stage> and does not wake the session.
Stopping wins over a poll in flight. A trace stopped (or started again) while its poll is still waiting on gh is not woken, toasted or overwritten when that poll returns. If another hook refuses the wake prompt, tracer toasts TRACER: wake refused: <reason>.
The first poll is a baseline. /trace polls once at once and replies with where the chain stands. If the chain is already at its end (ALREADY THERE) or already failed (ALREADY FAILED), the reply says so and there is no wake.
gh failures. A missing gh, one that runs past its 30 s limit, and an unauthenticated gh each produce a one-line error with the fix:
/trace replies with the error and adds nothing when the PR or commit cannot be resolved.ERR on the status line and keeps the trace, so it recovers by itself.The mod never throws.
userConfig)| Option | Default | Meaning |
|---|---|---|
intervalSeconds | 60 | Seconds between polls (15–3600). |
timeoutMinutes | 120 | Stop following a trace this many minutes after /trace (1–1440). |
environments | "" | Comma-separated deployment environments to wait for, in order, e.g. staging, production. Names match without regard to case. Empty: follow whatever deployments appear. |
liveUrl | "" | Optional URL that shows the running version, e.g. https://api.example.com/version or https://app.example.com/health?v={short}. {sha} and {short} are filled in. Empty: no LIVE stage. |
liveMatch | "" | Optional text the live body must contain instead of the SHA, with {sha} and {short} filled in, e.g. "release":"r-{short}". |
wake | final | final: submit the wake line when the chain reaches its last stage or a stage fails. never: toast and redraw only. |
The pane (/trace) shows each trace as a level map. Each stage is a node on a dotted path:
The path turns orange where it has been travelled.
When the map is wider than the pane (many environments), the deploy nodes fold into one DEPLOY node tagged with how many environments are done, e.g. 2/5; in a pane too narrow even for that, the map is left out. The per-stage lines under the map always list every stage.
T R A C E R POLL 60S / WAKE FINAL / GIVE UP 120M
#42 Add login retry
▶ ON THE ROAD... WORLD abc1234 · 6M
▄▀▀▀▀▄ ▄▀▀▀▀▄ ▄▀▀▀▀▄ ▄▀▀▀▀▄ ▄▀▀▀▀▄
▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀ ▀ ▀▀▄▄▀▀
★ MERGED ★ BUILD ★ DEPLOY ● DEPLOY ● LIVE
STAGING PRODUCT~
★ MERGED abc1234
★ BUILD: 2/2 RUNS PASSED
★ DEPLOY:STAGING: SUCCESS
● DEPLOY:PRODUCTION: IN PROGRESS
● LIVE: WAITS FOR DEPLOY
[ STOP ]
A failed build:
#42 Add login retry
✕ GAME OVER: BUILD FAILED WORLD abc1234 · 3M
▄▀▀▀▀▄ ▀▀▄▄▀▀
▀▀▀▀▀▀ ▀ ▄▀▀▀▀▄
★ MERGED ✕ BUILD
★ MERGED abc1234
✕ BUILD: FAILED: "ci"
[ CLEAR ]
These captures are plain text. In the terminal the nodes and labels are drawn in the PICO-8 palette, with tracer's signature yellow. A finished trace reads ★ COURSE CLEAR! in lime, and a timed-out one ● TIME UP in orange. With nothing traced, the pane reads NO COURSE LOADED. /trace 42 TO INSERT COIN.
Status line (the newest trace, then +n more; under 40 columns):
TRACER #42 ★★★●● DEPLOY:PRODUCTION
TRACER #42 ★★★★★ CLEAR!
TRACER #42 ★✕ BUILD FAILED
TRACER abc1234 ★● TIME UP
TRACER #42 ★● BUILD ERR
A toast fires when a stage moves, e.g. TRACER #42 ★ BUILD or TRACER #42 ✕ DEPLOY:STAGING FAILED.
VS Code and claude -p have no pane, so the status line, the toasts and the /trace replies are what you see there.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
Through gh, to the GitHub API. One $.http.fetch GET of liveUrl per poll, only when you set it and only once the earlier stages are done; no credentials are attached. | gh only: gh repo view, gh pr view, gh api repos/…/commits/<sha>, gh api repos/…/actions/runs, gh api repos/…/deployments, gh api repos/…/deployments/<id>/statuses | None read or written. The traces live in session state ($.state); nothing goes to $.store. | No | Yes: one wake line per trace when the chain ends or fails; set wake: never to turn it off | Repo name, PR number and commit SHA, sent by gh to GitHub under your own gh login. The SHA, if your liveUrl contains {sha} or {short}, to the host you configured. The wake prompt goes to your own session's model. |
liveUrl to see it go live instead.claude plugin validate tracer
claude plugin test tracer
Pure logic lives in hooks/chain.ts (stages, outcome and every line of text), hooks/gh.ts (what /trace accepts, argv builders and output readers) and hooks/pixels.ts (the level map and the half-block renderer). hooks/register.tsx connects them to the engine. The tests use mock.clock, answer gh and the live URL through process.run and http.fetch hooks, and mount the pane on both terminal and desktop.
hooks/register.tsx 420 lines1// tracer: merged is not deployed. Follows a merged commit through its
2// workflow runs, deployments and an optional live URL, on a timer outside the
3// model, and wakes the session once when the chain ends.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7import type { TracerOutcome, TracerTrace } from '../types'
8import type { DeploymentState, LiveResult } from './chain'
9import {
10 GLYPH,
11 buildStages,
12 chainText,
13 currentStage,
14 isLiveBody,
15 latestPerEnvironment,
16 liveUrlFor,
17 needsLiveCheck,
18 outcomeOf,
19 parseEnvironments,
20 shortSha,
21 stageLabel,
22 statusLine,
23 toastText,
24 wakeLine,
25} from './chain'
26import {
27 REPO_VIEW_ARGV,
28 commitArgv,
29 deploymentStatusArgv,
30 deploymentsArgv,
31 describeGhFailure,
32 describeSpawnFailure,
33 parseJson,
34 parseTraceRef,
35 prViewArgv,
36 readCommit,
37 readDeployments,
38 readLatestStatus,
39 readPrView,
40 readRuns,
41 runsArgv,
42} from './gh'
43import type { TraceRef } from './gh'
44import { PICO, SIGNATURE, levelMap } from './pixels'
45
46const PANE = 'tracer'
47const GH_TIMEOUT_MS = 30_000
48const MAX_TRACES = 5
49const USAGE = 'Use /trace 42, /trace owner/repo#42, /trace abc1234, a PR or commit URL, or /trace stop.'
50
51const traces = atom({ plugin: 'tracer', key: 'traces' } as const, [] as TracerTrace[])
52
53type Settings = {
54 intervalMs: number
55 timeoutMs: number
56 environments: string[]
57 liveUrl: string
58 liveMatch: string
59 wake: string
60}
61type Gh = { stdout: string } | { error: string }
62type Polled = { trace: TracerTrace; toast: string | null; wake: string | null }
63
64// Timers cannot live in $.state; a reload cancels this one, and session.start
65// (raised on a reload too) arms it again.
66let timer: Timer | null = null
67let isPolling = false
68
69/** Runs gh; every failure (missing, timed out, unauthenticated, any non-zero exit) becomes `{ error }`. */
70async function gh($: EngineInterface, argv: readonly string[]): Promise<Gh> {
71 const startedAt = await $.clock.now()
72 try {
73 const ran = await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS })
74 return ran.exitCode === 0 ? { stdout: ran.stdout } : { error: describeGhFailure(ran.stderr) }
75 } catch (error) {
76 const elapsedMs = (await $.clock.now()) - startedAt
77 return { error: describeSpawnFailure(error instanceof Error ? error.message : String(error), elapsedMs, GH_TIMEOUT_MS) }
78 }
79}
80
81async function ghJson($: EngineInterface, argv: readonly string[]): Promise<{ json: unknown } | { error: string }> {
82 const ran = await gh($, argv)
83 return 'error' in ran ? ran : { json: parseJson(ran.stdout) }
84}
85
86type Target = { repo: string; sha: string; pr: number | null; title: string }
87
88/** The repo and full merge commit SHA a /trace argument names, or why not. */
89async function resolveTarget($: EngineInterface, ref: TraceRef): Promise<Target | { error: string }> {
90 let repo = ref.repo
91 if (repo === null) {
92 const found = await gh($, REPO_VIEW_ARGV)
93 if ('error' in found) return { error: `${found.error} (or name the repo: owner/repo#42)` }
94 repo = found.stdout.trim()
95 if (repo === '') return { error: 'gh found no GitHub repo here: name it, e.g. owner/repo#42.' }
96 }
97 if (ref.kind === 'pr') {
98 const ran = await ghJson($, prViewArgv(repo, ref.number))
99 if ('error' in ran) return ran
100 const pr = readPrView(ran.json)
101 if (pr === null) return { error: `PR #${ref.number} in ${repo}: unreadable gh output` }
102 if (pr.state !== 'MERGED' || pr.mergeSha === null) {
103 return { error: `PR #${ref.number} in ${repo} is not merged yet (${pr.state || 'unknown'}). Trace it once it merges.` }
104 }
105 return { repo, sha: pr.mergeSha, pr: ref.number, title: pr.title }
106 }
107 const ran = await ghJson($, commitArgv(repo, ref.sha))
108 if ('error' in ran) return ran
109 const commit = readCommit(ran.json)
110 if (commit === null) return { error: `no commit ${ref.sha} in ${repo}` }
111 return { repo, sha: commit.sha, pr: null, title: commit.title }
112}
113
114async function checkLive($: EngineInterface, settings: Settings, sha: string): Promise<LiveResult> {
115 try {
116 const response = await $.http.fetch(liveUrlFor(settings.liveUrl, sha))
117 if (!response.ok) return { isLive: false, detail: `HTTP ${response.status}` }
118 if (isLiveBody(response.text, sha, settings.liveMatch)) return { isLive: true, detail: settings.liveMatch ? 'MATCH SEEN' : 'SHA SEEN' }
119 return { isLive: false, detail: `HTTP ${response.status}, ${settings.liveMatch ? 'MATCH' : 'SHA'} NOT SEEN YET` }
120 } catch {
121 return { isLive: false, detail: 'FETCH FAILED' }
122 }
123}
124
125/** One read of the chain: runs, deployments (newest per environment) with their statuses, then the live URL. */
126async function readChain($: EngineInterface, settings: Settings, trace: TracerTrace) {
127 const runsRan = await ghJson($, runsArgv(trace.repo, trace.sha))
128 if ('error' in runsRan) return runsRan
129 const runs = readRuns(runsRan.json)
130 if (runs === null) return { error: 'unreadable workflow runs' }
131 const deploymentsRan = await ghJson($, deploymentsArgv(trace.repo, trace.sha))
132 if ('error' in deploymentsRan) return deploymentsRan
133 const listed = readDeployments(deploymentsRan.json)
134 if (listed === null) return { error: 'unreadable deployments' }
135 const deployments: DeploymentState[] = []
136 for (const deployment of latestPerEnvironment(listed)) {
137 const statusRan = await ghJson($, deploymentStatusArgv(trace.repo, deployment.id))
138 if ('error' in statusRan) return statusRan
139 const status = readLatestStatus(statusRan.json)
140 if (status === null) return { error: `unreadable status of deployment ${deployment.id}` }
141 deployments.push({ ...deployment, state: status.state })
142 }
143 const input = { runs, deployments, environments: settings.environments, hasLiveUrl: settings.liveUrl !== '', live: null }
144 const stages = buildStages(input)
145 if (!needsLiveCheck(stages)) return { stages }
146 return { stages: buildStages({ ...input, live: await checkLive($, settings, trace.sha) }) }
147}
148
149/**
150 * Polls one trace and says what moved (the toast and the wake line, for the
151 * caller to use only if the trace is still on the map). The first poll (from
152 * /trace) is a baseline: no toast and no wake, the reply says where it stands.
153 */
154async function pollTrace($: EngineInterface, settings: Settings, trace: TracerTrace, isFirst: boolean): Promise<Polled> {
155 const now = await $.clock.now()
156 if (!isFirst && now - trace.startedAt >= settings.timeoutMs) {
157 const next: TracerTrace = { ...trace, outcome: 'timeout', checkedAt: now }
158 return { trace: next, toast: toastText(trace, next), wake: null }
159 }
160 const read = await readChain($, settings, trace)
161 if ('error' in read) {
162 const toast = !isFirst && read.error !== trace.error ? `TRACER ${trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`}: ${read.error}` : null
163 return { trace: { ...trace, error: read.error, checkedAt: now }, toast, wake: null }
164 }
165 const outcome: TracerOutcome = outcomeOf(read.stages, trace.startedAt, now, isFirst ? Number.POSITIVE_INFINITY : settings.timeoutMs)
166 const isFinal = outcome === 'done' || outcome === 'failed'
167 const next: TracerTrace = { ...trace, stages: read.stages, outcome, error: null, checkedAt: now, hasWoken: trace.hasWoken || isFinal }
168 if (isFirst) return { trace: next, toast: null, wake: null }
169 return { trace: next, toast: toastText(trace, next), wake: isFinal && !trace.hasWoken ? wakeLine(next, now - trace.startedAt) : null }
170}
171
172async function refreshStatus($: EngineInterface) {
173 $.ui.status(statusLine(await read($, traces)))
174}
175
176/**
177 * Puts a polled trace back, but only over the same trace: one stopped while
178 * the poll was in flight stays gone, and one started again (a new startedAt)
179 * is not overwritten by the stale copy. Says whether it replaced anything.
180 */
181async function replaceTrace($: EngineInterface, trace: TracerTrace): Promise<boolean> {
182 let isReplaced = false
183 await update($, traces, list =>
184 list.map(one => {
185 if (one.sha !== trace.sha || one.repo !== trace.repo || one.startedAt !== trace.startedAt) return one
186 isReplaced = true
187 return trace
188 }),
189 )
190 return isReplaced
191}
192
193/** The timer's work: poll every trace still on the road, then wake once with every final line. */
194async function tick($: EngineInterface, settings: Settings) {
195 if (isPolling) return
196 isPolling = true
197 try {
198 const live = (await read($, traces)).filter(trace => trace.outcome === 'tracing')
199 if (live.length === 0) return
200 const lines: string[] = []
201 for (const trace of live) {
202 const polled = await pollTrace($, settings, trace, false)
203 if (!(await replaceTrace($, polled.trace))) continue
204 if (polled.toast !== null) $.ui.toast(polled.toast)
205 if (polled.wake !== null) lines.push(polled.wake)
206 }
207 await refreshStatus($)
208 if (settings.wake === 'final' && lines.length > 0) {
209 try {
210 const sent = await $.prompt.submit({ text: lines.join('\n') })
211 if (sent.drop !== undefined) $.ui.toast(`TRACER: wake refused: ${sent.drop}`)
212 } catch {
213 $.ui.toast('TRACER: could not submit the wake prompt')
214 }
215 }
216 } finally {
217 isPolling = false
218 }
219}
220
221function who(trace: TracerTrace): string {
222 return trace.pr === null ? shortSha(trace.sha) : `PR #${trace.pr} (${shortSha(trace.sha)})`
223}
224
225async function startTrace($: EngineInterface, settings: Settings, argument: string): Promise<string> {
226 const ref = parseTraceRef(argument)
227 if (ref === null) return `tracer: "${argument}" is not a PR or a commit. ${USAGE}`
228 const target = await resolveTarget($, ref)
229 if ('error' in target) return `tracer: not tracing: ${target.error}`
230 const now = await $.clock.now()
231 const fresh: TracerTrace = {
232 ...target,
233 startedAt: now,
234 checkedAt: now,
235 stages: buildStages({ runs: [], deployments: [], environments: settings.environments, hasLiveUrl: settings.liveUrl !== '', live: null }),
236 outcome: 'tracing',
237 hasWoken: false,
238 error: null,
239 }
240 const { trace } = await pollTrace($, settings, fresh, true)
241 await update($, traces, list =>
242 [trace, ...list.filter(one => !(one.sha === trace.sha && one.repo === trace.repo))].slice(0, MAX_TRACES),
243 )
244 await refreshStatus($)
245 const head = `${trace.repo} ${who(trace)}${trace.title ? ` "${trace.title.slice(0, 60)}"` : ''}`
246 const chain = chainText(trace.stages)
247 if (trace.error !== null) return `TRACER ON THE ROAD: ${head}, but GitHub could not be read yet: ${trace.error}. Retrying every ${Math.round(settings.intervalMs / 1000)}s.`
248 if (trace.outcome === 'done') return `TRACER: ${head} is ALREADY THERE — ${chain}.`
249 if (trace.outcome === 'failed') {
250 const failed = trace.stages.find(stage => stage.state === 'failed')
251 return `TRACER: ${head} ALREADY FAILED at ${failed ? stageLabel(failed) : 'a stage'} — ${failed?.detail ?? ''}. ${chain}.`
252 }
253 const promise = settings.wake === 'final' ? 'I will wake you when it gets there or a stage fails.' : 'Watch the status line (wake is off).'
254 return `TRACER ON THE ROAD: ${head} — ${chain}. ${promise}`
255}
256
257async function stopTraces($: EngineInterface, argument: string): Promise<string> {
258 const ref = argument === '' ? null : parseTraceRef(argument)
259 if (argument !== '' && ref === null) return `tracer: "${argument}" is not a PR or a commit. ${USAGE}`
260 const isGone = (trace: TracerTrace) =>
261 ref === null ||
262 (ref.kind === 'pr' ? trace.pr === ref.number : trace.sha.startsWith(ref.sha)) && (ref.repo === null || ref.repo === trace.repo)
263 const list = await read($, traces)
264 const gone = list.filter(isGone)
265 if (gone.length === 0) return 'tracer: nothing to stop.'
266 await update($, traces, all => all.filter(trace => !isGone(trace)))
267 await refreshStatus($)
268 return `tracer: stopped ${gone.map(trace => (trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`)).join(', ')}.`
269}
270
271async function removeTrace($: EngineInterface, trace: TracerTrace) {
272 await update($, traces, list => list.filter(one => !(one.sha === trace.sha && one.repo === trace.repo)))
273 await refreshStatus($)
274}
275
276const OUTCOME: Record<TracerOutcome, { label: (trace: TracerTrace) => string; color: string }> = {
277 tracing: { label: () => '▶ ON THE ROAD...', color: PICO.yellow },
278 done: { label: () => '★ COURSE CLEAR!', color: PICO.lime },
279 failed: {
280 label: trace => {
281 const stage = trace.stages.find(one => one.state === 'failed')
282 return `✕ GAME OVER: ${stage ? stageLabel(stage) : ''} FAILED`
283 },
284 color: PICO.red,
285 },
286 timeout: { label: () => '● TIME UP', color: PICO.orange },
287}
288
289export const register: Register = (on, options) => {
290 const settings: Settings = {
291 intervalMs: Math.max(15, Number(options.intervalSeconds ?? 60) || 60) * 1000,
292 timeoutMs: Math.max(1, Number(options.timeoutMinutes ?? 120) || 120) * 60_000,
293 environments: parseEnvironments(options.environments),
294 liveUrl: String(options.liveUrl ?? '').trim(),
295 liveMatch: String(options.liveMatch ?? ''),
296 wake: String(options.wake ?? 'final'),
297 }
298
299 on('session.start', async ($, e, next) => {
300 try {
301 await $.command.register({
302 name: 'trace',
303 description: 'tracer: follow a merged PR or commit until it is deployed; no argument opens the map',
304 argumentHint: '[pr | sha | stop]',
305 })
306 } catch {
307 $.ui.toast('TRACER: /trace could not be registered')
308 }
309 timer?.cancel()
310 timer = $.clock.every(settings.intervalMs, () => {
311 void tick($, settings).catch(() => undefined)
312 })
313 await refreshStatus($)
314 return next(e)
315 })
316
317 on('command.run', { command: 'trace' }, async ($, e) => {
318 const argument = (e.args ?? '').trim()
319 const [word = '', ...rest] = argument.split(/\s+/)
320 if (argument === '') {
321 await $.ui.open({ id: PANE, title: 'TRACER' })
322 const list = await read($, traces)
323 return {
324 text:
325 list.length === 0
326 ? `TRACER: nothing on the map. /trace 42 to insert coin. ${USAGE}`
327 : list.map(trace => `TRACER ${who(trace)}: ${chainText(trace.stages)}`).join('\n'),
328 }
329 }
330 if (word.toLowerCase() === 'stop' || word.toLowerCase() === 'off') return { text: await stopTraces($, rest.join(' ')) }
331 return { text: await startTrace($, settings, argument) }
332 })
333
334 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
335 const { Box, Button, Text } = $.ui.resolve(e)
336 const list = await read($, traces)
337 const now = await $.clock.now()
338 const width = Math.max(20, e.props.bodyColumns - 6)
339
340 return (
341 <Box flexDirection="column">
342 <Box flexDirection="row">
343 <Text bold color={SIGNATURE}>
344 T R A C E R
345 </Text>
346 <Text dimColor>
347 {' '}POLL {Math.round(settings.intervalMs / 1000)}S / WAKE {settings.wake.toUpperCase()} / GIVE UP {Math.round(settings.timeoutMs / 60_000)}M
348 </Text>
349 </Box>
350 {list.length === 0 && (
351 <Box key="empty" marginTop={1}>
352 <Text color={PICO.lightGrey}>NO COURSE LOADED. /trace 42 TO INSERT COIN</Text>
353 </Box>
354 )}
355 {list.map(trace => {
356 const map = levelMap(trace.stages, trace.outcome === 'tracing', e.props.bodyColumns)
357 const outcome = OUTCOME[trace.outcome]
358 const elapsed = (trace.outcome === 'tracing' ? now : trace.checkedAt) - trace.startedAt
359 const title = trace.title.length > width ? `${trace.title.slice(0, width - 1)}~` : trace.title
360 const current = currentStage(trace.stages)
361 return (
362 <Box key={`trace-${trace.sha}`} flexDirection="column" marginTop={1}>
363 <Box flexDirection="row">
364 <Text bold color={SIGNATURE}>
365 {trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`}{' '}
366 </Text>
367 <Text wrap="truncate-end">{title}</Text>
368 </Box>
369 <Box flexDirection="row">
370 <Box key={`outcome-${trace.sha}`}>
371 <Text bold color={outcome.color}>
372 {outcome.label(trace)}
373 </Text>
374 </Box>
375 <Text dimColor>
376 {' '}WORLD {shortSha(trace.sha)} · {Math.floor(elapsed / 60_000)}M
377 </Text>
378 </Box>
379 {map.pixels.length > 0 && (
380 <Box key={`map-${trace.sha}`} flexDirection="column" marginTop={1}>
381 {map.pixels.map(runs => (
382 <Box flexDirection="row">
383 {runs.map(run => (
384 <Text color={run.color} backgroundColor={run.backgroundColor}>
385 {run.text}
386 </Text>
387 ))}
388 </Box>
389 ))}
390 {map.labels.map(cells => (
391 <Box flexDirection="row">
392 {cells.map(cell => (
393 <Text color={cell.color}>{cell.text}</Text>
394 ))}
395 </Box>
396 ))}
397 </Box>
398 )}
399 {trace.stages.map(stage => (
400 <Text color={stage.state === 'failed' ? PICO.red : stage === current && trace.outcome === 'tracing' ? PICO.blue : PICO.lightGrey}>
401 {GLYPH[stage.state]} {stageLabel(stage)}{stage.kind === 'merged' ? ` ${shortSha(trace.sha)}` : `: ${stage.detail}`}
402 </Text>
403 ))}
404 {trace.error !== null && <Text color={PICO.red}>{trace.error}</Text>}
405 <Box flexDirection="row">
406 <Button
407 key={`stop-${trace.sha}`}
408 label={trace.outcome === 'tracing' ? 'STOP' : 'CLEAR'}
409 dimColor
410 onPress={() => removeTrace($, trace)}
411 />
412 </Box>
413 </Box>
414 )
415 })}
416 </Box>
417 )
418 })
419}
420hooks/chain.ts 240 lines1// The chain MERGED ▸ BUILD ▸ DEPLOY:<env> ▸ LIVE: stages from GitHub data,
2// the outcome, and every line of text tracer says. Pure: no `$` here.
3import type { TracerOutcome, TracerStage, TracerTrace } from '../types'
4
5/** One workflow run on the merge commit, as `actions/runs?head_sha=` lists it. */
6export type WorkflowRun = { name: string; status: string; conclusion: string | null }
7
8/** One deployment of the merge commit, with the state of its newest status (null: none yet). */
9export type DeploymentState = { id: number; environment: string; createdAt: string; state: string | null }
10
11/** What the live URL said, or null when it was not fetched. */
12export type LiveResult = { isLive: boolean; detail: string }
13
14export type ChainInput = {
15 /** Workflow runs on the SHA. */
16 runs: WorkflowRun[]
17 deployments: DeploymentState[]
18 /** `userConfig.environments`: deploy stages to wait for, in order. */
19 environments: string[]
20 hasLiveUrl: boolean
21 live: LiveResult | null
22}
23
24const PASSED = new Set(['success', 'neutral', 'skipped'])
25const WAITING = new Set(['action_required'])
26const DEPLOY_DONE = new Set(['success', 'inactive'])
27const DEPLOY_FAILED = new Set(['failure', 'error'])
28
29export const GLYPH: Record<TracerStage['state'], string> = { pending: '●', done: '★', failed: '✕' }
30
31/** Workflow and environment names come from whoever wrote them: plain characters, 40 columns. */
32export function cleanName(name: string): string {
33 return name.replace(/[^A-Za-z0-9 .:/()_-]/g, '').trim().slice(0, 40)
34}
35
36function quotedList(names: readonly string[]): string {
37 const unique = [...new Set(names.map(cleanName))]
38 const shown = unique.slice(0, 3).map(name => `"${name}"`)
39 return unique.length > 3 ? `${shown.join(', ')} +${unique.length - 3} more` : shown.join(', ')
40}
41
42function buildStage(runs: readonly WorkflowRun[]): TracerStage {
43 const stage = (state: TracerStage['state'], detail: string): TracerStage => ({ id: 'build', kind: 'build', state, detail })
44 if (runs.length === 0) return stage('pending', 'NO RUNS YET')
45 const completed = runs.filter(run => run.status === 'completed')
46 const failed = completed.filter(run => !PASSED.has(run.conclusion ?? '') && !WAITING.has(run.conclusion ?? ''))
47 if (failed.length > 0) return stage('failed', `FAILED: ${quotedList(failed.map(run => run.name))}`)
48 const passed = completed.filter(run => PASSED.has(run.conclusion ?? '')).length
49 const waiting = completed.length - passed
50 const running = runs.length - completed.length
51 if (running === 0 && waiting === 0) return stage('done', `${passed}/${runs.length} RUNS PASSED`)
52 const parts = [running > 0 ? `${running} RUNNING` : '', waiting > 0 ? `${waiting} WAITING FOR APPROVAL` : '', passed > 0 ? `${passed} PASSED` : '']
53 return stage('pending', parts.filter(part => part !== '').join(', '))
54}
55
56function newest<T extends { id: number; createdAt: string }>(a: T, b: T): T {
57 if (a.createdAt !== b.createdAt) return a.createdAt > b.createdAt ? a : b
58 return a.id > b.id ? a : b
59}
60
61type Dated = { id: number; environment: string; createdAt: string }
62
63/** The newest deployment of each environment (names compared without case), so only those need a status read. */
64export function latestPerEnvironment<T extends Dated>(deployments: readonly T[]): T[] {
65 const latest = new Map<string, T>()
66 for (const deployment of deployments) {
67 const key = deployment.environment.toLowerCase()
68 const known = latest.get(key)
69 latest.set(key, known === undefined ? deployment : newest(known, deployment))
70 }
71 return [...latest.values()]
72}
73
74function deployStage(environment: string, deployment: DeploymentState | undefined): TracerStage {
75 const base = { id: `deploy:${environment}`, kind: 'deploy' as const, environment }
76 if (deployment === undefined) return { ...base, state: 'pending', detail: 'NO DEPLOYMENT YET' }
77 const state = deployment.state
78 if (state === null) return { ...base, state: 'pending', detail: 'NO STATUS YET' }
79 const detail = state.replace(/_/g, ' ').toUpperCase()
80 if (DEPLOY_DONE.has(state)) return { ...base, state: 'done', detail }
81 if (DEPLOY_FAILED.has(state)) return { ...base, state: 'failed', detail }
82 return { ...base, state: 'pending', detail }
83}
84
85/**
86 * MERGED (always done: only merged commits are traced), BUILD, one DEPLOY per
87 * environment (the configured ones first, then any others in the order they
88 * appeared; the newest deployment of each wins) and LIVE when a URL is set.
89 */
90export function buildStages(input: ChainInput): TracerStage[] {
91 const latest = new Map<string, DeploymentState>()
92 const firstSeen: string[] = []
93 const ordered = [...input.deployments].sort((a, b) => (a.createdAt === b.createdAt ? a.id - b.id : a.createdAt < b.createdAt ? -1 : 1))
94 for (const deployment of ordered) {
95 const key = deployment.environment.toLowerCase()
96 const known = latest.get(key)
97 if (known === undefined) firstSeen.push(deployment.environment)
98 latest.set(key, known === undefined ? deployment : newest(known, deployment))
99 }
100 const configured = new Set(input.environments.map(name => name.toLowerCase()))
101 const environments = [...input.environments, ...firstSeen.filter(name => !configured.has(name.toLowerCase()))]
102
103 const stages: TracerStage[] = [
104 { id: 'merged', kind: 'merged', state: 'done', detail: 'MERGED' },
105 buildStage(input.runs),
106 ...environments.map(name => deployStage(name, latest.get(name.toLowerCase()))),
107 ]
108 if (input.hasLiveUrl) {
109 const live = input.live
110 stages.push({
111 id: 'live',
112 kind: 'live',
113 state: live?.isLive ? 'done' : 'pending',
114 detail: live === null ? 'WAITS FOR DEPLOY' : live.detail,
115 })
116 }
117 return stages
118}
119
120/** True when every stage before LIVE is done, so the live URL is worth a fetch. */
121export function needsLiveCheck(stages: readonly TracerStage[]): boolean {
122 const live = stages.at(-1)
123 if (live?.kind !== 'live' || live.state === 'done') return false
124 return stages.slice(0, -1).every(stage => stage.state === 'done')
125}
126
127/** The first stage not done yet: where the player stands on the map. */
128export function currentStage(stages: readonly TracerStage[]): TracerStage | undefined {
129 return stages.find(stage => stage.state !== 'done')
130}
131
132export function outcomeOf(stages: readonly TracerStage[], startedAt: number, now: number, timeoutMs: number): TracerOutcome {
133 if (stages.some(stage => stage.state === 'failed')) return 'failed'
134 if (stages.every(stage => stage.state === 'done')) return 'done'
135 return now - startedAt >= timeoutMs ? 'timeout' : 'tracing'
136}
137
138export function stageLabel(stage: TracerStage): string {
139 if (stage.kind === 'deploy') return `DEPLOY:${cleanName(stage.environment ?? '').toUpperCase()}`
140 return stage.kind.toUpperCase()
141}
142
143export function shortSha(sha: string): string {
144 return sha.slice(0, 7)
145}
146
147/** The live URL with `{sha}` and `{short}` filled in. */
148export function liveUrlFor(template: string, sha: string): string {
149 return fillSha(template, sha, encodeURIComponent)
150}
151
152function fillSha(template: string, sha: string, encode: (text: string) => string = text => text): string {
153 return template.replace(/\{sha\}/g, encode(sha)).replace(/\{short\}/g, encode(shortSha(sha)))
154}
155
156/** Live when the body holds `liveMatch` (placeholders filled), or else the full or short SHA. */
157export function isLiveBody(body: string, sha: string, liveMatch: string): boolean {
158 if (liveMatch.trim() !== '') return body.includes(fillSha(liveMatch, sha))
159 return body.includes(sha) || body.includes(shortSha(sha))
160}
161
162export function parseEnvironments(text: unknown): string[] {
163 if (typeof text !== 'string') return []
164 return text
165 .split(',')
166 .map(name => name.trim())
167 .filter(name => name !== '')
168}
169
170function who(trace: TracerTrace): string {
171 return trace.pr === null ? shortSha(trace.sha) : `#${trace.pr}`
172}
173
174function whoLong(trace: TracerTrace): string {
175 return trace.pr === null ? shortSha(trace.sha) : `PR #${trace.pr} (${shortSha(trace.sha)})`
176}
177
178export function glyphs(stages: readonly TracerStage[]): string {
179 return stages.map(stage => GLYPH[stage.state]).join('')
180}
181
182/** `★ MERGED ▸ ★ BUILD ▸ ● LIVE` for replies and the pane. */
183export function chainText(stages: readonly TracerStage[]): string {
184 return stages.map(stage => `${GLYPH[stage.state]} ${stageLabel(stage)}`).join(' ▸ ')
185}
186
187function where(trace: TracerTrace): string {
188 if (trace.outcome === 'done') return 'CLEAR!'
189 if (trace.outcome === 'failed') {
190 // The stage that failed, not the first one still pending ahead of it.
191 const failed = trace.stages.find(one => one.state === 'failed')
192 return `${failed ? stageLabel(failed) : ''} FAILED`
193 }
194 if (trace.outcome === 'timeout') return 'TIME UP'
195 const stage = currentStage(trace.stages)
196 return stage ? stageLabel(stage) : ''
197}
198
199const STATUS_COLUMNS = 40
200
201/** `TRACER #42 ★★●● DEPLOY:PRODUCTION` for the newest trace, then `+n` more; under 40 columns. */
202export function statusLine(traces: readonly TracerTrace[]): string | undefined {
203 const [first, ...rest] = traces
204 if (first === undefined) return undefined
205 const tail = `${first.error !== null && first.outcome === 'tracing' ? ' ERR' : ''}${rest.length > 0 ? ` +${rest.length}` : ''}`
206 const line = `TRACER ${who(first)} ${glyphs(first.stages)} ${where(first)}`
207 const room = STATUS_COLUMNS - tail.length
208 return `${line.length > room ? `${line.slice(0, room - 1)}~` : line}${tail}`
209}
210
211function minutes(elapsedMs: number): string {
212 return `${Math.max(0, Math.floor(elapsedMs / 60_000))}m`
213}
214
215/** The one line the session is woken with, when the chain is done or failed. */
216export function wakeLine(trace: TracerTrace, elapsedMs: number): string {
217 const path = trace.stages.map(stageLabel).join(' ▸ ')
218 if (trace.outcome === 'failed') {
219 const stage = trace.stages.find(one => one.state === 'failed')
220 return `TRACER: ${whoLong(trace)} FAILED at ${stage ? stageLabel(stage) : 'a stage'} — ${stage?.detail ?? 'unknown'}. Investigate.`
221 }
222 const last = trace.stages.at(-1)
223 if (last?.kind === 'live') return `TRACER: ${whoLong(trace)} is LIVE — ${path} in ${minutes(elapsedMs)}.`
224 const note = last?.kind === 'build' ? ' No deployments to follow.' : ''
225 return `TRACER: ${whoLong(trace)} reached ${last ? stageLabel(last) : 'the end'} — ${path} in ${minutes(elapsedMs)}.${note}`
226}
227
228/** What moved between two polls, or null when nothing did. */
229export function toastText(before: TracerTrace, after: TracerTrace): string | null {
230 if (after.outcome === 'timeout' && before.outcome !== 'timeout') {
231 const stage = currentStage(after.stages)
232 return `TRACER ${who(after)} TIME UP AT ${stage ? stageLabel(stage) : 'THE END'}`
233 }
234 const previous = new Map(before.stages.map(stage => [stage.id, stage.state]))
235 const moved = after.stages.filter(stage => stage.state !== 'pending' && previous.get(stage.id) !== stage.state)
236 if (moved.length === 0) return null
237 const parts = moved.map(stage => (stage.state === 'failed' ? `✕ ${stageLabel(stage)} FAILED` : `★ ${stageLabel(stage)}`))
238 return `TRACER ${who(after)} ${parts.join(' ')}`
239}
240hooks/gh.ts 142 lines1// gh: what /trace accepts, argv builders, output readers and failure
2// messages. Pure: no `$` here.
3import type { WorkflowRun } from './chain'
4
5export type TraceRef =
6 | { kind: 'pr'; number: number; repo: string | null }
7 | { kind: 'sha'; sha: string; repo: string | null }
8
9const REPO = String.raw`[\w.-]+\/[\w.-]+`
10const PR_URL = new RegExp(String.raw`^(?:https?:\/\/)?(?:www\.)?github\.com\/(${REPO})\/pull\/(\d+)(?:[/?#].*)?$`, 'i')
11const COMMIT_URL = new RegExp(String.raw`^(?:https?:\/\/)?(?:www\.)?github\.com\/(${REPO})\/commit\/([0-9a-f]{7,40})(?:[/?#].*)?$`, 'i')
12const PR_SLUG = new RegExp(String.raw`^(${REPO})#(\d+)$`)
13const SHA_SLUG = new RegExp(String.raw`^(${REPO})@([0-9a-f]{7,40})$`, 'i')
14const PR_NUMBER = /^#?(\d+)$/
15const SHA = /^[0-9a-f]{7,40}$/i
16
17function prRef(digits: string | undefined, repo: string | null): TraceRef | null {
18 const number = Number(digits)
19 return Number.isInteger(number) && number > 0 ? { kind: 'pr', number, repo } : null
20}
21
22/**
23 * `42`, `#42`, `owner/repo#42` or a PR URL (a PR); `abc1234`, `owner/repo@abc1234`
24 * or a commit URL (a SHA, 7 to 40 hex characters). All digits reads as a PR.
25 */
26export function parseTraceRef(argument: string): TraceRef | null {
27 const text = argument.trim()
28 let match = PR_URL.exec(text) ?? PR_SLUG.exec(text)
29 if (match) return prRef(match[2], match[1] ?? null)
30 match = PR_NUMBER.exec(text)
31 if (match) return prRef(match[1], null)
32 match = COMMIT_URL.exec(text) ?? SHA_SLUG.exec(text)
33 if (match) return { kind: 'sha', sha: (match[2] ?? '').toLowerCase(), repo: match[1] ?? null }
34 if (SHA.test(text)) return { kind: 'sha', sha: text.toLowerCase(), repo: null }
35 return null
36}
37
38export const REPO_VIEW_ARGV = ['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'] as const
39
40export function prViewArgv(repo: string, number: number): string[] {
41 return ['gh', 'pr', 'view', String(number), '--repo', repo, '--json', 'number,title,state,mergeCommit']
42}
43
44export function commitArgv(repo: string, sha: string): string[] {
45 return ['gh', 'api', `repos/${repo}/commits/${sha}`]
46}
47
48export function runsArgv(repo: string, sha: string): string[] {
49 return ['gh', 'api', `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`]
50}
51
52export function deploymentsArgv(repo: string, sha: string): string[] {
53 return ['gh', 'api', `repos/${repo}/deployments?sha=${sha}&per_page=100`]
54}
55
56/** The newest status of one deployment (GitHub lists them newest first). */
57export function deploymentStatusArgv(repo: string, id: number): string[] {
58 return ['gh', 'api', `repos/${repo}/deployments/${id}/statuses?per_page=1`]
59}
60
61/** JSON.parse that survives raw control characters in strings (each becomes a space). */
62export function parseJson(text: string): unknown {
63 try {
64 // eslint-disable-next-line no-control-regex
65 return JSON.parse(text.replace(/[\u0000-\u001f]/g, ' '))
66 } catch {
67 return null
68 }
69}
70
71function field(value: unknown, key: string): unknown {
72 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
73}
74
75export type PrView = { number: number; title: string; state: string; mergeSha: string | null }
76
77export function readPrView(json: unknown): PrView | null {
78 const number = field(json, 'number')
79 if (typeof number !== 'number') return null
80 const oid = field(field(json, 'mergeCommit'), 'oid')
81 return {
82 number,
83 title: String(field(json, 'title') ?? ''),
84 state: String(field(json, 'state') ?? ''),
85 mergeSha: typeof oid === 'string' && oid !== '' ? oid.toLowerCase() : null,
86 }
87}
88
89export function readCommit(json: unknown): { sha: string; title: string } | null {
90 const sha = field(json, 'sha')
91 if (typeof sha !== 'string' || sha === '') return null
92 const message = String(field(field(json, 'commit'), 'message') ?? '')
93 return { sha: sha.toLowerCase(), title: message.split('\n')[0]?.trim() ?? '' }
94}
95
96export function readRuns(json: unknown): WorkflowRun[] | null {
97 const list = field(json, 'workflow_runs')
98 if (!Array.isArray(list)) return null
99 return list.map(run => ({
100 name: String(field(run, 'name') ?? ''),
101 status: String(field(run, 'status') ?? ''),
102 conclusion: (field(run, 'conclusion') as string | null | undefined) ?? null,
103 }))
104}
105
106export type Deployment = { id: number; environment: string; createdAt: string }
107
108export function readDeployments(json: unknown): Deployment[] | null {
109 if (!Array.isArray(json)) return null
110 return json.flatMap(item => {
111 const id = field(item, 'id')
112 if (typeof id !== 'number') return []
113 return [{ id, environment: String(field(item, 'environment') ?? 'unknown'), createdAt: String(field(item, 'created_at') ?? '') }]
114 })
115}
116
117export function readLatestStatus(json: unknown): { state: string | null } | null {
118 if (!Array.isArray(json)) return null
119 const state = field(json[0], 'state')
120 return { state: typeof state === 'string' ? state : null }
121}
122
123const UNAUTHENTICATED = /gh auth login|not logged in|authentication|bad credentials|HTTP 401|GH_TOKEN/i
124
125/** A gh run that exited non-zero, said in one line. */
126export function describeGhFailure(stderr: string): string {
127 if (UNAUTHENTICATED.test(stderr)) return 'gh is not authenticated: run `gh auth login`, then /trace again.'
128 const first = stderr.trim().split('\n')[0]?.trim() ?? ''
129 return first === '' ? 'gh failed with no message' : `gh failed: ${first.slice(0, 200)}`
130}
131
132const TIMED_OUT = /timed? ?out|timeout|ETIMEDOUT|still running/i
133
134/** A gh run that rejected: it timed out, or it could not start at all. */
135export function describeSpawnFailure(message: string, elapsedMs = 0, timeoutMs = Number.POSITIVE_INFINITY): string {
136 if (elapsedMs >= timeoutMs || TIMED_OUT.test(message)) {
137 const seconds = Number.isFinite(timeoutMs) ? `${Math.round(timeoutMs / 1000)}s` : 'its time limit'
138 return `gh timed out after ${seconds} (network?); tracer will try again next poll.`
139 }
140 return `gh could not run (${message.slice(0, 120)}): install the GitHub CLI (cli.github.com) and put it on PATH.`
141}
142hooks/pixels.ts 200 lines1// Pixel art: the PICO-8 palette, the level-map nodes and a half-block renderer.
2import type { TracerStage } from '../types'
3import { GLYPH, cleanName, currentStage } from './chain'
4
5export const PICO = {
6 black: '#000000',
7 navy: '#1D2B53',
8 plum: '#7E2553',
9 green: '#008751',
10 brown: '#AB5236',
11 darkGrey: '#5F574F',
12 lightGrey: '#C2C3C7',
13 white: '#FFF1E8',
14 red: '#FF004D',
15 orange: '#FFA300',
16 yellow: '#FFEC27',
17 lime: '#00E436',
18 blue: '#29ADFF',
19 lavender: '#83769C',
20 pink: '#FF77A8',
21 peach: '#FFCCAA',
22} as const
23
24/** tracer's signature colour. */
25export const SIGNATURE = PICO.yellow
26
27const KEYS: Record<string, string> = {
28 k: PICO.black,
29 y: PICO.yellow,
30 w: PICO.white,
31 r: PICO.red,
32 b: PICO.blue,
33 d: PICO.darkGrey,
34 o: PICO.orange,
35}
36
37/** Map nodes, 6 x 4 pixels (2 terminal rows). `.` is transparent. */
38const NODE = {
39 // A coin with a shine: a stage cleared.
40 done: ['.yyyy.', 'yywwyy', 'yyyyyy', '.yyyy.'],
41 // An empty ring: a stage still ahead.
42 pending: ['.dddd.', 'dd..dd', 'dd..dd', '.dddd.'],
43 // The player: the stage tracer is waiting on now.
44 current: ['.bbbb.', 'bbwwbb', 'bbwwbb', '.bbbb.'],
45 // A red cross: the stage failed.
46 failed: ['rr..rr', '.rrrr.', '.rrrr.', 'rr..rr'],
47} as const
48
49const NODE_WIDTH = 6
50
51/** One run of same-styled cells in a terminal row. */
52export type PixelRun = { text: string; color?: string; backgroundColor?: string }
53
54/**
55 * Two pixel rows per terminal row: `▀` with the top pixel as `color` and the
56 * bottom as `backgroundColor`; `▄` when only the bottom is set; a space when
57 * neither. Adjacent cells with the same style merge into one run.
58 */
59export function halfBlockRows(grid: readonly string[]): PixelRun[][] {
60 const width = Math.max(0, ...grid.map(line => line.length))
61 const rows: PixelRun[][] = []
62 for (let top = 0; top < grid.length; top += 2) {
63 const runs: PixelRun[] = []
64 for (let column = 0; column < width; column += 1) {
65 const upper = KEYS[grid[top]?.[column] ?? '.']
66 const lower = KEYS[grid[top + 1]?.[column] ?? '.']
67 const cell: PixelRun =
68 upper !== undefined
69 ? lower !== undefined
70 ? { text: '▀', color: upper, backgroundColor: lower }
71 : { text: '▀', color: upper }
72 : lower !== undefined
73 ? { text: '▄', color: lower }
74 : { text: ' ' }
75 const last = runs[runs.length - 1]
76 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && last.text[0] === cell.text) {
77 last.text += cell.text
78 } else {
79 runs.push(cell)
80 }
81 }
82 rows.push(runs)
83 }
84 return rows
85}
86
87type Look = keyof typeof NODE
88
89function lookOf(stage: TracerStage, current: TracerStage | undefined, isTracing: boolean): Look {
90 if (stage.state === 'done') return 'done'
91 if (stage.state === 'failed') return 'failed'
92 return isTracing && stage === current ? 'current' : 'pending'
93}
94
95const LABEL_COLOR: Record<Look, string> = {
96 done: PICO.yellow,
97 failed: PICO.red,
98 current: PICO.blue,
99 pending: PICO.lightGrey,
100}
101
102function center(text: string, width: number): string {
103 const left = Math.floor((width - text.length) / 2)
104 return `${' '.repeat(left)}${text}`.padEnd(width)
105}
106
107function environmentTag(stage: TracerStage): string {
108 if (stage.kind !== 'deploy') return ''
109 const name = cleanName(stage.environment ?? '').toUpperCase()
110 return name.length > 8 ? `${name.slice(0, 7)}~` : name
111}
112
113export type LabelCell = { text: string; color: string }
114
115/** One node to draw: a stage, or every deploy stage folded into one. */
116type MapNode = { state: TracerStage['state']; look: Look; kind: string; tag: string }
117
118function nodeOf(stage: TracerStage, current: TracerStage | undefined, isTracing: boolean): MapNode {
119 const kind = `${GLYPH[stage.state]} ${stage.kind === 'deploy' ? 'DEPLOY' : stage.kind.toUpperCase()}`
120 return { state: stage.state, look: lookOf(stage, current, isTracing), kind, tag: environmentTag(stage) }
121}
122
123/** Every DEPLOY node folded into one `DEPLOY` node tagged `2/5` (environments done of all). */
124function foldedNodes(stages: readonly TracerStage[], current: TracerStage | undefined, isTracing: boolean): MapNode[] {
125 const deploys = stages.filter(stage => stage.kind === 'deploy')
126 if (deploys.length < 2) return stages.map(stage => nodeOf(stage, current, isTracing))
127 const state: TracerStage['state'] = deploys.some(stage => stage.state === 'failed')
128 ? 'failed'
129 : deploys.every(stage => stage.state === 'done')
130 ? 'done'
131 : 'pending'
132 const look: Look = state !== 'pending' ? state : isTracing && current?.kind === 'deploy' ? 'current' : 'pending'
133 const done = deploys.filter(stage => stage.state === 'done').length
134 const folded: MapNode = { state, look, kind: `${GLYPH[state]} DEPLOY`, tag: `${done}/${deploys.length}` }
135 const nodes: MapNode[] = []
136 for (const stage of stages) {
137 if (stage.kind !== 'deploy') nodes.push(nodeOf(stage, current, isTracing))
138 else if (stage === deploys[0]) nodes.push(folded)
139 }
140 return nodes
141}
142
143function nodeWidth(node: MapNode): number {
144 return Math.max(NODE_WIDTH, node.kind.length, node.tag.length) + 2
145}
146
147function mapWidth(nodes: readonly MapNode[]): number {
148 return nodes.reduce((sum, node) => sum + nodeWidth(node), 0)
149}
150
151/**
152 * The level map: one node per stage joined by a dotted path (orange where it
153 * has been travelled), two terminal rows of half-block pixels, then a row of
154 * `★ BUILD`-style labels and a row of deploy environment names.
155 *
156 * Wider than `maxColumns`, the deploy nodes fold into one `DEPLOY 2/5` node;
157 * still wider, there is no map (the per-stage lines below it carry it all).
158 */
159export function levelMap(
160 stages: readonly TracerStage[],
161 isTracing: boolean,
162 maxColumns = Number.POSITIVE_INFINITY,
163): { pixels: PixelRun[][]; labels: LabelCell[][] } {
164 const current = currentStage(stages)
165 let nodes = stages.map(stage => nodeOf(stage, current, isTracing))
166 if (mapWidth(nodes) > maxColumns) nodes = foldedNodes(stages, current, isTracing)
167 if (mapWidth(nodes) > maxColumns) return { pixels: [], labels: [] }
168 const columns = nodes.map(node => ({ ...node, width: nodeWidth(node) }))
169 const total = mapWidth(nodes)
170 const grid = [0, 1, 2, 3].map(() => Array.from({ length: total }, () => '.'))
171 let left = 0
172 let previousRight = -1
173 columns.forEach(column => {
174 const start = left + Math.floor((column.width - NODE_WIDTH) / 2)
175 if (previousRight >= 0) {
176 const road = column.state === 'done' ? 'o' : 'd'
177 // A dotted path at the nodes' waist, one pixel every other column.
178 for (let x = previousRight + 1; x < start - 1; x += 2) grid[2]![x] = road
179 }
180 NODE[column.look].forEach((line, row) => {
181 for (let x = 0; x < NODE_WIDTH; x += 1) grid[row]![start + x] = line[x] ?? '.'
182 })
183 previousRight = start + NODE_WIDTH
184 left += column.width
185 })
186 return {
187 pixels: halfBlockRows(grid.map(row => row.join(''))),
188 labels: [
189 columns.map(column => ({ text: center(column.kind, column.width), color: LABEL_COLOR[column.look] })),
190 columns.map(column => ({ text: center(column.tag, column.width), color: LABEL_COLOR[column.look] })),
191 ],
192 }
193}
194
195/** The plain-text capture of the map (what a monochrome terminal shows). */
196export function plainMap(stages: readonly TracerStage[], isTracing: boolean, maxColumns = Number.POSITIVE_INFINITY): string[] {
197 const map = levelMap(stages, isTracing, maxColumns)
198 return [...map.pixels, ...map.labels].map(runs => runs.map(run => run.text).join(''))
199}
200types/index.d.ts 41 lines1/** Where one stage of the chain stands: ● pending, ★ done, ✕ failed. */
2export type TracerStageState = 'pending' | 'done' | 'failed'
3
4/** One node on the level map: MERGED, BUILD, DEPLOY:<env> or LIVE. */
5export type TracerStage = {
6 /** `merged`, `build`, `deploy:<env>` or `live`. */
7 id: string
8 kind: 'merged' | 'build' | 'deploy' | 'live'
9 /** The environment name, for a deploy stage. */
10 environment?: string
11 state: TracerStageState
12 /** One short line on why the stage stands where it does. */
13 detail: string
14}
15
16/** How a trace ended, or `tracing` while it still polls. */
17export type TracerOutcome = 'tracing' | 'done' | 'failed' | 'timeout'
18
19/** One merged commit being followed until it is deployed (and optionally live). */
20export type TracerTrace = {
21 repo: string
22 /** The full merge commit SHA. */
23 sha: string
24 pr: number | null
25 title: string
26 startedAt: number
27 checkedAt: number
28 stages: TracerStage[]
29 outcome: TracerOutcome
30 /** Set once the session was woken for this trace, so it is never woken twice. */
31 hasWoken: boolean
32 /** Why the last poll could not read GitHub (or the live URL), or null. */
33 error: string | null
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 tracer: { traces: TracerTrace[] }
39 }
40}
41