/** * RunCommand - Interactive AI coding session * DD §17. Full chain: input → MainAgent → Scheduler → Worker → LLM → tools → result. * * @module packages/cli/src/commands/run */ import { loadConfig } from '../bootstrap/loadConfig.js' import { createRuntime } from '../bootstrap/createRuntime.js' import { initCommand } from './init.js' import { TuiApp } from '@aircoding/tui' import { MainAgent } from '@aircoding/runtime' import { OpenAICompatibleAdapter } from '@aircoding/llm' import { existsSync, mkdirSync } from 'fs' import { join } from 'path' import { createInterface } from 'readline' import { randomUUID } from 'crypto' export async function runCommand(project_path?: string): Promise { const config = loadConfig(project_path) const project_root = config.project_root || process.cwd() // Auto-init if needed if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) { console.log('Project not initialized. Running air init...\n') await initCommand(project_root) } // Create LLM provider const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY || '' const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' const model = process.env.AIRCODING_MODEL || 'glm-5.1' const adapter = new OpenAICompatibleAdapter({ base_url: apiUrl, api_key: apiKey, model }) const provider = { adapters: new Map([['openai-compatible', adapter]]), current_adapter: adapter, current_model: model, async complete_text(msgs: unknown[], opts: any = {}) { return (adapter as any).complete_text(msgs, opts) } } // Create runtime and wire everything const runtime = await createRuntime(config) const app = runtime.app // Wire ProviderManager into WorkerManager for llm.request IPC app.worker_manager.set_provider_manager(provider as any) app.worker_manager.set_context({ session_id: app.session_id, project_id: app.project_id, project_root }) await app.start() // Push initial session projection runtime.projection_client.receive_snapshot({ session_id: app.session_id, project_id: app.project_id, status: 'running', title: project_root.split('/').pop() || 'AirCoding', tasks: [], agents: [], tool_runs: [], command_runs: [], artifacts: [], permission_prompts: [], blockers: [], updated_at: new Date().toISOString() }) // Start TUI const tui = new TuiApp({ client: runtime.projection_client }) await tui.start() // Create MainAgent const agent = new MainAgent({ session_id: app.session_id, project_id: app.project_id, classify_mode: 'regex', provider_manager: provider as any, context_assembler: app.context_assembler, project_root, agent_id: 'main-agent' as any, classify_model: model }) const session_id = app.session_id // Track task results for /results command const taskResults = new Map; state: string }>() let pendingConfirmation: string | undefined console.log('') console.log('══════════════════════════════════════════════') console.log(' AirCoding v1.0.0-alpha') if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled') console.log(' Type your task, or /help for commands, Ctrl+C to quit') console.log('══════════════════════════════════════════════\n') const dispatchTask = async (input: string) => { const taskId = `task_${randomUUID().slice(0, 8)}` await app.scheduler.create_tasks([{ id: taskId, type: 'execute', title: input.slice(0, 80), description: input }]) console.log(`Task ${taskId} created. Dispatching worker...`) const runPromise = app.scheduler.run_until_idle() let dots = 0 const progressInterval = setInterval(() => { dots = (dots + 1) % 4 process.stdout.write(`\r Running${'.'.repeat(dots)} `) }, 500) let finalState: string try { finalState = await runPromise } finally { clearInterval(progressInterval) process.stdout.write('\r \r') } console.log(`Task complete. Scheduler: ${finalState}`) const workerResult = app.worker_manager.get_result_for_task?.(taskId) const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : [] const recentFiles: Array<{ path: string; size: number }> = [] if (resultFiles.length > 0) { const { statSync: st, existsSync: ex } = await import('fs') for (const file of resultFiles) { if (!file || file.startsWith('.air/') || file.includes('/.air/')) continue const full = join(project_root, file) if (!ex(full)) continue const s = st(full) if (s.isFile()) recentFiles.push({ path: file, size: s.size }) } } else { const { readdirSync: rd, statSync: st, existsSync: ex } = await import('fs') const scanDir = (d: string, depth: number) => { if (depth > 3 || !ex(d)) return try { for (const e of rd(d)) { if (e.startsWith('.')) continue const p = join(d, e) try { const s = st(p) if (s.isDirectory()) scanDir(p, depth + 1) else if (s.mtimeMs > Date.now() - 60000) recentFiles.push({ path: p.replace(project_root + '/', ''), size: s.size }) } catch {} } } catch {} } scanDir(project_root, 0) } if (recentFiles.length > 0) { console.log(' Produced files:') for (const f of recentFiles.slice(0, 10)) { console.log(` 📄 ${f.path} (${f.size}B)`) } } taskResults.set(taskId, { title: input.slice(0, 80), files: recentFiles, state: finalState }) runtime.projection_client.receive_snapshot({ session_id, project_id: app.project_id, status: finalState === 'COMPLETED' ? 'completed' : 'running', title: project_root.split('/').pop() || 'AirCoding', tasks: [{ id: taskId as any, type: 'execute', status: finalState === 'COMPLETED' ? 'completed' : 'running', title: input.slice(0, 80), retry_count: 0, attempts: 1, created_at: new Date().toISOString() }], agents: [], tool_runs: [], command_runs: [], artifacts: [], permission_prompts: [], blockers: [], updated_at: new Date().toISOString() }) } // Interactive input loop const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' }) rl.prompt() rl.on('line', async (line: string) => { const input = line.trim() if (!input) { rl.prompt(); return } if (pendingConfirmation) { if (/^(y|yes|是|确认|确定)$/i.test(input)) { const confirmedInput = pendingConfirmation pendingConfirmation = undefined await agent.handle_confirmation(true) await dispatchTask(confirmedInput) } else if (/^(n|no|否|取消)$/i.test(input)) { pendingConfirmation = undefined await agent.handle_confirmation(false) console.log('Cancelled. No task was created.\n') } else { console.log('Please answer y/n to confirm or cancel the pending destructive request.\n') } rl.prompt() return } // Handle slash commands if (input.startsWith('/')) { await handleSlashCommand(input, app, runtime, tui, rl, taskResults) rl.prompt() return } // Route through MainAgent const classification = await agent.handle_user_message(input) console.log(`[${classification.action}]`) if (classification.action === 'answer') { console.log('\n' + (classification.response || 'No response') + '\n') } else if (classification.action === 'delegate') { if (agent.state === 'CONFIRMING' && classification.response) { pendingConfirmation = input console.log('\n' + classification.response + '\n') } else { await dispatchTask(input) } } else { console.log(`Result: ${classification.response || 'Done'}`) } rl.prompt() }) rl.on('close', async () => { console.log('\nShutting down...') tui.stop() await app.shutdown() process.exit(0) }) process.on('SIGINT', () => { rl.close() }) await new Promise(() => {}) // Wait forever } async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any, taskResults?: Map): Promise { const cmd = input.slice(1).toLowerCase() switch (cmd) { case 'help': console.log('\nCommands:') console.log(' /help — Show this help') console.log(' /status — Show scheduler and worker status') console.log(' /tools — List registered tools') console.log(' /tasks — Show task graph') console.log(' /results — Show produced files from completed tasks') console.log(' /quit — Exit AirCoding\n') break case 'results': if (!taskResults || taskResults.size === 0) { console.log(' No task results yet. Submit a task first.\n') } else { console.log('') for (const [taskId, result] of taskResults) { console.log(` Task: ${result.title} [${result.state}]`) if (result.files.length > 0) { for (const f of result.files) { console.log(` 📄 ${f.path} (${f.size}B)`) } } else { console.log(' (no files produced)') } } console.log('') } break case 'status': console.log(`\n Scheduler: ${app.scheduler.get_state()}`) console.log(` Workers: ${app.worker_manager.list().length}`) console.log(` DB: ${app.db.isOpen() ? 'open' : 'closed'}\n`) break case 'tools': { const tools = app.tool_registry.list() console.log(`\n ${tools.length} tools registered:`) const cats = new Map() for (const t of tools) { const list = cats.get(t.category) || [] list.push(t.name) cats.set(t.category, list) } for (const [cat, names] of cats) { console.log(` ${cat}: ${names.join(', ')}`) } console.log('') break } case 'tasks': { const counts = app.scheduler.get_graph().count_by_status() console.log(`\n Task graph:`) for (const [status, count] of Object.entries(counts)) { console.log(` ${status}: ${count}`) } console.log('') break } case 'quit': case 'exit': rl.close() break default: console.log(`Unknown command: ${cmd}. Try /help\n`) } }