SLOPSHOPPER

ai

Multi-agent loop for Claude Code: Fable/Opus plans, Codex challenges, Opus builds (effort medium), cheap OpenCode models (free ones first) do the rote parts…

newbandtimer
v0.14.4no licenseupdated 2026-10-07mrkpatchaa/dotfiles/claude/ai
A shopper browsing a rack in a slop shop
README

dotfiles

Source 2 files
hooks/review-status.tsx 121 lines
1// review-status — a band above the prompt while a codex-review or grunt-run lane runs in the background, and the last
2// result for ten minutes after it ends. Reads the status files the plugin's scripts write under
3// ${XDG_STATE_HOME:-~/.local/state}/ai-loop (active/<pid>.json while a lane runs, last.json when one ends); no process is
4// started and nothing is sent anywhere.
5import type { Register } from 'claude-code'
6
7import type { LaneRow, LastRow } from '../types'
8
9const POLL_MS = 4000
10const STALE_S = 3 * 3600      // an active file older than this belongs to a lane that died without cleaning up
11const SHOW_LAST_S = 10 * 60   // how long the last result stays on the band
12
13const dur = (s: number): string => {
14  if (s < 60) return `${s}s`
15  if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
16  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
17}
18
19export const register: Register = on => {
20  let stateDir = ''
21
22  on('session.start', async ($, e, next) => {
23    const xdg = await $.env.get('XDG_STATE_HOME')
24    const home = await $.env.get('HOME')
25    stateDir = `${xdg || `${home ?? ''}/.local/state`}/ai-loop`
26
27    const poll = async () => {
28      const now = Math.floor((await $.clock.now()) / 1000)
29      const rows: LaneRow[] = []
30      try {
31        const entries = await $.fs.list(`${stateDir}/active`)
32        for (const entry of entries) {
33          if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
34          try {
35            const text = await $.fs.read(`${stateDir}/active/${entry.name}`)
36            const j = JSON.parse(text) as Partial<LaneRow> & { started?: number }
37            const started = Number(j.started ?? 0)
38            if (!started || now - started > STALE_S) continue
39            rows.push({
40              tool: String(j.tool ?? ''),
41              lane: String(j.lane ?? ''),
42              detail: String(j.detail ?? ''),
43              repo: String(j.repo ?? ''),
44              started,
45              elapsed: dur(Math.max(0, now - started)),
46            })
47          } catch {
48            // a file mid-write or already gone: skip it this round
49          }
50        }
51      } catch {
52        // no active/ directory yet: nothing runs
53      }
54      rows.sort((a, b) => a.started - b.started)
55      await $.state.set({ plugin: 'ai', key: 'lanes' }, rows)
56
57      let recent: LastRow | null = null
58      try {
59        const j = JSON.parse(await $.fs.read(`${stateDir}/last.json`)) as Partial<LastRow> & { finished?: number }
60        const finished = Number(j.finished ?? 0)
61        if (finished && now - finished <= SHOW_LAST_S) {
62          recent = {
63            tool: String(j.tool ?? ''),
64            lane: String(j.lane ?? ''),
65            result: String(j.result ?? ''),
66            detail: String(j.detail ?? ''),
67            repo: String(j.repo ?? ''),
68            finished,
69            ago: dur(Math.max(0, now - finished)),
70          }
71        }
72      } catch {
73        // no last.json yet
74      }
75      await $.state.set({ plugin: 'ai', key: 'last' }, recent)
76    }
77
78    void poll()
79    $.clock.every(POLL_MS, () => {
80      void poll()
81    })
82
83    return next(e)
84  })
85
86  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87    const heldLanes = await $.state.get({ plugin: 'ai', key: 'lanes' })
88    const heldLast = await $.state.get({ plugin: 'ai', key: 'last' })
89    const running: LaneRow[] = heldLanes?.value ?? []
90    const recent: LastRow | null = heldLast?.value ?? null
91    if (e.props.hasSurvey || (running.length === 0 && recent === null)) {
92      return next(e)
93    }
94
95    const { Box, Text } = $.ui.resolve(e)
96
97    const isBad = recent !== null && (recent.result.includes('BLOCK') || recent.result.startsWith('no '))
98
99    return (
100      <Box flexDirection="column">
101        {running.map(row => (
102          <Box key={`${row.tool}-${row.started}`}>
103            <Text color="yellow">{'⟳ '}</Text>
104            <Text bold>{row.tool}</Text>
105            <Text dimColor>
106              {` · ${row.lane}${row.detail ? ` (${row.detail})` : ''} · ${row.elapsed}${row.repo ? ` · ${row.repo}` : ''}`}
107            </Text>
108          </Box>
109        ))}
110        {running.length === 0 && recent !== null && (
111          <Box key="last">
112            <Text color={isBad ? 'red' : 'green'}>{isBad ? '✗ ' : '✓ '}</Text>
113            <Text bold>{recent.tool}</Text>
114            <Text dimColor>{` · ${recent.lane} · ${recent.result} · ${recent.ago} ago${recent.repo ? ` · ${recent.repo}` : ''}`}</Text>
115          </Box>
116        )}
117      </Box>
118    )
119  })
120}
121
types/index.d.ts 9 lines
1export type LaneRow = { tool: string; lane: string; detail: string; repo: string; started: number; elapsed: string }
2export type LastRow = { tool: string; lane: string; result: string; detail: string; repo: string; finished: number; ago: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    ai: { lanes: LaneRow[]; last: LastRow | null }
7  }
8}
9