A pane of what is in flight: subagents, your open PRs and their CI, deploys, and background shells.

A kit for AI agents.
npx add-skill andybons/kit
Or using Claude Code commands:
claude /install https://github.com/andybons/kit.git
Strategic product thinking framework combining the Minto Pyramid Principle with Clayton Christensen's Jobs-to-be-Done theory.
Use for:
Opinionated constraints for building better interfaces with agents. Covers Tailwind CSS, accessible components, animations, typography, and performance.
By ibelick · ui-skills.com
Visual hierarchy principles for UX design based on Nielsen Norman Group research. Covers color/contrast, scale, and grouping methods to guide user attention.
Use for:
Source: Nielsen Norman Group
Go code and API rules, one line each with its source: layout, naming, API design, errors, concurrency, testing, and comments.
Whole-system review of a change: spec divergence, contracts, ownership, parity, structure, tests, docs, and proportion. Pairs with go-guide.
Bring a drifted codebase back into shape, then keep it there: whole-architecture review, test and comment sweeps, a limits ratchet against the merge base, and a bounded maintenance loop with independent review. Pairs with architecture-review.
A Claude Code mod that opens a pane of what is in flight: subagents (running, waiting or paused, failed), your open PRs with CI state and auto-merge, deploy.yml and roll-golden-warmpool.yml runs that are running or failed in the last 6 hours, and background shell tasks. It polls gh read-only every 45 seconds. Requires Claude Code 2.1.287 or later and an authenticated gh.
Install:
/plugin install inflight --marketplace andybons/kit
Open the pane with /inflight. It also opens by itself in a terminal 144 columns wide or more. Below that width, run /inflight; the pane then sits above the prompt. Set the repositories in /config (repos, default meetneptune/boxes,majorcontext/harness; deploy_repo, default meetneptune/boxes).
hooks/register.tsx 245 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, PrRow, RunRow, ShellRow } from '../types'
5import { inflightRuns, parseNotification, toPr } from './model'
6import type { RawPr, RawRun } from './model'
7
8const PANE = 'inflight'
9const AGENT_POLL_MS = 3_000
10const GH_POLL_MS = 45_000
11const WORKFLOWS = ['deploy.yml', 'roll-golden-warmpool.yml']
12const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/
13
14const agents = atom({ plugin: 'inflight', key: 'agents' } as const, [])
15const prs = atom({ plugin: 'inflight', key: 'prs' } as const, {})
16const runs = atom({ plugin: 'inflight', key: 'runs' } as const, {})
17const shells = atom({ plugin: 'inflight', key: 'shells' } as const, [])
18const problem = atom({ plugin: 'inflight', key: 'problem' } as const, null)
19
20const AGENT_STATE = {
21 running: 'run',
22 pending: 'wait',
23 paused: 'wait',
24 waiting: 'wait',
25 failed: 'fail',
26} as const
27
28function repoList(value: unknown): string[] {
29 return String(value ?? '')
30 .split(',')
31 .map(s => s.trim())
32 .filter(s => REPO_PATTERN.test(s))
33}
34
35async function gh<T>($: EngineInterface, args: string[]): Promise<T> {
36 const r = await $.process.run(['gh', ...args], {
37 env: { GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' },
38 timeoutMs: 20_000,
39 })
40 if (r.exitCode !== 0) {
41 throw new Error(`gh ${args[0]} ${args[1]}: ${r.stderr.trim().split('\n')[0] ?? 'failed'}`)
42 }
43 return JSON.parse(r.stdout) as T
44}
45
46const PR_FIELDS = 'number,title,url,isDraft,autoMergeRequest,statusCheckRollup'
47const RUN_FIELDS = 'status,conclusion,displayTitle,url,updatedAt'
48
49const memo = { lastAgents: '', isPolling: false }
50
51async function refreshAgents($: EngineInterface) {
52 try {
53 const rows: AgentRow[] = []
54 for (const a of await $.agent.list()) {
55 const status = AGENT_STATE[a.status as keyof typeof AGENT_STATE]
56 if (status) rows.push({ id: a.id, label: a.description || a.name || a.type, type: a.type, status })
57 }
58 const key = JSON.stringify(rows)
59 if (key === memo.lastAgents) return
60 memo.lastAgents = key
61 await update($, agents, () => rows)
62 } catch {}
63}
64
65async function collect<T>(
66 keys: string[],
67 fetch: (key: string) => Promise<T>,
68 errors: string[],
69): Promise<Record<string, T>> {
70 const out: Record<string, T> = {}
71 await Promise.all(
72 keys.map(async key => {
73 try {
74 out[key] = await fetch(key)
75 } catch (err) {
76 errors.push(err instanceof Error ? err.message : String(err))
77 }
78 }),
79 )
80 return out
81}
82
83async function refreshGh($: EngineInterface, repos: string[], deployRepo: string | undefined) {
84 if (memo.isPolling) return
85 memo.isPolling = true
86 const errors: string[] = []
87 try {
88 const now = await $.clock.now()
89 const prRows = await collect(
90 repos,
91 async repo => {
92 const list = await gh<RawPr[]>($, [
93 'pr', 'list', '--repo', repo, '--author', '@me', '--state', 'open',
94 '--limit', '30', '--json', PR_FIELDS,
95 ])
96 return list.map(p => toPr(repo, p))
97 },
98 errors,
99 )
100 const runRows = deployRepo
101 ? await collect(
102 WORKFLOWS,
103 async w => {
104 const list = await gh<RawRun[]>($, [
105 'run', 'list', '--repo', deployRepo, '--workflow', w,
106 '--limit', '100', '--json', RUN_FIELDS,
107 ])
108 return inflightRuns(w, list, now)
109 },
110 errors,
111 )
112 : {}
113 await update($, prs, prev => ({ ...prev, ...prRows }))
114 await update($, runs, prev => ({ ...prev, ...runRows }))
115 await update($, problem, () => errors[0] ?? null)
116 } finally {
117 memo.isPolling = false
118 }
119}
120
121export const register: Register = (on, options) => {
122 const repos = repoList(options.repos)
123 const deployRepo = repoList(options.deploy_repo)[0]
124
125 on('session.start', async ($, e, next) => {
126 if (!e.isInteractive) return next(e)
127 await $.command.register({ name: 'inflight', description: 'Open the inflight pane' })
128 $.ui.open({ id: PANE, title: 'Inflight' }).catch(() => undefined)
129 refreshAgents($).catch(() => undefined)
130 refreshGh($, repos, deployRepo).catch(() => undefined)
131 $.clock.every(AGENT_POLL_MS, () => void refreshAgents($).catch(() => undefined))
132 $.clock.every(GH_POLL_MS, () => void refreshGh($, repos, deployRepo).catch(() => undefined))
133
134 return next(e)
135 })
136
137 on('command.run', { command: 'inflight' }, async $ => {
138 await $.ui.open({ id: PANE, title: 'Inflight' })
139
140 return { text: 'Inflight pane opened.' }
141 })
142
143 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
144 const ran = await next(e)
145 const result = ran.result as { backgroundTaskId?: string } | undefined
146 const id = result?.backgroundTaskId
147 if (id) {
148 const row: ShellRow = { id, command: (e.description || e.command.split('\n')[0] || '').slice(0, 200) }
149 await update($, shells, list => [...list.filter(s => s.id !== id), row])
150 }
151
152 return ran
153 }).catch(($, e, next) => next(e))
154
155 on('prompt.submit', async ($, e, next) => {
156 if (e.origin?.kind === 'task-notification') {
157 const done = parseNotification(e.text)
158 if (done && done.status !== 'running') {
159 await update($, shells, list => list.filter(s => s.id !== done.id))
160 }
161 }
162
163 return next(e)
164 }).catch(($, e, next) => next(e))
165
166 on('classic.Stop', async ($, e, next) => {
167 const live = e.background_tasks?.map(t => t.id)
168 if (live) await update($, shells, list => list.filter(s => live.includes(s.id)))
169
170 return next(e)
171 }).catch(($, e, next) => next(e))
172
173 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
174 const { Box, Text } = $.ui.resolve(e)
175 const [agentRows, prMap, runMap, shellRows, err] = await Promise.all([
176 read($, agents), read($, prs), read($, runs), read($, shells), read($, problem),
177 ])
178 const prRows = repos.flatMap(r => prMap[r] ?? [])
179 const runRows = WORKFLOWS.flatMap(w => runMap[w] ?? [])
180 const prMissing = repos.filter(r => !(r in prMap))
181 const runMissing = deployRepo ? WORKFLOWS.filter(w => !(w in runMap)) : []
182 const gap = (missing: string[], empty: string) =>
183 missing.length === 0 ? empty : err ? `unavailable: ${missing.join(', ')}` : 'loading'
184 const more = (n: number, max: number) =>
185 n > max ? <Text dimColor> +{n - max} more</Text> : null
186 const GLYPH = { green: '✓', red: '✗', running: '●', none: '○' } as const
187 const COLOR = { green: 'success', red: 'error', running: 'warning', none: 'inactive' } as const
188 const AGENT_COLOR = { run: 'warning', wait: 'inactive', fail: 'error' } as const
189
190 return (
191 <Box flexDirection="column">
192 <Text bold>Subagents</Text>
193 {agentRows.length === 0 && <Text dimColor> none</Text>}
194 {agentRows.slice(0, 8).map(a => (
195 <Text key={a.id} wrap="truncate-end">
196 <Text color={AGENT_COLOR[a.status]}>{a.status.padEnd(4)}</Text> {a.label} <Text dimColor>{a.type}</Text>
197 </Text>
198 ))}
199 {more(agentRows.length, 8)}
200
201 <Text bold>{'\n'}Pull requests</Text>
202 {(prRows.length === 0 || prMissing.length > 0) && (
203 <Text dimColor wrap="truncate-end"> {gap(prMissing, 'none open')}</Text>
204 )}
205 {prRows.slice(0, 12).map(p => (
206 <Box key={`${p.repo}#${p.number}`} flexDirection="column">
207 <Text wrap="truncate-end">
208 <Text color={COLOR[p.ci]}>{GLYPH[p.ci]}</Text> {p.repo.split('/')[1]}#{p.number}{' '}
209 <Text color={p.isAutoMerge ? 'success' : 'inactive'}>{p.isAutoMerge ? 'auto' : 'manual'}</Text>
210 {p.isDraft ? <Text dimColor> draft</Text> : null} {p.title}
211 </Text>
212 {p.failing.length > 0 && (
213 <Text dimColor wrap="truncate-end"> {p.failing.slice(0, 2).join(', ')}</Text>
214 )}
215 </Box>
216 ))}
217 {more(prRows.length, 12)}
218
219 <Text bold>{'\n'}Deploys</Text>
220 {(runRows.length === 0 || runMissing.length > 0) && (
221 <Text dimColor wrap="truncate-end"> {gap(runMissing, 'none running, none failed in 6h')}</Text>
222 )}
223 {runRows.slice(0, 6).map(r => (
224 <Text key={r.url} wrap="truncate-end">
225 <Text color={r.state === 'failed' ? 'error' : 'warning'}>{r.state === 'failed' ? '✗' : '●'}</Text>{' '}
226 {r.workflow.replace('.yml', '')} <Text dimColor>{r.title}</Text>
227 </Text>
228 ))}
229 {more(runRows.length, 6)}
230
231 <Text bold>{'\n'}Background shells</Text>
232 {shellRows.length === 0 && <Text dimColor> none</Text>}
233 {shellRows.slice(0, 6).map(s => (
234 <Text key={s.id} wrap="truncate-end">
235 <Text color="warning">●</Text> {s.command}
236 </Text>
237 ))}
238 {more(shellRows.length, 6)}
239
240 {err && <Text color="error" wrap="truncate-end">{'\n'}{err}</Text>}
241 </Box>
242 )
243 })
244}
245hooks/model.ts 94 lines1import type { Ci, PrRow, RunRow } from '../types'
2
3export type Rollup = {
4 __typename?: string
5 name?: string
6 workflowName?: string
7 status?: string
8 conclusion?: string
9 startedAt?: string
10 context?: string
11 state?: string
12}
13
14export type RawPr = {
15 number: number
16 title: string
17 url: string
18 isDraft: boolean
19 autoMergeRequest: unknown
20 statusCheckRollup: Rollup[] | null
21}
22
23export type RawRun = {
24 status: string
25 conclusion: string
26 displayTitle: string
27 url: string
28 updatedAt: string
29}
30
31export const SIX_HOURS_MS = 6 * 60 * 60 * 1000
32
33const PASSED = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
34
35function checkState(c: Rollup): 'pass' | 'fail' | 'run' {
36 if (c.__typename === 'StatusContext') {
37 if (c.state === 'SUCCESS') return 'pass'
38 return c.state === 'PENDING' || c.state === 'EXPECTED' ? 'run' : 'fail'
39 }
40 if (c.status !== 'COMPLETED') return 'run'
41 return PASSED.includes(c.conclusion ?? '') ? 'pass' : 'fail'
42}
43
44const rank = (c: Rollup) => (c.startedAt?.startsWith('0001') ? '9999' : (c.startedAt ?? ''))
45
46export function summarize(rollup: Rollup[] | null): { ci: Ci; failing: string[] } {
47 const latest = new Map<string, Rollup>()
48 for (const c of rollup ?? []) {
49 const key = `${c.workflowName ?? ''}/${c.name ?? c.context ?? ''}`
50 const seen = latest.get(key)
51 if (!seen || rank(c) >= rank(seen)) latest.set(key, c)
52 }
53 const states = [...latest.entries()].map(([key, c]) => ({ key, c, s: checkState(c) }))
54 const failing = states.filter(x => x.s === 'fail').map(x => x.c.name ?? x.c.context ?? x.key)
55 if (failing.length > 0) return { ci: 'red', failing }
56 if (states.some(x => x.s === 'run')) return { ci: 'running', failing }
57 return { ci: states.length > 0 ? 'green' : 'none', failing }
58}
59
60export function toPr(repo: string, raw: RawPr): PrRow {
61 const { ci, failing } = summarize(raw.statusCheckRollup)
62 return {
63 repo,
64 number: raw.number,
65 title: raw.title,
66 url: raw.url,
67 isDraft: raw.isDraft,
68 ci,
69 failing,
70 isAutoMerge: raw.autoMergeRequest != null,
71 }
72}
73
74const FAILED = ['failure', 'timed_out', 'startup_failure']
75
76export function inflightRuns(workflow: string, raws: RawRun[], now: number): RunRow[] {
77 const rows: RunRow[] = []
78 const seen = new Set<string>()
79 for (const r of raws) {
80 if (seen.has(r.url)) continue
81 seen.add(r.url)
82 const isRecent = now - Date.parse(r.updatedAt) <= SIX_HOURS_MS
83 const state = r.status !== 'completed' ? 'running' : FAILED.includes(r.conclusion) && isRecent ? 'failed' : null
84 if (state) rows.push({ workflow, title: r.displayTitle, url: r.url, state, updatedAt: r.updatedAt })
85 }
86 return rows
87}
88
89export function parseNotification(text: string): { id: string; status: string } | null {
90 const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1]
91 const status = /<status>([^<]+)<\/status>/.exec(text)?.[1]
92 return id && status ? { id, status } : null
93}
94types/index.d.ts 42 lines1export type AgentRow = {
2 id: string
3 label: string
4 type: string
5 status: 'run' | 'wait' | 'fail'
6}
7
8export type Ci = 'green' | 'red' | 'running' | 'none'
9
10export type PrRow = {
11 repo: string
12 number: number
13 title: string
14 url: string
15 isDraft: boolean
16 ci: Ci
17 failing: string[]
18 isAutoMerge: boolean
19}
20
21export type RunRow = {
22 workflow: string
23 title: string
24 url: string
25 state: 'running' | 'failed'
26 updatedAt: string
27}
28
29export type ShellRow = { id: string; command: string }
30
31declare module 'claude-code' {
32 interface PluginState {
33 inflight: {
34 agents: AgentRow[]
35 prs: Record<string, PrRow[]>
36 runs: Record<string, RunRow[]>
37 shells: ShellRow[]
38 problem: string | null
39 }
40 }
41}
42