A side pane that follows a pull request's CI live through wipnote ci status: checks failed first with elapsed time, review, branch health, base, release and…

A Claude Code mod that follows a pull request's CI live in a pane beside the chat. It is a thin view over wipnote ci status --json: every GitHub read happens in the Go CLI (internal/gitwatch), so the mod makes no gh calls and every harness shares the same logic.
CI pane
PR #85 CI pane over the CLI
feat/ci-pane → main · open · feat-8354cf43
1 passed · 1 failed · 1 running · 1 skipped
✗ go-test 3:00
https://example.test/run/2
● lint 5:00
✓ build 4:10
– docs
review: approved · 2 open threads · merge blocked · requested core-team
branch feat/ci-pane: 2 ahead, 1 behind origin/main · 3 dirty · 1 unpushed
stale worktree old-pane (feat/old-pane): merged, idle 9d
base main: red
✗ Release (failure)
https://example.test/run/9
release v0.82.1 (2026-10-20), 14 commits since · release: green
note: release: gh release view: HTTP 502
live, every 15 s · read 23:05:00
/ci-pane follows the current branch's pull request (else your newest open one), /ci-pane 85 pull request 85, /ci-pane <branch> that branch's (the name follows /plan-review-pane; /wipnote:ci is the plugin command that answers in the chat). The argument goes to wipnote ci status unchanged; the CLI resolves it.wipnote ci watch's cadence: every 15 s while any check is pending, otherwise every 60 s. It stops once settled: at least one check and none pending, the PR merged or closed, or no PR matched. An open PR with no checks is watched for a 2-minute grace from the first read (NO_CHECKS_GRACE_MS, as ci watch --no-checks-grace), the last wait cut short so the grace ends on a fresh read; still none then, it settles showing no checks reported. Checks that appear inside the grace are followed as normal. The elapsed time of each check ticks every second.computing, requested reviewers), branch health (ahead/behind, dirty, unpushed, stale worktrees), the base branch's CI with its failing workflows, the latest release, and the CLI's notes.available: false (gh missing, signed out, a failed read) shows the reason dimmed and keeps polling every 60 s. Output it cannot read keeps the last snapshot and adds a last read failed line.CI #85: 3/5 passed · 1 failed · 1 running. Toast when it settles: CI #85: 2 failed, PR #85 merged, CI #85: no checks reported or CI #85: all 5 checks finished./ci-pane stop cancels both timers (the poll and the one-second tick) and clears the status line; the pane keeps the last read, marked stopped. Closing the pane (ui.close) does the same; /ci-pane opens it and starts again..claude-plugin/plugin.json manifest (name, types contract)
hooks/hooks.json { "modules": ["./register.tsx"] }
hooks/register.tsx hooks: session.start (/ci-pane), command.run, ui.close, session.end, ui.render (Pane); the two timers
hooks/pane.tsx the pane's tree, section by section
hooks/format.ts pure helpers: parse, fail-first sort, elapsed time, settle rule, cadence, section lines
types/index.d.ts the ci status JSON and the $.state contract (PluginState['wipnote-ci'])
prompt.md the one-line rule `wipnote claude` appends to the system prompt
tests/*.test.ts(x) claude plugin test suites; tests/fixtures holds snapshot JSON
wipnote claude loads it with every mod in mods/; WIPNOTE_NO_MODS=1 skips the mods. It runs wipnote from PATH. On its own:
claude --plugin-dir mods/wipnote-ci
claude plugin validate mods/wipnote-ci
claude plugin test mods/wipnote-ci # 26 tests
tsc -p mods/wipnote-ci # after a load lays .claude-plugin/types/hooks/register.tsx 132 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CiView, Snapshot } from '../types'
5import { failReason, nextWait, noChecksExpired, parseSnapshot, settled, summary, toastText } from './format'
6import { paneTree } from './pane'
7
8const PANE = 'wipnote-ci'
9const EMPTY: CiView = { arg: null, snap: null, fetchedAt: 0, error: '', done: false, since: 0, noChecks: false }
10
11// Change a tag when the fields of its value change: it declines what an
12// earlier version stored, which outlives a reload.
13const view = atom({ plugin: 'wipnote-ci', key: 'view' } as const, EMPTY, { shape: 'view-2' })
14const clock = atom({ plugin: 'wipnote-ci', key: 'now' } as const, 0, { shape: 'now-1' })
15
16// The next read (rescheduled after each one, at the watch cadence) and the
17// one-second tick of the elapsed times. `run` numbers each /ci-pane, so a read
18// still in flight when /ci-pane stop, a close or a new /ci-pane lands schedules nothing.
19let poll: Timer | undefined
20let tick: Timer | undefined
21let run = 0
22
23function stop() {
24 run += 1
25 poll?.cancel()
26 tick?.cancel()
27 poll = undefined
28 tick = undefined
29}
30
31/** One `wipnote ci status --json`: the snapshot, or why there is none. */
32async function readSnapshot($: EngineInterface, arg: string): Promise<Snapshot | string> {
33 const argv = arg === '' ? ['wipnote', 'ci', 'status', '--json'] : ['wipnote', 'ci', 'status', arg, '--json']
34 const ran = await $.process.run(argv, { timeoutMs: 60_000 }).catch(() => undefined)
35 if (ran === undefined) return 'wipnote unavailable'
36 const snap = parseSnapshot(ran.stdout)
37 if (snap !== undefined) return snap
38 return ran.exitCode !== 0 ? failReason(ran.stderr, ran.exitCode) : 'unreadable output from wipnote ci status'
39}
40
41/** Reads once into the view; a failed read keeps the last snapshot. A read
42 * that /ci-pane stop or a close overtook writes nothing. */
43async function refresh($: EngineInterface, arg: string, mine: number): Promise<CiView> {
44 const got = await readSnapshot($, arg)
45 if (mine !== run) return read($, view)
46 const now = await $.clock.now()
47 const before = await read($, view)
48 if (typeof got === 'string') {
49 return update($, view, old => ({ ...old, fetchedAt: now, error: got.slice(0, 200) }))
50 }
51 const since = before.since === 0 ? now : before.since
52 const noChecks = noChecksExpired(got, since, now)
53 const done = noChecks || settled(got)
54 const next = await update($, view, () => ({ arg, snap: got, fetchedAt: now, error: '', done, since, noChecks }))
55 await update($, clock, () => now)
56 if (got.pr !== undefined) await $.ui.status(summary(got))
57 const toast = toastText(got, noChecks)
58 if (done && !before.done && toast !== undefined) await $.ui.toast(toast)
59 return next
60}
61
62/** Reads, then waits the cadence the read calls for, until settled or stopped. */
63async function cycle($: EngineInterface, arg: string, mine: number) {
64 const v = await refresh($, arg, mine)
65 if (mine !== run) return
66 if (v.done) {
67 stop()
68 return
69 }
70 poll = $.clock.after(nextWait(v.snap, v.since, await $.clock.now()), () => void cycle($, arg, mine).catch(() => undefined))
71}
72
73async function follow($: EngineInterface, arg: string): Promise<string> {
74 stop()
75 const mine = run
76 await update($, view, () => ({ ...EMPTY, arg }))
77 await $.ui.open({ id: PANE, title: 'CI' })
78 await cycle($, arg, mine)
79 const v = await read($, view)
80 if (mine === run && !v.done) {
81 tick = $.clock.every(1000, () => void $.clock.now().then(now => update($, clock, () => now)).catch(() => undefined))
82 }
83 const s = v.snap
84 if (s === null) return `CI pane: ${v.error}. It tries again in a minute.`
85 if (!s.available) return `CI status not available: ${s.reason ?? 'unknown'}. The pane keeps trying.`
86 if (s.pr === undefined) return `No pull request found: ${s.reason ?? 'none matched'}.`
87 return `Following CI of PR #${s.pr.number} in the CI pane.`
88}
89
90/** /ci-pane stop and a close: both timers cancelled, the status line cleared, the last read kept. */
91async function halt($: EngineInterface) {
92 stop()
93 await update($, view, old => ({ ...old, arg: null }))
94 await $.ui.status(undefined)
95}
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 await $.command.register({
100 name: 'ci-pane',
101 description: 'Follow a pull request\'s CI live in a pane: /ci-pane, /ci-pane 85, /ci-pane <branch>, /ci-pane stop',
102 argumentHint: '[pr|branch|stop]',
103 })
104 return next(e)
105 })
106
107 on('session.end', async ($, e, next) => {
108 stop()
109 return next(e)
110 })
111
112 on('command.run', { command: 'ci-pane' }, async ($, e) => {
113 const arg = e.args.trim()
114 if (arg !== 'stop') return { text: await follow($, arg) }
115 await halt($)
116 return { text: 'CI pane stopped. /ci-pane starts it again.' }
117 })
118
119 // Closing the pane (the person's mark or key, a plugin, an unload) ends the
120 // following as /ci-pane stop does; /ci-pane opens it and starts again.
121 on('ui.close', {}, async ($, e, next) => {
122 if (e.id === PANE) await halt($).catch(() => undefined)
123 return next(e)
124 })
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
127 const v = await read($, view)
128 const now = (await read($, clock)) || v.fetchedAt
129 return paneTree($.ui.resolve(e), v, now, e.props.bodyColumns)
130 })
131}
132hooks/format.ts 171 lines1import type { Base, Branch, Check, Release, Review, Snapshot, Worktree } from '../types'
2
3export const RANK: Record<string, number> = { fail: 0, cancel: 1, pending: 2, pass: 3, skipping: 4 }
4export const ICON: Record<string, string> = { pass: '✓', fail: '✗', pending: '●', cancel: '–', skipping: '–' }
5export const COLOR: Record<string, string> = {
6 pass: 'success',
7 fail: 'error',
8 pending: 'warning',
9 cancel: 'inactive',
10 skipping: 'inactive',
11}
12
13/** The cadence of `wipnote ci watch`: checks while one is pending, else the slow sections. */
14export const CHECKS_MS = 15_000
15export const SLOW_MS = 60_000
16/** How long from the first read an open PR with no checks is still watched for them (ci watch --no-checks-grace). */
17export const NO_CHECKS_GRACE_MS = 120_000
18/** The note ci watch ends such a PR with. */
19export const NO_CHECKS_NOTE = 'no checks reported'
20
21const CLOSED = new Set(['MERGED', 'CLOSED'])
22
23/** Reads `wipnote ci status --json`; undefined when it is not a snapshot. */
24export function parseSnapshot(stdout: string): Snapshot | undefined {
25 try {
26 const raw = JSON.parse(stdout) as Snapshot
27 if (typeof raw !== 'object' || raw === null || typeof raw.available !== 'boolean') return undefined
28 return raw
29 } catch {
30 return undefined
31 }
32}
33
34/** Failed first, then cancelled, running, passed, skipped; by name within each. */
35export function sortChecks(checks: Check[] | null | undefined): Check[] {
36 return [...(checks ?? [])].sort(
37 (a, b) => (RANK[a.bucket] ?? 9) - (RANK[b.bucket] ?? 9) || a.name.localeCompare(b.name),
38 )
39}
40
41/** A time GitHub gave, or null for none (a check not finished can read as year 1). */
42export function time(text: string | null | undefined): number | null {
43 if (!text) return null
44 const ms = Date.parse(text)
45 return Number.isNaN(ms) || ms < Date.UTC(2000, 0, 1) ? null : ms
46}
47
48/** "m:ss" from the check's start to its end, or to now while it runs. */
49export function span(check: Check, now: number): string {
50 const start = time(check.started_at)
51 if (start === null) return ''
52 const end = time(check.completed_at) ?? now
53 const seconds = Math.max(0, Math.round((end - start) / 1000))
54 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
55}
56
57export function cut(text: string, width: number): string {
58 return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
59}
60
61/** The status line: "CI #85: 3/5 passed · 1 failed · 1 running". */
62export function summary(s: Snapshot): string {
63 const n = s.counts
64 const parts = [`${n.pass}/${n.total} passed`]
65 if (n.fail > 0) parts.push(`${n.fail} failed`)
66 if (n.pending > 0) parts.push(`${n.pending} running`)
67 return `CI #${s.pr?.number ?? '?'}: ${parts.join(' · ')}`
68}
69
70/** "branch → base · state · work item", under the title. */
71export function prLine(s: Snapshot): string {
72 if (s.pr === undefined) return ''
73 const parts = [`${s.pr.branch} → ${s.pr.base}`, s.pr.state.toLowerCase()]
74 if (s.work_item !== '') parts.push(s.work_item)
75 return parts.join(' · ')
76}
77
78/** Settled as `wipnote ci watch` settles: the PR merged or closed, or at
79 * least one check and none pending; also when no PR matched. Never while
80 * wipnote cannot read GitHub. */
81export function settled(s: Snapshot): boolean {
82 if (!s.available) return false
83 if (s.pr === undefined) return true
84 if (CLOSED.has(s.pr.state)) return true
85 return s.counts.total > 0 && s.counts.pending === 0
86}
87
88/** How long until the next read. */
89export function interval(s: Snapshot | null): number {
90 return s !== null && s.counts.pending > 0 ? CHECKS_MS : SLOW_MS
91}
92
93/** An open PR still without a check once the grace from `since` has run out. */
94export function noChecksExpired(s: Snapshot, since: number, now: number): boolean {
95 if (!s.available || s.pr === undefined || CLOSED.has(s.pr.state)) return false
96 return s.counts.total === 0 && now - since >= NO_CHECKS_GRACE_MS
97}
98
99/** The wait before the next read, cut short at the grace end while there are no checks. */
100export function nextWait(s: Snapshot | null, since: number, now: number): number {
101 const wait = interval(s)
102 if (s === null || !s.available || s.pr === undefined || s.counts.total > 0) return wait
103 const left = since + NO_CHECKS_GRACE_MS - now
104 return left > 0 ? Math.min(wait, left) : wait
105}
106
107/** The toast when a followed PR settles; none without a PR. */
108export function toastText(s: Snapshot, noChecks = false): string | undefined {
109 if (s.pr === undefined) return undefined
110 const n = s.pr.number
111 if (noChecks) return `CI #${n}: ${NO_CHECKS_NOTE}`
112 if (s.counts.fail > 0) return `CI #${n}: ${s.counts.fail} failed`
113 if (s.pr.state === 'MERGED') return `PR #${n} merged`
114 return `CI #${n}: all ${s.counts.total} checks finished`
115}
116
117/** "review: approved · 2 open threads · merge blocked · requested team". */
118export function reviewLine(r: Review): string {
119 const merge = r.mergeable === 'computing' ? 'computing' : r.merge_state.toLowerCase()
120 let line = `review: ${r.decision.toLowerCase()} · ${r.threads_open} open threads · merge ${merge}`
121 if (r.requested && r.requested.length > 0) line += ` · requested ${r.requested.join(', ')}`
122 return line
123}
124
125/** "branch x: 2 ahead, 1 behind origin/main · 3 dirty · 1 unpushed". */
126export function branchLine(b: Branch): string {
127 const where = b.base !== '' ? `${b.ahead} ahead, ${b.behind} behind ${b.base}` : b.sync
128 const parts = [`branch ${b.name}: ${where}`, b.dirty > 0 ? `${b.dirty} dirty` : 'clean']
129 if (b.unpushed > 0) parts.push(`${b.unpushed} unpushed`)
130 return parts.join(' · ')
131}
132
133/** One line per stale worktree, named by its folder. */
134export function staleLines(worktrees: Worktree[] | null | undefined): string[] {
135 return (worktrees ?? [])
136 .filter(w => (w.stale ?? []).length > 0)
137 .map(w => {
138 const reasons = (w.stale ?? []).map(r => (r === 'idle' ? `idle ${w.idle_days}d` : r))
139 const folder = w.path.split('/').filter(p => p !== '').pop() ?? w.path
140 return `stale worktree ${folder}${w.branch ? ` (${w.branch})` : ''}: ${reasons.join(', ')}`
141 })
142}
143
144export type Line = { text: string; bad: boolean }
145
146/** The base line, then each failing workflow and its link. */
147export function baseLines(b: Base): Line[] {
148 const lines: Line[] = [{ text: `base ${b.name}: ${b.state}`, bad: b.state === 'red' }]
149 for (const f of b.failing ?? []) {
150 lines.push({ text: ` ✗ ${f.workflow} (${f.conclusion})`, bad: true })
151 if (f.url !== '') lines.push({ text: ` ${f.url}`, bad: false })
152 }
153 return lines
154}
155
156/** "release v1 (2026-10-20), 14 commits since · release: green", as ci status prints it. */
157export function releaseLine(r: Release): string {
158 if (r.state === 'none') return 'release: none'
159 let line = `release ${r.tag ?? ''}`
160 if (r.published_at) line += ` (${r.published_at.slice(0, 10)})`
161 line += r.commits_since !== undefined ? `, ${r.commits_since} commits since` : ', commits since unknown'
162 if (r.workflow) line += ` · ${r.workflow.name}: ${r.workflow.state}`
163 return line
164}
165
166/** The first line of a failed run, short enough for the pane. */
167export function failReason(stderr: string, exitCode: number): string {
168 const line = stderr.split('\n').map(l => l.trim()).find(l => l !== '')
169 return (line ?? `wipnote exited ${exitCode}`).replace(/^Error:\s*/, '').slice(0, 160)
170}
171hooks/pane.tsx 113 lines1import type { EngineInterface } from 'claude-code'
2
3import type { CiView, Snapshot } from '../types'
4import {
5 COLOR, ICON, NO_CHECKS_NOTE, baseLines, branchLine, cut, interval, prLine, releaseLine, reviewLine, sortChecks, span, staleLines,
6} from './format'
7
8type UI = ReturnType<EngineInterface['ui']['resolve']>
9
10function Idle({ Box, Text }: UI) {
11 return (
12 <Box flexDirection="column">
13 <Text dimColor>No pull request is followed.</Text>
14 <Text dimColor>/ci-pane follows the pull request of this branch.</Text>
15 <Text dimColor>/ci-pane 85 follows PR 85, /ci-pane <branch> that branch's, /ci-pane stop ends it.</Text>
16 </Box>
17 )
18}
19
20function Head({ Box, Text }: UI, s: Snapshot, width: number) {
21 if (!s.available || s.pr === undefined) {
22 return (
23 <Box flexDirection="column">
24 <Text weight="bold">{s.available ? 'No pull request' : 'CI status not available'}</Text>
25 <Text dimColor wrap="truncate-end">{s.reason ?? ''}</Text>
26 </Box>
27 )
28 }
29 const n = s.counts
30 return (
31 <Box flexDirection="column">
32 <Text weight="bold" wrap="truncate-end">{`PR #${s.pr.number} ${cut(s.pr.title, width - 10)}`}</Text>
33 <Text dimColor wrap="truncate-end">{prLine(s)}</Text>
34 <Text>
35 <Text color="success">{`${n.pass} passed`}</Text>
36 {' · '}
37 <Text color={n.fail > 0 ? 'error' : 'inactive'}>{`${n.fail} failed`}</Text>
38 {' · '}
39 <Text color={n.pending > 0 ? 'warning' : 'inactive'}>{`${n.pending} running`}</Text>
40 {n.other > 0 ? <Text dimColor>{` · ${n.other} skipped`}</Text> : null}
41 </Text>
42 </Box>
43 )
44}
45
46// Every check goes in the tree: the pane's body scrolls a taller one.
47function Checks({ Box, Text }: UI, v: CiView, s: Snapshot, now: number, width: number) {
48 if (s.pr === undefined) return null
49 const sorted = sortChecks(s.checks)
50 if (sorted.length === 0) return <Text dimColor>{v.noChecks ? NO_CHECKS_NOTE : 'No checks reported yet.'}</Text>
51 return (
52 <Box flexDirection="column">
53 {sorted.map((c, i) => {
54 const took = span(c, now)
55 return (
56 <Box key={`c${i}`} flexDirection="column">
57 <Text color={COLOR[c.bucket] ?? 'inactive'} wrap="truncate-end">
58 {`${ICON[c.bucket] ?? '?'} ${cut(c.name, width - 10)}`}
59 {took !== '' ? <Text dimColor tabular>{` ${took}`}</Text> : null}
60 </Text>
61 {c.bucket === 'fail' && c.link !== '' ? <Text dimColor wrap="truncate-end">{c.link}</Text> : null}
62 </Box>
63 )
64 })}
65 </Box>
66 )
67}
68
69/** Review, branch, stale worktrees, base, release and notes: one dim line each. */
70function Sections({ Box, Text }: UI, s: Snapshot) {
71 const lines: { text: string; bad: boolean }[] = []
72 if (s.review) lines.push({ text: reviewLine(s.review), bad: false })
73 if (s.branch) lines.push({ text: branchLine(s.branch), bad: false })
74 for (const t of staleLines(s.worktrees)) lines.push({ text: t, bad: false })
75 if (s.base) lines.push(...baseLines(s.base))
76 if (s.release) lines.push({ text: releaseLine(s.release), bad: false })
77 for (const n of s.notes ?? []) lines.push({ text: `note: ${n}`, bad: false })
78 if (lines.length === 0) return null
79 return (
80 <Box flexDirection="column">
81 {lines.map((l, i) => (
82 <Text key={`l${i}`} wrap="truncate-end" color={l.bad ? 'error' : undefined} dimColor={!l.bad}>{l.text}</Text>
83 ))}
84 </Box>
85 )
86}
87
88function footer(v: CiView, s: Snapshot): string {
89 const at = v.fetchedAt === 0 ? 'not yet' : new Date(v.fetchedAt).toTimeString().slice(0, 8)
90 if (v.arg === null) return `stopped · read ${at} · /ci-pane starts it again`
91 if (v.done) return `${s.pr === undefined ? 'nothing to follow' : 'finished'} · read ${at}`
92 return `live, every ${interval(s) / 1000} s · read ${at}`
93}
94
95/** The pane's tree for the view, drawn on the surface's elements. */
96export function paneTree(ui: UI, v: CiView, now: number, width: number) {
97 const { Box, Text } = ui
98 const s = v.snap
99 if (s === null) return Idle(ui)
100 return (
101 <Box flexDirection="column">
102 {Head(ui, s, width)}
103 <Text> </Text>
104 {Checks(ui, v, s, now, width)}
105 <Text> </Text>
106 {Sections(ui, s)}
107 <Text> </Text>
108 <Text dimColor>{footer(v, s)}</Text>
109 {v.error !== '' ? <Text color="error" wrap="truncate-end">{`last read failed: ${v.error}`}</Text> : null}
110 </Box>
111 )
112}
113types/index.d.ts 103 lines1// The JSON `wipnote ci status [pr|branch] --json` prints (internal/gitwatch:
2// Snapshot and its sections). Go's nil slices arrive as null.
3
4export type Check = {
5 name: string
6 workflow: string
7 /** pass, fail, pending, skipping or cancel */
8 bucket: string
9 required: boolean
10 started_at: string | null
11 completed_at: string | null
12 link: string
13}
14
15export type Counts = { pass: number; fail: number; pending: number; other: number; total: number }
16
17export type PR = { number: number; title: string; branch: string; base: string; state: string; url: string; base_oid?: string }
18
19export type Review = {
20 decision: string
21 requested: string[] | null
22 threads_open: number
23 threads_outdated: number
24 /** "computing" while GitHub is still working out mergeability */
25 mergeable: string
26 merge_state: string
27}
28
29export type BaseRun = { workflow: string; head_sha: string; status: string; conclusion: string; url: string; created_at: string }
30
31export type Base = { name: string; state: string; base_red_too: boolean; failing: BaseRun[] | null; runs: BaseRun[] | null }
32
33export type ReleaseWorkflow = { name: string; state: string; status?: string; conclusion?: string; url?: string; created_at?: string }
34
35export type Release = {
36 state: string
37 tag?: string
38 published_at?: string
39 url?: string
40 commits_since?: number
41 workflow?: ReleaseWorkflow
42}
43
44export type Branch = {
45 name: string
46 base: string
47 ahead: number
48 behind: number
49 dirty: number
50 unpushed: number
51 upstream?: string
52 sync: string
53}
54
55export type Worktree = {
56 path: string
57 branch?: string
58 main?: boolean
59 locked?: boolean
60 prunable?: boolean
61 idle_days: number
62 stale?: string[] | null
63}
64
65export type Snapshot = {
66 available: boolean
67 reason?: string
68 pr?: PR
69 work_item: string
70 checks: Check[] | null
71 counts: Counts
72 review?: Review
73 base?: Base
74 release?: Release
75 fetched_at: string
76 branch?: Branch
77 worktrees?: Worktree[] | null
78 notes?: string[] | null
79}
80
81/** What the pane follows and the last snapshot it read. */
82export type CiView = {
83 /** The argument /ci-pane was given ('' for the current branch); null before the first /ci-pane, after /ci-pane stop or a close */
84 arg: string | null
85 snap: Snapshot | null
86 /** When the last read finished, in milliseconds since the epoch */
87 fetchedAt: number
88 /** The last read's problem, empty when it worked */
89 error: string
90 /** Settled (no check pending, the PR merged or closed, no PR matched, or no checks after the grace): polling has stopped */
91 done: boolean
92 /** When the first read of this /ci-pane finished: the no-checks grace runs from here */
93 since: number
94 /** Settled because the PR still had no checks when the grace ran out */
95 noChecks: boolean
96}
97
98declare module 'claude-code' {
99 interface PluginState {
100 'wipnote-ci': { view: Shaped<CiView>; now: Shaped<number> }
101 }
102}
103