A pane beside the conversation showing the loop: what's waiting, what each issue is on, and its pull request

scripts/loop.py works without you, which also means you can't see it work. This mod opens a pane beside the conversation that shows it:
starter ● Running 2 · up 1h 04m
[ Watch it ] [ Copy attach command ]
──────────────────────────────────────────────────────────────
[ Queue 1 ] [ Active 2 ] [ Finished 4 ]
● #14 Gate: an empty Role cell passes
started 14:22 · running 6m 12s · 840k tokens · ~$3.40
building, round 2 of 5
tests failed (1), review FIX
[ Watch ]
● #15 Proposal: stamp the phase
started 14:27 · running 1m 03s · 95k tokens · ~$0.41
planning
[ Watch ]
The top line names the repo, and on the right whether the loop is running, how many jobs it's on and how long it has been up. In a narrow pane the state drops to the second line; the header always takes two, so nothing under it moves. While the loop is on a job, the dot before Running pulses with the jobs' dots.
The dot beside a job the loop is on pulses, bright then dim, about once a second, and the clock beside it counts up. If the loop dies mid-step, the dot stops and turns red with the row. A job that has ended keeps a still dot in its result's colour, green for a pull request and red for a block, and says how long it took. A job started by a loop.py older than this one has no clock.
After the time come the job's tokens and, beside them, its cost, totalled over every run of the issue. The cost is Claude Code's estimate at API prices, not a bill. A job with no tokens recorded, or from a loop.py older than #179, shows neither, and in a narrow pane the usage is the part that's cut.
An issue on the Queue tab that depends on one still open says so:
#16 Gate: check the fee total · waiting on #14, #15
Pick Finished and it lists the last day's jobs, three at a time:
▲ 1 more
● #1 Gate: a fee row with 0 hours passes
started 09:10 · took 18m 03s · 1.2M tokens · ~$3.40
pull request open
tests passed, review PASS
[ Copy pull request link ]
● #9 README: link the loop section
started 08:02 · took 1h 04m
blocked
tests passed, review FIX. still failing after 5 rounds, see .loop/issue-9/
● #7 Gate: a blank fee cell passes
started 07:41 · took 12m 40s
pull request open
tests passed, review PASS
[ Copy pull request link ]
It's a Claude Code mod, so it works in Claude Code only: it was seen working in the terminal, the VS Code extension and Claude Desktop's Code tab on 2026-10-23, with Claude Code 2.1.309. It was built and tested on Claude Code 2.1.296. Codex and Kiro don't have mods.
Type /loop-pane. Not /loop: that's Claude Code's own command for running a prompt on a schedule.
In fullscreen, from 110 columns, the pane docks beside the conversation. Narrower, it sits above the prompt. Close it with the × in its corner.
It loads by itself, the same way as the harness lens: the harness's .claude/settings.json turns it on. Don't see /loop-pane? Install it from inside a session, at the root:
/plugin marketplace add ./
/plugin install loop-pane@sanvio-harness-starter
Or load it for one session with claude --plugin-dir examples/mods/loop-pane.
On a machine that already knew this marketplace from another copy of the harness, the first session in a new copy can miss it: Claude Code installs the enabled mods before it re-reads the marketplace. The second session has it.
| Line | Shows |
|---|---|
| The header | The repo's folder on the left. On the right, Running while a live loop holds its lock and works a job (Running 2 for two), Idle in dim green while it holds the lock with nothing to work, Stopping after this step after loop.py stop, Not running otherwise, with how long a live loop has been up. Red when the last run was killed: its lock is still there but its process isn't, and the next run replaces it. A loop.py older than this one writes no start, so no uptime |
| Copy attach command | While a loop started in tmux is running, copies the command that opens its session. loop-control starts the loop that way when tmux is installed |
| Watch it | On a Mac, opens a new Terminal window attached to that session, read-only: you see the loop work and can't type into it. Close the window and the loop keeps running. If the window can't open, it copies the read-only attach command instead. It shows only while the loop runs in tmux on macOS |
| Queue | Ready issues the loop will take next, lowest first. One that depends on an open issue says waiting on and which |
| Active | Issues being worked: what each is on (planning, building, checking, opening the pull request) with the round. One left part way by a run that stopped shows where it was left. In tmux, Watch opens a read-only Terminal window on that issue's own issue-N window, or copies its attach command when it can't. tmux has one current window per session, so it also switches any other window attached to the session, Watch it's included |
| Finished | Issues that reached an end in the last 24 hours: a pull request, blocked, stopped or skipped. Older ones drop off; .loop/status.json still has them |
| Under an issue | The last round's test and review results, and why it was blocked |
Each tab shows its count. The pane opens on Active when something is being worked, else Queue, else Finished, and stays on the tab you pick. A tab shows three jobs at a time. Scroll over the pane, or press the ▲ more and ▼ more lines, to move through the rest; the header and the tabs stay put.
While the pane is open, an issue that reaches an end also pops up a short notice, so you hear about it without reading the pane.
It only reads. It never starts, stops or changes the loop: run python3 scripts/loop.py for that. Watch it opens .loop/watch.command, which loop.py writes when a run starts inside tmux on macOS and removes when it ends, and a job's Watch opens .loop/issue-N/watch.command, written when its window opens and removed when it closes; the windows they open are read-only. Every two seconds while the pane is open, and not at all while it's closed, it reads .loop/status.json, which the loop rewrites at every step, and checks .loop/STOP and .loop/lock, with kill -0 on the process the lock names.
It shows the loop of the harness the session started in: the nearest folder at or above it with scripts/loop.py.
hooks/logic.ts holds the decisions as plain functions, so they can be tested. hooks/register.tsx reads the files and draws the pane. From examples/mods/:
claude plugin validate loop-pane
claude plugin test loop-pane
python3 -m unittest discover -s tests, and so the loop, runs these wherever Claude Code is installed. CI doesn't install Claude Code, so it skips them.
hooks/register.tsx 333 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { LOCK_FILE, PAGE, STATUS_FILE, STOP_FILE, TABS, WATCH_FILE, attach, changes, clip, clock, describe, dot, findRoot, firstTab, header, isPulsing, jobAttach, jobWatchFile, lists, lockPid, page, parseStatus, sameSeen, waitingOn, watch, watchCommand } from './logic'
5import type { Lists, Tone } from './logic'
6import type { Row, Seen, Tab, View } from '../types'
7
8const PANE = 'loop'
9// Not /loop: that's Claude Code's own command for running a prompt on a schedule.
10const COMMAND = 'loop-pane'
11const POLL_MS = 2000
12// The pulse's half beat. The pane redraws this often only while a job's dot is pulsing.
13const TICK_MS = 500
14
15const launchAtom = atom({ plugin: 'loop-pane', key: 'launch' } as const, null)
16const seenAtom = atom({ plugin: 'loop-pane', key: 'seen' } as const, null)
17const viewAtom = atom({ plugin: 'loop-pane', key: 'view' } as const, null)
18
19const COLORS: Record<Tone, { color?: string; bold?: boolean; dimColor?: boolean }> = {
20 now: { color: 'cyan', bold: true },
21 pass: { color: 'green', bold: true },
22 fail: { color: 'red', bold: true },
23 quiet: { dimColor: true },
24 idle: { color: 'green', dimColor: true },
25}
26
27// A working job's dot, on alternate ticks. Bright and dim rather than on and off: a dot that vanishes
28// every other frame reads as a redraw glitch.
29const PULSE = { bright: { color: 'cyan', bold: true }, dim: { color: 'cyan', dimColor: true } } as const
30let beat = true
31
32// A job's rows: its title, when it started, where it is, two for the last round's results, and its
33// pull request.
34const JOB_ROWS = 6
35
36const EMPTY: Record<Tab, string> = {
37 queue: 'Nothing waiting. Label an issue loop-ready to queue it.',
38 active: 'Nothing being worked on.',
39 finished: 'Nothing finished in the last 24 hours.',
40}
41
42let stopPolling: (() => void) | null = null
43
44// Open a read-only Terminal window from a watch file; when it can't, copy the command that does the
45// same and say so. Closing the window leaves the loop running.
46function openWatch($: EngineInterface, file: string, copy: () => Promise<unknown>): void {
47 void (async () => {
48 const ran = await $.process.run(['open', file]).catch(() => null)
49 if (ran?.exitCode === 0) return
50 await copy()
51 $.ui.toast("Couldn't open a window, so the read-only attach command is copied")
52 })().catch(() => {})
53}
54
55// The tab in view and its first job, kept inside the list. Before the person picks, the busiest tab.
56function current(view: View | null, l: Lists): View {
57 const tab = view?.tab ?? firstTab(l)
58 return { tab, start: page(l[tab], view?.tab ? view.start : 0).start }
59}
60
61// Move the tab's list by `by` jobs. Says whether it moved: at either end it doesn't.
62async function move($: EngineInterface, by: number): Promise<boolean> {
63 const seen = await read($, seenAtom)
64 const l = lists(seen?.status ?? null, Date.now())
65 const now = current(await read($, viewAtom), l)
66 const next = page(l[now.tab], now.start + by).start
67 if (next === now.start) return false
68 await update($, viewAtom, () => ({ tab: now.tab, start: next }))
69 return true
70}
71
72async function rootOf($: EngineInterface): Promise<string | null> {
73 const launch = await read($, launchAtom)
74 return launch ? findRoot(launch, path => $.fs.exists(path)) : null
75}
76
77// Whether the process named in the lock is alive. A killed run leaves its lock behind.
78async function lockHolderAlive($: EngineInterface, root: string): Promise<boolean | null> {
79 const text = await $.fs.read(`${root}/${LOCK_FILE}`).catch(() => null)
80 if (text === null) return null
81 const pid = lockPid(text)
82 if (pid === null) return true
83 const ran = await $.process.run(['kill', '-0', String(pid)]).catch(() => null)
84 // kill -0 also fails on another user's process; count only "no such process" as gone.
85 return !ran || ran.exitCode === 0 || !/no such process/i.test(ran.stderr)
86}
87
88// One look at the loop's files. It only reads: the loop is the only writer.
89async function look($: EngineInterface, root: string): Promise<Seen> {
90 const text = await $.fs.read(`${root}/${STATUS_FILE}`).catch(() => null)
91 const alive = await lockHolderAlive($, root)
92 return {
93 root,
94 status: parseStatus(text),
95 isLocked: alive === true,
96 isStaleLock: alive === false,
97 isStopping: await $.fs.exists(`${root}/${STOP_FILE}`),
98 hasWatchFile: await $.fs.exists(`${root}/${WATCH_FILE}`),
99 }
100}
101
102async function refresh($: EngineInterface): Promise<void> {
103 const root = await rootOf($)
104 if (!root) return
105 const now = await look($, root)
106 const before = await read($, seenAtom)
107 if (sameSeen(before, now)) return
108 await update($, seenAtom, () => now)
109 for (const line of changes(before?.status ?? null, now.status)) $.ui.toast(line)
110 $.ui.invalidate('ui.render')
111}
112
113// Poll only while the pane is up, so a session that never opens it reads nothing.
114function startPolling($: EngineInterface): void {
115 if (stopPolling) return
116 // every() hands back a timer, not a function: calling it threw, so the poll outlived the pane.
117 // It ticks at the pulse's pace and reads the loop's files on every POLL_MS / TICK_MS-th tick.
118 let ticks = 0
119 const timer = $.clock.every(TICK_MS, () => {
120 void (async () => {
121 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
122 if (!isUp) {
123 stopPolling?.()
124 stopPolling = null
125 return
126 }
127 beat = !beat
128 const isRead = ticks++ % (POLL_MS / TICK_MS) === 0
129 if (isRead) await refresh($)
130 // The dot and the running clock move between reads, so redraw for them while a job is on. The
131 // header's uptime moves too, so a live loop redraws at least at every read.
132 const seen = await read($, seenAtom)
133 if (isPulsing(seen) || (isRead && seen?.isLocked)) $.ui.invalidate('ui.render')
134 })().catch(() => {})
135 })
136 stopPolling = () => timer.cancel()
137}
138
139export const register: Register = on => {
140 on('session.start', async ($, e, next) => {
141 // A reload fires session.start again; the directory the session started in is the first one.
142 await update($, launchAtom, prev => prev ?? e.cwd)
143 await $.command.register({
144 name: COMMAND,
145 description: "Open a pane showing the loop: what's waiting, what each issue is on, and its pull request",
146 })
147 // A pane left open across a reload keeps updating.
148 if ((await $.ui.panes()).some(pane => pane.id === PANE)) startPolling($)
149 return next(e)
150 })
151
152 on('command.run', { command: COMMAND }, async $ => {
153 try {
154 const root = await rootOf($)
155 if (!root) return { text: 'No scripts/loop.py at or above the folder this session started in, so there is no loop to show.' }
156 await refresh($)
157 const opened = await $.ui.open({ id: PANE, title: 'Loop' })
158 startPolling($)
159 return { text: opened.isPlaced ? 'Loop pane opened.' : 'Loop pane opened. Widen the terminal to see it.' }
160 } catch {
161 return { text: "The loop pane couldn't open. .loop/loop.log has the same story: tail -f .loop/loop.log" }
162 }
163 })
164
165 // The wheel and scroll keys move the job list, under a header that stays put. At either end of the
166 // list, or with nothing to move, the pane scrolls as usual.
167 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
168 const moved = e.by !== 0 && (await move($, Math.sign(e.by)).catch(() => false))
169 return moved ? {} : next(e)
170 })
171
172 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173 const { Box, Button, Text } = $.ui.resolve(e)
174 const seen = await read($, seenAtom)
175 const width = Math.max(10, e.props.bodyColumns)
176 // A job's lines sit under its title, past the dot.
177 const inner = width - 2
178 const now = Date.now()
179 // Claude Desktop and VS Code wrap text, so there a long line shows whole instead of being cut.
180 const wraps = e.surface === 'desktop' || e.surface === 'vscode'
181 const fit = (s: string, n: number) => (wraps ? s : clip(s, n))
182
183 if (!seen) return <Text dimColor>Looking for the loop…</Text>
184
185 const top = header(seen, now, width)
186 // The state's dot is its first character, drawn on its own so it can pulse with the jobs' dots.
187 const state = (
188 <Box flexDirection="row">
189 <Text {...(top.isPulsing ? PULSE[beat ? 'bright' : 'dim'] : COLORS[top.right.tone])}>{top.right.text.slice(0, 1)}</Text>
190 <Text {...COLORS[top.right.tone]}>{top.right.text.slice(1)}</Text>
191 </Box>
192 )
193 const tmux = attach(seen)
194 const watchFile = watch(seen)
195 const l = lists(seen.status, now)
196 const view = current(await read($, viewAtom), l)
197 const queue = page(l.queue, view.start)
198 const jobs = view.tab === 'queue' ? null : page(l[view.tab], view.start)
199 const { above, below } = jobs ?? queue
200 const isEmpty = (jobs ?? queue).shown.length === 0
201
202 // On the terminal every job takes the same rows, and the list always takes three jobs' worth, so
203 // nothing below it moves as jobs scroll by or gain a result. Desktop and VS Code wrap instead, so
204 // a job takes the rows its text needs.
205 const job = (row: Row) => {
206 const { head: state, detail } = describe(row, seen.isLocked)
207 const mark = dot(row, seen.isLocked)
208 const time = clock(row, seen.isLocked, now)
209 const stateText = fit(state.text, inner)
210 const jobCommand = jobAttach(seen, row)
211 // Every job has a dot, in the same column, so a title never moves as its job starts or ends.
212 return (
213 <Box key={`issue-${row.number}`} flexDirection="column" marginTop={1} height={wraps ? undefined : JOB_ROWS}>
214 <Box flexDirection="row" columnGap={1}>
215 <Text {...(mark.isPulsing ? PULSE[beat ? 'bright' : 'dim'] : COLORS[mark.tone])}>●</Text>
216 <Text bold>{fit(`#${row.number} ${row.title}`, inner)}</Text>
217 </Box>
218 <Box flexDirection="column" marginLeft={2}>
219 {/* Blank for a job from a loop.py that writes no start, so every job takes the same rows. */}
220 <Box key={`clock-${row.number}`}>
221 <Text dimColor>{time ? fit(time, inner) : ' '}</Text>
222 </Box>
223 <Text {...COLORS[state.tone]}>{stateText}</Text>
224 <Box key={`detail-${row.number}`} height={wraps ? undefined : 2} overflow={wraps ? undefined : 'hidden'}>
225 <Text dimColor>{detail ? fit(detail, inner * 2) : ' '}</Text>
226 </Box>
227 {jobCommand || row.pr ? (
228 <Box flexDirection="row" columnGap={1}>
229 {jobCommand && (
230 <Button
231 key={`watch-${row.number}`}
232 label="Watch"
233 onPress={press =>
234 openWatch($, `${seen.root}/${jobWatchFile(row.number)}`, () =>
235 $.ui.copy({ text: jobCommand, surface: press.surface }))}
236 />
237 )}
238 {row.pr ? (
239 <Button
240 key={`pr-${row.number}`}
241 label={fit('Copy pull request link', inner)}
242 onPress={press => $.ui.copy({ text: row.pr ?? '', surface: press.surface })}
243 />
244 ) : null}
245 </Box>
246 ) : (
247 <Text> </Text>
248 )}
249 </Box>
250 </Box>
251 )
252 }
253 const gap = (n: number, rows: number) =>
254 Array.from({ length: n }, (_, i) => <Box key={`gap-${i}`} marginTop={rows > 1 ? 1 : 0} height={rows} />)
255 const shownCount = (jobs ?? queue).shown.length
256
257 return (
258 <Box flexDirection="column">
259 {/* Two rows whatever the state, so nothing under the header moves when it wraps. */}
260 {/* The layout pushes the state to the right edge, not padding: Claude Desktop's font needn't be monospace. */}
261 <Box key="header" flexDirection="row" justifyContent="space-between">
262 <Text bold>{top.left}</Text>
263 {!top.wraps && state}
264 </Box>
265 {top.wraps ? state : <Text> </Text>}
266 {/* The buttons' row stays when there are none, so the tabs don't move as the loop starts or stops. */}
267 {!tmux && <Text key="actions"> </Text>}
268 {tmux && (
269 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
270 {watchFile && (
271 <Button
272 key="watch"
273 label="Watch it"
274 onPress={press =>
275 openWatch($, watchFile, () => $.ui.copy({ text: watchCommand(seen) ?? '', surface: press.surface }))}
276 />
277 )}
278 <Button
279 key="tmux"
280 label="Copy attach command"
281 onPress={press => $.ui.copy({ text: tmux, surface: press.surface })}
282 />
283 </Box>
284 )}
285 <Text dimColor>{'─'.repeat(width)}</Text>
286 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
287 {TABS.map(({ tab, label }) => (
288 <Button
289 key={`tab-${tab}`}
290 label={`${label} ${l[tab].length}`}
291 variant={tab === view.tab ? 'primary' : undefined}
292 dimColor={tab !== view.tab}
293 onPress={() => update($, viewAtom, () => ({ tab, start: 0 }))}
294 />
295 ))}
296 </Box>
297 {/* The lines above and below the list are always there, so it doesn't jump as it scrolls. */}
298 {above > 0 ? (
299 <Button key="up" plain label={`▲ ${above} more`} dimColor onPress={() => move($, -1)} />
300 ) : (
301 <Text> </Text>
302 )}
303 {isEmpty && (
304 <Box marginTop={jobs ? 1 : 0} height={jobs ? JOB_ROWS : 1} overflow="hidden">
305 <Text dimColor>{clip(EMPTY[view.tab], width)}</Text>
306 </Box>
307 )}
308 {jobs
309 ? jobs.shown.map(job)
310 : queue.shown.map(q => {
311 // The title gives way to what it waits on, down to a short stub.
312 const note = waitingOn(q)
313 const tag = note ? ` · ${note}` : ''
314 const full = `#${q.number} ${q.title}`
315 const title = wraps ? full : clip(full, Math.max(width - tag.length, Math.min(width, 16)))
316 return (
317 <Box key={`queue-${q.number}`} flexDirection="row">
318 <Text>{title}</Text>
319 {tag ? <Text dimColor>{fit(tag, width - title.length)}</Text> : null}
320 </Box>
321 )
322 })}
323 {gap(PAGE - shownCount - (isEmpty ? 1 : 0), jobs ? JOB_ROWS : 1)}
324 {below > 0 ? (
325 <Button key="down" plain label={`▼ ${below} more`} dimColor onPress={() => move($, 1)} />
326 ) : (
327 <Text> </Text>
328 )}
329 </Box>
330 )
331 })
332}
333hooks/logic.ts 319 lines1// The pane's decisions as plain functions, so they can be tested without a session.
2import type { QueueItem, Row, Seen, Status, Tab } from '../types'
3
4export type Tone = 'now' | 'pass' | 'fail' | 'quiet' | 'idle'
5export type Line = { tone: Tone; text: string }
6
7export const STATUS_FILE = '.loop/status.json'
8export const LOCK_FILE = '.loop/lock'
9export const STOP_FILE = '.loop/STOP'
10export const WATCH_FILE = '.loop/watch.command'
11export const SCRIPT = 'scripts/loop.py'
12
13const STEPS: Record<string, string> = {
14 SHAPE: 'planning',
15 BUILD: 'building',
16 VALIDATE: 'checking',
17 SCALE: 'opening the pull request',
18}
19
20const ENDED: Record<string, { tone: Tone; text: string }> = {
21 pr: { tone: 'pass', text: 'pull request open' },
22 blocked: { tone: 'fail', text: 'blocked' },
23 stopped: { tone: 'quiet', text: 'stopped' },
24 skipped: { tone: 'quiet', text: 'skipped' },
25}
26
27const str = (v: unknown): string => (typeof v === 'string' ? v : '')
28const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
29const orNull = (v: unknown): string | null => (typeof v === 'string' && v ? v : null)
30const positive = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined)
31
32// The status file, or null when there's none or it can't be read. A row without a number is dropped.
33export function parseStatus(text: string | null): Status | null {
34 if (!text) return null
35 let raw: unknown
36 try {
37 raw = JSON.parse(text)
38 } catch {
39 return null
40 }
41 if (!raw || typeof raw !== 'object') return null
42 const data = raw as Record<string, unknown>
43 const queue: QueueItem[] = (Array.isArray(data.queue) ? data.queue : [])
44 .filter((q): q is Record<string, unknown> => !!q && typeof q === 'object' && num((q as Record<string, unknown>).number) > 0)
45 .map(q => {
46 const item: QueueItem = { number: num(q.number), title: str(q.title) }
47 const on = Array.isArray(q.waiting_on) ? q.waiting_on.filter(n => num(n) > 0) : []
48 if (on.length) item.waiting_on = on
49 return item
50 })
51 const issues: Row[] = (Array.isArray(data.issues) ? data.issues : [])
52 .filter((r): r is Record<string, unknown> => !!r && typeof r === 'object' && num((r as Record<string, unknown>).number) > 0)
53 .map(r => ({
54 number: num(r.number),
55 title: str(r.title),
56 step: str(r.step),
57 state: str(r.state),
58 round: num(r.round),
59 max_rounds: num(r.max_rounds) || undefined,
60 tests: orNull(r.tests),
61 review: orNull(r.review),
62 pr: orNull(r.pr),
63 note: str(r.note),
64 started: orNull(r.started) ?? undefined,
65 updated: orNull(r.updated) ?? undefined,
66 window: orNull(r.window) ?? undefined,
67 tokens: positive(r.tokens),
68 cost_usd: positive(r.cost_usd),
69 }))
70 return {
71 running: data.running === true, updated: orNull(data.updated), tmux: orNull(data.tmux),
72 started: orNull(data.started), queue, issues,
73 }
74}
75
76// The first line of the pane. The lock is the truth about running: the file's own flag can be left
77// true by a run that was killed.
78export function headline(seen: Seen): Line {
79 if (seen.isLocked && seen.isStopping) return { tone: 'now', text: 'Stopping after this step' }
80 if (seen.isLocked) return { tone: 'now', text: 'Running' }
81 if (seen.isStaleLock) return { tone: 'fail', text: 'Not running. The last run was killed and left its lock behind' }
82 if (!seen.status) return { tone: 'quiet', text: "Hasn't run here yet" }
83 if (seen.status.running) return { tone: 'fail', text: "Not running. The last run didn't finish cleanly" }
84 return { tone: 'quiet', text: 'Not running' }
85}
86
87export type Header = { left: string; right: Line; isPulsing: boolean; wraps: boolean; rows: [string, string] }
88
89// The pane's top: the repo on the left, the loop's state on the right. Always two rows: the state
90// drops to the second when both don't fit on the first, and the second stays blank when they do, so
91// nothing below moves as the state changes. The state's dot pulses while the loop is on a job, like
92// that job's own dot.
93export function header(seen: Seen, now: number, width: number): Header {
94 const left = seen.root.split('/').filter(Boolean).pop() ?? seen.root
95 const right = loopState(seen, now)
96 const isPulsing = seen.isLocked && !seen.isStopping && !!seen.status?.issues.some(r => r.state === 'working')
97 const wraps = left.length + 1 + right.text.length > width
98 if (wraps) {
99 const state = { tone: right.tone, text: clip(right.text, width) }
100 return { left: clip(left, width), right: state, isPulsing, wraps, rows: [clip(left, width), state.text] }
101 }
102 return { left, right, isPulsing, wraps, rows: [left + ' '.repeat(width - left.length - right.text.length) + right.text, ''] }
103}
104
105// The header's state. Running counts the jobs being worked, and says so past one; a run with none is
106// idle. Up is how long this run has held the lock, from a loop.py that writes when it started.
107function loopState(seen: Seen, now: number): Line {
108 const from = seen.status?.started ? Date.parse(seen.status.started) : Number.NaN
109 const up = Number.isFinite(from) ? ` · up ${duration(now - from)}` : ''
110 if (seen.isLocked && seen.isStopping) return { tone: 'now', text: '● Stopping after this step' }
111 if (seen.isLocked) {
112 const working = seen.status?.issues.filter(r => r.state === 'working').length ?? 0
113 if (!working) return { tone: 'idle', text: `● Idle${up}` }
114 return { tone: 'now', text: `● Running${working > 1 ? ` ${working}` : ''}${up}` }
115 }
116 const line = headline(seen)
117 return { tone: line.tone, text: `○ ${line.text}` }
118}
119
120// The command that opens the tmux session a running loop is in, or null. A session named by a run
121// that's no longer alive is gone, so it isn't offered.
122export function attach(seen: Seen): string | null {
123 const name = seen.status?.tmux
124 if (!seen.isLocked || !name) return null
125 return /^[\w.@%+=:,-]+$/.test(name) ? `tmux attach -t ${name}` : `tmux attach -t '${name.replace(/'/g, `'\\''`)}'`
126}
127
128// The file that opens a window on the running loop's tmux session, read-only, or null. loop.py
129// writes it only on macOS inside tmux, so elsewhere, or with an older loop.py, there's no button.
130export function watch(seen: Seen): string | null {
131 return attach(seen) && seen.hasWatchFile ? `${seen.root}/${WATCH_FILE}` : null
132}
133
134// What the window runs, to copy when it can't open: read-only, and the name matched exactly. Always
135// quoted, because zsh reads a word starting with = as a command lookup.
136export function watchCommand(seen: Seen): string | null {
137 const name = seen.status?.tmux
138 return attach(seen) && name ? `tmux attach -r -t '=${name.replace(/'/g, `'\\''`)}'` : null
139}
140
141// The file that opens a window on one job's tmux window, read-only, under the root. loop.py writes it
142// only on macOS, when it opens the window, and removes it when the window closes.
143export function jobWatchFile(n: number): string {
144 return `.loop/issue-${n}/watch.command`
145}
146
147// What that file runs, to copy when it can't open, or null when the job has no window in a live run.
148export function jobAttach(seen: Seen, row: Row): string | null {
149 const name = seen.status?.tmux
150 if (!attach(seen) || !name || !row.window) return null
151 return `tmux attach -r -t '=${`${name}:${row.window}`.replace(/'/g, `'\\''`)}'`
152}
153
154// What a queued issue waits on, beside its title: the open issues it depends on. Null when nothing.
155export function waitingOn(item: QueueItem): string | null {
156 const on = item.waiting_on ?? []
157 return on.length ? `waiting on ${on.map(n => `#${n}`).join(', ')}` : null
158}
159
160// Jobs in view at once; the rest scroll.
161export const PAGE = 3
162// How long a finished job stays on the Finished tab.
163export const FINISHED_MS = 24 * 60 * 60 * 1000
164
165export const TABS: { tab: Tab; label: string }[] = [
166 { tab: 'queue', label: 'Queue' },
167 { tab: 'active', label: 'Active' },
168 { tab: 'finished', label: 'Finished' },
169]
170
171export type Lists = { queue: QueueItem[]; active: Row[]; finished: Row[] }
172
173// The three tabs' lists. A finished job counts from its last update, which loop.py writes in local
174// time; one with no time, or older than a day, is left out.
175export function lists(status: Status | null, now: number): Lists {
176 const rows = status?.issues ?? []
177 const finished = rows.filter(r => {
178 if (!ENDED[r.state]) return false
179 const at = r.updated ? Date.parse(r.updated) : Number.NaN
180 return Number.isFinite(at) && now - at <= FINISHED_MS
181 })
182 return { queue: waiting(status), active: rows.filter(r => !ENDED[r.state]), finished }
183}
184
185// The tab to open on: what's being worked, else what's waiting, else what finished.
186export function firstTab(l: Lists): Tab {
187 if (l.active.length) return 'active'
188 if (l.queue.length) return 'queue'
189 return 'finished'
190}
191
192// Up to PAGE items from `start`, with `start` kept inside the list, and how many sit either side.
193export function page<T>(items: T[], start: number): { shown: T[]; start: number; above: number; below: number } {
194 const from = Math.max(0, Math.min(Math.trunc(start) || 0, items.length - PAGE))
195 const shown = items.slice(from, from + PAGE)
196 return { shown, start: from, above: from, below: items.length - from - shown.length }
197}
198
199// Ready issues not already being worked on.
200export function waiting(status: Status | null): QueueItem[] {
201 if (!status) return []
202 const busy = new Set(status.issues.filter(r => r.state === 'working').map(r => r.number))
203 return status.queue.filter(q => !busy.has(q.number))
204}
205
206// One issue: what it's on, or how it ended, and the last round's results under it.
207export function describe(row: Row, isRunning: boolean): { head: Line; detail: string | null } {
208 const ended = ENDED[row.state]
209 let head: Line
210 if (ended) {
211 head = ended
212 } else if (row.state === 'working' && !isRunning) {
213 head = { tone: 'fail', text: `left at ${STEPS[row.step] ?? 'an unknown step'}` }
214 } else {
215 const step = STEPS[row.step] ?? (row.step.toLowerCase() || 'starting')
216 const round = row.round > 0 ? `, round ${row.round}${row.max_rounds ? ` of ${row.max_rounds}` : ''}` : ''
217 head = { tone: 'now', text: step + round }
218 }
219 const results = [row.tests && `tests ${row.tests}`, row.review && `review ${row.review}`].filter(Boolean).join(', ')
220 const detail = [results, row.state === 'pr' ? '' : row.note].filter(Boolean).join('. ')
221 return { head, detail: detail || null }
222}
223
224// The dot beside a job: whether it pulses, and its colour. It pulses only while the loop is alive and
225// on that job, so a run that dies mid-step stops it, the same moment the row turns red.
226export function dot(row: Row, isRunning: boolean): { tone: Tone; isPulsing: boolean } {
227 const ended = ENDED[row.state]
228 if (ended) return { tone: ended.tone, isPulsing: false }
229 if (row.state === 'working' && !isRunning) return { tone: 'fail', isPulsing: false }
230 return { tone: 'now', isPulsing: true }
231}
232
233// Whether any dot is pulsing, so the pane only redraws fast while one is.
234export function isPulsing(seen: Seen | null): boolean {
235 return !!seen?.isLocked && !!seen.status?.issues.some(r => dot(r, true).isPulsing)
236}
237
238const pad = (n: number): string => String(n).padStart(2, '0')
239const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
240
241// How long, the way a person reads it: 42s, 6m 05s, 1h 04m.
242export function duration(ms: number): string {
243 const s = Math.max(0, Math.floor(ms / 1000))
244 if (s < 60) return `${s}s`
245 const m = Math.floor(s / 60)
246 if (m < 60) return `${m}m ${pad(s % 60)}s`
247 return `${Math.floor(m / 60)}h ${pad(m % 60)}m`
248}
249
250// What a job has used, over every run: 840k tokens · ~$3.40. The cost is Claude Code's estimate at API
251// prices and shows only beside tokens. Null when the row has none, as from an older loop.py.
252export function usage(row: Row): string | null {
253 const t = row.tokens
254 if (t === undefined || !(t > 0)) return null
255 const whole = Math.round(t)
256 const thousands = Math.round(whole / 1000)
257 const tokens = whole < 1000 ? String(whole) : thousands < 1000 ? `${thousands}k` : `${(whole / 1_000_000).toFixed(1)}M`
258 return `${tokens} tokens${row.cost_usd && row.cost_usd > 0 ? ` · ~$${row.cost_usd.toFixed(2)}` : ''}`
259}
260
261// When a job started and how long it has been running, or took, then what it has used. loop.py writes
262// local time with no zone, which Date.parse reads as local too. A start on another day carries its
263// date. A row from an older loop.py has no start, so no clock.
264export function clock(row: Row, isRunning: boolean, now: number): string | null {
265 const from = row.started ? Date.parse(row.started) : Number.NaN
266 if (!Number.isFinite(from)) return null
267 const at = new Date(from)
268 const time = `${pad(at.getHours())}:${pad(at.getMinutes())}`
269 const when = at.toDateString() === new Date(now).toDateString() ? time : `${MONTHS[at.getMonth()]} ${at.getDate()} ${time}`
270 const end = row.updated ? Date.parse(row.updated) : Number.NaN
271 let line = `started ${when}`
272 if (dot(row, isRunning).isPulsing) line += ` · running ${duration(now - from)}`
273 else if (Number.isFinite(end)) line += ` · ${ENDED[row.state] ? 'took' : 'ran'} ${duration(end - from)}`
274 const used = usage(row)
275 return used ? `${line} · ${used}` : line
276}
277
278// A short line for each issue that has just reached an end: a pull request, a block or a stop.
279// The first look has nothing to compare with, so it says nothing: opening the pane isn't news.
280export function changes(before: Status | null, after: Status | null): string[] {
281 if (!before || !after) return []
282 const was = new Map((before?.issues ?? []).map(r => [r.number, r.state]))
283 const out: string[] = []
284 for (const row of after.issues) {
285 if (was.get(row.number) === row.state) continue
286 if (row.state === 'pr') out.push(`Loop: #${row.number} has a pull request`)
287 else if (row.state === 'blocked') out.push(`Loop: #${row.number} is blocked`)
288 else if (row.state === 'stopped') out.push(`Loop: #${row.number} stopped`)
289 }
290 return out
291}
292
293// The process id in .loop/lock, or null when it isn't a number.
294export function lockPid(text: string): number | null {
295 const pid = Number.parseInt(text.trim(), 10)
296 return Number.isInteger(pid) && pid > 0 ? pid : null
297}
298
299// Fit one line to the pane, with an ellipsis where it was cut.
300export function clip(text: string, width: number): string {
301 if (width < 2) return ''
302 return text.length <= width ? text : text.slice(0, width - 1) + '…'
303}
304
305// The nearest folder at or above `from` that holds the loop script. None means no loop here.
306export async function findRoot(from: string, exists: (path: string) => Promise<boolean>): Promise<string | null> {
307 let dir = from.replace(/\/+$/, '')
308 while (dir) {
309 if (await exists(`${dir}/${SCRIPT}`)) return dir
310 dir = dir.slice(0, dir.lastIndexOf('/'))
311 }
312 return null
313}
314
315// Whether two looks differ in anything the pane draws.
316export function sameSeen(a: Seen | null, b: Seen | null): boolean {
317 return JSON.stringify(a) === JSON.stringify(b)
318}
319types/index.d.ts 60 lines1// What scripts/loop.py writes to .loop/status.json. Every field is optional here: an older or
2// half-written file still draws, with what it has.
3// waiting_on: the open issues it depends on, while the loop waits for them to close.
4export type QueueItem = { number: number; title: string; waiting_on?: number[] }
5
6export type Row = {
7 number: number
8 title: string
9 step: string
10 state: string
11 round: number
12 max_rounds?: number
13 tests: string | null
14 review: string | null
15 pr: string | null
16 note: string
17 // When this run of the issue began, in local time. An older loop.py doesn't write it.
18 started?: string
19 updated?: string
20 // Totals over every run of the issue. A loop.py older than #179 doesn't write them.
21 tokens?: number
22 cost_usd?: number
23 // The issue's tmux window, issue-N, while it's open.
24 window?: string
25}
26
27export type Status = {
28 running: boolean
29 updated: string | null
30 // The tmux session a running loop is in, when it was started in one.
31 tmux: string | null
32 // When the running loop started, in UTC with its offset. An older loop.py doesn't write it.
33 started?: string | null
34 queue: QueueItem[]
35 issues: Row[]
36}
37
38// What the pane draws from: the status file, plus whether the lock and a stop request are there.
39export type Seen = {
40 root: string
41 status: Status | null
42 // A lock whose process is alive: the loop is running.
43 isLocked: boolean
44 // A lock left by a process that's gone: the last run was killed. The next run replaces it.
45 isStaleLock: boolean
46 isStopping: boolean
47 // .loop/watch.command is there: loop.py wrote it, on macOS inside tmux, for the Watch it button.
48 hasWatchFile: boolean
49}
50
51// Which tab the pane shows, and the first job of its list in view. Null until the person picks a tab.
52export type Tab = 'queue' | 'active' | 'finished'
53export type View = { tab: Tab; start: number }
54
55declare module 'claude-code' {
56 interface PluginState {
57 'loop-pane': { launch: string | null; seen: Seen | null; view: View | null }
58 }
59}
60