Created
January 26, 2026 17:19
-
-
Save mlshv/2091472c8e9b036062dae288b967ba41 to your computer and use it in GitHub Desktop.
ralph loop on beads
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| const { spawn, execSync } = require('child_process') | |
| const args = process.argv.slice(2) | |
| const INTERACTIVE = args.includes('-i') | |
| const ISSUE_ID = args.find(a => a !== '-i') | |
| if (!ISSUE_ID) { | |
| console.log(`Usage: loop.cjs <beads-issue-id> [-i] | |
| Options: | |
| -i Interactive mode (full Claude UI instead of JSON stream) | |
| Example: | |
| node .claude/loop.cjs 01acc-1i7 | |
| node .claude/loop.cjs all | |
| node .claude/loop.cjs all -i | |
| `) | |
| process.exit(1) | |
| } | |
| const MAX_ITERATIONS = 30 | |
| // colors - using 24-bit true color to match Claude Code palette | |
| const orange = (t) => `\x1b[38;2;232;131;103m${t}\x1b[0m` // coral/salmon #E88367 | |
| const dim = (t) => `\x1b[2m${t}\x1b[0m` | |
| const cyan = (t) => `\x1b[38;2;130;170;200m${t}\x1b[0m` | |
| const green = (t) => `\x1b[38;2;108;184;108m${t}\x1b[0m` // muted sage green | |
| const yellow = (t) => `\x1b[38;2;220;180;100m${t}\x1b[0m` | |
| const red = (t) => `\x1b[38;2;220;100;100m${t}\x1b[0m` | |
| const bold = (t) => `\x1b[1m${t}\x1b[0m` | |
| const log = (msg) => console.log(`${orange('✦')} ${msg}`) | |
| const indent = (text, prefix = ' ') => text.split('\n').map(l => prefix + l).join('\n') | |
| // known properties to ignore at event level | |
| const IGNORED_EVENT_PROPS = ['session_id', 'uuid', 'parent_tool_use_id'] | |
| // known properties to ignore in message object | |
| const IGNORED_MESSAGE_PROPS = ['id', 'type', 'role', 'stop_reason', 'stop_sequence', 'context_management', 'container'] | |
| const hasUnknownKeys = (obj, knownKeys) => Object.keys(obj).some(k => !knownKeys.includes(k)) | |
| const formatToolCall = (name, input) => { | |
| if (!input) return name | |
| if (input.command) { | |
| const cmd = input.command.length > 60 ? input.command.slice(0, 60) + '…' : input.command | |
| return `${name}(${cmd})` | |
| } | |
| if (name === 'Write' && input.file_path) return `${name}(${input.file_path})` | |
| if (name === 'Edit' && input.file_path) return `${name}(${input.file_path})` | |
| if (name === 'Read' && input.file_path) return `${name}(${input.file_path})` | |
| if (input.todos) return `${name}(${input.todos.length} todos)` | |
| if (input.file_path) return `${name}(${input.file_path})` | |
| if (input.pattern) return `${name}(${input.pattern})` | |
| return name | |
| } | |
| let lastToolId = null | |
| let lastToolLine = null | |
| const processContentBlock = (block) => { | |
| if (block.type === 'text') { | |
| console.log('') | |
| console.log(`⏺ ${block.text}`) | |
| return true | |
| } | |
| if (block.type === 'tool_use') { | |
| lastToolId = block.id | |
| lastToolLine = formatToolCall(block.name, block.input) | |
| // don't print yet - wait for result to know success/failure | |
| return true | |
| } | |
| if (block.type === 'tool_result') return true | |
| return false | |
| } | |
| let authFailed = false | |
| const processAssistantEvent = (event) => { | |
| if (event.error) { | |
| console.log(`${yellow('⚠')} ${bold('Error:')} ${event.error}`) | |
| if (event.message?.content?.[0]?.text) { | |
| console.log(indent(dim(event.message.content[0].text), ' ')) | |
| } | |
| if (event.error === 'authentication_failed') authFailed = true | |
| return | |
| } | |
| const msg = event.message | |
| if (!msg) { | |
| console.log(yellow(`[assistant] no message:`) + '\n' + JSON.stringify(event, null, 2)) | |
| return | |
| } | |
| const eventKeys = Object.keys(event).filter(k => !IGNORED_EVENT_PROPS.includes(k)) | |
| const knownEventKeys = ['type', 'message', 'error'] | |
| if (hasUnknownKeys(Object.fromEntries(eventKeys.map(k => [k, event[k]])), knownEventKeys)) { | |
| const unknownKeys = eventKeys.filter(k => !knownEventKeys.includes(k)) | |
| console.log(yellow(`[assistant] unknown event props: ${unknownKeys.join(', ')}`)) | |
| console.log(dim(JSON.stringify(event, null, 2))) | |
| return | |
| } | |
| const msgKeys = Object.keys(msg).filter(k => !IGNORED_MESSAGE_PROPS.includes(k)) | |
| const knownMsgKeys = ['model', 'content', 'usage'] | |
| if (hasUnknownKeys(Object.fromEntries(msgKeys.map(k => [k, msg[k]])), knownMsgKeys)) { | |
| const unknownKeys = msgKeys.filter(k => !knownMsgKeys.includes(k)) | |
| console.log(yellow(`[assistant] unknown message props: ${unknownKeys.join(', ')}`)) | |
| console.log(dim(JSON.stringify(msg, null, 2))) | |
| return | |
| } | |
| const content = msg.content || [] | |
| for (const block of content) { | |
| if (!processContentBlock(block)) { | |
| console.log(yellow(`[assistant] unknown content type: ${block.type}`)) | |
| console.log(dim(JSON.stringify(block, null, 2))) | |
| } | |
| } | |
| } | |
| const processResultEvent = (event) => { | |
| const { total_cost_usd, cost_usd, duration_ms, num_turns, modelUsage } = event | |
| const cost = total_cost_usd ?? cost_usd | |
| const durationSec = duration_ms ? (duration_ms / 1000).toFixed(0) : null | |
| const parts = [] | |
| if (durationSec) { | |
| const mins = Math.floor(durationSec / 60) | |
| const secs = durationSec % 60 | |
| parts.push(mins > 0 ? `${mins}m ${secs}s` : `${secs}s`) | |
| } | |
| if (cost !== undefined) parts.push(`$${cost.toFixed(2)}`) | |
| if (num_turns !== undefined) parts.push(`${num_turns} turns`) | |
| console.log(`✻ Baked for ${parts.join(' · ')}`) | |
| if (modelUsage && Object.keys(modelUsage).length > 1) { | |
| for (const [model, data] of Object.entries(modelUsage)) { | |
| const shortModel = model.replace('claude-', '').replace(/-\d{8}$/, '') | |
| console.log(dim(` ${shortModel}: $${data.costUSD?.toFixed(2) || '?'}`)) | |
| } | |
| } | |
| const knownResultProps = [ | |
| 'type', 'subtype', 'is_error', 'session_id', 'uuid', | |
| 'cost_usd', 'total_cost_usd', 'total_tokens', 'duration_ms', 'duration_api_ms', 'num_turns', | |
| 'result', 'usage', 'modelUsage', 'permission_denials' | |
| ] | |
| if (hasUnknownKeys(event, knownResultProps)) { | |
| const unknownKeys = Object.keys(event).filter(k => !knownResultProps.includes(k)) | |
| console.log(yellow(`[result] unknown props: ${unknownKeys.join(', ')}`)) | |
| console.log(dim(JSON.stringify(event, null, 2))) | |
| } | |
| } | |
| const processSystemEvent = (event) => { | |
| const { subtype } = event | |
| if (subtype === 'init') { | |
| const { model, cwd, tools, mcp_servers, claude_code_version } = event | |
| const shortModel = model?.replace('claude-', '').replace(/-\d{8}$/, '') || 'unknown' | |
| const version = claude_code_version || '?' | |
| const shortCwd = cwd?.replace(process.env.HOME, '~') || '?' | |
| const mcpConnected = mcp_servers?.filter(s => s.status === 'connected').map(s => s.name) || [] | |
| console.log('') | |
| console.log(orange(' ▐▛███▜▌') + ` Claude Code v${version}`) | |
| console.log(orange('▝▜█████▛▘') + ` ${shortModel} · ${tools?.length || 0} tools`) | |
| console.log(orange(' ▘▘ ▝▝') + ` ${shortCwd}`) | |
| if (mcpConnected.length) console.log(dim(` mcp: ${mcpConnected.join(', ')}`)) | |
| console.log('') | |
| return | |
| } | |
| if (subtype === 'hook_started') { | |
| const { hook_name } = event | |
| console.log(`${dim('⟳')} ${dim(`hook: ${hook_name || 'unknown'}`)}`) | |
| return | |
| } | |
| if (subtype === 'hook_response') { | |
| // hook_started already printed the hook name, no need to print again on success | |
| const { hook_name, exit_code, stderr } = event | |
| if (exit_code !== 0) { | |
| console.log(`${yellow('⟳')} hook ${hook_name || 'unknown'} exit=${exit_code}`) | |
| if (stderr) console.log(indent(yellow(stderr.slice(0, 200)), ' ')) | |
| } | |
| return | |
| } | |
| if (subtype === 'task_notification') { | |
| const { task_id, status, summary } = event | |
| const icon = status === 'completed' ? green('✓') : status === 'failed' ? red('✗') : dim('⏳') | |
| console.log(`${icon} ${dim(`task ${task_id}:`)} ${status}`) | |
| if (summary) console.log(indent(dim(summary.slice(0, 100)), ' ')) | |
| return | |
| } | |
| console.log(dim(`[system:${subtype || '?'}] ${JSON.stringify(event).slice(0, 200)}`)) | |
| } | |
| const formatDiff = (structuredPatch, filePath) => { | |
| const lines = [] | |
| const totalAdded = structuredPatch.reduce((n, h) => n + h.lines.filter(l => l.startsWith('+')).length, 0) | |
| const totalRemoved = structuredPatch.reduce((n, h) => n + h.lines.filter(l => l.startsWith('-')).length, 0) | |
| lines.push(` ⎿ Updated ${filePath || 'file'}`) | |
| if (totalAdded || totalRemoved) { | |
| lines.push(dim(` ${totalAdded ? `+${totalAdded}` : ''} ${totalRemoved ? `-${totalRemoved}` : ''} lines`)) | |
| } | |
| for (const hunk of structuredPatch.slice(0, 2)) { | |
| for (const line of hunk.lines.slice(0, 8)) { | |
| if (line.startsWith('+')) lines.push(` ${green(line)}`) | |
| else if (line.startsWith('-')) lines.push(` ${red(line)}`) | |
| else lines.push(` ${dim(line)}`) | |
| } | |
| if (hunk.lines.length > 8) lines.push(dim(` … +${hunk.lines.length - 8} lines`)) | |
| } | |
| if (structuredPatch.length > 2) lines.push(dim(` … +${structuredPatch.length - 2} more hunks`)) | |
| return lines.join('\n') | |
| } | |
| const isToolSuccess = (event) => { | |
| const { tool_use_result } = event | |
| if (tool_use_result?.error) return false | |
| if (tool_use_result?.exit_code !== undefined) return tool_use_result.exit_code === 0 | |
| const content = event.message?.content?.[0] | |
| if (content?.type === 'tool_result') { | |
| const text = typeof content.content === 'string' ? content.content : '' | |
| const exitMatch = text.match(/Exit code[:\s]+(\d+)/i) | |
| if (exitMatch) return exitMatch[1] === '0' | |
| if (content.is_error) return false | |
| } | |
| return true | |
| } | |
| const processUserEvent = (event) => { | |
| const { tool_use_result } = event | |
| // print the pending tool call line with colored circle | |
| if (lastToolLine) { | |
| const success = isToolSuccess(event) | |
| const circle = success ? green('⏺') : red('⏺') | |
| console.log('') | |
| console.log(`${circle} ${bold(lastToolLine)}`) | |
| lastToolLine = null | |
| lastToolId = null | |
| } | |
| if (tool_use_result?.agentId || (tool_use_result?.status === 'completed' && tool_use_result?.totalDurationMs)) { | |
| const { agentId, totalDurationMs, totalTokens } = tool_use_result | |
| const duration = totalDurationMs ? `${(totalDurationMs / 1000).toFixed(1)}s` : '' | |
| const tokens = totalTokens ? `${totalTokens} tokens` : '' | |
| console.log(` ⎿ ${dim(`Agent ${agentId || '?'} completed ${duration} ${tokens}`)}`) | |
| return | |
| } | |
| if (tool_use_result?.structuredPatch) { | |
| console.log(formatDiff(tool_use_result.structuredPatch, tool_use_result.filePath)) | |
| return | |
| } | |
| if (tool_use_result?.file) { | |
| const { filePath, numLines } = tool_use_result.file | |
| console.log(` ⎿ ${dim(`${filePath} (${numLines} lines)`)}`) | |
| return | |
| } | |
| if (tool_use_result?.type === 'text' && !tool_use_result.file) { | |
| const preview = (tool_use_result.text || '').slice(0, 100) | |
| console.log(` ⎿ ${preview}${preview.length >= 100 ? '...' : ''}`) | |
| return | |
| } | |
| const content = event.message?.content?.[0] | |
| if (content?.type === 'tool_result') { | |
| const text = typeof content.content === 'string' | |
| ? content.content | |
| : Array.isArray(content.content) | |
| ? content.content.find(c => c.type === 'text')?.text || '' | |
| : '' | |
| const firstLine = text.split('\n')[0]?.slice(0, 80) || '' | |
| if (firstLine) console.log(` ⎿ ${firstLine}${firstLine.length >= 80 ? '...' : ''}`) | |
| } | |
| } | |
| const processEvent = (event) => { | |
| const { type } = event | |
| if (type === 'assistant') processAssistantEvent(event) | |
| else if (type === 'user') processUserEvent(event) | |
| else if (type === 'result') processResultEvent(event) | |
| else if (type === 'tool_result' && event.error) console.log(yellow(`[tool_result] error: ${event.error}`)) | |
| else if (type === 'error') console.log(yellow(`[error] ${JSON.stringify(event)}`)) | |
| else if (type === 'system') processSystemEvent(event) | |
| else { | |
| console.log(yellow(`[unknown type: ${type}]`)) | |
| console.log(dim(JSON.stringify(event, null, 2))) | |
| } | |
| } | |
| const buildPrompt = () => ISSUE_ID === 'all' | |
| ? `Pick the most important issue from \`bd list\` and implement it. | |
| When done, close it using \`bd close <id>\`.` | |
| : `Implement this beads task: ${ISSUE_ID} | |
| Use \`bd show ${ISSUE_ID}\` to learn details of the task. | |
| If it has children, pick the most important child task and work on it. When it's done, close it using \`bd close <id>\`.` | |
| const checkDone = () => { | |
| try { | |
| if (ISSUE_ID === 'all') { | |
| const output = execSync('bd list --json', { encoding: 'utf8' }) | |
| const issues = JSON.parse(output) | |
| const openCount = issues.filter(i => i.status !== 'closed').length | |
| log(`Checking: ${openCount} open issues remaining`) | |
| return openCount === 0 | |
| } else { | |
| const output = execSync(`bd show ${ISSUE_ID} --json`, { encoding: 'utf8' }) | |
| const issues = JSON.parse(output) | |
| const status = issues[0]?.status | |
| log(`Checking issue ${ISSUE_ID}: status=${status}`) | |
| return status === 'closed' | |
| } | |
| } catch (err) { | |
| log(`Check failed: ${err.message}`) | |
| return false | |
| } | |
| } | |
| const runIteration = (iteration) => new Promise((resolve, reject) => { | |
| console.log('') | |
| log(`${'━'.repeat(50)}`) | |
| log(`Iteration ${iteration}/${MAX_ITERATIONS}`) | |
| log(`${'━'.repeat(50)}`) | |
| if (checkDone()) { | |
| log(ISSUE_ID === 'all' ? '✅ All issues closed. Exiting.' : `✅ Issue ${ISSUE_ID} is closed. Exiting.`) | |
| process.exit(0) | |
| } | |
| const prompt = buildPrompt() | |
| log(`Prompt:`) | |
| console.log(indent(dim(prompt), ' ')) | |
| log(`Spawning Claude${INTERACTIVE ? ' (interactive)' : ''}...`) | |
| const claudeArgs = INTERACTIVE | |
| ? ['--dangerously-skip-permissions', prompt] | |
| : ['-p', '--verbose', '--output-format=stream-json', '--dangerously-skip-permissions', prompt] | |
| const claude = spawn('claude', claudeArgs, { | |
| stdio: INTERACTIVE ? 'inherit' : ['inherit', 'pipe', 'inherit'] | |
| }) | |
| if (!INTERACTIVE) { | |
| let buffer = '' | |
| claude.stdout.on('data', (chunk) => { | |
| buffer += chunk.toString() | |
| const lines = buffer.split('\n') | |
| buffer = lines.pop() || '' | |
| for (const line of lines) { | |
| if (!line.trim()) continue | |
| try { | |
| processEvent(JSON.parse(line)) | |
| } catch { | |
| console.log(dim(`[parse error] ${line}`)) | |
| } | |
| } | |
| }) | |
| claude.on('close', (code) => { | |
| if (buffer.trim()) { | |
| try { processEvent(JSON.parse(buffer)) } catch { console.log(dim(buffer)) } | |
| } | |
| log(`Claude exited with code ${code}`) | |
| resolve() | |
| }) | |
| } else { | |
| claude.on('close', (code) => { | |
| log(`Claude exited with code ${code}`) | |
| resolve() | |
| }) | |
| } | |
| claude.on('error', (err) => { | |
| log(`Failed to start Claude: ${err.message}`) | |
| reject(err) | |
| }) | |
| }) | |
| const main = async () => { | |
| console.log('') | |
| log(`${'═'.repeat(50)}`) | |
| log(`Starting Autonomous Loop${INTERACTIVE ? ' (interactive)' : ''}`) | |
| log(`Target: ${ISSUE_ID === 'all' ? 'all open issues' : ISSUE_ID}`) | |
| log(`Max iterations: ${MAX_ITERATIONS}`) | |
| log(`${'═'.repeat(50)}`) | |
| for (let i = 1; i <= MAX_ITERATIONS; i++) { | |
| await runIteration(i) | |
| if (authFailed) { | |
| log(`❌ Authentication failed. Run 'claude' and use /login to authenticate.`) | |
| process.exit(1) | |
| } | |
| log(`Pausing 2s before next iteration...`) | |
| await new Promise(r => setTimeout(r, 2000)) | |
| } | |
| log(`⚠️ Max iterations (${MAX_ITERATIONS}) reached.`) | |
| } | |
| main().catch(err => { | |
| console.error(err) | |
| process.exit(1) | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment