feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复

Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)

Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)

Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名

Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)

Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-09 16:13:16 +08:00
parent e383d5f6a7
commit 5e282a39b4
44 changed files with 2905 additions and 1343 deletions

View File

@@ -30,6 +30,7 @@ const DEFAULT_CONFIG: AirConfig = {
export function loadConfig(project_root?: string): AirConfig {
let config = { ...DEFAULT_CONFIG }
const resolved_project_root = project_root || process.env.AIRCODING_PROJECT_ROOT
// Load global config: ~/.air/config.json
const global_path = join(homedir(), '.air', 'config.json')
@@ -43,8 +44,8 @@ export function loadConfig(project_root?: string): AirConfig {
}
// Load project config: .air/local/config.json
if (project_root) {
const project_path = join(project_root, '.air', 'local', 'config.json')
if (resolved_project_root) {
const project_path = join(resolved_project_root, '.air', 'local', 'config.json')
if (existsSync(project_path)) {
try {
const project = JSON.parse(readFileSync(project_path, 'utf-8'))
@@ -53,7 +54,7 @@ export function loadConfig(project_root?: string): AirConfig {
// Ignore malformed project config
}
}
config.project_root = project_root
config.project_root = resolved_project_root
}
return config

View File

@@ -1,6 +1,6 @@
/**
* AskCommand - Direct AI task execution
* air ask "task description" → MainAgent → LLM → tools → result
* air ask "task description" → MainAgent → Scheduler → Worker → LLM → tools → WorkerResult
*
* @module packages/cli/src/commands/ask
*/
@@ -10,9 +10,8 @@ import { createRuntime } from '../bootstrap/createRuntime.js'
import { initCommand } from './init.js'
import { existsSync } from 'fs'
import { join } from 'path'
import { ProviderManager, createAnthropicAdapter, OpenAICompatibleAdapter } from '@aircoding/llm'
import { OpenAICompatibleAdapter } from '@aircoding/llm'
import { MainAgent } from '@aircoding/runtime'
import type { ProviderAdapter } from '@aircoding/contracts'
export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise<void> {
if (!prompt) {
@@ -24,221 +23,97 @@ export async function askCommand(prompt: string, opts?: { model?: string; maxTur
const config = loadConfig()
const projectRoot = config.project_root || process.cwd()
// Auto-init if not already initialized
if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) {
console.log('Project not initialized. Running air init first...\n')
await initCommand(projectRoot)
}
const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1'
const maxTurns = opts?.maxTurns || 10
console.log('══════════════════════════════════════════════')
console.log(' AirCoding v1.0.0-alpha')
console.log(' Project:', projectRoot)
console.log(' Model:', model)
console.log('══════════════════════════════════════════════\n')
// Create ProviderManager with available adapter
const provider = createProvider(model)
// Create runtime
const runtime = await createRuntime(config)
await runtime.start()
const app = runtime.app
// Create MainAgent with regex classifier (中文 support added)
const agent = new MainAgent({
session_id: 'ask-session',
project_id: 'ask-project',
classify_mode: 'regex',
provider_manager: provider as any,
context_assembler: app.context_assembler,
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: projectRoot,
agent_id: 'ask-agent' as any,
classify_model: model
})
console.log('Task:', prompt)
console.log('')
try {
await runtime.start()
// Classify intent
const classification = await agent.handle_user_message(prompt)
console.log(`[${classification.action}] ${agent.state}`)
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: projectRoot,
agent_id: 'ask-agent' as any,
classify_model: model,
})
// Execute based on action
if (classification.action === 'delegate') {
await execute_task(prompt, provider, app.tool_registry, app.context_assembler, model, projectRoot, maxTurns)
} else if (classification.action === 'answer') {
console.log('\n' + (classification.response || 'No response'))
} else {
console.log('Direct mode not yet supported for ask. Use implementation requests.')
}
console.log('══════════════════════════════════════════════')
console.log(' AirCoding v1.0.0-alpha')
console.log(' Project:', projectRoot)
console.log(' Model:', model)
console.log('══════════════════════════════════════════════\n')
console.log('Task:', prompt)
console.log('')
await runtime.shutdown()
}
const classification = await agent.handle_user_message(prompt)
console.log(`[${classification.action}] ${agent.state}`)
async function execute_task(
task: string,
provider: any,
toolRegistry: any,
contextAssembler: any,
model: string,
projectRoot: string,
maxTurns: number
): Promise<void> {
const ctx = {
session_id: 'ask-session',
project_id: 'ask-project',
project_root: projectRoot,
agent_id: 'ask-agent',
agent_type: 'executor' as const,
}
const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands.
Available tools:
- fs.read(path) — Read a file
- fs.write(path, content) — Write/create a file
- fs.edit(path, old_str, new_str) — Edit a file
- fs.list(path, depth?) — List directory contents
- fs.stat(path) — Get file info
- shell.run(command, timeout?) — Run a shell command
- git.status() — Show git status
- project.scan(root) — Scan project for source files
- cpp.detect() — Detect C++ project
- cpp.build(target?) — Build C++ project
- cpp.test(filter?) — Run C++ tests
To use a tool, output EXACTLY:
\`\`\`json
{"tool": "fs.read", "args": {"path": "file.cpp"}}
\`\`\`
When you are done, output:
DONE: <summary of what was done>`
const messages: any[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: task }
]
let turn = 0
const changedFiles: string[] = []
while (turn < maxTurns) {
turn++
console.log(`─ Turn ${turn}/${maxTurns}`)
// Call LLM
const result = await provider.complete_text(messages, { model, max_tokens: 4096 })
let text = result.content || ''
// Strip GLM reasoning tags if present
text = text.replace(/<\/?think>/g, '')
// Show LLM response summary
const preview = text.slice(0, 100).replace(/\n/g, ' ')
console.log(` LLM: ${preview}...`)
// Parse tool calls FIRST (before DONE check — LLM may emit both)
const tools = parseToolCalls(text)
if (tools.length > 0) {
console.log(` Tools: ${tools.map(t => t.name).join(', ')}`)
// Add assistant message
messages.push({ role: 'assistant', content: text })
// Execute each tool call
for (const tool of tools) {
try {
const toolResult = await toolRegistry.call(
{ call_id: `ask-${Date.now()}`, name: tool.name, arguments: tool.args },
ctx
)
const output = toolResult.status === 'ok'
? JSON.stringify(toolResult.output).slice(0, 500)
: `Error: ${JSON.stringify(toolResult.error)}`
console.log(`${tool.name}: ${output.slice(0, 100)}`)
if (tool.name === 'fs.write' || tool.name === 'fs.edit') {
changedFiles.push(String(tool.args.path || 'unknown'))
}
messages.push({
role: 'user',
content: `Tool ${tool.name} result: ${output}`
})
} catch (e: any) {
messages.push({
role: 'user',
content: `Tool ${tool.name} error: ${e.message}`
})
}
}
// After executing tools, check for DONE
if (text.includes('DONE:')) {
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
console.log(`\n✅ ${summary}`)
break
}
// Ask LLM to continue with remaining work
messages.push({
role: 'user',
content: 'Tools executed successfully. If the task is complete, respond with DONE: <summary>. Otherwise continue with more tool calls.'
})
continue
if (classification.action === 'answer') {
console.log('\n' + (classification.response || 'No response'))
return
}
// No tool calls — LLM is just talking
// Check for DONE (no tools to execute)
if (text.includes('DONE:')) {
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
console.log(`\n✅ ${summary}`)
break
if (classification.action !== 'delegate') {
console.log(classification.response || 'No task created')
return
}
}
if (turn >= maxTurns) {
console.log(`\n⚠ Reached max ${maxTurns} turns. Task may be incomplete.`)
}
if (agent.state === 'CONFIRMING') {
console.log('\n' + (classification.response || 'Confirmation required'))
console.log('No task was created. Use `air run` for interactive confirmation.')
return
}
if (changedFiles.length > 0) {
console.log(`\nChanged files: ${changedFiles.join(', ')}`)
}
}
const taskId = `ask_${Date.now().toString(36)}`
await app.scheduler.create_tasks([{
id: taskId as any,
type: 'execute',
title: prompt.slice(0, 80),
description: prompt,
task_spec: {
id: taskId,
title: prompt.slice(0, 80),
description: prompt,
acceptance_criteria: ['Task completed successfully'],
model,
max_turns: opts?.maxTurns,
},
}])
function parseToolCalls(text: string): Array<{ name: string; args: Record<string, unknown> }> {
const calls: Array<{ name: string; args: Record<string, unknown> }> = []
console.log(`Compiling task ${taskId} through Scheduler/Worker...`)
const finalState = await app.scheduler.run_until_idle()
const workerResult = app.worker_manager.get_result_for_task(taskId)
// Pattern 1: ```tool or ```json code blocks
const blockRe = /```(?:tool|json)\s*\n?([\s\S]*?)```/g
for (const match of text.matchAll(blockRe)) {
try {
const parsed = JSON.parse(match[1].trim())
if (parsed.tool) calls.push({ name: parsed.tool, args: parsed.args || {} })
} catch { /* skip */ }
}
// Pattern 2: {"tool": "..."} inline JSON anywhere in text
const jsonRe = /\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}/g
for (const match of text.matchAll(jsonRe)) {
try {
const name = match[1]
const args = JSON.parse(match[2])
if (!calls.some(c => c.name === name)) {
calls.push({ name, args })
console.log(`Scheduler state: ${finalState}`)
if (workerResult) {
console.log(`Worker status: ${workerResult.status}`)
if (workerResult.summary) console.log(workerResult.summary)
if (workerResult.changed_files.length > 0) {
console.log(`Changed files: ${workerResult.changed_files.join(', ')}`)
}
} catch { /* skip */ }
} else {
console.log('No WorkerResult was returned.')
}
} finally {
await runtime.shutdown()
}
return calls
}
function createProvider(model: string): any {
@@ -246,24 +121,22 @@ function createProvider(model: string): any {
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
if (!apiKey) {
console.log('⚠️ No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.')
console.log(' Export: export AIRCODING_API_KEY="your-key"')
console.log('No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.')
process.exit(1)
}
const adapter = new OpenAICompatibleAdapter({
base_url: apiUrl,
api_key: apiKey,
model
model,
})
// Wrap adapter in a simple provider interface
return {
adapters: new Map([['openai-compatible', adapter]]),
current_adapter: adapter,
current_model: model,
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) {
return (adapter as any).complete_text(messages, options)
}
},
}
}

View File

@@ -1,13 +1,56 @@
/**
* CompactCommand - Trigger context compaction
* CompactCommand - Trigger context compaction through Scheduler/Worker.
* DD §17.
*/
export function compactCommand(target_tokens?: number): void {
const tokens = target_tokens || 80000
console.log(`Context compaction requested: target ~${tokens} tokens`)
console.log('Compaction will:')
console.log(' 1. Summarize conversation history')
console.log(' 2. Keep recent messages intact')
console.log(' 3. Insert compaction marker')
console.log(`Target: ${tokens} tokens (handled by Compactor worker automatically)`)
import { randomUUID } from 'crypto'
import { existsSync } from 'fs'
import { join } from 'path'
import { loadConfig } from '../bootstrap/loadConfig.js'
import { createRuntime } from '../bootstrap/createRuntime.js'
import { initCommand } from './init.js'
export async function compactCommand(target_tokens?: number): Promise<void> {
const config = loadConfig(process.env.AIRCODING_PROJECT_ROOT || process.cwd())
const project_root = config.project_root || process.cwd()
const tokens = target_tokens || config.token_budget || 80000
if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) {
console.log('Project not initialized. Running air init...\n')
await initCommand(project_root)
}
const runtime = await createRuntime(config)
const app = runtime.app
app.worker_manager.set_context({
session_id: app.session_id,
project_id: app.project_id,
project_root,
})
await app.start()
try {
const taskId = `compact_${randomUUID().slice(0, 8)}`
await app.scheduler.create_tasks([{
id: taskId,
type: 'compact',
title: `Compact context to ${tokens} tokens`,
description: `Context compaction requested for target budget ${tokens}`,
task_spec: {
task_id: taskId,
current_tokens: config.token_budget || tokens,
threshold: tokens,
target_budget_tokens: tokens,
source_content: 'CLI-triggered context compaction. Rebuild durable context from event store and summaries.',
},
}])
console.log(`Compaction task ${taskId} created. Dispatching compactor worker...`)
const finalState = await app.scheduler.run_until_idle()
const result = app.worker_manager.get_result_for_task(taskId)
console.log(`Compaction scheduler state: ${finalState}`)
console.log(result?.summary || 'Compaction finished without summary')
} finally {
await app.shutdown()
}
}

View File

@@ -53,6 +53,10 @@ function runCmd(label: string, cmd: string, args: string[], cwd?: string, timeou
function runTest(label: string, testPath: string, repoRoot: string): { pass: boolean; detail: string } {
const bun = findBun()
const paths = testPath.split(' ').filter(p => p.length > 0).map(p => join(repoRoot, p.replace(/^\.\//, '')))
const missing = paths.filter(p => !existsSync(p))
if (missing.length > 0) {
return { pass: false, detail: `\n ${label} missing test paths:\n ${missing.map(p => p.replace(repoRoot + '/', '')).join('\n ')}` }
}
return runCmd(label, bun, ['test', ...paths])
}
@@ -111,7 +115,7 @@ export function e2eCommand(): void {
{ label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) },
// P1: Storage/Events
{ label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/storage/ ./packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts', repoRoot) },
{ label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts ./packages/runtime/test/regression/task-attempt-repository.test.ts ./packages/runtime/test/regression/evidence-store-persistence.test.ts', repoRoot) },
// P2: Tools/Permission
{ label: 'P2: Tools/Permission (test)', fn: () => runTest('P2', './packages/runtime/test/regression/tool-stubs.test.ts ./packages/runtime/test/regression/permission-engine-actions.test.ts ./packages/runtime/test/regression/path-classifier-categories.test.ts ./packages/runtime/test/regression/command-risk-analyzer.test.ts', repoRoot) },

View File

@@ -8,14 +8,19 @@
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 type { TuiApp as TuiAppInstance } from '@aircoding/tui'
import { MainAgent, eventIngestor } from '@aircoding/runtime'
import { OpenAICompatibleAdapter } from '@aircoding/llm'
import { existsSync, mkdirSync } from 'fs'
import { existsSync } from 'fs'
import { join } from 'path'
import { createInterface } from 'readline'
import { randomUUID } from 'crypto'
type TaskResultSummary = {
title: string
files: Array<{ path: string; size: number }>
state: string
}
export async function runCommand(project_path?: string): Promise<void> {
const config = loadConfig(project_path)
const project_root = config.project_root || process.cwd()
@@ -55,26 +60,6 @@ export async function runCommand(project_path?: string): Promise<void> {
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,
@@ -87,18 +72,21 @@ export async function runCommand(project_path?: string): Promise<void> {
classify_model: model
})
const session_id = app.session_id
await import('@aircoding/tui/preload')
const { TuiApp } = await import('@aircoding/tui')
// Track task results for /results command
const taskResults = new Map<string, { title: string; files: Array<{ path: string; size: number }>; state: string }>()
const taskResults = new Map<string, TaskResultSummary>()
let pendingConfirmation: string | undefined
let tui: TuiAppInstance
let shuttingDown = false
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 shutdown = async () => {
if (shuttingDown) return
shuttingDown = true
tui?.stop()
await app.shutdown()
process.exit(0)
}
const dispatchTask = async (input: string) => {
const taskId = `task_${randomUUID().slice(0, 8)}`
@@ -110,87 +98,37 @@ export async function runCommand(project_path?: string): Promise<void> {
}])
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')
}
tui.set_status(`Task ${taskId} running`)
const finalState = await app.scheduler.run_until_idle()
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 }> = []
const producedFiles: Array<{ path: string; size: number }> = []
if (resultFiles.length > 0) {
const { statSync: st, existsSync: ex } = await import('fs')
const { statSync, existsSync: fileExists } = 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 })
if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue
const fullPath = join(project_root, file)
if (!fileExists(fullPath)) continue
const stat = statSync(fullPath)
if (stat.isFile()) producedFiles.push({ path: file, size: stat.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) {
if (producedFiles.length > 0) {
console.log(' Produced files:')
for (const f of recentFiles.slice(0, 10)) {
console.log(` 📄 ${f.path} (${f.size}B)`)
for (const file of producedFiles.slice(0, 10)) {
console.log(` ${file.path} (${file.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()
})
taskResults.set(taskId, { title: input.slice(0, 80), files: producedFiles, state: finalState })
tui.set_status(`Task ${taskId}: ${finalState}`)
}
// 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 }
const handleSubmit = async (input: string) => {
if (pendingConfirmation) {
if (/^(y|yes|是|确认|确定)$/i.test(input)) {
const confirmedInput = pendingConfirmation
@@ -201,59 +139,89 @@ export async function runCommand(project_path?: string): Promise<void> {
pendingConfirmation = undefined
await agent.handle_confirmation(false)
console.log('Cancelled. No task was created.\n')
tui.set_status('Cancelled')
} else {
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
tui.set_status('Waiting for confirmation')
}
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')
tui.set_status('Answered')
} else if (classification.action === 'delegate') {
if (agent.state === 'CONFIRMING' && classification.response) {
pendingConfirmation = input
console.log('\n' + classification.response + '\n')
tui.set_status('Waiting for confirmation')
} else {
await dispatchTask(input)
}
} else {
console.log(`Result: ${classification.response || 'Done'}`)
tui.set_status(classification.response || 'Done')
}
}
rl.prompt()
const resolvePermission = async (prompt_id: string, selected_option: string) => {
await eventIngestor.ingest({
id: `evt_${prompt_id}_resolved_${randomUUID().slice(0, 8)}`,
type: 'permission.prompt.resolved',
version: 1,
session_id: app.session_id,
project_id: app.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'main', id: 'tui' },
route: ['cli', 'tui', 'permission'],
payload: {
prompt_id,
selected_option,
decision_id: `decision_${randomUUID().slice(0, 8)}`,
resolved_by: 'user',
},
})
tui.set_status(`Permission ${selected_option}`)
}
tui = new TuiApp({
client: runtime.projection_client,
onSubmit: handleSubmit,
onSlashCommand: (input) => handleSlashCommand(input, app, tui, taskResults, shutdown),
onResolvePermission: resolvePermission,
onExit: shutdown,
})
await tui.start()
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')
process.once('SIGINT', () => {
void shutdown()
})
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
await new Promise(() => {})
}
async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any, taskResults?: Map<string, any>): Promise<void> {
async function handleSlashCommand(
input: string,
app: any,
tui: TuiAppInstance,
taskResults: Map<string, TaskResultSummary>,
shutdown: () => Promise<void>,
): Promise<void> {
const cmd = input.slice(1).toLowerCase()
switch (cmd) {
case 'help':
tui.set_view('help')
console.log('\nCommands:')
console.log(' /help — Show this help')
console.log(' /status — Show scheduler and worker status')
@@ -264,15 +232,15 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
break
case 'results':
if (!taskResults || taskResults.size === 0) {
if (taskResults.size === 0) {
console.log(' No task results yet. Submit a task first.\n')
} else {
console.log('')
for (const [taskId, result] of taskResults) {
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)`)
for (const file of result.files) {
console.log(` ${file.path} (${file.size}B)`)
}
} else {
console.log(' (no files produced)')
@@ -289,13 +257,14 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
break
case 'tools': {
tui.set_view('tools')
const tools = app.tool_registry.list()
console.log(`\n ${tools.length} tools registered:`)
const cats = new Map<string, string[]>()
for (const t of tools) {
const list = cats.get(t.category) || []
list.push(t.name)
cats.set(t.category, list)
for (const tool of tools) {
const list = cats.get(tool.category) || []
list.push(tool.name)
cats.set(tool.category, list)
}
for (const [cat, names] of cats) {
console.log(` ${cat}: ${names.join(', ')}`)
@@ -305,6 +274,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
}
case 'tasks': {
tui.set_view('tasks')
const counts = app.scheduler.get_graph().count_by_status()
console.log(`\n Task graph:`)
for (const [status, count] of Object.entries(counts)) {
@@ -316,7 +286,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
case 'quit':
case 'exit':
rl.close()
await shutdown()
break
default:

View File

@@ -20,7 +20,6 @@
* @module packages/cli
*/
import { runCommand } from './commands/run.js'
import { initCommand } from './commands/init.js'
import { doctorCommand } from './commands/doctor.js'
import { providerCommand } from './commands/provider.js'
@@ -39,9 +38,11 @@ export async function main(argv: string[]): Promise<void> {
const rest = args.slice(1)
switch (command) {
case 'run':
case 'run': {
const { runCommand } = await import('./commands/run.js')
await runCommand(rest[0])
break
}
case 'ask':
await askCommand(rest.join(' '), {
@@ -71,7 +72,7 @@ export async function main(argv: string[]): Promise<void> {
break
case 'compact':
compactCommand(rest[0] ? parseInt(rest[0]) : undefined)
await compactCommand(rest[0] ? parseInt(rest[0]) : undefined)
break
case 'history':