SLOPSHOPPER

loop-pane

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

newpanecommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loop-pane
│ ┃ loop ✕ › fix the failing auth test and add an audit log call │ ┃ Looking for the loop… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /loop-pane │ ⎿ loop-pane: No scripts/loop.py at or above the folder this sessio │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · loop
Looking for the loop…
README

Loop pane

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.

Open it

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.

Reading it

LineShows
The headerThe 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 commandWhile 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 itOn 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
QueueReady issues the loop will take next, lowest first. One that depends on an open issue says waiting on and which
ActiveIssues 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
FinishedIssues 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 issueThe 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.

What it doesn't do

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.

Changing it

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.

Source 3 files
hooks/register.tsx 333 lines
1import { 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}
333
hooks/logic.ts 319 lines
1// 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}
319
types/index.d.ts 60 lines
1// 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