Live status of a bussin-autoloop run: progress, running items, elapsed time, ETA, tokens, cost and models, in the status line and a pane, with a Stop button…

<img src="./bussin-feature/assets/bussin-logo.svg" alt="Bussin" width="360">
<h1 align="center">Skills</h1>
<a href="https://skills.sh/nksrentas/skills"><img src="https://skills.sh/b/nksrentas/skills" alt="skills.sh installs"></a>
init expands a task into a feature list with verify gates and a commit guard, then run finishes one item per fresh session until every item passes with evidence.Every skill has a short alias that takes the same arguments, and the slash menu shows those arguments as you type.
| Alias | Skill | Arguments | ||
|---|---|---|---|---|
/autoloop | bussin-autoloop | `init <task> [--mode fast\ | standard\ | thorough] [--skills a,b,review:c] [--no-checkpoint] [--new] [--stack <text>] [--base <ref>] [--name <text>], run, retro or status` |
/feature | bussin-feature | <feature to build or change> | ||
/lean | bussin-lean | [path, file or commit range to clean up] | ||
/arch | bussin-ts-architecture | [feature to place or wiring question] | ||
/drizzle | bussin-drizzle | [table, column, migration or query task] | ||
/react | bussin-react | [component, hook, form or page to build or review] | ||
/ui | bussin-ui | [page, section or screen to build or restyle] | ||
/honest | bussin-honest-feedback | <idea, plan, draft or decision to assess> | ||
/humanize | bussin-write-like-a-human | <draft text or path to the draft> | ||
/a11y | bussin-a11y | [page, component or URL to build or audit] | ||
/seo | bussin-seo | [page, route or URL] | ||
/schema | bussin-schema | [page, entity or URL] |
Aliases are user-only: Claude never picks one by itself, so they do not compete with the real skills for triggering. When the skills come from the plugin, the aliases are namespaced too, for example /bussin:autoloop.
npx github:nksrentas/skills
Without Node, the same installer runs with curl -fsSL https://raw.githubusercontent.com/nksrentas/skills/main/install.sh | bash.
It clones this repository into ~/dev/skills, or pulls it if it is already there, and links every skill and alias into ~/.claude/skills. It also installs or updates the bussin-loop-status mod. When Codex or Copilot is installed, it copies the skills for them too. Run the same command again any time to update. It needs git; jq is needed by bussin-autoloop. On Windows, run it inside WSL. Set SKILLS_DIR to clone somewhere else.
One install gives you all twelve skills:
claude plugin marketplace add nksrentas/skills
claude plugin install bussin@bussin
Plugin skills are namespaced, so the loop command becomes /bussin:bussin-autoloop.
The repository follows the open Agent Skills format, so it works with Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot and the other tools the skills CLI supports.
Install everything:
npx skills add nksrentas/skills -g --all
Or one skill:
npx skills add nksrentas/skills --skill bussin-autoloop -g
Omit -g for a project-local installation. npx skills update -g pulls updates.
bussin-lean also runs the ponytail plugin's over-engineering review when it is installed:
claude plugin marketplace add DietrichGebert/ponytail
claude plugin install ponytail@ponytail
bussin-ui hands motion craft to Emil Kowalski's skills. Install them next to it:
npx skills add emilkowalski/skills --skill emil-design-eng --skill animate --skill find-animation-opportunities --skill improve-animations --skill review-animations --skill animation-performance --skill animation-vocabulary --skill apple-design
/bussin-autoloop init "Invoice reminders" --skills bussin-ts-architecture,bussin-drizzle
Init researches the codebase and writes .loop/. That holds the feature list (functional, edge-case and regression items), a verify gate, and a commit guard that only allows commits of code that passed the gate. Init then shows you the list to confirm, and prints the command that runs one fresh session per item until every item passes:
bash ~/.claude/skills/bussin-autoloop/scripts/loop.sh
To run independent items at the same time, each in its own git worktree, set a cap: LOOP_WORKERS=3 bash ~/.claude/skills/bussin-autoloop/scripts/loop.sh. Init works out which items depend on which and what files each touches; you can edit both in .loop/feature_list.json.
For UI work add bussin-ui and review:review-animations to --skills.
Run one loop per checkout; a second loop.sh in the same checkout refuses to start while the first runs. For several loops at once, from different chats or terminals, give each its own git worktree (git worktree add ../app-billing) or repo. Each loop is named after its directory, so its worker branches (loop/<name>/<id>), dev-server ports and test databases never collide with another loop's.
The loop runs with Codex or GitHub Copilot too: install the skill for that agent, start init there ($bussin-autoloop init … in Codex, /bussin-autoloop init … in Copilot), then run the driver with LOOP_AGENT=codex or LOOP_AGENT=copilot. Every agent's commits go through the same commit guard, here as a git pre-commit hook, and the driver re-runs the gate after Codex and Copilot sessions. See Agents for what each one limits and reports.
From any terminal, whichever agent runs the loop:
bash ~/.claude/skills/bussin-autoloop/scripts/loop-status.sh --watch
It shows every loop in the repository and its worktrees: progress, ETA, each running item's step (Orient, Implement, Gate, Review, Browser, Commit) and its latest log lines, what runs next, cost, tokens, models, and how many loop sessions this Mac runs. Follow one item's full log with tail -f .loop/logs/<item>.log. loop-status.sh --title prints a one-line title such as [loop] telu-fyi · 2 loops · 11/19 · 3/23; a terminal running loop.sh shows it as its window title, and in the Claude desktop app Claude renames the session to it when it starts or checks loops. Give a loop a better name with --rename "Landing page".
Run as many loops as you like, in different projects or worktrees: they share one machine-wide limit on agent sessions (half the CPU cores or (RAM − 4 GB) ÷ 2 GB, whichever is lower; LOOP_MACHINE_SESSIONS overrides it). A loop that would go over the limit waits as queued instead of slowing everything down.
To stop a loop without losing in-flight work, run bash ~/.claude/skills/bussin-autoloop/scripts/loop-status.sh --stop. Running sessions finish and parallel workers merge, then the loop exits. Run loop.sh again to resume.
Inside Claude Code, the bussin-loop-status mod puts every running loop in a band above the prompt, one row per loop, including loops in git worktrees. The status line stays empty while loops run and shows a loop's outcome once none is running. /autoloop-status, or a row's Details button, opens a pane. The band rows and the pane each have a Stop button that works like --stop. The pane shows:
claude plugin marketplace add nksrentas/skills
claude plugin install bussin-loop-status@bussin
See bussin-autoloop for modes, gates and running unattended safely.
CI runs every tests/*.test.sh with the system bash on ubuntu-latest and macos-latest, on pull requests and on pushes to main. Run them locally with:
for t in tests/*.test.sh; do bash "$t" || exit 1; done
The evals/ folder holds trigger cases for claude plugin eval: one per skill, plus negative cases where no bussin skill may fire. Run the suite from the repo root:
claude plugin eval . --runs 1 --ablation none --no-publish --trust-plugin
Evals run real Claude sessions on your own Claude credentials and cost money, roughly $0.06-0.09 per case.
The portable behavior lives in each skill's SKILL.md, with optional references/, scripts/, and assets/. Agent-specific metadata such as agents/openai.yaml enhances supported clients without changing the shared skill instructions.
hooks/register.tsx 608 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ItemState, LogLine, LoopSnapshot, LoopView } from '../types'
5import { currentStep, parseLog, SAFE_ITEM, stepStrip } from './session-log'
6import {
7 baseName,
8 commonDirFromGitFile,
9 compactLine,
10 compute,
11 customName,
12 distinctLabels,
13 formatClock,
14 formatCost,
15 formatDuration,
16 formatTokens,
17 isActive,
18 isDriverCommand,
19 isWorkerHead,
20 livePid,
21 loopLabel,
22 loopTask,
23 orderLoops,
24 parentDir,
25 progressBar,
26 resumeCommand,
27 shortModel,
28 shortName,
29 shortTitle,
30 statusLine,
31} from './snapshot'
32
33const REFRESH_MS = 5000
34const LOG_ROWS = 6
35const MAX_LOG_READ = 256 * 1024
36
37export const PANE = 'autoloop-status'
38
39export const loopsAtom = atom({ plugin: 'bussin-loop-status', key: 'loops' } as const, [] as LoopView[])
40export const selectedAtom = atom({ plugin: 'bussin-loop-status', key: 'selected' } as const, null as string | null)
41export const renamingAtom = atom({ plugin: 'bussin-loop-status', key: 'renaming' } as const, null as string | null)
42export const showTokensAtom = atom({ plugin: 'bussin-loop-status', key: 'showTokens' } as const, false)
43
44const STATE_COLOR: Record<ItemState, string> = { done: 'success', running: 'claude', waiting: 'subtle', failing: 'error', ready: 'text' }
45const LOOP_COLOR: Record<LoopSnapshot['loopState'], string> = {
46 running: 'claude',
47 stopping: 'warning',
48 queued: 'warning',
49 finished: 'success',
50 stopped: 'subtle',
51 idle: 'subtle',
52}
53const KIND_COLOR: Record<string, string> = { Error: 'error', Done: 'success', Edit: 'claude', Bash: 'text', Say: 'subtle' }
54
55function costSummary(snapshot: LoopSnapshot) {
56 const priced = snapshot.sessions - snapshot.unpricedSessions
57 if (snapshot.sessions === 0) return 'no sessions recorded yet'
58 if (priced === 0) return `unknown · ${snapshot.sessions} sessions`
59 const base = `${formatCost(snapshot.costUsd)} · ${priced} priced sessions · ${formatCost(snapshot.costUsd / priced)} each`
60 return snapshot.unpricedSessions > 0 ? `${base} · ${snapshot.unpricedSessions} sessions with unknown cost` : base
61}
62
63function loopIcon(snapshot: LoopSnapshot) {
64 if (snapshot.stale) return '⚠'
65 return { running: '●', stopping: '◌', queued: '◌', finished: '✓', stopped: '■', idle: '·' }[snapshot.loopState]
66}
67
68function stateWords(snapshot: LoopSnapshot) {
69 if (snapshot.stale) return 'driver not running'
70 if (snapshot.loopState === 'queued') return 'queued: this Mac is full'
71 return snapshot.loopState
72}
73
74function canStop(snapshot: LoopSnapshot) {
75 return (snapshot.loopState === 'running' || snapshot.loopState === 'queued') && !snapshot.stale
76}
77
78function primaryLoop(loops: LoopView[], selected: string | null) {
79 return loops.find(loop => loop.path === selected) ?? loops[0]
80}
81
82function runningLoops(loops: LoopView[]) {
83 return loops.filter(loop => isActive(loop.snapshot.loopState))
84}
85
86function summaryText(loops: LoopView[]) {
87 const primary = loops[0]
88 if (primary === undefined) return undefined
89 if (loops.length === 1) return statusLine(primary.snapshot)
90 const running = runningLoops(loops)
91 if (running.length === 0) return `${primary.label}: ${statusLine(primary.snapshot)}`
92 return `autoloop ${running.map(loop => compactLine(loop.label, loop.snapshot)).join(' │ ')}`
93}
94
95function statusText(loops: LoopView[]) {
96 return runningLoops(loops).length > 0 ? undefined : summaryText(loops)
97}
98
99function machineText(loops: LoopView[]) {
100 const machine = runningLoops(loops).find(loop => loop.snapshot.machine !== null)?.snapshot.machine
101 if (machine === undefined || machine === null) return null
102 const room = machine.sessions - machine.inUse
103 return `This Mac: ${machine.inUse} of ${machine.sessions} loop sessions in use · ${room > 0 ? `room for ${room} more` : 'full, new sessions wait for a free one'}`
104}
105
106function workingOn(view: LoopView) {
107 return view.snapshot.runningRows
108 .map(row => {
109 const lines = view.logs[row.item] ?? []
110 return lines.length > 0 ? `${row.item} ${currentStep(lines)}` : row.item
111 })
112 .join(', ')
113}
114
115async function readOptional($: EngineInterface, path: string) {
116 try {
117 return await $.fs.read(path)
118 } catch {
119 return undefined
120 }
121}
122
123async function gitCommonDir($: EngineInterface, root: string) {
124 try {
125 const git = await $.fs.stat(`${root}/.git`)
126 if (git.kind === 'dir') return `${root}/.git`
127 const pointer = await $.fs.read(`${root}/.git`)
128 return typeof pointer === 'string' ? commonDirFromGitFile(pointer) : undefined
129 } catch {
130 return undefined
131 }
132}
133
134async function loopDirs($: EngineInterface) {
135 const root = await $.session.root()
136 const dirs = [root]
137 const common = await gitCommonDir($, root)
138 const repoName = common !== undefined && common.endsWith('/.git') ? baseName(parentDir(common)) : baseName(root)
139 if (common !== undefined) {
140 if (common.endsWith('/.git')) dirs.push(parentDir(common))
141 try {
142 for (const entry of await $.fs.list(`${common}/worktrees`)) {
143 if (isWorkerHead(await readOptional($, `${common}/worktrees/${entry.name}/HEAD`))) continue
144 const gitdir = await readOptional($, `${common}/worktrees/${entry.name}/gitdir`)
145 if (typeof gitdir === 'string' && gitdir.trim() !== '') dirs.push(parentDir(gitdir.trim()))
146 }
147 } catch {
148 return { dirs: [...new Set(dirs)], repoName }
149 }
150 }
151 return { dirs: [...new Set(dirs)], repoName }
152}
153
154async function driverAlive($: EngineInterface, pid: number | undefined) {
155 if (pid === undefined) return undefined
156 try {
157 const { exitCode, stdout } = await $.process.run(['ps', '-p', String(pid), '-o', 'command='], { timeoutMs: 5000 })
158 return exitCode === 0 && isDriverCommand(stdout)
159 } catch {
160 return undefined
161 }
162}
163
164async function readLogTail($: EngineInterface, path: string) {
165 try {
166 const info = await $.fs.stat(path)
167 if (info.size <= MAX_LOG_READ) return await $.fs.read(path)
168 const { exitCode, stdout } = await $.process.run(['tail', '-n', '40', path], { timeoutMs: 5000 })
169 return exitCode === 0 ? stdout : undefined
170 } catch {
171 return undefined
172 }
173}
174
175async function readLogs($: EngineInterface, dir: string, snapshot: LoopSnapshot) {
176 const logs: Record<string, LogLine[]> = {}
177 for (const row of snapshot.runningRows) {
178 if (!SAFE_ITEM.test(row.item)) continue
179 const text = await readLogTail($, `${dir}/.loop/logs/${row.item}.log`)
180 if (typeof text === 'string') logs[row.item] = parseLog(text)
181 }
182 return logs
183}
184
185async function loadLoop($: EngineInterface, dir: string, repoName: string, now: number): Promise<LoopView | null> {
186 const features = await readOptional($, `${dir}/.loop/feature_list.json`)
187 if (typeof features !== 'string') return null
188 const live = await readOptional($, `${dir}/.loop/live.json`)
189 const snapshot = compute({
190 features,
191 usage: await readOptional($, `${dir}/.loop/usage.jsonl`),
192 live,
193 attempts: await readOptional($, `${dir}/.loop/attempts.json`),
194 stopRequested: (await readOptional($, `${dir}/.loop/stop`)) !== undefined,
195 driverAlive: await driverAlive($, livePid(live)),
196 now,
197 })
198 if (snapshot === null) return null
199 const name = shortName(baseName(dir), repoName)
200 const config = await readOptional($, `${dir}/.loop/config.json`)
201 return {
202 name,
203 label: loopLabel(config, name, await readOptional($, `${dir}/.loop/name`)),
204 task: loopTask(config),
205 path: dir,
206 snapshot,
207 logs: await readLogs($, dir, snapshot),
208 }
209}
210
211async function loadLoops($: EngineInterface) {
212 const now = Math.floor((await $.clock.now()) / 1000)
213 const views: LoopView[] = []
214 const { dirs, repoName } = await loopDirs($)
215 for (const dir of dirs) {
216 const view = await loadLoop($, dir, repoName, now)
217 if (view !== null) views.push(view)
218 }
219 return orderLoops(distinctLabels(views))
220}
221
222async function refresh($: EngineInterface) {
223 const loops = await loadLoops($)
224 await update($, loopsAtom, () => loops)
225 $.ui.status(statusText(loops))
226 return loops
227}
228
229async function requestStop($: EngineInterface, loop: LoopView) {
230 try {
231 await $.fs.write(`${loop.path}/.loop/stop`, '')
232 } catch (error) {
233 $.ui.toast(`Could not stop ${loop.label}: ${error instanceof Error ? error.message : String(error)}`)
234 return
235 }
236 $.ui.toast(`${loop.label} stops after its running items finish`)
237 await refresh($)
238}
239
240async function rename($: EngineInterface, loop: LoopView, value: string) {
241 const name = customName(value)
242 await update($, renamingAtom, () => null)
243 if (name.length === 0) return
244 try {
245 await $.fs.write(`${loop.path}/.loop/name`, `${name}\n`)
246 } catch (error) {
247 $.ui.toast(`Could not rename ${loop.label}: ${error instanceof Error ? error.message : String(error)}`)
248 return
249 }
250 $.ui.toast(`Renamed to ${name}`)
251 await refresh($)
252}
253
254function openPane($: EngineInterface) {
255 return $.ui.open({ id: PANE, title: 'Autoloop status' })
256}
257
258export const register: Register = on => {
259 on('session.start', async ($, e, next) => {
260 await $.command.register({ name: 'autoloop-status', description: 'Show the bussin-autoloop runs in this repository and its worktrees' })
261 const loops = await refresh($)
262 $.clock.every(REFRESH_MS, async () => {
263 await refresh($)
264 })
265 if (runningLoops(loops).length > 0) void openPane($)
266 return next(e)
267 })
268
269 on('command.run', { command: 'autoloop-status' }, async $ => {
270 const loops = await refresh($)
271 if (loops.length === 0) return { text: 'No .loop/feature_list.json in this repository or its worktrees. Start one with /bussin-autoloop init.' }
272 await openPane($)
273 return { text: summaryText(loops) ?? '' }
274 })
275
276 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
277 const running = runningLoops(await read($, loopsAtom))
278 if (e.props.hasSurvey || running.length === 0) return next(e)
279 const { Box, Button, Text } = $.ui.resolve(e)
280 const room = Math.max(1, e.props.maxRows)
281 if (running.length > room && room === 1) {
282 return (
283 <Text color="claude" wrap="truncate-end">
284 autoloop {running.map(loop => compactLine(loop.label, loop.snapshot)).join(' │ ')}
285 </Text>
286 )
287 }
288 const shown = running.length > room ? running.slice(0, room - 1) : running
289 return (
290 <Box flexDirection="column">
291 {shown.map(loop => {
292 const snapshot = loop.snapshot
293 const busy = workingOn(loop)
294 const eta =
295 snapshot.loopState !== 'running' || snapshot.stale ? '' : snapshot.etaSec === null ? ' · ETA after first item' : ` · ETA ${formatDuration(snapshot.etaSec)}`
296 return (
297 <Box key={`band-${loop.path}`} flexDirection="row" gap={1}>
298 <Text color={snapshot.stale ? 'warning' : LOOP_COLOR[snapshot.loopState]} wrap="truncate-end">
299 {loopIcon(snapshot)} {loop.label}
300 {snapshot.stale ? ' driver not running' : snapshot.loopState === 'running' ? '' : ` ${stateWords(snapshot)}`} {progressBar(snapshot.done, snapshot.total, 10)}{' '}
301 {snapshot.done}/{snapshot.total}
302 {busy !== '' ? ` · ${busy}` : ''}
303 {eta}
304 {snapshot.sessions > snapshot.unpricedSessions ? ` · ${formatCost(snapshot.costUsd)}` : ''}
305 </Text>
306 <Button
307 key={`details-${loop.name}`}
308 label="Details"
309 onPress={async () => {
310 await update($, selectedAtom, () => loop.path)
311 await openPane($)
312 }}
313 />
314 {canStop(snapshot) && <Button key={`stop-${loop.name}`} label="Stop" onPress={() => requestStop($, loop)} />}
315 </Box>
316 )
317 })}
318 {running.length > shown.length && <Text dimColor>+{running.length - shown.length} more running · /autoloop-status</Text>}
319 </Box>
320 )
321 })
322
323 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
324 const elements = $.ui.resolve(e)
325 const { Box, Button, Text } = elements
326 const Input = 'Input' in elements ? elements.Input : undefined
327 const loops = await read($, loopsAtom)
328 const view = primaryLoop(loops, await read($, selectedAtom))
329 if (view === undefined) {
330 return (
331 <Box flexDirection="column">
332 <Text dimColor>No autoloop in this repository or its worktrees.</Text>
333 <Text dimColor>Start one with /bussin-autoloop init.</Text>
334 </Box>
335 )
336 }
337 const snapshot = view.snapshot
338 const renaming = (await read($, renamingAtom)) === view.path
339 const showTokens = await read($, showTokensAtom)
340
341 const label = (text: string) => (
342 <Box width={9}>
343 <Text bold>{text}</Text>
344 </Box>
345 )
346 const line = (key: string, heading: string, id: string, title: string, note: string, color: string) => (
347 <Box key={key} flexDirection="row">
348 {label(heading)}
349 <Box width={6}>
350 <Text color={color}>{id}</Text>
351 </Box>
352 <Box flexGrow={1} flexShrink={1}>
353 <Text color={color} wrap="truncate-end">
354 {title}
355 </Text>
356 </Box>
357 <Box flexShrink={0}>
358 <Text dimColor> {note}</Text>
359 </Box>
360 </Box>
361 )
362
363 const cards = loops.length > 1 && (
364 <Box flexDirection="column" width={28} flexShrink={0}>
365 {loops.map(loop => (
366 <Box
367 key={`card-${loop.name}`}
368 flexDirection="column"
369 borderStyle="round"
370 borderColor={loop.path === view.path ? 'claude' : 'subtle'}
371 paddingX={1}
372 >
373 <Button
374 key={`select-${loop.name}`}
375 plain
376 label={`${loopIcon(loop.snapshot)} ${loop.label}`}
377 onPress={() => update($, selectedAtom, () => loop.path)}
378 />
379 <Text color={loop.snapshot.stale ? 'warning' : LOOP_COLOR[loop.snapshot.loopState]}>
380 {progressBar(loop.snapshot.done, loop.snapshot.total, 12)} {loop.snapshot.done}/{loop.snapshot.total}
381 </Text>
382 <Text dimColor wrap="truncate-end">
383 {loop.snapshot.loopState === 'running' && !loop.snapshot.stale
384 ? loop.snapshot.etaSec === null
385 ? 'ETA after first item'
386 : `ETA ${formatDuration(loop.snapshot.etaSec)}`
387 : stateWords(loop.snapshot)}
388 </Text>
389 </Box>
390 ))}
391 </Box>
392 )
393
394 const color = snapshot.stale ? 'warning' : LOOP_COLOR[snapshot.loopState]
395 const mode = snapshot.mode === 'parallel' ? `parallel × ${snapshot.workers}` : 'sequential'
396 const eta =
397 snapshot.loopState !== 'running' || snapshot.stale
398 ? null
399 : snapshot.etaSec === null || snapshot.finishAt === null
400 ? 'ETA after the first item'
401 : `ETA ~${formatDuration(snapshot.etaSec)} (around ${formatClock(snapshot.finishAt)})`
402 const elapsed = snapshot.elapsedSec > 0 ? `elapsed ${formatDuration(snapshot.elapsedSec)}` : null
403 const spent = snapshot.sessions > snapshot.unpricedSessions ? `${formatCost(snapshot.costUsd)} so far` : null
404 const resume = resumeCommand(view.path, snapshot)
405 const runningIds = snapshot.runningRows.map(row => row.item).join(', ')
406 const open = snapshot.items.filter(row => row.state === 'failing' || row.state === 'ready')
407 const nextShown = open.slice(0, 4)
408
409 const header = (
410 <Box flexDirection="column">
411 <Box flexDirection="row" justifyContent="space-between" gap={1}>
412 <Text bold color={color} wrap="truncate-end">
413 {loopIcon(snapshot)} {view.label}
414 </Text>
415 <Box flexDirection="row" gap={1} flexShrink={0}>
416 <Button key="pane-rename" label="Rename" onPress={() => update($, renamingAtom, () => view.path)} />
417 {canStop(snapshot) && <Button key="pane-stop" label="Stop" onPress={() => requestStop($, view)} />}
418 </Box>
419 </Box>
420 {renaming && (
421 <Box flexDirection="row" gap={1}>
422 {Input !== undefined ? (
423 <Input key="rename-input" label="Loop name" value={view.label} submitLabel="Save" autoFocus onSubmit={(value: string) => rename($, view, value)} />
424 ) : (
425 <Text dimColor>Rename it from a terminal: loop-status.sh --rename "New name"</Text>
426 )}
427 <Button key="rename-cancel" label="Cancel" onPress={() => update($, renamingAtom, () => null)} />
428 </Box>
429 )}
430 {view.task !== '' && <Text dimColor>{view.task}</Text>}
431 <Text color={color}>
432 {stateWords(snapshot)} · {mode}
433 {snapshot.agent !== '' ? ` · ${snapshot.agent}` : ''}
434 {view.label !== view.name ? ` · ${view.name}` : ''}
435 </Text>
436 <Text>
437 {progressBar(snapshot.done, snapshot.total, 30)} {snapshot.done}/{snapshot.total}
438 </Text>
439 <Text dimColor>{[eta, elapsed, spent].filter(Boolean).join(' · ') || ' '}</Text>
440 </Box>
441 )
442
443 const stale = snapshot.stale && (
444 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
445 <Text color="warning" bold>
446 ⚠ The loop driver is not running
447 </Text>
448 <Text dimColor>
449 Its process has exited, so nothing is working on {runningIds || 'any item'}. Run loop.sh again to resume; attempt counts are kept.
450 </Text>
451 {resume !== null && <Text dimColor>{resume}</Text>}
452 {resume !== null && (
453 <Box flexDirection="row" gap={1}>
454 <Button
455 key="copy-resume"
456 label="Copy resume command"
457 onPress={async press => {
458 const copied = await $.ui.copy({ text: resume, surface: press.surface })
459 $.ui.toast(copied.isCopied ? 'Resume command copied' : `Could not copy: ${resume}`)
460 }}
461 />
462 </Box>
463 )}
464 </Box>
465 )
466
467 const now = snapshot.runningRows.length > 0 && (
468 <Box flexDirection="column">
469 <Text bold>Now</Text>
470 {snapshot.runningRows.map(row => {
471 const lines = view.logs[row.item] ?? []
472 return (
473 <Box key={`now-${row.item}`} flexDirection="column" borderStyle="round" borderColor={snapshot.stale ? 'warning' : 'subtle'} paddingX={1}>
474 <Box flexDirection="row" gap={1}>
475 <Text bold color={snapshot.stale ? 'warning' : 'claude'}>
476 {row.item}
477 </Text>
478 <Box flexGrow={1} flexShrink={1}>
479 <Text wrap="truncate-end">{shortTitle(row.title)}</Text>
480 </Box>
481 <Text dimColor>
482 {[row.worker > 0 ? `w${row.worker}` : '', formatDuration(row.seconds), row.attempt > 1 ? `attempt ${row.attempt}` : ''].filter(Boolean).join(' · ')}
483 </Text>
484 </Box>
485 {lines.length > 0 && (
486 <Box flexDirection="row" flexWrap="wrap" gap={1}>
487 {stepStrip(lines).map(step => (
488 <Text
489 key={`step-${row.item}-${step.name}`}
490 color={step.state === 'now' ? 'claude' : step.state === 'done' ? 'success' : 'subtle'}
491 bold={step.state === 'now'}
492 >
493 {step.state === 'done' ? '✓ ' : step.state === 'now' ? '› ' : ''}
494 {step.name}
495 </Text>
496 ))}
497 </Box>
498 )}
499 {lines.slice(-LOG_ROWS).map((entry, index) => (
500 <Box key={`log-${row.item}-${index}`} flexDirection="row" gap={1}>
501 <Text dimColor>{entry.time}</Text>
502 <Box width={6} flexShrink={0}>
503 <Text color={KIND_COLOR[entry.kind] ?? 'subtle'}>{entry.kind}</Text>
504 </Box>
505 <Box flexGrow={1} flexShrink={1}>
506 <Text wrap="truncate-end">{entry.text}</Text>
507 </Box>
508 </Box>
509 ))}
510 {lines.length === 0 && <Text dimColor>No session log yet; it appears once this loop runs on the latest bussin-autoloop.</Text>}
511 </Box>
512 )
513 })}
514 </Box>
515 )
516
517 const next = (nextShown.length > 0 || snapshot.blockedGroups.length > 0) && (
518 <Box flexDirection="column">
519 {nextShown.map((row, index) => line(`next-${row.id}`, index === 0 ? 'Up next' : '', row.id, shortTitle(row.title), row.note, STATE_COLOR[row.state]))}
520 {open.length > nextShown.length && (
521 <Box key="next-more" flexDirection="row">
522 {label('')}
523 <Text dimColor>… {open.length - nextShown.length} more ready</Text>
524 </Box>
525 )}
526 {snapshot.blockedGroups.map((group, index) => (
527 <Box key={`blocked-${group.waitingFor}`} flexDirection="row">
528 {label(index === 0 && nextShown.length === 0 ? 'Up next' : '')}
529 <Box flexGrow={1} flexShrink={1}>
530 <Text dimColor wrap="truncate-end">
531 {group.items.length} wait for {group.waitingFor}: {group.items.slice(0, 6).join(' ')}
532 {group.items.length > 6 ? ' …' : ''}
533 </Text>
534 </Box>
535 </Box>
536 ))}
537 </Box>
538 )
539
540 const recent = snapshot.recent.length > 0 && (
541 <Box flexDirection="column">
542 {snapshot.recent.map((session, index) => (
543 <Box key={`recent-${index}`} flexDirection="row">
544 {label(index === 0 ? 'Recent' : '')}
545 <Box width={2}>
546 <Text color={session.ok === true ? 'success' : session.ok === false ? 'error' : 'subtle'}>{session.ok === true ? '✓' : session.ok === false ? '✗' : '·'}</Text>
547 </Box>
548 <Box flexGrow={1} flexShrink={1}>
549 <Text wrap="truncate-end">
550 {session.item} {session.outcome}
551 </Text>
552 </Box>
553 <Text dimColor>
554 {' '}
555 {[session.seconds > 0 ? formatDuration(session.seconds) : '', session.costUsd === null ? '' : formatCost(session.costUsd), session.endedAt === null ? '' : formatClock(session.endedAt)]
556 .filter(Boolean)
557 .join(' · ')}
558 </Text>
559 </Box>
560 ))}
561 </Box>
562 )
563
564 const spend = (
565 <Box flexDirection="column">
566 <Box flexDirection="row" gap={1}>
567 {label('Spend')}
568 <Box flexGrow={1} flexShrink={1}>
569 <Text>
570 {[costSummary(snapshot), snapshot.models.map(model => `${shortModel(model.name)} ${model.priced ? formatCost(model.costUsd) : 'unpriced'}`).join(' · ')]
571 .filter(Boolean)
572 .join(' · ')}
573 </Text>
574 </Box>
575 {snapshot.sessions > 0 && <Button key="show-tokens" label={showTokens ? 'Hide tokens' : 'Tokens'} onPress={() => update($, showTokensAtom, shown => !shown)} />}
576 </Box>
577 {showTokens && (
578 <Box flexDirection="row" gap={1}>
579 {label('')}
580 <Text>
581 out {formatTokens(snapshot.outputTokens)} · in {formatTokens(snapshot.inputTokens)} · cache read {formatTokens(snapshot.cacheReadTokens)} · cache write{' '}
582 {formatTokens(snapshot.cacheWriteTokens)}
583 </Text>
584 </Box>
585 )}
586 </Box>
587 )
588
589 const machine = machineText(loops)
590 return (
591 <Box flexDirection="column" gap={1}>
592 <Box flexDirection="row" gap={1}>
593 {cards}
594 <Box flexDirection="column" flexGrow={1} flexShrink={1} gap={1}>
595 {header}
596 {stale}
597 {now}
598 {next}
599 {recent}
600 {spend}
601 </Box>
602 </Box>
603 {machine !== null && <Text dimColor>{machine}</Text>}
604 </Box>
605 )
606 })
607}
608hooks/session-log.ts 30 lines1import type { LogLine } from '../types'
2import { clean } from './snapshot'
3
4export const STEPS = ['orient', 'implement', 'gate', 'review', 'browser', 'commit'] as const
5
6export type StepState = { name: string; state: 'done' | 'now' | 'todo' }
7
8export const SAFE_ITEM = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
9
10export const parseLog = (text: string | undefined, keep = 40): LogLine[] =>
11 (text ?? '')
12 .split('\n')
13 .filter(line => line.trim().length > 0)
14 .slice(-keep)
15 .map(line => {
16 const [time = '', step = '-', kind = '', ...rest] = line.split('\t')
17 return { time: clean(time), step: clean(step), kind: clean(kind), text: clean(rest.join(' ')) }
18 })
19
20const isStep = (step: string) => (STEPS as readonly string[]).includes(step)
21
22export const currentStep = (lines: LogLine[]) => [...lines].reverse().find(line => isStep(line.step))?.step ?? 'orient'
23
24export const stepStrip = (lines: LogLine[]): StepState[] => {
25 const now = currentStep(lines)
26 const seen = new Set(lines.map(line => line.step).filter(isStep))
27 if (now !== 'orient') seen.add('orient')
28 return STEPS.map(name => ({ name: name[0]?.toUpperCase() + name.slice(1), state: name === now ? 'now' : seen.has(name) ? 'done' : 'todo' }))
29}
30hooks/snapshot.ts 410 lines1import type { BlockedGroup, ItemRow, LoopSnapshot, ModelRow, RunningRow, SessionRow } from '../types'
2
3type Feature = { id: string; description?: string; passes?: boolean; depends_on?: string[] }
4
5type Usage = {
6 item: string
7 outcome: string
8 duration_ms?: number
9 cost_usd?: number
10 input_tokens?: number
11 output_tokens?: number
12 cache_read_tokens?: number
13 cache_write_tokens?: number
14 started_at?: number
15 ended_at?: number
16 models?: Record<string, { input_tokens?: number; output_tokens?: number; cost_usd?: number }>
17}
18
19type Live = {
20 state?: string
21 mode?: string
22 agent?: string
23 script?: string
24 pid?: number
25 workers?: number
26 started_at?: number
27 updated_at?: number
28 running?: { item: string; worker: number; started_at?: number }[]
29 machine?: { sessions: number; inUse: number }
30}
31
32export type LoopFiles = {
33 features: string
34 usage?: string
35 live?: string
36 attempts?: string
37 stopRequested?: boolean
38 driverAlive?: boolean
39 now: number
40}
41
42const parseJson = <T>(text: string | undefined): T | undefined => {
43 if (!text) return undefined
44 try {
45 return JSON.parse(text) as T
46 } catch {
47 return undefined
48 }
49}
50
51const SILENT_DRIVER_SEC = 15 * 60
52
53const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g
54
55export const clean = (text: unknown) => String(text ?? '').replace(CONTROL_CHARACTERS, '')
56
57const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
58
59const optionalNum = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
60
61const isRecord = (value: unknown): value is Record<string, unknown> =>
62 typeof value === 'object' && value !== null && !Array.isArray(value)
63
64const toUsage = (row: Record<string, unknown>): Usage => ({
65 item: clean(row.item),
66 outcome: clean(row.outcome),
67 duration_ms: num(row.duration_ms),
68 cost_usd: optionalNum(row.cost_usd),
69 input_tokens: num(row.input_tokens),
70 output_tokens: num(row.output_tokens),
71 cache_read_tokens: num(row.cache_read_tokens),
72 cache_write_tokens: num(row.cache_write_tokens),
73 started_at: optionalNum(row.started_at),
74 ended_at: optionalNum(row.ended_at),
75 models: Object.fromEntries(
76 Object.entries(isRecord(row.models) ? row.models : {}).map(([name, model]) => {
77 const fields = isRecord(model) ? model : {}
78 return [clean(name), { input_tokens: num(fields.input_tokens), output_tokens: num(fields.output_tokens), cost_usd: num(fields.cost_usd) }]
79 }),
80 ),
81})
82
83const toLive = (value: unknown): Live | undefined => {
84 if (!isRecord(value)) return undefined
85 return {
86 state: clean(value.state),
87 mode: clean(value.mode ?? 'sequential'),
88 agent: clean(value.agent ?? ''),
89 script: typeof value.script === 'string' ? clean(value.script) : undefined,
90 pid: optionalNum(value.pid),
91 workers: Math.max(1, Math.floor(num(value.workers) || 1)),
92 started_at: optionalNum(value.started_at),
93 updated_at: optionalNum(value.updated_at),
94 running: (Array.isArray(value.running) ? value.running : [])
95 .filter(isRecord)
96 .map(row => ({ item: clean(row.item), worker: Math.floor(num(row.worker)), started_at: optionalNum(row.started_at) })),
97 machine: isRecord(value.machine)
98 ? { sessions: Math.max(1, Math.floor(num(value.machine.sessions) || 1)), inUse: Math.max(0, Math.floor(num(value.machine.in_use))) }
99 : undefined,
100 }
101}
102
103const toFeature = (value: Record<string, unknown>): Feature => ({
104 id: clean(value.id),
105 description: clean(value.description),
106 passes: value.passes === true,
107 depends_on: (Array.isArray(value.depends_on) ? value.depends_on : []).map(clean),
108})
109
110const parseLines = (text: string | undefined): Usage[] =>
111 (text ?? '')
112 .split('\n')
113 .map(line => parseJson<unknown>(line.trim()))
114 .filter(isRecord)
115 .filter(row => typeof row.item === 'string')
116 .map(toUsage)
117
118const sum = (rows: Usage[], pick: (row: Usage) => number | undefined) =>
119 rows.reduce((total, row) => total + (pick(row) ?? 0), 0)
120
121const title = (feature: Feature | undefined) => feature?.description ?? ''
122
123const loopStateOf = (live: Live | undefined, done: number, total: number, stopRequested: boolean): LoopSnapshot['loopState'] => {
124 if (live?.state === 'running' && stopRequested) return 'stopping'
125 if (live?.state === 'running' || live?.state === 'stopping' || live?.state === 'queued' || live?.state === 'finished' || live?.state === 'stopped') return live.state
126 return done === total && total > 0 ? 'finished' : 'idle'
127}
128
129export const isActive = (loopState: LoopSnapshot['loopState']) => loopState === 'running' || loopState === 'stopping' || loopState === 'queued'
130
131const modelRows = (usage: Usage[]): ModelRow[] => {
132 const byName = new Map<string, ModelRow>()
133 for (const row of usage) {
134 for (const [name, model] of Object.entries(row.models ?? {})) {
135 const label = name
136 const seen = byName.get(label) ?? { name: label, costUsd: 0, priced: false, inputTokens: 0, outputTokens: 0 }
137 const priced = row.cost_usd !== undefined
138 byName.set(label, {
139 name: label,
140 costUsd: seen.costUsd + (priced ? (model.cost_usd ?? 0) : 0),
141 priced: seen.priced || priced,
142 inputTokens: seen.inputTokens + (model.input_tokens ?? 0),
143 outputTokens: seen.outputTokens + (model.output_tokens ?? 0),
144 })
145 }
146 }
147 return [...byName.values()].sort((a, b) => b.costUsd - a.costUsd)
148}
149
150const spend = (sessions: Usage[]) => {
151 const priced = sessions.filter(row => row.cost_usd !== undefined)
152 const minutes = Math.round(sum(sessions, row => row.duration_ms) / 60000)
153 return [
154 priced.length > 0 ? formatCost(sum(priced, row => row.cost_usd)) : sessions.length > 0 ? 'cost unknown' : '',
155 minutes > 0 ? `${minutes}m` : '',
156 ].filter(Boolean)
157}
158
159const itemRow = (feature: Feature, passed: Set<string>, running: Map<string, number>, usage: Usage[]): ItemRow => {
160 const sessions = usage.filter(row => row.item === feature.id)
161 if (feature.passes) {
162 return { id: feature.id, title: title(feature), state: 'done', note: spend(sessions).join(' · ') || 'done' }
163 }
164 const worker = running.get(feature.id)
165 if (worker !== undefined) {
166 return { id: feature.id, title: title(feature), state: 'running', note: worker > 0 ? `worker ${worker}` : 'now' }
167 }
168 if (sessions.length > 0) {
169 const attempts = sessions.length === 1 ? '1 attempt' : `${sessions.length} attempts`
170 return { id: feature.id, title: title(feature), state: 'failing', note: [attempts, ...spend(sessions)].join(' · ') }
171 }
172 const waitingFor = (feature.depends_on ?? []).filter(id => !passed.has(id))
173 if (waitingFor.length > 0) {
174 return { id: feature.id, title: title(feature), state: 'waiting', note: `waiting for ${waitingFor.join(', ')}`, waitingFor }
175 }
176 return { id: feature.id, title: title(feature), state: 'ready', note: 'ready' }
177}
178
179const OUTCOME_OK: Record<string, boolean> = { passed: true, merged: true, failed: false, error: false, 'not merged': false }
180
181const sessionRows = (usage: Usage[]): SessionRow[] =>
182 usage
183 .slice(-3)
184 .reverse()
185 .map(row => ({
186 item: row.item,
187 outcome: row.outcome,
188 ok: OUTCOME_OK[row.outcome] ?? null,
189 seconds: Math.round((row.duration_ms ?? 0) / 1000),
190 costUsd: row.cost_usd ?? null,
191 endedAt: row.ended_at ?? null,
192 }))
193
194const blockedGroups = (items: ItemRow[]): BlockedGroup[] => {
195 const groups = new Map<string, string[]>()
196 for (const row of items) {
197 if (row.state !== 'waiting') continue
198 const key = (row.waitingFor ?? []).join(', ')
199 groups.set(key, [...(groups.get(key) ?? []), row.id])
200 }
201 return [...groups.entries()].map(([waitingFor, ids]) => ({ waitingFor, items: ids })).sort((a, b) => b.items.length - a.items.length)
202}
203
204const attemptCounts = (text: string | undefined) => {
205 const parsed = parseJson<unknown>(text)
206 return isRecord(parsed) ? parsed : {}
207}
208
209export const compute = (files: LoopFiles): LoopSnapshot | null => {
210 const parsed = parseJson<unknown>(files.features)
211 const list = isRecord(parsed) ? parsed.features : undefined
212 if (!Array.isArray(list)) return null
213 const features = list.filter(isRecord).map(toFeature)
214 const usage = parseLines(files.usage)
215 const live = toLive(parseJson<unknown>(files.live))
216 const passed = new Set(features.filter(feature => feature.passes).map(feature => feature.id))
217 const runningList = live?.state === 'running' || live?.state === 'stopping' ? (live.running ?? []) : []
218 const running = new Map(runningList.map(row => [row.item, row.worker]))
219 const items = features.map(feature => itemRow(feature, passed, running, usage))
220 const total = features.length
221 const done = passed.size
222 const loopState = loopStateOf(live, done, total, files.stopRequested === true)
223 const mode = live?.mode ?? 'sequential'
224 const workers = live?.workers ?? 1
225 const silent = files.driverAlive === undefined && runningList.length > 0 && files.now - (live?.updated_at ?? files.now) > SILENT_DRIVER_SEC
226 const stale = isActive(loopState) && (files.driverAlive === false || silent)
227 const firstStart = live?.started_at ?? usage[0]?.started_at
228 const lastSeen = isActive(loopState) && !stale ? files.now : (live?.updated_at ?? usage[usage.length - 1]?.ended_at ?? files.now)
229 const elapsedSec = firstStart === undefined ? 0 : Math.max(0, lastSeen - firstStart)
230 const remaining = total - done
231 const sessionSec = sum(usage, row => row.duration_ms) / 1000
232 const parallelism = mode === 'parallel' ? Math.max(1, Math.min(workers, remaining)) : 1
233 const doneByLoop = new Set(usage.filter(row => passed.has(row.item)).map(row => row.item)).size
234 const attempts = attemptCounts(files.attempts)
235 const etaSec =
236 loopState === 'running' && !stale && doneByLoop > 0 && remaining > 0 ? Math.round((remaining * sessionSec) / doneByLoop / parallelism) : null
237 const runningRows: RunningRow[] = runningList.map(row => ({
238 item: row.item,
239 title: title(features.find(feature => feature.id === row.item)),
240 worker: row.worker,
241 seconds: Math.max(0, files.now - (row.started_at ?? files.now)),
242 attempt: Math.floor(num(attempts[row.item])),
243 }))
244 return {
245 loopState,
246 stale,
247 mode,
248 agent: live?.agent ?? '',
249 script: live?.script ?? null,
250 workers,
251 total,
252 done,
253 running: runningRows.length,
254 ready: items.filter(row => row.state === 'ready').length,
255 waiting: items.filter(row => row.state === 'waiting').length,
256 failing: items.filter(row => row.state === 'failing').length,
257 elapsedSec,
258 etaSec,
259 finishAt: etaSec === null ? null : files.now + etaSec,
260 sessions: usage.length,
261 unpricedSessions: usage.filter(row => row.cost_usd === undefined).length,
262 costUsd: sum(usage, row => row.cost_usd),
263 inputTokens: sum(usage, row => row.input_tokens),
264 outputTokens: sum(usage, row => row.output_tokens),
265 cacheReadTokens: sum(usage, row => row.cache_read_tokens),
266 cacheWriteTokens: sum(usage, row => row.cache_write_tokens),
267 models: modelRows(usage),
268 runningRows,
269 items,
270 blockedGroups: blockedGroups(items),
271 recent: sessionRows(usage),
272 machine: live?.machine ?? null,
273 }
274}
275
276export const formatDuration = (seconds: number) => {
277 const minutes = Math.floor(seconds / 60)
278 if (minutes < 1) return `${Math.max(0, Math.round(seconds))}s`
279 if (minutes < 60) return `${minutes}m`
280 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
281}
282
283export const formatTokens = (count: number) => {
284 if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`
285 if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`
286 return String(count)
287}
288
289export const formatCost = (usd: number) => `$${usd.toFixed(2)}`
290
291export const formatClock = (epochSec: number) => {
292 const at = new Date(epochSec * 1000)
293 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
294}
295
296export const progressBar = (done: number, total: number, width: number) => {
297 const filled = total === 0 ? 0 : Math.round((done / total) * width)
298 return '█'.repeat(filled) + '░'.repeat(width - filled)
299}
300
301const pricedCost = (snapshot: LoopSnapshot) =>
302 snapshot.sessions - snapshot.unpricedSessions > 0 ? formatCost(snapshot.costUsd) : undefined
303
304export const statusLine = (snapshot: LoopSnapshot) => {
305 const progress = `${snapshot.done}/${snapshot.total}`
306 const cost = pricedCost(snapshot)
307 const elapsed = snapshot.elapsedSec > 0 ? formatDuration(snapshot.elapsedSec) : undefined
308 const parts = (head: string, ...rest: (string | undefined)[]) => [head, ...rest].filter(Boolean).join(' · ')
309 if (snapshot.stale) return parts(`autoloop driver not running at ${progress}`, cost)
310 if (snapshot.loopState === 'running') {
311 const eta = snapshot.etaSec === null ? 'ETA after first item' : `ETA ${formatDuration(snapshot.etaSec)}`
312 return parts(`autoloop ${progress} done`, `${snapshot.running} running`, eta, cost)
313 }
314 if (snapshot.loopState === 'stopping') return parts(`autoloop stopping at ${progress}`, `${snapshot.running} running`, cost)
315 if (snapshot.loopState === 'queued') return parts(`autoloop queued at ${progress}`, 'waiting for a free session on this Mac', cost)
316 if (snapshot.loopState === 'finished') return parts(`autoloop finished ${progress}`, elapsed, cost)
317 if (snapshot.loopState === 'stopped') return parts(`autoloop stopped at ${progress}`, cost)
318 return parts(`autoloop ${progress} done`, 'not running')
319}
320
321export const parentDir = (path: string) => path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
322
323export const baseName = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
324
325export const commonDirFromGitFile = (text: string) => {
326 const match = /^gitdir:\s*(.+)$/m.exec(text)
327 return match?.[1] === undefined ? undefined : parentDir(parentDir(match[1].trim()))
328}
329
330const LOOP_ORDER: Record<LoopSnapshot['loopState'], number> = { running: 0, stopping: 0, queued: 0, stopped: 1, idle: 2, finished: 3 }
331
332export const orderLoops = <T extends { name: string; snapshot: LoopSnapshot }>(loops: T[]) =>
333 [...loops].sort((a, b) => LOOP_ORDER[a.snapshot.loopState] - LOOP_ORDER[b.snapshot.loopState] || a.name.localeCompare(b.name))
334
335const labelOf = (text: string) => {
336 const head = text.split(': ')[0] ?? text
337 const title = head.length > 0 && head.length <= 40 ? head : text
338 return title.length > 40 ? `${title.slice(0, 41).replace(/\s+\S*$/, '')}…` : title
339}
340
341export const customName = (text: string | undefined) => clean((text ?? '').split('\n')[0]).trim().slice(0, 60)
342
343export const loopTask = (config: string | undefined) => {
344 const parsed = parseJson<unknown>(config)
345 return isRecord(parsed) ? clean(parsed.task ?? '').trim() : ''
346}
347
348export const loopLabel = (config: string | undefined, folder: string, nameFile?: string) => {
349 const custom = customName(nameFile)
350 if (custom.length > 0) return custom
351 const parsed = parseJson<unknown>(config)
352 const fields = isRecord(parsed) ? parsed : {}
353 const named = clean(fields.name ?? '').trim()
354 if (named.length > 0) return labelOf(named)
355 const task = clean(fields.task ?? '').trim()
356 if (task.length > 0) return labelOf(task.split(/\.\s/)[0] ?? task)
357 return folder
358}
359
360export const distinctLabels = <T extends { name: string; label: string }>(loops: T[]) =>
361 loops.map(loop =>
362 loops.filter(other => other.label === loop.label).length > 1 ? { ...loop, label: `${loop.label} (${loop.name})` } : loop,
363 )
364
365export const shortName = (name: string, repoName: string | undefined) =>
366 repoName !== undefined && name !== repoName && name.startsWith(`${repoName}-`) ? name.slice(repoName.length + 1) : name
367
368export const compactLine = (name: string, snapshot: LoopSnapshot) => {
369 if (snapshot.stale) return [`⚠ ${name} ${snapshot.done}/${snapshot.total} driver not running`, pricedCost(snapshot)].filter(Boolean).join(' · ')
370 if (snapshot.loopState === 'stopping') return [`◌ ${name} ${snapshot.done}/${snapshot.total} stopping`, pricedCost(snapshot)].filter(Boolean).join(' · ')
371 if (snapshot.loopState === 'queued') return [`◌ ${name} ${snapshot.done}/${snapshot.total} queued`, pricedCost(snapshot)].filter(Boolean).join(' · ')
372 const eta = snapshot.etaSec === null ? 'ETA after first item' : `ETA ${formatDuration(snapshot.etaSec)}`
373 return [`● ${name} ${snapshot.done}/${snapshot.total}`, eta, pricedCost(snapshot)].filter(Boolean).join(' · ')
374}
375
376export const shortTitle = (text: string, max = 64) => {
377 const head = text.split(/:\s/)[0]?.trim() ?? text
378 const title = head.length > 0 && head.length <= max ? head : text
379 return title.length > max ? `${title.slice(0, max - 1).trimEnd()}…` : title
380}
381
382export const shortModel = (name: string) => name.replace(/^claude-/, '')
383
384const shellQuote = (text: string) => `'${text.replace(/'/g, `'\\''`)}'`
385
386const DRIVER_PATH = /^\/(?:[^/\0]+\/)*bussin-autoloop\/scripts\/loop\.sh$/
387
388const KNOWN_AGENTS = ['claude', 'codex', 'copilot']
389
390const trustedDriver = (path: string, script: string) =>
391 DRIVER_PATH.test(script) && !script.split('/').includes('..') && !`${script}/`.startsWith(`${path.replace(/\/+$/, '')}/`)
392
393export const resumeCommand = (path: string, snapshot: LoopSnapshot) => {
394 if (snapshot.script === null || !trustedDriver(path, snapshot.script) || !KNOWN_AGENTS.includes(snapshot.agent || 'claude')) return null
395 const env = [
396 snapshot.agent !== '' && snapshot.agent !== 'claude' ? `LOOP_AGENT=${snapshot.agent}` : '',
397 snapshot.mode === 'parallel' ? `LOOP_WORKERS=${snapshot.workers}` : '',
398 ].filter(Boolean)
399 return `cd ${shellQuote(path)} && ${[...env, 'bash', shellQuote(snapshot.script)].join(' ')}`
400}
401
402export const isWorkerHead = (head: string | undefined) => /^ref: refs\/heads\/loop\/[^/\s]+\/[^/\s]+\s*$/.test(head ?? '')
403
404export const isDriverCommand = (command: string) => /(^|[\s/])loop\.sh(\s|$)/.test(command.trim().split('\n')[0] ?? '')
405
406export const livePid = (text: string | undefined) => {
407 const live = toLive(parseJson<unknown>(text))
408 return (live?.state === 'running' || live?.state === 'stopping') && live.pid !== undefined && Number.isInteger(live.pid) && live.pid > 0 ? live.pid : undefined
409}
410types/index.d.ts 53 lines1export type ItemState = 'done' | 'running' | 'waiting' | 'failing' | 'ready'
2
3export type ItemRow = { id: string; title: string; state: ItemState; note: string; waitingFor?: string[] }
4
5export type BlockedGroup = { waitingFor: string; items: string[] }
6
7export type SessionRow = { item: string; outcome: string; ok: boolean | null; seconds: number; costUsd: number | null; endedAt: number | null }
8
9export type RunningRow = { item: string; title: string; worker: number; seconds: number; attempt: number }
10
11export type ModelRow = { name: string; costUsd: number; priced: boolean; inputTokens: number; outputTokens: number }
12
13export type LoopSnapshot = {
14 loopState: 'running' | 'stopping' | 'queued' | 'finished' | 'stopped' | 'idle'
15 stale: boolean
16 mode: string
17 agent: string
18 script: string | null
19 workers: number
20 total: number
21 done: number
22 running: number
23 ready: number
24 waiting: number
25 failing: number
26 elapsedSec: number
27 etaSec: number | null
28 finishAt: number | null
29 sessions: number
30 unpricedSessions: number
31 costUsd: number
32 inputTokens: number
33 outputTokens: number
34 cacheReadTokens: number
35 cacheWriteTokens: number
36 models: ModelRow[]
37 runningRows: RunningRow[]
38 items: ItemRow[]
39 blockedGroups: BlockedGroup[]
40 recent: SessionRow[]
41 machine: { sessions: number; inUse: number } | null
42}
43
44export type LogLine = { time: string; step: string; kind: string; text: string }
45
46export type LoopView = { name: string; label: string; task: string; path: string; snapshot: LoopSnapshot; logs: Record<string, LogLine[]> }
47
48declare module 'claude-code' {
49 interface PluginState {
50 'bussin-loop-status': { loops: LoopView[]; selected: string | null; renaming: string | null; showTokens: boolean }
51 }
52}
53