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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { describe, it, expect, afterEach } from 'bun:test'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { loadConfig } from '../src/bootstrap/loadConfig.js'
|
||||
|
||||
describe('run command result presentation', () => {
|
||||
const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8')
|
||||
@@ -22,3 +25,44 @@ describe('run command result presentation', () => {
|
||||
expect(source).toContain('No task was created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ask command architecture boundary', () => {
|
||||
const source = readFileSync(join(import.meta.dir, '../src/commands/ask.ts'), 'utf-8')
|
||||
|
||||
it('dispatches delegate work through Scheduler and WorkerManager', () => {
|
||||
expect(source).toContain('app.scheduler.create_tasks')
|
||||
expect(source).toContain('app.scheduler.run_until_idle')
|
||||
expect(source).toContain('app.worker_manager.get_result_for_task')
|
||||
})
|
||||
|
||||
it('does not implement an inline LLM-to-tool loop', () => {
|
||||
expect(source).not.toContain('parseToolCalls')
|
||||
expect(source).not.toContain('toolRegistry.call')
|
||||
expect(source).not.toContain('while (turn <')
|
||||
})
|
||||
})
|
||||
|
||||
describe('project root configuration', () => {
|
||||
const created: string[] = []
|
||||
afterEach(() => {
|
||||
delete process.env.AIRCODING_PROJECT_ROOT
|
||||
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('uses AIRCODING_PROJECT_ROOT when no explicit project path is passed', () => {
|
||||
const projectRoot = mkdtempSync(join(tmpdir(), 'air-load-config-'))
|
||||
created.push(projectRoot)
|
||||
process.env.AIRCODING_PROJECT_ROOT = projectRoot
|
||||
|
||||
expect(loadConfig().project_root).toBe(projectRoot)
|
||||
})
|
||||
|
||||
it('explicit project path wins over AIRCODING_PROJECT_ROOT', () => {
|
||||
const envRoot = mkdtempSync(join(tmpdir(), 'air-load-config-env-'))
|
||||
const explicitRoot = mkdtempSync(join(tmpdir(), 'air-load-config-explicit-'))
|
||||
created.push(envRoot, explicitRoot)
|
||||
process.env.AIRCODING_PROJECT_ROOT = envRoot
|
||||
|
||||
expect(loadConfig(explicitRoot).project_root).toBe(explicitRoot)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user