SLOPSHOPPER

bussin-loop-status

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…

panebandcommandtoaststatusprocess
v0.4.1no licenseupdated 2026-10-25nksrentas/skills/mods/bussin-loop-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bussin-loop-status
│ ┃ autoloop-status ✕ › fix the failing auth test and add an audit log call │ ┃ No autoloop in this repository or its │ ┃ worktrees. ⏺ Read(src/auth.ts) │ ┃ Start one with /bussin-autoloop init. ⎿ 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 │ │ › /autoloop-status │ ⎿ bussin-loop-status: No .loop/feature_list.json in this repositor │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · autoloop-status
No autoloop in this repository or its worktrees. Start one with /bussin-autoloop init.
README

<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>

Included skills

  • bussin-autoloop: Autonomous build loop. 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.
  • bussin-feature: The workflow for building a feature end to end: ground it in the repo, cover loading, error, empty and success states, update every dependent file, keep the folder portable, and verify.
  • bussin-lean: Lean code while writing it, then a mandatory cleanup pass on the diff: no leftovers from earlier implementations, nothing over-engineered, no wasteful allocation or data access.
  • bussin-ts-architecture: Feature-based folders for TypeScript backends and Next.js apps, where every feature folder is portable: it imports nothing from other features and works when copied into another project.
  • bussin-drizzle: Drizzle ORM data access with schemas per feature, drizzle-kit migrations, typed queries inside services, and tests against a real database.
  • bussin-react: React 19 and Next.js App Router rules: server components first, shadcn/ui owned in the shared kit, compound components, TanStack Query, forms on the feature's DTOs, and performance and security rules.
  • bussin-ui: House UI system for landing pages and dashboards in Next.js, Tailwind CSS v4, GSAP and Lenis.
  • bussin-a11y: Build and audit accessible UI to WCAG 2.2 AA, native HTML first and ARIA only where needed. Adapted from addyosmani/web-quality-skills (MIT).
  • bussin-seo: On-page and technical SEO plus Core Web Vitals, with Next.js App Router metadata, sitemap and robots rules. Adapted from addyosmani/web-quality-skills (MIT).
  • bussin-schema: Schema.org JSON-LD that stays eligible for Google rich results, with an up-to-date list of deprecated types. Adapted from AgriciDaniel/claude-seo (MIT).
  • bussin-honest-feedback: Direct assessments instead of agreeable ones: verdict first, a rating out of 10 against fixed anchors, weak assumptions named, "you're wrong" with the reason, and "I don't know" when that is the truth.
  • bussin-write-like-a-human: Check and revise drafts against the narrative choices that separate human from AI fiction, using measured rates from the StoryScope study of 61,608 stories.

Short aliases

Every skill has a short alias that takes the same arguments, and the slash menu shows those arguments as you type.

AliasSkillArguments
/autoloopbussin-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`
/featurebussin-feature<feature to build or change>
/leanbussin-lean[path, file or commit range to clean up]
/archbussin-ts-architecture[feature to place or wiring question]
/drizzlebussin-drizzle[table, column, migration or query task]
/reactbussin-react[component, hook, form or page to build or review]
/uibussin-ui[page, section or screen to build or restyle]
/honestbussin-honest-feedback<idea, plan, draft or decision to assess>
/humanizebussin-write-like-a-human<draft text or path to the draft>
/a11ybussin-a11y[page, component or URL to build or audit]
/seobussin-seo[page, route or URL]
/schemabussin-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.

Install or update, on any computer

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.

Other ways to install

As a Claude Code plugin

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.

With the skills CLI

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.

Ponytail for bussin-lean

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

Animation skills for bussin-ui

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

Quick start: an autonomous feature loop

/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.

Running several loops

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.

Watching a run

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:

  • a card per loop on the left, and the selected loop's task, progress and ETA
  • each running item's step strip and its last log lines, with worker and attempt
  • elapsed time and the estimated finish
  • Rename and Stop, and how many loop sessions this Mac runs
  • a warning, and a copyable resume command, when the driver process is gone
  • blocked items grouped by what they wait for, and the latest sessions
  • input, output and cache tokens
  • cost, per model and per session
claude plugin marketplace add nksrentas/skills
claude plugin install bussin-loop-status@bussin

See bussin-autoloop for modes, gates and running unattended safely.

Tests and evals

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.

Compatibility

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.

Source 4 files
hooks/register.tsx 608 lines
1import { 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}
608
hooks/session-log.ts 30 lines
1import 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}
30
hooks/snapshot.ts 410 lines
1import 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}
410
types/index.d.ts 53 lines
1export 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