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…

hooks/review-status.tsx 121 lines1// 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}
121types/index.d.ts 9 lines1export 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