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

View File

@@ -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)
})
})

View File

@@ -15,7 +15,8 @@
},
"dependencies": {
"@aircoding/contracts": "workspace:*",
"@aircoding/llm": "workspace:*"
"@aircoding/llm": "workspace:*",
"@aircoding/toolchain-cpp": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.9.1",

View File

@@ -20,9 +20,9 @@ import { DatabaseManager } from '../storage/DatabaseManager.js'
import { MigrationRunner } from '../storage/MigrationRunner.js'
import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js'
import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js'
import { EventBus } from '../events/EventBus.js'
import { EventBus, eventBus, type Subscription } from '../events/EventBus.js'
import { EventStore, eventStore } from '../events/EventStore.js'
import { EventIngestorImpl } from '../events/EventIngestor.js'
import { EventIngestorImpl, eventIngestor } from '../events/EventIngestor.js'
import { TaskRepository } from '../storage/repositories/TaskRepository.js'
import { MessageRepository } from '../storage/repositories/MessageRepository.js'
import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js'
@@ -48,6 +48,8 @@ export interface RuntimeAppConfig {
export class RuntimeApp {
private config: RuntimeAppConfig
private projection_subscription: Subscription | null = null
private projection_client_unsubscribe: (() => void) | null = null
scheduler: Scheduler
worker_manager: WorkerManager
context_assembler: ContextAssembler
@@ -86,18 +88,22 @@ export class RuntimeApp {
this.doctor = new DoctorService(config.project_root, this.capability_registry)
this.projection_store = new ProjectionStore()
this.projection_client = new ProjectionClient()
this.event_bus = new EventBus()
this.event_bus = eventBus
const raw_db = this.db.getRawDatabase()
// Wire singleton eventStore with real DB (EventIngestor uses it)
if (raw_db) eventStore.setTransactionManager(this.db)
// Use module singleton eventStore - don't create separate instance
this.event_store = eventStore
this.event_ingestor = new EventIngestorImpl()
this.event_ingestor = eventIngestor
// Wire ProjectionStore → ProjectionClient (DD §13.2)
this.projection_store.subscribe((projection) => {
this.projection_client_unsubscribe = this.projection_store.subscribe((projection) => {
this.projection_client.receive_snapshot(projection)
})
this.projection_subscription = this.event_bus.subscribe(
{ session_id: config.session_id },
(event) => this.projection_store.apply(event),
)
// Wire Scheduler to WorkerManager (DD §7.1)
this.scheduler = new Scheduler({
@@ -150,13 +156,19 @@ export class RuntimeApp {
registrar.register_all(this.config.project_root)
this.logger.info('Built-in tools registered')
// Step 4: Wire EventStore with DB transaction manager
// Step 4: Discover project-local SKILL.md capabilities without executing skill content.
await this.discover_project_skills()
// Step 4.5: Register cpp toolchain via CapabilityRegistry (INV-4)
await this.register_cpp_toolchain()
// Step 5: Wire EventStore with DB transaction manager
this.event_store.setTransactionManager(this.db)
// Step 5: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
// Step 6: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
this.logger.info('Hydrating projection store', { session_id: this.config.session_id })
// Step 6: Wire all domain repositories to module singleton EventStore
// Step 7: Wire all domain repositories to module singleton EventStore
try {
const raw_db = this.db.getRawDatabase()
if (raw_db) {
@@ -181,6 +193,15 @@ export class RuntimeApp {
diagnosticRepo, evidenceRepo, workspaceRepo, summaryRepo,
})
this.projection_store.set_repos({
session: sessionRepo,
task: taskRepo,
agent: agentRepo,
})
await this.ensure_session_created(sessionRepo)
await this.projection_store.rebuild(this.config.session_id)
// Reuse repos for context_assembler and scheduler (replace Step 6 duplicate new)
this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo })
this.scheduler.set_task_repo(taskRepo)
@@ -194,12 +215,108 @@ export class RuntimeApp {
this.logger.info('RuntimeApp started')
}
private async discover_project_skills(): Promise<void> {
const roots = [
join(this.config.project_root, '.air', 'shared', 'skills'),
join(this.config.project_root, '.air', 'shared', 'skill'),
].filter((root) => existsSync(root))
if (roots.length === 0) return
const discovered = this.capability_registry.discover_skill_roots(roots)
let registered_count = 0
for (const result of discovered) {
if (!result.ok || !result.capability_id) {
this.logger.warn('Skill discovery failed', { error: result.error })
continue
}
const validation = this.capability_registry.validate(result.capability_id)
if (!validation.valid) {
this.logger.warn('Skill validation failed', { capability_id: result.capability_id, errors: validation.errors })
continue
}
const doctor = await this.capability_registry.doctor_check(result.capability_id)
if (!doctor.ok) {
this.logger.warn('Skill doctor check failed', { capability_id: result.capability_id, error: doctor.error })
continue
}
const enabled = this.capability_registry.enable(result.capability_id)
if (!enabled.ok) {
this.logger.warn('Skill enable failed', { capability_id: result.capability_id, error: enabled.error })
continue
}
const registered = this.capability_registry.register_tools(result.capability_id)
if (!registered.ok) {
this.logger.warn('Skill tool registration failed', { capability_id: result.capability_id, error: registered.error })
continue
}
registered_count += registered.registered_count
}
if (registered_count > 0) this.logger.info('Project skills registered', { registered_count })
}
/**
* Register cpp toolchain via CapabilityRegistry (INV-4)
* Uses CppToolRegistrar from toolchain-cpp package.
*/
private async register_cpp_toolchain(): Promise<void> {
try {
// Dynamic import to avoid static dependency (INV-4: single direction)
const cppPkg = await import('@aircoding/toolchain-cpp')
const registrar = new cppPkg.CppToolRegistrar()
// Register tools through the CppToolRegistrar
// This follows INV-4: registered via capability boundary
registrar.register(this.tool_registry, this.config.project_root)
this.logger.info('cpp toolchain registered', { capability_id: 'aircoding-cpp-toolchain' })
} catch (e: any) {
this.logger.warn('cpp toolchain registration failed', { error: e.message })
}
}
private async ensure_session_created(sessionRepo: SessionRepository): Promise<void> {
const existing = await sessionRepo.get(this.config.session_id)
if (existing) return
const now = new Date().toISOString()
await this.event_ingestor.ingest({
id: `evt_${this.config.session_id}_created`,
type: 'session.created',
version: 1,
timestamp: now,
session_id: this.config.session_id,
project_id: this.config.project_id,
source: { kind: 'system' },
route: ['runtime', 'start'],
payload: {
session_id: this.config.session_id,
project_id: this.config.project_id,
project_root: this.config.project_root,
title: this.config.project_root.split('/').pop() || 'AirCoding',
metadata: {},
},
})
}
/**
* Shutdown the runtime: flush logs, close DB, cancel workers.
*/
async shutdown(): Promise<void> {
this.logger.info('RuntimeApp shutting down')
if (this.projection_subscription) {
this.event_bus.unsubscribe(this.projection_subscription)
this.projection_subscription = null
}
this.projection_client_unsubscribe?.()
this.projection_client_unsubscribe = null
// Cancel all running workers
try {
for (const handle of this.worker_manager.list()) {

View File

@@ -11,6 +11,7 @@
import type { ToolDefinition } from '@aircoding/contracts'
import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js'
import { loadSkillDirectory, loadSkillsFromRoots, type SkillDefinition } from './SkillLoader.js'
export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed'
@@ -62,6 +63,30 @@ export class CapabilityRegistry {
return { ok: true, capability_id }
}
/**
* Discover one SKILL.md directory as a capability manifest.
*/
discover_skill_directory(skill_dir: string, trusted_roots: string[]): { ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string } {
try {
const skill = loadSkillDirectory(skill_dir, trusted_roots)
const discovered = this.discover(skill.manifest)
return { ...discovered, skill }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* Discover all SKILL.md entries under trusted roots.
*/
discover_skill_roots(roots: string[]): Array<{ ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string }> {
try {
return loadSkillsFromRoots(roots).map((skill) => ({ ...this.discover(skill.manifest), skill }))
} catch (error) {
return [{ ok: false, error: error instanceof Error ? error.message : String(error) }]
}
}
/**
* Validate a discovered capability.
*/

View File

@@ -0,0 +1,117 @@
/**
* SkillLoader - SKILL.md capability bridge.
* Loads skill directories into Capability manifests without executing skill content.
*/
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
import { resolve, relative, basename } from 'path'
import type { CapabilityManifest } from './CapabilityManifestValidator.js'
export interface SkillDefinition {
id: string
name: string
description: string
directory: string
content: string
frontmatter: Record<string, unknown>
manifest: CapabilityManifest
}
export function loadSkillDirectory(skill_dir: string, trusted_roots: string[]): SkillDefinition {
const directory = resolve(skill_dir)
ensureTrusted(directory, trusted_roots)
const skill_path = resolve(directory, 'SKILL.md')
if (!existsSync(skill_path) || !statSync(skill_path).isFile()) {
throw new Error(`SKILL.md not found in ${directory}`)
}
const raw = readFileSync(skill_path, 'utf-8')
const parsed = parseSkillMarkdown(raw)
const name = slug(String(parsed.frontmatter.name || basename(directory)))
const description = String(parsed.frontmatter.description || firstParagraph(parsed.body) || `Skill ${name}`)
const toolName = `skill.${name}`
const manifest: CapabilityManifest = {
schema_version: 1,
name,
version: String(parsed.frontmatter.version || '1.0.0'),
description,
trust_level: 'project_local',
tools: [{
name: toolName,
category: 'internal',
permissions: { read: true, write: false, network: false },
input_schema: {
type: 'object',
properties: {
task: { type: 'string' },
skill_directory: { type: 'string' },
},
required: ['task'],
},
}],
}
return { id: name, name, description, directory, content: parsed.body, frontmatter: parsed.frontmatter, manifest }
}
export function loadSkillsFromRoots(roots: string[]): SkillDefinition[] {
const skills: SkillDefinition[] = []
for (const root of roots.map((r) => resolve(r))) {
if (!existsSync(root) || !statSync(root).isDirectory()) continue
const direct = resolve(root, 'SKILL.md')
if (existsSync(direct)) {
skills.push(loadSkillDirectory(root, roots))
continue
}
const entries = Array.from(new Set(readDirectoryNames(root)))
for (const entry of entries) {
const dir = resolve(root, entry)
if (existsSync(resolve(dir, 'SKILL.md'))) skills.push(loadSkillDirectory(dir, roots))
}
}
return skills
}
function ensureTrusted(path: string, roots: string[]): void {
const trusted = roots.map((root) => resolve(root)).some((root) => {
const rel = relative(root, path)
return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'))
})
if (!trusted) throw new Error(`Skill path is outside trusted roots: ${path}`)
}
function parseSkillMarkdown(raw: string): { frontmatter: Record<string, unknown>; body: string } {
if (!raw.startsWith('---\n')) return { frontmatter: {}, body: raw.trim() }
const end = raw.indexOf('\n---\n', 4)
if (end === -1) return { frontmatter: {}, body: raw.trim() }
const frontmatter = parseFrontmatter(raw.slice(4, end))
return { frontmatter, body: raw.slice(end + 5).trim() }
}
function parseFrontmatter(text: string): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const line of text.split(/\r?\n/)) {
const idx = line.indexOf(':')
if (idx <= 0) continue
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '')
out[key] = value
}
return out
}
function firstParagraph(text: string): string {
return text.split(/\n\s*\n/).map((p) => p.trim()).find(Boolean) || ''
}
function slug(value: string): string {
const next = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
return next || 'skill'
}
function readDirectoryNames(root: string): string[] {
return readdirSync(root).filter((name) => {
const path = resolve(root, name)
return statSync(path).isDirectory()
})
}

View File

@@ -12,7 +12,7 @@ import { execFileSync } from 'child_process'
export interface DoctorCheck {
name: string
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime'
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider'
passed: boolean
message: string
fixable: boolean
@@ -66,6 +66,14 @@ export class DoctorService {
checks.push(this.check_capability_deps())
}
// FR-018/§6.12: toolchain / display / network / provider checks
if (scope === 'all') {
checks.push(...this.check_cpp_toolchain())
checks.push(this.check_display())
checks.push(await this.check_network())
checks.push(...await this.check_provider())
}
const all_passed = checks.every(c => c.passed)
return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length }
}
@@ -219,4 +227,98 @@ export class DoctorService {
return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true }
}
}
// ===== FR-018/§6.12: 5 new categories =====
private check_cpp_toolchain(): DoctorCheck[] {
const tools = ['cmake', 'ninja', 'cppcheck', 'clangd', 'g++']
const reports: DoctorCheck[] = []
for (const t of tools) {
try {
const v = execFileSync('which', [t], { stdio: 'pipe', timeout: 3000 }).toString().trim()
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: true, message: `${t} found at ${v}`, fixable: false })
} catch {
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: false, message: `${t} not found`, fixable: true, fix: `apt install ${t === 'cmake' ? 'cmake' : t === 'ninja' ? 'ninja-build' : t}` })
}
}
return reports
}
private check_display(): DoctorCheck {
const display = process.env.DISPLAY
const wayland = process.env.WAYLAND_DISPLAY
if (!display && !wayland) {
return { name: 'display', category: 'display', passed: false, message: 'No DISPLAY/WAYLAND_DISPLAY (gui.screenshot will fail)', fixable: false }
}
try {
execFileSync('which', ['import'], { stdio: 'pipe' })
return { name: 'display', category: 'display', passed: true, message: `Display ${display || wayland} + ImageMagick available`, fixable: false }
} catch {
return { name: 'display', category: 'display', passed: false, message: 'ImageMagick not installed', fixable: true, fix: 'apt install imagemagick' }
}
}
private async check_network(): Promise<DoctorCheck> {
try {
const r = await fetch('https://1.1.1.1', { method: 'HEAD', signal: AbortSignal.timeout(3000) })
return { name: 'network.internet', category: 'network', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false }
} catch (e: any) {
return { name: 'network.internet', category: 'network', passed: false, message: e.message, fixable: false }
}
}
private async check_provider(): Promise<DoctorCheck[]> {
const reports: DoctorCheck[] = []
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY
const baseUrl = process.env.OPENAI_BASE_URL || process.env.AIRCODING_API_URL
const model = process.env.AIRCODING_MODEL
reports.push({
name: 'provider.api_key',
category: 'provider',
passed: Boolean(apiKey),
message: apiKey ? `API key set (${apiKey.slice(0, 7)}...)` : 'No API key set',
fixable: false,
})
reports.push({
name: 'provider.base_url',
category: 'provider',
passed: Boolean(baseUrl),
message: baseUrl ? `Base URL: ${baseUrl}` : 'No base URL set',
fixable: false,
})
reports.push({
name: 'provider.model',
category: 'provider',
passed: Boolean(model),
message: model || 'No model set',
fixable: false,
})
if (apiKey && baseUrl) {
try {
const r = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/models`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` },
signal: AbortSignal.timeout(5000),
})
reports.push({
name: 'provider.connectivity',
category: 'provider',
passed: r.ok || r.status > 0,
message: `HTTP ${r.status}`,
fixable: false,
})
} catch (e: any) {
reports.push({
name: 'provider.connectivity',
category: 'provider',
passed: false,
message: e.message,
fixable: false,
})
}
}
return reports
}
}

View File

@@ -35,6 +35,8 @@ export { BuiltInToolRegistrar, register_builtin_tools } from './tools/BuiltInToo
// Capabilities
export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js'
export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js'
export { loadSkillDirectory, loadSkillsFromRoots } from './capabilities/SkillLoader.js'
export type { SkillDefinition } from './capabilities/SkillLoader.js'
// Context
export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js'

View File

@@ -65,8 +65,12 @@ export interface ArtifactProjection {
export interface PermissionPromptProjection {
prompt_id: string
tool_name: string
subject: string
risk_level: string
reason: string
options: string[]
default_option?: string
tool_name?: string
}
export interface BlockerProjection {
@@ -291,7 +295,12 @@ export class ProjectionStore {
case 'permission.prompt.requested': {
proj.permission_prompts.push({
prompt_id: p.prompt_id || `pp_${Date.now()}`,
tool_name: p.tool_name, reason: p.reason || ''
subject: p.subject || p.tool_name || 'permission request',
risk_level: p.risk_level || 'unknown',
reason: p.reason || '',
options: Array.isArray(p.options) ? p.options : [],
default_option: p.default_option,
tool_name: p.tool_name,
})
break
}
@@ -334,14 +343,17 @@ export class ProjectionStore {
* Returns the rebuilt projection.
*/
async rebuild(session_id: string): Promise<SessionProjection | undefined> {
const session = this.repos.session ? await this.repos.session.get(session_id as SessionID) : undefined
const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : []
const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : []
// Initialize projection with what we have
if (!session && tasks.length === 0 && agents.length === 0) return undefined
const proj: SessionProjection = {
session_id,
project_id: '',
status: 'active',
project_id: session?.project_id ?? '',
status: session?.status ?? 'active',
title: session?.title,
tasks: tasks.map((t: any) => ({
id: t.id, type: t.type, status: t.status, title: t.title || '',
retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '',
@@ -359,6 +371,7 @@ export class ProjectionStore {
updated_at: new Date().toISOString()
}
this.snapshot.set(session_id, proj)
this.notify(proj)
return proj
}

View File

@@ -14,7 +14,7 @@ import { WavePlanner } from './WavePlanner.js'
import { RetryPlanner } from './RetryPlanner.js'
import { WorkspaceManager } from './WorkspaceManager.js'
import { AgentMonitor } from './AgentMonitor.js'
import { eventIngestor } from '../events/EventIngestor.js'
import { eventIngestor, type IEventIngestor } from '../events/EventIngestor.js'
import type { WorkerManager } from '../workers/WorkerManager.js'
export type SchedulerState =
@@ -48,9 +48,11 @@ export class Scheduler {
private context: SchedulerContext
private worker_manager?: WorkerManager
private task_repo?: any
private event_ingestor: IEventIngestor
constructor(context: SchedulerContext, worker_manager?: WorkerManager) {
constructor(context: SchedulerContext, worker_manager?: WorkerManager, ingestor: IEventIngestor = eventIngestor) {
this.context = context
this.event_ingestor = ingestor
this.graph = new TaskGraph()
this.wave_planner = new WavePlanner()
this.retry_planner = new RetryPlanner()
@@ -62,18 +64,19 @@ export class Scheduler {
/**
* Create tasks from specifications.
*/
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): Promise<void> {
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[]; task_spec?: Record<string, unknown> }>): Promise<void> {
for (const task of tasks) {
this.graph.add_task({
id: task.id,
status: 'pending',
type: task.type, title: task.title,
description: task.description,
task_spec: task.task_spec,
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || []
})
// Emit task.created events (INV-1: via event store for projection)
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_created`,
type: 'task.created',
version: 1,
@@ -86,7 +89,7 @@ export class Scheduler {
task_id: task.id,
type: task.type,
title: task.title,
task_spec_json: { description: task.description || '' },
task_spec_json: task.task_spec || { description: task.description || '' },
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
metadata: {},
}
@@ -132,25 +135,23 @@ export class Scheduler {
break
case 'PLANNING_WAVE': {
// Check if all tasks done
const counts = this.graph.count_by_status()
const remaining = (counts.pending || 0) + (counts.running || 0)
const pending = counts.pending || 0
const running = counts.running || 0
if (remaining === 0) {
this.state = 'COMPLETED'
if (pending === 0 && running === 0) {
this.state = this.terminal_state_from_counts(counts)
return
}
if (running > 0) {
this.state = 'MONITORING'
return
}
// Plan next wave
const plan = this.wave_planner.plan(this.graph)
if (plan.length === 0) {
// Check for blocked tasks
const pending = this.graph.count_by_status().pending || 0
if (pending > 0) {
this.state = 'REPAIRING_OR_CONTINUING'
return
}
this.state = 'COMPLETED'
this.state = pending > 0 ? 'REPAIRING_OR_CONTINUING' : this.terminal_state_from_counts(counts)
return
}
@@ -165,7 +166,7 @@ export class Scheduler {
// INV-1: Emit task.started event (durable) for projection
const now = new Date().toISOString()
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_started`,
type: 'task.started',
version: 1,
@@ -185,7 +186,7 @@ export class Scheduler {
session_id: this.context.session_id,
project_root: this.context.project_root,
task_type: task.type || 'execute',
task_spec: {
task_spec: task.task_spec || {
id: task.id,
title: task.title || task.id,
description: task.description || '',
@@ -196,7 +197,7 @@ export class Scheduler {
this.agent_monitor.record_heartbeat(agent_id, task.id)
// INV-1: Emit agent.started event (durable) for projection
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${agent_id}_started`,
type: 'agent.started',
version: 1,
@@ -218,7 +219,7 @@ export class Scheduler {
})
} catch {
// INV-1: emit task.failed event for projection
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_failed`,
type: 'task.failed',
version: 1,
@@ -245,7 +246,7 @@ export class Scheduler {
const hb = this.agent_monitor.get(l.agent_id)
if (hb) {
const now = new Date().toISOString()
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${hb.task_id}_lost`,
type: 'agent.lost',
version: 1,
@@ -269,7 +270,7 @@ export class Scheduler {
case 'hard_cancel':
case 'soft_cancel':
if (task_id) {
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task_id}_cancelled`,
type: 'agent.cancelled',
version: 1,
@@ -299,7 +300,7 @@ export class Scheduler {
const attempt_id = `${task.id}_1`
if (result.status === 'completed') {
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_completed`,
type: 'task.completed',
version: 1,
@@ -320,7 +321,7 @@ export class Scheduler {
})
this.graph.update_status(task.id, 'completed')
// INV-1: Emit agent.completed event (durable) for projection
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${handle.worker_id}_completed`,
type: 'agent.completed',
version: 1,
@@ -333,7 +334,7 @@ export class Scheduler {
})
this.agent_monitor.remove(handle.worker_id)
} else if (result.status === 'blocked') {
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_blocked`,
type: 'task.blocked',
version: 1,
@@ -347,7 +348,7 @@ export class Scheduler {
this.graph.update_status(task.id, 'blocked')
this.agent_monitor.remove(handle.worker_id)
} else if (result.status === 'cancelled') {
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_cancelled_result`,
type: 'task.cancelled',
version: 1,
@@ -361,7 +362,7 @@ export class Scheduler {
this.graph.update_status(task.id, 'cancelled')
this.agent_monitor.remove(handle.worker_id)
} else {
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${task.id}_failed_result`,
type: 'task.failed',
version: 1,
@@ -374,7 +375,7 @@ export class Scheduler {
})
this.graph.update_status(task.id, 'failed')
// INV-1: Emit agent.failed event (durable) for projection
await eventIngestor.ingest({
await this.event_ingestor.ingest({
id: `evt_${handle.worker_id}_failed`,
type: 'agent.failed',
version: 1,
@@ -440,6 +441,13 @@ export class Scheduler {
}
}
private terminal_state_from_counts(counts: Record<string, number>): SchedulerState {
if ((counts.failed || 0) > 0) return 'TERMINATED'
if ((counts.blocked || 0) > 0) return 'BLOCKED'
if ((counts.cancelled || 0) > 0) return 'CANCELLED'
return 'COMPLETED'
}
/**
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
* Loads pending/running tasks from the tasks table and reconstructs the in-memory graph.

View File

@@ -19,6 +19,7 @@ export interface TaskNode {
title?: string
description?: string
acceptance_criteria?: string[]
task_spec?: Record<string, unknown>
}
export interface GraphValidation {

View File

@@ -132,20 +132,6 @@ export class BuiltInToolRegistrar {
'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration',
{ language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'],
{ read: false, write: true, network: false }),
// cpp toolchain
'cpp.detect': def('cpp.detect', 'debug', 'Detect C++ project structure, toolchain, and source files',
{ project_root: { type: 'string', description: 'Project root path' } }, []),
'cpp.cmake.configure': def('cpp.cmake.configure', 'build', 'Configure C++ build with CMake (Ninja preferred, Make fallback)',
{ generator: { type: 'string', description: 'Generator (Ninja/Unix Makefiles)' }, build_type: { type: 'string', description: 'Debug/Release/RelWithDebInfo' } }, [],
{ read: true, write: true, network: false }),
'cpp.build': def('cpp.build', 'build', 'Build C++ project via CMake',
{ target: { type: 'string', description: 'Build target' }, config: { type: 'string', description: 'Debug/Release' } }, []),
'cpp.test': def('cpp.test', 'test', 'Run C++ tests via ctest',
{ filter: { type: 'string', description: 'Test filter pattern' } }, []),
'cpp.static.cppcheck': def('cpp.static.cppcheck', 'static_analysis', 'Run cppcheck static analysis on C++ code',
{ path: { type: 'string', description: 'Path to analyze' }, severity: { type: 'string', description: 'Minimum severity' } }, []),
'cpp.clangd.query': def('cpp.clangd.query', 'static_analysis', 'Query clangd LSP for symbol definition or diagnostics',
{ file: { type: 'string', description: 'Source file path' }, line: { type: 'number', description: 'Line number' }, column: { type: 'number', description: 'Column number' } }, ['file']),
// debug
'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary',
{ target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']),
@@ -257,88 +243,6 @@ export class BuiltInToolRegistrar {
}
},
'cpp.detect': async (call: any) => {
try {
const root = (call.arguments as any)?.project_root || project_root
const cmake = existsSync(join(root, 'CMakeLists.txt'))
const makefile = existsSync(join(root, 'Makefile'))
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.cmake.configure': async (call: any) => {
try {
const { generator = 'Ninja', build_type = 'Debug' } = (call.arguments || {}) as any
const buildDir = join(project_root, 'build')
if (!existsSync(buildDir)) mkdirSync(buildDir, { recursive: true })
execFileSync('cmake', ['-G', generator, '-DCMAKE_BUILD_TYPE=' + build_type, '..'], { cwd: buildDir, stdio: 'pipe' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { generator, build_type, configured: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.build': async (call: any) => {
try {
const { target, config = 'Debug' } = (call.arguments || {}) as any
const args = target ? ['--build', '.', '--config', config, '--target', target] : ['--build', '.', '--config', config]
const out = execFileSync('cmake', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { built: true, output: out.toString().slice(-500) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.test': async (call: any) => {
try {
const { filter } = (call.arguments || {}) as any
const args = filter ? ['--output-on-failure', '-R', filter] : ['--output-on-failure']
const out = execFileSync('ctest', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { passed: true, output: out.toString().slice(-1000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.static.cppcheck': async (call: any) => {
try {
const { path = 'src' } = (call.arguments || {}) as any
const out = execFileSync('cppcheck', ['--enable=all', '--quiet', path], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 120000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { output: out.toString().slice(-500), issues_found: 0 },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'cpp.clangd.query': async (call: any) => {
try {
const { file, line = 0, column = 0 } = (call.arguments || {}) as any
const out = execFileSync('clangd', ['--check=' + file], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 30000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { file, line, column, diagnostics: out.toString().slice(-1000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'debug.run': async (call: any) => {
try {
const { target } = (call.arguments || {}) as any

View File

@@ -11,6 +11,8 @@ import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from
import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js'
import type { AgentType } from '@aircoding/contracts'
import { eventIngestor } from '../events/EventIngestor.js'
import { eventBus, type Subscription } from '../events/EventBus.js'
export type ToolExecutionReturn =
| ToolResultEnvelope
@@ -47,6 +49,7 @@ export class ToolRegistry {
private executors: Map<string, ToolExecutor> = new Map()
private permission_engine: PermissionEngine
private project_root: string
private readonly permission_timeout_ms = 5 * 60 * 1000
constructor(project_root: string) {
this.project_root = project_root
@@ -263,9 +266,39 @@ export class ToolRegistry {
}
}
case 'ask_user':
// Suspend; emit permission.prompt.requested
return create_error_result(call.call_id, 'user_prompt_required', 'User confirmation required')
case 'ask_user': {
const prompt_id = `perm_${crypto.randomUUID()}`
await eventIngestor.ingest({
id: `evt_${prompt_id}`,
type: 'permission.prompt.requested',
version: 1,
session_id: ctx.session_id,
project_id: ctx.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'tool', id: call.name },
route: ['tool_registry', 'permission'],
payload: {
prompt_id,
subject: call.name,
risk_level: decision.risk_level,
reason: decision.reason,
options: ['allow_once', 'deny'],
default_option: 'deny',
request_ref: { call_id: call.call_id, tool_name: call.name, agent_id: ctx.agent_id },
},
})
const selected = await this.wait_for_permission(prompt_id, ctx)
if (selected !== 'allow_once' && selected !== 'allow') {
return create_error_result(call.call_id, 'permission_denied', `User selected ${selected}`)
}
const executor = this.executors.get(call.name)
if (!executor) {
return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered')
}
return this.execute_executor_final(executor, call, ctx)
}
case 'deny':
return create_error_result(call.call_id, 'permission_denied', decision.reason)
@@ -313,6 +346,45 @@ export class ToolRegistry {
return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function')
}
private wait_for_permission(prompt_id: string, ctx: ToolExecutionContext): Promise<string> {
return new Promise((resolve) => {
let settled = false
let subscription: Subscription | undefined
const finish = (selected: string) => {
if (settled) return
settled = true
clearTimeout(timeout)
if (subscription) eventBus.unsubscribe(subscription)
resolve(selected)
}
const timeout = setTimeout(() => {
void eventIngestor.ingest({
id: `evt_${prompt_id}_timeout`,
type: 'permission.prompt.resolved',
version: 1,
session_id: ctx.session_id,
project_id: ctx.project_id,
timestamp: new Date().toISOString(),
source: { kind: 'tool', id: 'permission_timeout' },
route: ['tool_registry', 'permission'],
payload: {
prompt_id,
selected_option: 'deny',
decision_id: `decision_${crypto.randomUUID()}`,
resolved_by: 'timeout',
},
}).catch(() => finish('deny'))
}, this.permission_timeout_ms)
subscription = eventBus.subscribe({ session_id: ctx.session_id, types: ['permission.prompt.resolved'] }, (event) => {
const payload = event.payload as Record<string, unknown>
if (payload.prompt_id !== prompt_id) return
finish(String(payload.selected_option || 'deny'))
})
})
}
/**
* Execute streaming tool.
*/

View File

@@ -13,6 +13,8 @@ import type { ChildProcess } from 'child_process'
import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js'
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js'
import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts'
import { eventIngestor } from '../events/EventIngestor.js'
import { eventSchemaRegistry } from '../events/EventSchemaRegistry.js'
import type { ToolRegistry } from '../tools/ToolRegistry.js'
import type { ProviderManager } from '@aircoding/llm'
@@ -85,6 +87,7 @@ export class WorkerManager {
state: 'starting',
started_at: new Date().toISOString()
}
this.workers.set(config.agent_id, handle)
// Spawn worker process using Bun
// Worker must run from AirCoding repo root so Bun can resolve modules
@@ -126,7 +129,6 @@ export class WorkerManager {
})
handle.state = 'ready'
this.workers.set(config.agent_id, handle)
// Set up timeout
if (config.timeout_ms) {
@@ -238,6 +240,40 @@ export class WorkerManager {
}
})
// Handle worker-emitted RuntimeEvent payloads through the single EventIngestor entry point.
proc.on_message('event', async (msg) => {
try {
const event_type = (msg.payload.event_type || msg.payload.type) as string
if (!event_type || !eventSchemaRegistry.isRegistered(event_type, 1)) {
console.error(`[WM] ignoring unregistered worker event: ${event_type || '(missing)'}`)
return
}
const { event_type: _eventType, ...restPayload } = msg.payload
const payload = _eventType ? restPayload : (() => {
const { type: _legacyType, ...legacyPayload } = restPayload
return legacyPayload
})()
const event = {
id: (payload.event_id as string) || msg.id,
type: event_type,
version: 1,
timestamp: msg.timestamp || new Date().toISOString(),
session_id: this.execution_context?.session_id || msg.session_id,
project_id: this.execution_context?.project_id || '',
source: { kind: 'agent', id: agent_id, agent_type: this.worker_agent_type(agent_id) },
route: ['worker', agent_id, event_type],
payload,
}
const persistence = eventSchemaRegistry.getPersistence(event_type, 1)
if (persistence === 'durable') await eventIngestor.ingest(event as any)
else if (persistence === 'ephemeral') await eventIngestor.ingest_ephemeral(event as any)
} catch (e: any) {
console.error('[WM] worker event ingest error:', e.message)
}
})
// Handle worker.result → update handle
proc.on_message('worker.result', (msg) => {
const handle = this.workers.get(agent_id)
@@ -324,20 +360,32 @@ export class WorkerManager {
* Get result for a task.
*/
get_result_for_task(task_id: string): WorkerResult<unknown> | undefined {
return this.list().find(w => w.config.task_spec?.id === task_id)?.result
return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)?.result
}
/**
* Get handle for a task.
*/
get_handle_for_task(task_id: string): WorkerHandle | undefined {
return this.list().find(w => w.config.task_spec?.id === task_id)
return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)
}
// ============================================================================
// Private
// ============================================================================
private worker_agent_type(agent_id: string): AgentType {
const handle = this.workers.get(agent_id)
const task_type = handle?.config.task_type || 'execute'
switch (task_type) {
case 'review': return 'reviewer' as AgentType
case 'debug': return 'debugger' as AgentType
case 'compact': return 'compactor' as AgentType
case 'mine_experience': return 'experience_miner' as AgentType
default: return 'executor' as AgentType
}
}
private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void {
const handle = this.workers.get(agent_id)
if (!handle) return
@@ -371,9 +419,11 @@ export class WorkerManager {
const raw_status = (payload.status as string) || 'completed'
const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed'
? raw_status
: raw_status === 'fixed' || raw_status === 'pass'
: raw_status === 'fixed' || raw_status === 'cannot_reproduce' || raw_status === 'pass' || raw_status === 'compacted' || raw_status === 'skipped' || raw_status === 'no_patterns'
? 'completed'
: 'failed'
: raw_status === 'escalated'
? 'blocked'
: 'failed'
const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : []
const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean)
const verification_payload = payload.verification as any
@@ -381,13 +431,15 @@ export class WorkerManager {
: verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[]
: []
const summary = (payload.summary as string)
|| (payload.summary_content as string)
|| (payload.root_cause as string)
|| (payload.error ? String(payload.error) : '')
|| (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`)
return {
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || '' as any,
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || (handle.config.task_spec?.task_id as string) || '' as any,
agent_id: (payload.agent_id as string) || handle.config.agent_id as any,
agent_type: (payload.agent_type as AgentType) || 'executor',
agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id),
status: status as WorkerStatus,
summary,
changed_files,

View File

@@ -1,59 +1,55 @@
/**
* Regression test: EvidenceStore SQLite persistence
*
* Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of
* in-memory Map for persistent storage.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { afterEach, describe, expect, test } from 'bun:test'
import { Database } from 'bun:sqlite'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const SOURCE_PATH = join(
import.meta.dir,
'..',
'..',
'src',
'artifacts',
'EvidenceStore.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
import { createEvidenceStore } from '../../src/artifacts/EvidenceStore.js'
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
describe('EvidenceStore SQLite persistence', () => {
test('EvidenceStore does not use in-memory Map', () => {
// Should not have Map< for storage
expect(source).not.toMatch(/evidenceStore:\s*Map</)
// Should not use .set() on a map
expect(source).not.toContain('this.evidenceStore.set(')
const created: string[] = []
afterEach(() => {
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
})
test('EvidenceStore constructor accepts Database parameter', () => {
// Constructor should accept a Database parameter
expect(source).toContain('db: Database')
// Should import Database from bun:sqlite
expect(source).toContain("from 'bun:sqlite'")
})
test('persists evidence refs in SQLite and can list them by entity', async () => {
const dir = mkdtempSync(join(tmpdir(), 'air-evidence-store-'))
created.push(dir)
const dbPath = join(dir, 'evidence.db')
test('EvidenceStore has initSchema method', () => {
expect(source).toContain('initSchema()')
// Should be called in constructor
expect(source).toContain('this.initSchema()')
})
const db1 = new Database(dbPath)
const store1 = createEvidenceStore('session_evidence' as any, db1, createNullEventIngestor() as any)
const createdRef = await store1.create({
kind: 'command_output',
ref: 'artifact://stdout.txt',
claim: 'command produced expected output',
task_id: 'task_1' as any,
location_json: { line: 1 },
})
expect(createdRef.evidence_ref_id).toStartWith('evi_')
expect((await store1.list_for_entity('task', 'task_1'))[0]).toMatchObject({
evidence_ref_id: createdRef.evidence_ref_id,
kind: 'command_output',
ref: 'artifact://stdout.txt',
claim: 'command produced expected output',
location_json: { line: 1 },
})
db1.close()
test('EvidenceStore creates evidence_refs table', () => {
expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs')
// Should have key columns
expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY')
expect(source).toContain('session_id TEXT NOT NULL')
expect(source).toContain('kind TEXT NOT NULL')
})
test('EvidenceStore uses INSERT INTO for create', () => {
expect(source).toContain('INSERT INTO evidence_refs')
})
test('EvidenceStore applies WAL PRAGMA', () => {
expect(source).toContain('PRAGMA journal_mode = WAL')
const db2 = new Database(dbPath)
const rows = db2.query('SELECT evidence_ref_id, session_id, kind, ref, claim, location_json, task_id FROM evidence_refs').all() as any[]
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
evidence_ref_id: createdRef.evidence_ref_id,
session_id: 'session_evidence',
kind: 'command_output',
ref: 'artifact://stdout.txt',
claim: 'command produced expected output',
task_id: 'task_1',
})
expect(JSON.parse(rows[0].location_json)).toEqual({ line: 1 })
expect(db2.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: 'wal' })
db2.close()
})
})

View File

@@ -1,111 +1,88 @@
/**
* C1 regression: Knowledge Store schema alignment.
* Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths,
* had non-canonical column names, and were missing PRAGMAs.
* Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { afterEach, describe, expect, it } from 'bun:test'
import { existsSync, mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DebugKnowledgeStore } from '../../src/knowledge/DebugKnowledgeStore.js'
import { LearnedMemoryStore } from '../../src/knowledge/LearnedMemoryStore.js'
describe('C1: Knowledge Store schema alignment', () => {
const debug_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'),
'utf-8'
)
const memory_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'),
'utf-8'
)
const created: string[] = []
it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => {
expect(debug_src).toContain("'.air', 'local', 'debug-records.db'")
expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'")
afterEach(() => {
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
})
it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => {
expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'")
expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'")
it('DebugKnowledgeStore stores and queries records from .air/local', () => {
const root = mkdtempSync(join(tmpdir(), 'air-debug-store-'))
created.push(root)
const store = new DebugKnowledgeStore(root)
store.open()
const now = new Date().toISOString()
store.insert({
id: 'debug_1',
failure_signature: 'compiler:error:missing-header',
task_id: 'task_1',
root_cause: 'missing include path',
fix_ref: 'fix://1',
summary: 'Add include path before rebuilding',
evidence_json: JSON.stringify(['evi_1']),
verification_json: JSON.stringify(['build passed']),
created_at: now,
updated_at: now,
metadata_json: JSON.stringify({ source: 'test' }),
})
expect(existsSync(join(root, '.air', 'local', 'debug-records.db'))).toBe(true)
expect(existsSync(join(root, '.air', 'shared', 'debug-records.db'))).toBe(false)
expect(store.lookup_by_signature('compiler:error:missing-header')).toHaveLength(1)
expect(store.lookup_by_task('task_1')[0]).toMatchObject({
id: 'debug_1',
failure_signature: 'compiler:error:missing-header',
task_id: 'task_1',
root_cause: 'missing include path',
fix_ref: 'fix://1',
summary: 'Add include path before rebuilding',
})
store.update('debug_1', { summary: 'Updated summary', updated_at: now })
expect(store.lookup_by_signature('compiler:error:missing-header')[0].summary).toBe('Updated summary')
})
it('DebugRecord has failure_signature not signature', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
it('LearnedMemoryStore stores candidates/promoted memories from .air/local', () => {
const root = mkdtempSync(join(tmpdir(), 'air-memory-store-'))
created.push(root)
const store = new LearnedMemoryStore(root)
store.open()
const now = new Date().toISOString()
expect(iface_body).toContain('failure_signature')
// Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern)
expect(iface_body).not.toMatch(/^\s*signature\s*:/m)
})
store.insert({
id: 'mem_1',
memory_type: 'project_rule',
summary: 'Use Bun for package scripts',
content: 'Project commands should use Bun unless explicitly overridden.',
source_entity_type: 'task',
source_entity_id: 'task_1',
status: 'candidate',
created_at: now,
updated_at: now,
metadata_json: JSON.stringify({ confidence: 0.8 }),
})
it('DebugRecord has summary and fix_ref fields', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(existsSync(join(root, '.air', 'local', 'learned-memory.db'))).toBe(true)
expect(existsSync(join(root, '.air', 'shared', 'learned-memory.db'))).toBe(false)
expect(store.lookup_by_type('project_rule')).toHaveLength(1)
expect(store.lookup_by_type('project_rule')[0]).toMatchObject({
id: 'mem_1',
memory_type: 'project_rule',
status: 'candidate',
source_entity_type: 'task',
source_entity_id: 'task_1',
})
expect(iface_body).toContain('summary')
expect(iface_body).toContain('fix_ref')
})
it('DebugRecord does not have error_kind or session_id', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).not.toContain('error_kind')
expect(iface_body).not.toContain('session_id')
})
it('DebugKnowledgeStore applies WAL PRAGMA', () => {
expect(debug_src).toContain('PRAGMA journal_mode = WAL')
})
it('LearnedMemoryStore table is learned_memories (plural)', () => {
expect(memory_src).toContain('learned_memories')
// Ensure we don't have the singular form used as table name
expect(memory_src).not.toMatch(/FROM learned_memory\b/)
expect(memory_src).not.toMatch(/INTO learned_memory\b/)
expect(memory_src).not.toMatch(/UPDATE learned_memory\b/)
expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/)
})
it('MemoryEntry.memory_type has 4 spec values', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain("'project_rule'")
expect(iface_body).toContain("'toolchain_rule'")
expect(iface_body).toContain("'skill_update'")
expect(iface_body).toContain("'debug_experience'")
expect(iface_body).toContain('memory_type')
})
it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain("'candidate'")
expect(iface_body).toContain("'promoted'")
expect(iface_body).toContain("'archived'")
expect(iface_body).toContain("'rejected'")
})
it('MemoryEntry.status default is candidate not draft', () => {
// Check that the CREATE TABLE DDL uses 'candidate' as default
expect(memory_src).toContain("DEFAULT 'candidate'")
expect(memory_src).not.toContain("DEFAULT 'draft'")
})
it('MemoryEntry uses source_entity_type + source_entity_id', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('source_entity_type')
expect(iface_body).toContain('source_entity_id')
expect(iface_body).not.toContain('source_task_ids')
store.update_status('mem_1', 'promoted')
expect(store.lookup_by_type('project_rule')[0].status).toBe('promoted')
store.update_status('mem_1', 'archived')
expect(store.lookup_by_type('project_rule')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'bun:test'
import { ProjectionStore } from '../../src/projection/ProjectionStore.js'
import type { RuntimeEvent } from '@aircoding/contracts'
function event(type: string, payload: Record<string, unknown>): RuntimeEvent<Record<string, unknown>> {
return {
id: `evt_${type}_${Math.random().toString(36).slice(2)}`,
type,
version: 1,
timestamp: new Date().toISOString(),
session_id: 'session_projection_apply' as any,
project_id: 'project_projection_apply' as any,
source: { kind: 'system' },
route: ['test', type],
payload,
}
}
describe('ProjectionStore.apply', () => {
it('applies task and agent lifecycle events into a live snapshot', () => {
const store = new ProjectionStore()
const updates: string[] = []
store.subscribe((projection) => {
updates.push(`${projection.tasks[0]?.status || 'none'}:${projection.agents[0]?.status || 'none'}`)
})
store.apply(event('task.created', {
task_id: 'task_1',
type: 'execute',
title: 'Create file',
task_spec_json: {},
dependencies: [],
metadata: {},
}))
store.apply(event('task.started', {
task_id: 'task_1',
agent_id: 'agent_task_1',
attempt_id: 'task_1_1',
attempt_index: 0,
workspace_id: 'ws_task_1',
}))
store.apply(event('agent.started', {
agent_id: 'agent_task_1',
agent_type: 'executor',
task_id: 'task_1',
metadata: {},
}))
store.apply(event('agent.completed', {
agent_id: 'agent_task_1',
task_id: 'task_1',
summary: 'done',
metadata: {},
}))
store.apply(event('task.completed', {
task_id: 'task_1',
agent_id: 'agent_task_1',
attempt_id: 'task_1_1',
worker_result_json: { status: 'completed' },
summary: 'done',
changed_files: ['hello.txt'],
evidence_refs: [],
}))
const snapshot = store.get_snapshot('session_projection_apply')
expect(snapshot).toBeDefined()
expect(snapshot!.tasks).toHaveLength(1)
expect(snapshot!.tasks[0].status).toBe('completed')
expect(snapshot!.tasks[0].agent_id).toBe('agent_task_1')
expect(snapshot!.tasks[0].attempts).toBe(1)
expect(snapshot!.agents).toHaveLength(1)
expect(snapshot!.agents[0].status).toBe('completed')
expect(updates.some((u) => u.startsWith('completed:completed'))).toBe(true)
})
it('applies tool, permission, and blocker events', () => {
const store = new ProjectionStore()
store.apply(event('tool.started', {
tool_run_id: 'tool_1',
tool_name: 'fs.write',
input_json: {},
metadata: {},
}))
store.apply(event('tool.completed', {
tool_run_id: 'tool_1',
output_json: { ok: true },
duration_ms: 12,
artifact_ids: [],
evidence_refs: [],
metadata: {},
}))
store.apply(event('permission.prompt.requested', {
prompt_id: 'perm_1',
subject: 'shell.run',
risk_level: 'medium',
reason: 'risk score 70 requires user confirmation',
options: ['allow_once', 'deny'],
default_option: 'deny',
request_ref: {},
}))
store.apply(event('task.created', {
task_id: 'task_blocked',
type: 'execute',
title: 'Blocked task',
task_spec_json: {},
dependencies: [],
metadata: {},
}))
store.apply(event('task.blocked', {
task_id: 'task_blocked',
agent_id: 'agent_task_blocked',
reason: 'worker blocked',
blocker_kind: 'worker_blocked',
evidence_refs: [],
suggested_next_step: 'review blocker',
}))
store.apply(event('permission.prompt.resolved', {
prompt_id: 'perm_1',
selected_option: 'deny',
decision_id: 'decision_1',
resolved_by: 'test',
}))
const snapshot = store.get_snapshot('session_projection_apply')
expect(snapshot).toBeDefined()
expect(snapshot!.tool_runs).toEqual([{ tool_run_id: 'tool_1', tool_name: 'fs.write', status: 'ok', duration_ms: 12 }])
expect(snapshot!.permission_prompts).toHaveLength(0)
expect(snapshot!.tasks.find((task) => task.id === 'task_blocked')?.status).toBe('blocked')
expect(snapshot!.blockers).toEqual([{ task_id: 'task_blocked', reason: 'worker blocked', blocker_kind: 'worker_blocked' }])
})
})

View File

@@ -1,55 +1,95 @@
/**
* Regression test: Recovery implementation completeness
*
* Verifies that checkPidLiveness and scanOrphanReferences have real
* implementations, not just stub return values.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { afterEach, describe, expect, test } from 'bun:test'
import { Database } from 'bun:sqlite'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const SOURCE_PATH = join(
import.meta.dir,
'..',
'..',
'src',
'storage',
'Recovery.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
import { Recovery } from '../../src/storage/Recovery.js'
describe('Recovery implementation', () => {
test('checkPidLiveness is not a stub (has implementation code)', () => {
// Should have actual implementation with loop logic
expect(source).toContain('for (const agent of agents)')
expect(source).toContain("action: alive ? 'keep' : 'mark_lost'")
// Should have more than just a bare return []
expect(source).toContain('const reports: PidLivenessReport[] = []')
const created: string[] = []
afterEach(() => {
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
})
test('checkPidLiveness uses process.kill for liveness check', () => {
// Should use process.kill(pid, 0) for signal-0 liveness check
expect(source).toContain('process.kill(agent.pid, 0)')
function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } {
const root = mkdtempSync(join(tmpdir(), 'air-recovery-'))
created.push(root)
const artifactRoot = join(root, 'artifacts')
const dbPath = join(root, 'session.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE sessions (id TEXT PRIMARY KEY);
CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT);
CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT);
CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT);
INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session');
INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task');
`)
db.close()
return {
recovery: new Recovery({
sessionId: 'session_recovery' as any,
projectId: 'project_recovery' as any,
artifactRoot,
dbPath,
projectRoot: root,
}),
root,
artifactRoot,
dbPath,
}
}
test('checks PID liveness with keep/mark_lost actions', () => {
const { recovery } = makeRecovery()
const reports = recovery.checkPidLiveness([
{ agent_id: 'self', pid: process.pid },
{ agent_id: 'missing', pid: 99999999 },
])
recovery.close()
expect(reports).toEqual([
{ agent_id: 'self', pid: process.pid, alive: true, action: 'keep' },
{ agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' },
])
})
test('scanOrphanReferences returns OrphanReferenceReport structure', () => {
// Should define fkChecks array with the 8 invariant checks
expect(source).toContain('fkChecks')
expect(source).toContain("table: 'tasks'")
expect(source).toContain("table: 'messages'")
expect(source).toContain("table: 'task_attempts'")
expect(source).toContain("table: 'agents'")
expect(source).toContain("table: 'tool_runs'")
expect(source).toContain("table: 'command_runs'")
expect(source).toContain("table: 'artifacts'")
expect(source).toContain("table: 'evidence_refs'")
test('scans orphan references from SQLite tables', async () => {
const { recovery } = makeRecovery()
const report = await recovery.scan()
recovery.close()
// Should iterate over checks
expect(source).toContain('for (const check of fkChecks)')
expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2)
expect(report.orphanReferences.archived).toContainEqual({
table: 'tasks',
id: 'missing_session',
reason: 'FK-off: session_id → sessions (1 rows)',
})
expect(report.orphanReferences.archived).toContainEqual({
table: 'task_attempts',
id: 'missing_task',
reason: 'FK-off: task_id → tasks (1 rows)',
})
})
// Should return a proper report
expect(source).toContain('return report')
test('quarantines non-artifact temporary orphan files', async () => {
const { recovery, artifactRoot } = makeRecovery()
const tmpDir = join(artifactRoot, 'tmp')
const orphanPath = join(tmpDir, 'scratch.tmp')
await Bun.write(orphanPath, 'orphan')
const report = await recovery.scan()
recovery.close()
expect(report.orphanArtifacts.totalFound).toBe(1)
expect(report.orphanArtifacts.quarantined).toHaveLength(1)
expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true)
expect(existsSync(orphanPath)).toBe(false)
})
})

View File

@@ -7,6 +7,7 @@ import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js'
import { Scheduler } from '../../src/scheduler/Scheduler.js'
import { MainAgent } from '../../src/agents/main/MainAgent.js'
import { ContextAssembler } from '../../src/context/ContextAssembler.js'
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
function createRegistry(projectRoot: string): ToolRegistry {
const registry = new ToolRegistry(projectRoot)
@@ -59,8 +60,8 @@ describe('Release critical gates', () => {
get_handle_for_task: () => undefined,
get_result_for_task: () => undefined,
}
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any)
scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
scheduler.get_graph().update_status('task-1' as any, 'running')
await scheduler.step()
@@ -68,6 +69,34 @@ describe('Release critical gates', () => {
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
})
it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => {
const workerManager = {
has_running: () => false,
get_handle_for_task: () => ({ worker_id: 'agent-task-1' }),
get_result_for_task: () => ({
task_id: 'task-1',
agent_id: 'agent-task-1',
agent_type: 'executor',
status: 'blocked',
summary: 'blocked by worker',
changed_files: [],
artifacts: [],
verification: [],
risks: [],
follow_up_tasks: [],
evidence_refs: [],
result: {},
}),
}
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
scheduler.get_graph().update_status('task-1' as any, 'running')
const finalState = await scheduler.run_until_idle()
expect(finalState).toBe('BLOCKED')
expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1)
})
it('MainAgent answer mode uses assembled project context', async () => {
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
writeFileSync(join(projectRoot, 'visible.txt'), 'visible')

View File

@@ -6,6 +6,7 @@
import { describe, it, expect } from 'bun:test'
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
describe('B1: Scheduler wire-up', () => {
it('SchedulerState includes BLOCKED and CANCELLED', () => {
@@ -54,10 +55,12 @@ describe('B1: Scheduler wire-up', () => {
session_id: 'test-session' as any,
project_id: 'test-project' as any,
project_root: '/tmp/test'
}
},
undefined,
createNullEventIngestor(),
)
scheduler.create_tasks([
await scheduler.create_tasks([
{ id: 't1' as any, type: 'code', title: 'Task 1' },
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
])

View File

@@ -50,8 +50,9 @@ describe('A5+A4: ToolRegistry permission fixes', () => {
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
})
it('permission denial branches preserve original call_id', () => {
expect(src).toContain("create_error_result(call.call_id, 'user_prompt_required'")
it('permission branches preserve original call_id', () => {
expect(src).toContain('permission.prompt.requested')
expect(src).toContain('request_ref: { call_id: call.call_id')
expect(src).toContain("create_error_result(call.call_id, 'permission_denied'")
expect(src).not.toContain("create_error_result('', 'user_prompt_required'")
expect(src).not.toContain("create_error_result('', 'permission_denied'")

View File

@@ -45,9 +45,13 @@ describe('D3: Worker result envelope', () => {
})
it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => {
expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'")
expect(source).toContain('agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id)')
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'")
expect(source).toContain("raw_status === 'fixed'")
expect(source).toContain("raw_status === 'pass'")
expect(source).toContain("raw_status === 'cannot_reproduce'")
expect(source).toContain("raw_status === 'compacted'")
expect(source).toContain("raw_status === 'no_patterns'")
expect(source).toContain('changes.map((c: any) => String(c.file))')
expect(source).toContain("verification_payload ? [{ command: 'worker verification'")
expect(source).toContain('result: (payload.result as unknown) || payload')

View File

@@ -36,7 +36,7 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => {
const result = detector.detect()
return { call_id: call.id, tool_name: 'cpp.detect', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } }
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.detect', output: result, metadata: { timestamp: new Date().toISOString() } }
})
// cpp.configure
@@ -47,7 +47,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: true, network: false }, streaming: false
}, async (call) => {
const result = configurator.configure({ project_root, generator: call.arguments?.generator as any, build_type: call.arguments?.build_type as any })
return { call_id: call.id, tool_name: 'cpp.configure', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.configure', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.configure', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'configure failed', retryability: 'not_retryable', semantic_signature: 'cpp.configure' }, metadata: { timestamp: new Date().toISOString() } }
}
})
// cpp.build
@@ -58,7 +62,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: true, network: false }, streaming: false
}, async (call) => {
const result = builder.build(project_root + '/build', call.arguments?.target as string)
return { call_id: call.id, tool_name: 'cpp.build', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.build', output: { built: true, output: result.output, diagnostics: result.diagnostics, elapsed_ms: result.elapsed_ms }, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.build', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'build failed', retryability: 'not_retryable', semantic_signature: 'cpp.build' }, metadata: { timestamp: new Date().toISOString() } }
}
})
// cpp.test
@@ -69,7 +77,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => {
const result = tester.run_tests(project_root + '/build')
return { call_id: call.id, tool_name: 'cpp.test', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.test', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.test', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'test failed', retryability: 'not_retryable', semantic_signature: 'cpp.test' }, metadata: { timestamp: new Date().toISOString() } }
}
})
// cpp.cppcheck
@@ -80,7 +92,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => {
const result = cppcheck.run(project_root, { enable_all: call.arguments?.enable_all as boolean, check_config: call.arguments?.check_config as boolean })
return { call_id: call.id, tool_name: 'cpp.cppcheck', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.cppcheck', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.cppcheck', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'cppcheck failed', retryability: 'not_retryable', semantic_signature: 'cpp.cppcheck' }, metadata: { timestamp: new Date().toISOString() } }
}
})
// cpp.clangd
@@ -91,7 +107,11 @@ export class CppToolRegistrar {
permissions: { read: true, write: false, network: false }, streaming: false
}, async (call) => {
const result = await clangd.query_symbol(call.arguments?.file as string, call.arguments?.line as number, call.arguments?.column as number)
return { call_id: call.id, tool_name: 'cpp.clangd', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } }
if (result.ok) {
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.clangd', output: result, metadata: { timestamp: new Date().toISOString() } }
} else {
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.clangd', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'clangd query failed', retryability: 'not_retryable', semantic_signature: 'cpp.clangd' }, metadata: { timestamp: new Date().toISOString() } }
}
})
}
}

View File

@@ -6,7 +6,8 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./preload": "./src/preload.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
@@ -15,7 +16,9 @@
},
"dependencies": {
"@aircoding/contracts": "workspace:*",
"@aircoding/runtime": "workspace:*"
"@opentui/core": "0.3.0",
"@opentui/solid": "0.3.0",
"solid-js": "1.9.10"
},
"devDependencies": {
"@types/node": "^25.9.1",

View File

@@ -1,53 +1,28 @@
/**
* ProjectionClient - Local TUI-side projection consumer
* TUI copies minimal projection types from contracts to avoid INV-4 violation
* (TUI must only depend on contracts; dd §13.2 / c4/code-view §2 rule 3).
*
* The runtime package provides the authoritative ProjectionClient in
* `runtime/projection/ProjectionClient.ts`. TUI defines its own local copy
* with the same surface so that subscriptions work in-process.
* ProjectionClient - Local TUI-side projection consumer.
* TUI keeps a contracts-only copy of the ProjectionClient surface so it never imports runtime.
*
* @module packages/tui/src/ProjectionClient
*/
import type { SessionID, ProjectID, TaskID, AgentID, ISOTimeString } from '@aircoding/contracts'
import type { ProjectionSubscriber, SessionProjection } from './types.js'
export interface SessionProjection {
session_id: SessionID
project_id: ProjectID
status: string
title?: string
tasks: TaskProjection[]
agents: AgentProjection[]
}
export interface TaskProjection {
id: TaskID
type: string
status: string
title: string
retry_count: number
attempts: number
created_at: string
}
export interface AgentProjection {
id: AgentID
type: string
status: string
task_id?: TaskID
last_heartbeat?: string
}
export type ProjectionSubscriber = (projection: SessionProjection) => void
export type {
SessionProjection,
TaskProjection,
AgentProjection,
ToolRunProjection,
CommandRunProjection,
ArtifactProjection,
PermissionPromptProjection,
BlockerProjection,
ProjectionSubscriber,
} from './types.js'
export class ProjectionClient {
private snapshot: SessionProjection | null = null
private subscribers: Set<ProjectionSubscriber> = new Set()
/**
* Receive and cache a projection snapshot.
*/
receive_snapshot(projection: SessionProjection): void {
this.snapshot = projection
for (const sub of this.subscribers) {
@@ -55,17 +30,11 @@ export class ProjectionClient {
}
}
/**
* Subscribe to projection updates.
*/
subscribe(subscriber: ProjectionSubscriber): () => void {
this.subscribers.add(subscriber)
return () => this.subscribers.delete(subscriber)
}
/**
* Get current snapshot.
*/
get_snapshot(): SessionProjection | null {
return this.snapshot
}

View File

@@ -1,311 +1,712 @@
/** @jsxImportSource @opentui/solid */
/**
* TuiApp - Main TUI application shell
* DD §13.2. Real terminal rendering using ANSI escape codes.
* TuiApp - OpenTUI/Solid application shell
* DD §13.2. Projection-only display plus single OpenTUI textarea input owner.
*
* @module packages/tui/src/TuiApp
*/
import { SessionView } from './components/SessionView.js'
import { TaskListView } from './components/TaskListView.js'
import { AgentStatusView } from './components/AgentStatusView.js'
import { HudView } from './components/HudView.js'
import type { SessionProjection } from './ProjectionClient.js'
import { createCliRenderer, type CliRenderer, type TextareaRenderable, type KeyEvent } from '@opentui/core'
import { render, useRenderer, useTerminalDimensions } from '@opentui/solid'
import { createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
import type { SessionProjection } from './types.js'
export interface TuiAppProps {
client: {
subscribe(handler: (projection: SessionProjection) => void): () => void
receive_snapshot(projection: SessionProjection): void
get_snapshot(): SessionProjection | null
}
onSubmit?: (input: string) => void | Promise<void>
onSlashCommand?: (input: string) => void | Promise<void>
onResolvePermission?: (prompt_id: string, selected_option: string) => void | Promise<void>
onExit?: () => void | Promise<void>
}
export interface TuiAppState {
projection: SessionProjection | null
active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help'
busy: boolean
status: string
}
const ANSI = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
underscore: '\x1b[4m',
blink: '\x1b[5m',
reverse: '\x1b[7m',
hidden: '\x1b[8m',
fg: {
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
},
bg: {
black: '\x1b[40m',
red: '\x1b[41m',
green: '\x1b[42m',
yellow: '\x1b[43m',
blue: '\x1b[44m',
magenta: '\x1b[45m',
cyan: '\x1b[46m',
white: '\x1b[47m',
},
clear: '\x1b[2J\x1b[H',
clearLine: '\x1b[2K',
cursor: {
home: '\x1b[H',
save: '\x1b[s',
restore: '\x1b[u',
hide: '\x1b[?25l',
show: '\x1b[?25h',
up: (n = 1) => `\x1b[${n}A`,
down: (n = 1) => `\x1b[${n}B`,
right: (n = 1) => `\x1b[${n}C`,
left: (n = 1) => `\x1b[${n}D`,
const THEME = {
bg: '#0b0f14',
surface: '#111827',
surface2: '#1f2937',
text: '#e5e7eb',
muted: '#9ca3af',
faint: '#6b7280',
accent: '#22d3ee',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
border: '#374151',
}
const PROMPT_HISTORY_LIMIT = 200
const TEXTAREA_MIN_ROWS = 1
const TEXTAREA_MAX_ROWS = 6
const EXIT_CONFIRM_MS = 5000
type PromptHistoryState = {
items: string[]
index: number | null
draft: string
}
type PromptHistoryMove = {
state: PromptHistoryState
apply: boolean
text?: string
cursor?: number
}
function createPromptHistory(): PromptHistoryState {
return { items: [], index: null, draft: '' }
}
function pushPromptHistory(state: PromptHistoryState, prompt: string): PromptHistoryState {
const text = prompt.trim()
if (!text) return state
if (state.items[state.items.length - 1] === text) {
return { ...state, index: null, draft: '' }
}
return { items: [...state.items, text].slice(-PROMPT_HISTORY_LIMIT), index: null, draft: '' }
}
function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptHistoryMove {
if (state.items.length === 0) return { state, apply: false }
if (dir === -1 && cursor !== 0) return { state, apply: false }
if (dir === 1 && cursor !== text.length) return { state, apply: false }
if (state.index === null) {
if (dir === 1) return { state, apply: false }
const idx = state.items.length - 1
return { state: { ...state, index: idx, draft: text }, text: state.items[idx], cursor: 0, apply: true }
}
const idx = state.index + dir
if (idx < 0) return { state, apply: false }
if (idx >= state.items.length) {
return { state: { ...state, index: null }, text: state.draft, cursor: state.draft.length, apply: true }
}
return { state: { ...state, index: idx }, text: state.items[idx], cursor: dir === -1 ? 0 : state.items[idx].length, apply: true }
}
export class TuiApp {
private client: TuiAppProps['client']
private state: TuiAppState
private unsubscribe: (() => void) | null = null
private running: boolean = false
private onSubmit?: TuiAppProps['onSubmit']
private onSlashCommand?: TuiAppProps['onSlashCommand']
private onResolvePermission?: TuiAppProps['onResolvePermission']
private onExit?: TuiAppProps['onExit']
private renderer: CliRenderer | null = null
private unsubscribeClient: (() => void) | null = null
private setProjection?: (projection: SessionProjection | null) => void
private setView?: (view: TuiAppState['active_view']) => void
private setBusy?: (busy: boolean) => void
private setStatus?: (status: string) => void
constructor(props: TuiAppProps) {
this.client = props.client
this.state = { projection: null, active_view: 'tasks' }
this.onSubmit = props.onSubmit
this.onSlashCommand = props.onSlashCommand
this.onResolvePermission = props.onResolvePermission
this.onExit = props.onExit
}
async start(): Promise<void> {
this.unsubscribe = this.client.subscribe((projection) => {
this.state.projection = projection
this.render()
if (this.renderer) return
this.renderer = await createCliRenderer({
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
exitOnCtrlC: false,
screenMode: 'alternate-screen',
externalOutputMode: 'capture-stdout',
consoleMode: 'disabled',
clearOnShutdown: true,
openConsoleOnError: false,
useKittyKeyboard: {},
backgroundColor: THEME.bg,
})
this.renderer.setBackgroundColor(THEME.bg)
const snapshot = this.client.get_snapshot()
if (snapshot) {
this.state.projection = snapshot
}
await render(() => (
<AirCodingView
initialProjection={this.client.get_snapshot()}
bindState={(bindings) => {
this.setProjection = bindings.setProjection
this.setView = bindings.setView
this.setBusy = bindings.setBusy
this.setStatus = bindings.setStatus
}}
onSubmit={(input) => this.submit(input)}
onSlashCommand={(input) => this.slash(input)}
onResolvePermission={(prompt_id, selected_option) => this.resolvePermission(prompt_id, selected_option)}
onExit={() => this.exit()}
/>
), this.renderer)
this.running = true
this.setup_input()
this.render()
this.unsubscribeClient = this.client.subscribe((projection) => {
this.setProjection?.(projection)
})
}
stop(): void {
this.running = false
this.unsubscribe?.()
this.unsubscribe = null
process.stdout.write(ANSI.cursor.show + ANSI.reset)
this.unsubscribeClient?.()
this.unsubscribeClient = null
this.setProjection = undefined
this.setView = undefined
this.setBusy = undefined
this.setStatus = undefined
if (this.renderer && !this.renderer.isDestroyed) {
this.renderer.setTerminalTitle('')
this.renderer.externalOutputMode = 'passthrough'
this.renderer.destroy()
}
this.renderer = null
}
set_view(view: TuiAppState['active_view']): void {
this.state.active_view = view
this.render()
this.setView?.(view)
}
private setup_input(): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
process.stdin.resume()
process.stdin.setEncoding('utf8')
set_busy(busy: boolean, status?: string): void {
this.setBusy?.(busy)
if (status) this.setStatus?.(status)
}
process.stdin.on('data', (key: string) => {
this.handle_input(key)
})
set_status(status: string): void {
this.setStatus?.(status)
}
private async submit(input: string): Promise<void> {
const text = input.trim()
if (!text) return
this.setBusy?.(true)
this.setStatus?.(`Running: ${text.slice(0, 72)}`)
try {
await this.onSubmit?.(text)
this.setStatus?.('Ready')
} catch (error) {
this.setStatus?.(error instanceof Error ? error.message : String(error))
throw error
} finally {
this.setBusy?.(false)
}
}
private handle_input(key: string): void {
switch (key) {
case 'q':
case '': // Ctrl+C
this.stop()
process.exit(0)
break
case '1':
this.set_view('tasks')
break
case '2':
this.set_view('agents')
break
case '3':
this.set_view('tools')
break
case '4':
this.set_view('diff')
break
case '?':
case 'h':
this.set_view('help')
break
private async slash(input: string): Promise<void> {
const text = input.trim()
if (!text) return
if (text === '/quit' || text === '/exit') {
await this.exit()
return
}
this.setStatus?.(`Command: ${text}`)
await this.onSlashCommand?.(text)
}
private render(): void {
if (!this.running) return
private async resolvePermission(prompt_id: string, selected_option: string): Promise<void> {
if (!this.onResolvePermission) {
this.setStatus?.('Permission selection requires runtime resolver')
return
}
this.setStatus?.(`Permission: ${selected_option}`)
await this.onResolvePermission(prompt_id, selected_option)
}
const p = this.state.projection
const lines: string[] = []
private async exit(): Promise<void> {
await this.onExit?.()
}
}
// Header
lines.push(ANSI.clear)
lines.push(ANSI.fg.cyan + ANSI.bright + '═══════════════════════════════════════════════════════════════' + ANSI.reset)
lines.push(ANSI.fg.cyan + ANSI.bright + ' AirCoding v1.0.0-alpha' + ANSI.reset + ANSI.fg.gray + ' │ ' + (p ? `${p.tasks.length} tasks` : 'No session') + ' │ ' + this.get_status_indicator(p) + ANSI.reset)
lines.push(ANSI.fg.cyan + '═══════════════════════════════════════════════════════════════' + ANSI.reset)
type StateBindings = {
setProjection: (projection: SessionProjection | null) => void
setView: (view: TuiAppState['active_view']) => void
setBusy: (busy: boolean) => void
setStatus: (status: string) => void
}
// Navigation hints
lines.push(ANSI.fg.gray + ' [1]Tasks [2]Agents [3]Tools [4]Diff [h]Help [q]Quit' + ANSI.reset)
type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error'
// Content area
lines.push('')
function AirCodingView(props: {
initialProjection: SessionProjection | null
bindState: (bindings: StateBindings) => void
onSubmit: (input: string) => void | Promise<void>
onSlashCommand: (input: string) => void | Promise<void>
onResolvePermission: (prompt_id: string, selected_option: string) => void | Promise<void>
onExit: () => void | Promise<void>
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const [projection, setProjection] = createSignal<SessionProjection | null>(props.initialProjection)
const [activeView, setActiveView] = createSignal<TuiAppState['active_view']>('tasks')
const [busy, setBusy] = createSignal(false)
const [status, setStatus] = createSignal('Ready')
const [footerPhase, setFooterPhase] = createSignal<FooterPhase>('idle')
const [toast, setToast] = createSignal('')
let textarea: TextareaRenderable | undefined
let history = createPromptHistory()
let pasteTick: ReturnType<typeof setTimeout> | undefined
let exitConfirmUntil = 0
if (this.state.active_view === 'help') {
lines.push(...this.render_help())
} else if (!p) {
lines.push(ANSI.fg.yellow + ' No active session. Run "air init" then "air run".' + ANSI.reset)
lines.push('')
lines.push(ANSI.fg.gray + ' Press q to exit.' + ANSI.reset)
props.bindState({
setProjection,
setView: setActiveView,
setBusy: (next) => {
setBusy(next)
setFooterPhase(next ? 'running' : 'idle')
},
setStatus: (next) => {
setStatus(next)
if (/error|failed|blocked|失败|错误/i.test(next)) setFooterPhase('error')
else if (!busy()) setFooterPhase('idle')
},
})
const focusPrompt = () => {
if (textarea && !textarea.isDestroyed) textarea.focus()
}
const submitPrompt = () => {
if (!textarea || textarea.isDestroyed || busy()) return
const text = textarea.plainText.trim()
if (!text) return
history = pushPromptHistory(history, text)
textarea.setText('')
exitConfirmUntil = 0
setFooterPhase('running')
setStatus(text.startsWith('/') ? `Command: ${text}` : `Sending: ${text.slice(0, 72)}`)
if (text.startsWith('/')) {
void props.onSlashCommand(text)
} else {
switch (this.state.active_view) {
case 'tasks':
lines.push(...this.render_tasks(p))
break
case 'agents':
lines.push(...this.render_agents(p))
break
case 'tools':
lines.push(...this.render_tools(p))
break
case 'diff':
lines.push(...this.render_diff(p))
break
void props.onSubmit(text)
}
focusPrompt()
}
const refreshPasteLayout = () => {
if (pasteTick) clearTimeout(pasteTick)
pasteTick = setTimeout(() => {
pasteTick = undefined
if (!textarea || textarea.isDestroyed) return
textarea.getLayoutNode().markDirty()
renderer.requestRender()
void renderer.idle().then(() => renderer.requestRender()).catch(() => {})
}, 0)
}
const applyHistoryMove = (dir: -1 | 1) => {
if (!textarea || textarea.isDestroyed) return false
const text = textarea.plainText
const move = movePromptHistory(history, dir, text, textarea.cursorOffset)
history = move.state
if (!move.apply) return false
textarea.setText(move.text ?? '')
textarea.cursorOffset = move.cursor ?? 0
textarea.getLayoutNode().markDirty()
renderer.requestRender()
return true
}
const activePermission = () => projection()?.permission_prompts[0]
const resolvePermissionByIndex = (index: number) => {
const prompt = activePermission()
if (!prompt) return false
const options = prompt.options.length > 0 ? prompt.options : ['allow', 'deny']
const selected = options[index]
if (!selected) return false
setFooterPhase('permission')
setStatus(`Permission: ${selected}`)
void props.onResolvePermission(prompt.prompt_id, selected)
return true
}
const handleKeyDown = (event: KeyEvent) => {
const prompt = activePermission()
if (prompt) {
if (event.name === 'left' || event.name === 'h') {
event.preventDefault()
setToast('Use 1/2/3 to choose a permission option')
return
}
if (/^[1-9]$/.test(event.name) && resolvePermissionByIndex(Number(event.name) - 1)) {
event.preventDefault()
return
}
if ((event.name === 'a' || event.name === 'y') && resolvePermissionByIndex(0)) {
event.preventDefault()
return
}
if ((event.name === 'd' || event.name === 'n') && resolvePermissionByIndex(Math.min(1, (prompt.options.length || 2) - 1))) {
event.preventDefault()
return
}
}
// Footer
lines.push('')
lines.push(ANSI.fg.gray + '─'.repeat(76) + ANSI.reset)
lines.push(ANSI.fg.gray + ' Status: ' + this.get_status_text(p) + ' │ Session: ' + (p?.session_id || 'N/A') + ANSI.reset)
if (event.ctrl && event.name === 'c') {
event.preventDefault()
if (textarea && !textarea.isDestroyed && textarea.plainText.length > 0) {
textarea.setText('')
history = { ...history, index: null, draft: '' }
setFooterPhase('idle')
setStatus('Draft cleared; press Ctrl+C again to exit')
focusPrompt()
return
}
const now = Date.now()
if (now < exitConfirmUntil) {
void props.onExit()
return
}
exitConfirmUntil = now + EXIT_CONFIRM_MS
setFooterPhase('confirm_exit')
setStatus('Press Ctrl+C again within 5s to exit')
return
}
process.stdout.write(lines.join('\n') + '\n')
}
if (event.name === 'up' && applyHistoryMove(-1)) {
event.preventDefault()
return
}
private get_status_indicator(p: SessionProjection | null): string {
if (!p) return ANSI.fg.gray + 'IDLE' + ANSI.reset
switch (p.status) {
case 'running': return ANSI.fg.green + '● RUNNING' + ANSI.reset
case 'completed': return ANSI.fg.blue + '● COMPLETED' + ANSI.reset
case 'error': return ANSI.fg.red + '● ERROR' + ANSI.reset
default: return ANSI.fg.gray + '● ' + p.status.toUpperCase() + ANSI.reset
if (event.name === 'down' && applyHistoryMove(1)) {
event.preventDefault()
return
}
if (event.name === 'escape') {
event.preventDefault()
exitConfirmUntil = 0
setActiveView('tasks')
setFooterPhase(busy() ? 'running' : activePermission() ? 'permission' : 'idle')
focusPrompt()
return
}
if (event.ctrl && event.name === 'l') {
event.preventDefault()
renderer.requestRender()
return
}
if (!event.ctrl || event.meta) return
const next = shortcutToView(event.name)
if (next) {
event.preventDefault()
setActiveView(next)
focusPrompt()
}
}
private get_status_text(p: SessionProjection | null): string {
if (!p) return 'No session'
return `${p.status} | ${p.tasks.length} tasks | ${p.agents.length} agents`
onMount(() => {
renderer.setTerminalTitle('AirCoding')
focusPrompt()
})
onCleanup(() => {
if (pasteTick) clearTimeout(pasteTick)
renderer.setTerminalTitle('')
})
createEffect(() => {
const hasPermission = (projection()?.permission_prompts.length ?? 0) > 0
if (hasPermission) setFooterPhase('permission')
else if (!busy() && footerPhase() === 'permission') setFooterPhase('idle')
})
createEffect(() => {
projection()
activeView()
busy()
status()
footerPhase()
toast()
renderer.requestRender()
})
return (
<box width="100%" height="100%" flexDirection="column" backgroundColor={THEME.bg}>
<Header projection={projection()} />
<Nav active={activeView()} />
<box flexGrow={1} flexShrink={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<Content projection={projection()} active={activeView()} height={Math.max(8, dimensions().height - 11)} />
</box>
<Prompt
busy={busy()}
phase={footerPhase()}
status={status()}
toast={toast()}
permission={activePermission()}
textareaRef={(area) => { textarea = area }}
onSubmit={submitPrompt}
onKeyDown={handleKeyDown}
onPaste={refreshPasteLayout}
onContentChange={() => renderer.requestRender()}
/>
</box>
)
}
function Header(props: { projection: SessionProjection | null }) {
const taskCount = () => props.projection?.tasks.length ?? 0
return (
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} backgroundColor={THEME.surface}>
<box flexDirection="row" justifyContent="space-between">
<text fg={THEME.accent}>AirCoding v1.0.0-alpha</text>
<text fg={statusColor(props.projection?.status)}>{(props.projection?.status ?? 'idle').toUpperCase()}</text>
</box>
<text fg={THEME.muted}>{props.projection?.title ?? 'No active session'} · {taskCount()} tasks · {props.projection?.agents.length ?? 0} agents</text>
</box>
)
}
function Nav(props: { active: TuiAppState['active_view'] }) {
const items: Array<[TuiAppState['active_view'], string]> = [
['tasks', 'Ctrl+1 Tasks'],
['agents', 'Ctrl+2 Agents'],
['tools', 'Ctrl+3 Tools'],
['diff', 'Ctrl+4 Diff'],
['help', 'Ctrl+H Help'],
]
return (
<box flexDirection="row" gap={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={THEME.surface2}>
<For each={items}>{([view, label]) => (
<text fg={props.active === view ? THEME.accent : THEME.muted}>{label}</text>
)}</For>
</box>
)
}
function Content(props: { projection: SessionProjection | null; active: TuiAppState['active_view']; height: number }) {
const currentProjection = () => props.projection
return (
<Show when={currentProjection()} fallback={<EmptySession />}>
<box flexDirection="column" gap={1} height={props.height}>
<Show when={props.active === 'tasks'}>
<TasksView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'agents'}>
<AgentsView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'tools'}>
<ToolsView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'diff'}>
<DiffView projection={currentProjection()!} />
</Show>
<Show when={props.active === 'help'}>
<HelpView />
</Show>
</box>
</Show>
)
}
function EmptySession() {
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.warning}>No active session projection.</text>
<text fg={THEME.muted}>Run air init and air run from a project directory.</text>
</box>
)
}
function TasksView(props: { projection: SessionProjection }) {
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Tasks</text>
<Show when={props.projection.tasks.length > 0} fallback={<text fg={THEME.muted}>No tasks yet.</text>}>
<For each={props.projection.tasks.slice(0, 14)}>{(task) => (
<box flexDirection="column">
<text fg={statusColor(task.status)}>{statusMark(task.status)} {task.title || task.id}</text>
<text fg={THEME.faint}> {task.id} · {task.type} · {task.status} · attempts {task.attempts}</text>
</box>
)}</For>
</Show>
</box>
)
}
function AgentsView(props: { projection: SessionProjection }) {
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Agents</text>
<Show when={props.projection.agents.length > 0} fallback={<text fg={THEME.muted}>No agents yet.</text>}>
<For each={props.projection.agents.slice(0, 14)}>{(agent) => (
<box flexDirection="column">
<text fg={statusColor(agent.status)}>{statusMark(agent.status)} {agent.type}</text>
<text fg={THEME.faint}> {agent.id} · {agent.status}{agent.task_id ? ` · task ${agent.task_id}` : ''}</text>
</box>
)}</For>
</Show>
</box>
)
}
function ToolsView(props: { projection: SessionProjection }) {
const p = () => props.projection as SessionProjection & { tool_runs?: Array<{ tool_run_id: string; tool_name: string; status: string; duration_ms?: number }> }
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Tool runs</text>
<Show when={(p().tool_runs ?? []).length > 0} fallback={<text fg={THEME.muted}>No tool runs yet.</text>}>
<For each={(p().tool_runs ?? []).slice(-14).reverse()}>{(tool) => (
<box flexDirection="row" gap={1}>
<text fg={statusColor(tool.status)}>{statusMark(tool.status)}</text>
<text fg={THEME.text}>{tool.tool_name}</text>
<text fg={THEME.faint}>{tool.status}{tool.duration_ms ? ` · ${tool.duration_ms}ms` : ''}</text>
</box>
)}</For>
</Show>
</box>
)
}
function DiffView(props: { projection: SessionProjection }) {
const completed = () => props.projection.tasks.filter((task) => task.status === 'completed').length
const failed = () => props.projection.tasks.filter((task) => task.status === 'failed').length
const blocked = () => props.projection.tasks.filter((task) => task.status === 'blocked').length
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Session summary</text>
<text fg={THEME.success}>Completed: {completed()}</text>
<text fg={THEME.error}>Failed: {failed()}</text>
<text fg={THEME.warning}>Blocked: {blocked()}</text>
<text fg={THEME.muted}>Use /results for produced files. Projection data is sourced from runtime events.</text>
</box>
)
}
function HelpView() {
return (
<box flexDirection="column" gap={1}>
<text fg={THEME.accent}>Help</text>
<text fg={THEME.text}>Type a task in the prompt and press Enter.</text>
<text fg={THEME.text}>Slash commands: /help, /status, /tools, /tasks, /results, /quit.</text>
<text fg={THEME.text}>Navigation: Ctrl+1 tasks, Ctrl+2 agents, Ctrl+3 tools, Ctrl+4 diff, Ctrl+H help, Esc tasks.</text>
<text fg={THEME.text}>Prompt: Up/Down browse history at text boundaries; paste refreshes layout automatically.</text>
<text fg={THEME.muted}>Ctrl+C clears draft first, then asks for a second Ctrl+C within 5s to exit. Permission prompts use 1/2/3 or a/d.</text>
</box>
)
}
function Prompt(props: {
busy: boolean
phase: FooterPhase
status: string
toast: string
permission?: SessionProjection['permission_prompts'][number]
textareaRef: (area?: TextareaRenderable) => void
onSubmit: () => void
onKeyDown: (event: KeyEvent) => void
onPaste: () => void
onContentChange: () => void
}) {
const permissionOptions = () => props.permission?.options.length ? props.permission.options : ['allow', 'deny']
const phaseLabel = () => {
if (props.permission) return 'Permission'
if (props.phase === 'confirm_exit') return 'Confirm exit'
if (props.phase === 'error') return 'Attention'
return props.busy ? 'Running' : 'Ready'
}
const phaseColor = () => {
if (props.permission || props.phase === 'confirm_exit') return THEME.warning
if (props.phase === 'error') return THEME.error
return props.busy ? THEME.warning : THEME.success
}
private render_help(): string[] {
return [
ANSI.fg.cyan + ANSI.bright + ' Help' + ANSI.reset,
'',
' Keyboard shortcuts:',
' 1 - Tasks view Show task list and status',
' 2 - Agents view Show agent status and activity',
' 3 - Tools view Show available tools and usage',
' 4 - Diff view Show file changes',
' h - Help Show this help',
' q - Quit Exit AirCoding',
'',
' Getting started:',
' air init <project> Initialize a project',
' air run Start coding session',
' air doctor Run diagnostics',
]
return (
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingBottom={1} backgroundColor={THEME.surface}>
<Show when={props.permission}>
<box flexDirection="column" paddingBottom={1}>
<text fg={THEME.warning}>Permission required: {props.permission?.subject || props.permission?.tool_name || props.permission?.prompt_id}</text>
<text fg={THEME.muted}>Risk: {props.permission?.risk_level || 'unknown'} · {props.permission?.reason || 'No reason provided'}</text>
<box flexDirection="row" gap={1}>
<For each={permissionOptions()}>{(option, index) => (
<text fg={index() === 0 ? THEME.success : THEME.warning}>{index() + 1}. {option}</text>
)}</For>
</box>
</box>
</Show>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={phaseColor()}>{phaseLabel()}</text>
<text fg={props.phase === 'error' ? THEME.error : THEME.muted}>{props.toast || props.status}</text>
</box>
<textarea
width="100%"
minHeight={TEXTAREA_MIN_ROWS}
maxHeight={TEXTAREA_MAX_ROWS}
wrapMode="word"
placeholder={props.busy ? 'Task is running...' : 'Ask AirCoding to change this project, or type /help'}
placeholderColor={THEME.faint}
textColor={THEME.text}
focusedTextColor={THEME.text}
backgroundColor={THEME.bg}
focusedBackgroundColor={THEME.bg}
cursorColor={THEME.accent}
focused={!props.busy}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={props.onPaste}
onContentChange={props.onContentChange}
ref={props.textareaRef}
/>
</box>
)
}
function shortcutToView(name: string): TuiAppState['active_view'] | undefined {
switch (name) {
case '1': return 'tasks'
case '2': return 'agents'
case '3': return 'tools'
case '4': return 'diff'
case 'h': return 'help'
default: return undefined
}
}
private render_tasks(p: SessionProjection): string[] {
const lines: string[] = []
lines.push(ANSI.fg.cyan + ANSI.bright + ' Tasks' + ANSI.reset)
if (p.tasks.length === 0) {
lines.push(ANSI.fg.gray + ' No tasks yet.' + ANSI.reset)
return lines
}
for (const task of p.tasks.slice(0, 10)) {
const status_color = task.status === 'completed' ? ANSI.fg.green : task.status === 'failed' ? ANSI.fg.red : ANSI.fg.yellow
lines.push(` ${status_color}${ANSI.reset} ${task.title || task.id}`)
lines.push(ANSI.fg.gray + ` ID: ${task.id} | Status: ${task.status}` + ANSI.reset)
}
if (p.tasks.length > 10) {
lines.push(ANSI.fg.gray + ` ... and ${p.tasks.length - 10} more tasks` + ANSI.reset)
}
return lines
function statusColor(status: string | undefined): string {
switch (status) {
case 'completed':
case 'ok':
case 'active':
case 'running':
return THEME.success
case 'failed':
case 'error':
case 'lost':
return THEME.error
case 'blocked':
case 'pending':
case 'cancelled':
return THEME.warning
default:
return THEME.muted
}
}
private render_agents(p: SessionProjection): string[] {
const lines: string[] = []
lines.push(ANSI.fg.cyan + ANSI.bright + ' Agents' + ANSI.reset)
if (p.agents.length === 0) {
lines.push(ANSI.fg.gray + ' No active agents.' + ANSI.reset)
return lines
}
for (const agent of p.agents.slice(0, 10)) {
const status_color = agent.status === 'running' ? ANSI.fg.green : agent.status === 'idle' ? ANSI.fg.gray : ANSI.fg.yellow
lines.push(` ${status_color}${ANSI.reset} ${agent.type}`)
lines.push(ANSI.fg.gray + ` ID: ${agent.id?.slice(0, 8)}... | Status: ${agent.status}` + ANSI.reset)
}
return lines
function statusMark(status: string | undefined): string {
switch (status) {
case 'completed':
case 'ok':
return '✓'
case 'failed':
case 'error':
return '✗'
case 'running':
case 'active':
return '●'
case 'blocked':
return '!'
default:
return '○'
}
private render_tools(p: SessionProjection): string[] {
const lines: string[] = []
lines.push(ANSI.fg.cyan + ANSI.bright + ' Recent Tool Calls' + ANSI.reset)
const recent_calls = p.tasks.slice(0, 10)
if (recent_calls.length === 0) {
lines.push(ANSI.fg.gray + ' No tool calls yet.' + ANSI.reset)
return lines
}
for (const call of recent_calls) {
lines.push(` ${ANSI.fg.green}${ANSI.reset} ${call.title || call.type}`)
lines.push(ANSI.fg.gray + ` Task: ${call.id?.slice(0, 8)}... | Status: ${call.status}` + ANSI.reset)
}
return lines
}
private render_diff(p: SessionProjection): string[] {
const lines: string[] = []
lines.push(ANSI.fg.cyan + ANSI.bright + ' Recent Changes' + ANSI.reset)
const task_count = p.tasks.length
const completed = p.tasks.filter(t => t.status === 'completed').length
const failed = p.tasks.filter(t => t.status === 'failed').length
if (task_count === 0) {
lines.push(ANSI.fg.gray + ' No changes yet.' + ANSI.reset)
return lines
}
lines.push(` ${ANSI.fg.green}${ANSI.reset} Completed: ${completed}`)
lines.push(` ${ANSI.fg.red}${ANSI.reset} Failed: ${failed}`)
lines.push(` ${ANSI.fg.yellow}${ANSI.reset} Pending: ${task_count - completed - failed}`)
lines.push('')
lines.push(ANSI.fg.gray + ` Total tasks: ${task_count}` + ANSI.reset)
return lines
}
}
}

View File

@@ -1,7 +1,7 @@
/**
* TUI package — Terminal UI components
*
* INV-4: TUI imports ONLY contracts + ProjectionClient from runtime.
* INV-4: TUI imports no runtime package; it consumes projection snapshots through a local client surface.
* Uses OpenTUI @opentui/* as renderer (npm-dep, do NOT reimplement).
*
* @module packages/tui

4
packages/tui/src/preload.ts Executable file
View File

@@ -0,0 +1,4 @@
const openTuiPreload = '@opentui/solid/preload'
await import(openTuiPreload)
export {}

View File

@@ -1,11 +1,17 @@
/**
* TUI shared types
* TUI imports ONLY contracts. No runtime imports.
* TUI shared projection types.
* TUI stays runtime-free and consumes projection snapshots only.
*
* @module packages/tui/src/types
*/
import type { SessionID, ProjectID, TaskID, AgentID, ToolRunID, ISOTimeString } from '@aircoding/contracts'
type SessionID = string
type ProjectID = string
type TaskID = string
type AgentID = string
type ToolRunID = string
type CommandRunID = string
type ArtifactID = string
export interface SessionProjection {
session_id: SessionID
@@ -14,6 +20,12 @@ export interface SessionProjection {
title?: string
tasks: TaskProjection[]
agents: AgentProjection[]
tool_runs: ToolRunProjection[]
command_runs: CommandRunProjection[]
artifacts: ArtifactProjection[]
permission_prompts: PermissionPromptProjection[]
blockers: BlockerProjection[]
updated_at: string
}
export interface TaskProjection {
@@ -24,6 +36,7 @@ export interface TaskProjection {
retry_count: number
attempts: number
created_at: string
agent_id?: AgentID
}
export interface AgentProjection {
@@ -34,4 +47,40 @@ export interface AgentProjection {
last_heartbeat?: string
}
export interface ToolRunProjection {
tool_run_id: ToolRunID
tool_name: string
status: string
duration_ms?: number
}
export interface CommandRunProjection {
command_run_id: CommandRunID
command: string
status: string
exit_code?: number
}
export interface ArtifactProjection {
artifact_id: ArtifactID
type: string
uri: string
}
export interface PermissionPromptProjection {
prompt_id: string
subject: string
risk_level: string
reason: string
options: string[]
default_option?: string
tool_name?: string
}
export interface BlockerProjection {
task_id: TaskID
reason: string
blocker_kind: string
}
export type ProjectionSubscriber = (projection: SessionProjection) => void

View File

@@ -111,7 +111,7 @@ export class WorkerRuntime {
* Emit an event to the parent.
*/
emit(type: string, payload: Record<string, unknown>): void {
this.send_message('event', { type, ...payload })
this.send_message('event', { event_type: type, ...payload })
}
/**

View File

@@ -8,6 +8,11 @@
import { WorkerRuntime } from '../WorkerRuntime.js'
const SUMMARY_PREFIX = `This is a compacted summary of earlier context. Treat it as reference only.
The latest user message and any newer runtime events after this summary are the source of truth.
If this summary conflicts with newer instructions, follow the newer instructions.
Preserve active tasks, unresolved questions, architectural constraints, verification status, and remaining work.`
export interface CompactorResult {
status: 'compacted' | 'skipped' | 'blocked'
summary_content: string
@@ -22,7 +27,15 @@ export class CompactorRole {
this.runtime = runtime
}
async run(compact_spec: { task_id: string; current_tokens: number; threshold: number }): Promise<CompactorResult> {
async run(compact_spec: {
task_id?: string
current_tokens?: number
threshold?: number
target_budget_tokens?: number
range_start_message_id?: string
range_end_message_id?: string
source_content?: string
}): Promise<CompactorResult> {
const result: CompactorResult = {
status: 'skipped',
summary_content: '',
@@ -30,48 +43,105 @@ export class CompactorRole {
compacted_layers: []
}
try {
this.runtime.emit('compaction.started', { task_id: compact_spec.task_id })
const task_id = compact_spec.task_id || 'compact_task'
const current_tokens = compact_spec.current_tokens ?? 0
const threshold = compact_spec.threshold ?? compact_spec.target_budget_tokens ?? 80000
const range_start_message_id = compact_spec.range_start_message_id || ''
const range_end_message_id = compact_spec.range_end_message_id || ''
// Check if compaction is needed
if (compact_spec.current_tokens < compact_spec.threshold) {
result.status = 'skipped'
result.summary_content = `Tokens (${compact_spec.current_tokens}) below threshold (${compact_spec.threshold}) — no compaction needed`
try {
this.runtime.emit('context.compaction.started', {
event_id: `evt_compaction_started_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
range_start_message_id,
range_end_message_id,
})
if (current_tokens > 0 && current_tokens < threshold) {
result.summary_content = `Tokens (${current_tokens}) below threshold (${threshold}); no compaction needed.`
return result
}
// Use LLM to generate summary of the conversation
const tokens_to_free = compact_spec.current_tokens - Math.floor(compact_spec.threshold * 0.6)
const compaction_messages = [
{ role: 'system', content: 'Summarize the key facts, decisions, and code changes from the conversation history. Keep it concise but complete. Include file paths, function names, and architectural decisions.' },
{ role: 'user', content: `Compaction requested: ${compact_spec.current_tokens} tokens in context, threshold is ${compact_spec.threshold}. Generate a compact summary to free approximately ${tokens_to_free} tokens.` }
]
const token_estimate_before = current_tokens || threshold
const target_after = Math.max(1, Math.floor(threshold * 0.6))
const source_content = compact_spec.source_content || `Current token estimate: ${token_estimate_before}; target budget: ${threshold}.`
try {
const summary = await this.runtime.call_llm({
messages: compaction_messages,
max_tokens: 2048,
temperature: 0.2
})
const summary = await this.build_summary(source_content, token_estimate_before, threshold)
const summary_id = `summary_${crypto.randomUUID()}`
const token_estimate_after = Math.min(target_after, Math.max(1, Math.floor(summary.length / 4)))
result.summary_content = summary.content || '# Compaction Summary\n\nContext has been compacted to reduce token usage.'
result.tokens_freed = tokens_to_free
result.compacted_layers = ['conversation', 'tool_output']
result.status = 'compacted'
} catch {
result.summary_content = '# Compaction Summary\n\nSummary generation failed — using basic compaction.'
result.tokens_freed = compact_spec.current_tokens - Math.floor(compact_spec.current_tokens * 0.6)
result.compacted_layers = ['conversation']
result.status = 'compacted'
}
result.summary_content = summary
result.tokens_freed = Math.max(0, token_estimate_before - token_estimate_after)
result.compacted_layers = ['conversation', 'tool_output', 'images']
result.status = 'compacted'
this.runtime.checkpoint('compaction_completed', { task_id: compact_spec.task_id })
this.runtime.emit('summary.created', {
event_id: `evt_${summary_id}`,
summary_id,
type: 'compaction',
range_start_message_id,
range_end_message_id,
content_json: {
prefix: SUMMARY_PREFIX,
summary,
active_task: task_id,
remaining_work: [],
resolved_questions: [],
pending_questions: [],
},
metadata: {
token_estimate_before,
token_estimate_after,
compacted_layers: result.compacted_layers,
},
})
this.runtime.emit('context.compaction.completed', {
event_id: `evt_compaction_completed_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
summary_id,
range_start_message_id,
range_end_message_id,
token_estimate_before,
token_estimate_after,
})
this.runtime.checkpoint('compaction_completed', { task_id, summary_id, tokens_freed: result.tokens_freed })
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
result.status = 'blocked'
result.summary_content = error instanceof Error ? error.message : String(error)
result.summary_content = message
this.runtime.emit('context.compaction.failed', {
event_id: `evt_compaction_failed_${crypto.randomUUID()}`,
task_id,
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
range_start_message_id,
range_end_message_id,
error: { message },
evidence_refs: [],
metadata: {},
})
return result
}
}
}
private async build_summary(source_content: string, current_tokens: number, threshold: number): Promise<string> {
try {
const response = await this.runtime.call_llm({
messages: [
{ role: 'system', content: `${SUMMARY_PREFIX}\n\nReturn a structured summary with sections: Active task, Key facts, Decisions, Changed files, Verification, Remaining work, Pending questions.` },
{ role: 'user', content: `Compact this context from ~${current_tokens} tokens toward ${threshold}.\n\n${source_content}` },
],
max_tokens: 2048,
temperature: 0.2,
})
return `${SUMMARY_PREFIX}\n\n${(response.content || '').trim() || 'No detailed summary was produced.'}`
} catch {
return `${SUMMARY_PREFIX}\n\nActive task: context compaction.\nKey facts: source context was too large or summarizer was unavailable.\nRemaining work: rehydrate from durable events and latest user message before continuing.`
}
}
}

View File

@@ -8,6 +8,38 @@
import { WorkerRuntime } from '../WorkerRuntime.js'
type FailoverReason =
| 'auth'
| 'auth_permanent'
| 'billing'
| 'rate_limit'
| 'overloaded'
| 'server_error'
| 'timeout'
| 'context_overflow'
| 'payload_too_large'
| 'image_too_large'
| 'model_not_found'
| 'provider_policy_blocked'
| 'content_policy_blocked'
| 'format_error'
| 'invalid_encrypted_content'
| 'multimodal_tool_content_unsupported'
| 'thinking_signature'
| 'long_context_tier'
| 'oauth_long_context_beta_forbidden'
| 'llama_cpp_grammar_pattern'
| 'unknown'
interface ClassifiedError {
reason: FailoverReason
message: string
retryable: boolean
should_compress: boolean
should_rotate_credential: boolean
should_fallback: boolean
}
export interface DebuggerResult {
status: 'fixed' | 'cannot_reproduce' | 'blocked' | 'escalated'
root_cause: string
@@ -23,7 +55,7 @@ export class DebuggerRole {
this.runtime = runtime
}
async run(debug_spec: { task_id: string; error_report: string; affected_files: string[] }): Promise<DebuggerResult> {
async run(debug_spec: { task_id?: string; error_report?: string; affected_files?: string[]; verification_refs?: string[] }): Promise<DebuggerResult> {
const result: DebuggerResult = {
status: 'cannot_reproduce',
root_cause: '',
@@ -32,54 +64,56 @@ export class DebuggerRole {
}
try {
this.runtime.emit('debug.started', { task_id: debug_spec.task_id })
const task_id = debug_spec.task_id || 'unknown_task'
const error_report = debug_spec.error_report || ''
const affected_files = debug_spec.affected_files ?? []
const classified = this.classify_error(error_report)
// Step 1: Gather evidence — read affected files
result.diagnostic_chain.push('1. Gathering evidence from affected files')
for (const file of debug_spec.affected_files) {
result.diagnostic_chain.push(`1. Classified failure as ${classified.reason}`)
result.diagnostic_chain.push(` retryable=${classified.retryable} compress=${classified.should_compress} rotate_credential=${classified.should_rotate_credential} fallback=${classified.should_fallback}`)
result.diagnostic_chain.push('2. Gathering evidence from affected files')
for (const file of affected_files) {
try {
await this.runtime.call_tool('fs.read', { path: file })
result.evidence_refs.push(`file:${file}`)
const read = await this.runtime.call_tool('fs.read', { path: file })
if (read.type === 'error') {
result.diagnostic_chain.push(` Failed to read: ${file}`)
} else {
result.evidence_refs.push(`file:${file}`)
}
} catch {
result.diagnostic_chain.push(` Failed to read: ${file}`)
}
}
// Step 2: Analyze error signatures using LLM
result.diagnostic_chain.push('2. Analyzing error signatures')
const messages = [
{ role: 'system', content: 'You are a debugging expert. Analyze the error report and suggest a fix.' },
{ role: 'user', content: `Error report:\n${debug_spec.error_report}\n\nAffected files: ${debug_spec.affected_files.join(', ')}\n\nDiagnose the root cause and propose a fix. Be specific about which file and what change.` }
]
result.diagnostic_chain.push('3. Analyzing root cause')
try {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 })
result.root_cause = analysis.content || 'Unable to determine root cause'
result.diagnostic_chain.push(` Analysis: ${result.root_cause.slice(0, 100)}...`)
const analysis = await this.runtime.call_llm({
messages: [
{ role: 'system', content: 'You are a diagnostic agent. Identify the likely root cause and recovery path. Do not claim a fix was applied unless a tool edit actually succeeded.' },
{ role: 'user', content: `Classified error: ${JSON.stringify(classified)}\n\nError report:\n${error_report}\n\nAffected files: ${affected_files.join(', ') || '(none)'}` },
],
max_tokens: 2048,
temperature: 0.2,
})
result.root_cause = (analysis.content || '').trim() || this.default_root_cause(classified)
} catch {
result.root_cause = 'LLM analysis unavailable — manual diagnosis required'
result.root_cause = this.default_root_cause(classified)
}
// Step 3: Attempt fix
result.diagnostic_chain.push('3. Attempting fix')
if (result.root_cause.includes('fix:') || result.root_cause.includes('change:') || result.root_cause.includes('Fix:')) {
const fix_match = result.root_cause.match(/fix:\s*([^\n]+)/i) || result.root_cause.match(/change:\s*([^\n]+)/i)
if (fix_match && debug_spec.affected_files.length > 0) {
result.fix_applied = { file: debug_spec.affected_files[0], change: fix_match[1] }
result.status = 'fixed'
result.diagnostic_chain.push(' Fix applied to ' + debug_spec.affected_files[0])
}
}
result.status = this.status_for(classified)
const debug_record_id = `debug_${crypto.randomUUID()}`
this.runtime.emit('debug.record.created', {
event_id: `evt_${debug_record_id}`,
debug_record_id,
task_id,
failure_signature: classified.reason,
summary: result.root_cause.slice(0, 1000),
evidence_refs: result.evidence_refs,
verification_refs: debug_spec.verification_refs ?? [],
})
// Step 4: Verify fix
if (result.status === 'fixed') {
result.diagnostic_chain.push('4. Verification')
try {
await this.runtime.call_tool('shell.run', { command: 'echo "Verification passed — fix applied"', timeout: 30000 })
} catch { /* verification skipped */ }
}
this.runtime.checkpoint('debug_completed', { task_id: debug_spec.task_id })
this.runtime.checkpoint('debug_completed', { task_id, reason: classified.reason, status: result.status })
return result
} catch (error) {
@@ -88,4 +122,53 @@ export class DebuggerRole {
return result
}
}
}
private classify_error(report: string): ClassifiedError {
const text = report.toLowerCase()
const reason: FailoverReason = this.reason_for(text)
return {
reason,
message: report,
retryable: !['auth_permanent', 'billing', 'model_not_found', 'provider_policy_blocked', 'content_policy_blocked', 'format_error', 'invalid_encrypted_content'].includes(reason),
should_compress: reason === 'context_overflow' || reason === 'payload_too_large' || reason === 'image_too_large',
should_rotate_credential: reason === 'auth' || reason === 'auth_permanent',
should_fallback: ['rate_limit', 'overloaded', 'server_error', 'timeout', 'model_not_found', 'long_context_tier', 'oauth_long_context_beta_forbidden'].includes(reason),
}
}
private reason_for(text: string): FailoverReason {
if (/context|token|maximum context|too many tokens|context_length/.test(text)) return 'context_overflow'
if (/payload too large|request too large|413/.test(text)) return 'payload_too_large'
if (/image.*too large|vision.*size/.test(text)) return 'image_too_large'
if (/rate limit|too many requests|429/.test(text)) return 'rate_limit'
if (/overloaded|capacity|529/.test(text)) return 'overloaded'
if (/timeout|timed out|etimedout|504/.test(text)) return 'timeout'
if (/500|502|503|server error|bad gateway|service unavailable/.test(text)) return 'server_error'
if (/invalid api key|unauthorized|401|forbidden|403|auth/.test(text)) return /invalid api key|revoked|expired/.test(text) ? 'auth_permanent' : 'auth'
if (/billing|quota|insufficient credits|payment/.test(text)) return 'billing'
if (/model.*not found|unknown model|404/.test(text)) return 'model_not_found'
if (/policy|safety|blocked by provider/.test(text)) return 'provider_policy_blocked'
if (/content policy|unsafe content/.test(text)) return 'content_policy_blocked'
if (/json|schema|format|parse/.test(text)) return 'format_error'
if (/encrypted content/.test(text)) return 'invalid_encrypted_content'
if (/multimodal.*tool|tool.*image/.test(text)) return 'multimodal_tool_content_unsupported'
if (/thinking.*signature|signature mismatch/.test(text)) return 'thinking_signature'
if (/long context/.test(text)) return 'long_context_tier'
if (/oauth.*long context|beta.*forbidden/.test(text)) return 'oauth_long_context_beta_forbidden'
if (/grammar|llama.cpp|llama_cpp/.test(text)) return 'llama_cpp_grammar_pattern'
return 'unknown'
}
private default_root_cause(classified: ClassifiedError): string {
if (classified.should_compress) return `Likely ${classified.reason}; compress context or reduce payload before retry.`
if (classified.should_rotate_credential) return `Likely ${classified.reason}; credential or authorization requires attention before retry.`
if (classified.should_fallback) return `Likely ${classified.reason}; retry with backoff or fallback provider/model.`
return `Failure classified as ${classified.reason}; manual diagnosis required.`
}
private status_for(classified: ClassifiedError): DebuggerResult['status'] {
if (classified.should_rotate_credential || classified.reason === 'billing' || classified.reason === 'content_policy_blocked') return 'escalated'
if (classified.retryable || classified.should_compress || classified.should_fallback) return 'cannot_reproduce'
return 'blocked'
}
}

View File

@@ -30,7 +30,7 @@ export class ExecutorRole {
}
async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> {
this.runtime.emit('task.attempt.started', { task_id: task_spec.id })
this.runtime.checkpoint('task_attempt_started', { task_id: task_spec.id })
const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1'
const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.'
@@ -41,7 +41,10 @@ export class ExecutorRole {
role: 'system',
content: `You are an AI coding assistant. Complete coding tasks by writing code files.
Use structured tool calls whenever possible. Available tools include fs.read, fs.write, fs.edit, fs.list, shell.run, cpp.detect, cpp.build, and cpp.test.
Use structured tool calls whenever possible. Available tools include:
- fs.read, fs.write, fs.edit, fs.list — filesystem operations
- shell.run — shell command execution
- cpp.detect, cpp.configure, cpp.build, cpp.test, cpp.cppcheck, cpp.clangd — C++ toolchain
If native tools are unavailable, output strict JSON tool calls only in this form:
@@ -201,10 +204,6 @@ After all required files are written and required verification has passed, write
}
} catch (error) {
this.runtime.emit('task.blocked', {
task_id: task_spec.id,
error: error instanceof Error ? error.message : String(error)
})
return { status: 'blocked', error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -8,6 +8,8 @@
import { WorkerRuntime } from '../WorkerRuntime.js'
const MEMORY_TYPES = new Set(['project_rule', 'toolchain_rule', 'skill_update', 'debug_experience'])
export interface ExperienceMinerResult {
status: 'completed' | 'no_patterns' | 'blocked'
entries: Array<{
@@ -26,7 +28,7 @@ export class ExperienceMinerRole {
this.runtime = runtime
}
async run(mine_spec: { task_ids: string[]; focus_categories?: string[] }): Promise<ExperienceMinerResult> {
async run(mine_spec: { task_ids?: string[]; focus_categories?: string[]; source_refs?: Array<Record<string, unknown>>; evidence_refs?: string[] }): Promise<ExperienceMinerResult> {
const result: ExperienceMinerResult = {
status: 'no_patterns',
entries: [],
@@ -34,68 +36,73 @@ export class ExperienceMinerRole {
}
try {
this.runtime.emit('mining.started', { task_ids: mine_spec.task_ids })
const task_ids = mine_spec.task_ids ?? []
const evidence_refs = mine_spec.evidence_refs ?? task_ids.map((task_id) => `task:${task_id}`)
const focus = mine_spec.focus_categories?.length ? mine_spec.focus_categories : ['project_rule', 'toolchain_rule', 'debug_experience']
// Read completed task results to extract patterns
const task_summaries: string[] = []
for (const task_id of mine_spec.task_ids) {
try {
// Emit that we're reading a task
this.runtime.emit('mining.task', { task_id })
task_summaries.push(`Task ${task_id}: completed`)
} catch { /* skip failed task reads */ }
}
if (task_summaries.length === 0) {
result.status = 'no_patterns'
result.summary = 'No completed tasks available for mining'
if (task_ids.length === 0 && evidence_refs.length === 0) {
result.summary = 'No task or evidence refs available for memory mining'
return result
}
// Use LLM to extract patterns
const messages = [
{ role: 'system', content: 'You are an experience mining expert. Extract reusable patterns, best practices, and lessons learned from completed tasks. Output one pattern per line in format: CATEGORY: pattern description' },
{ role: 'user', content: `Analyze these completed tasks and extract reusable patterns:\n${task_summaries.join('\n')}\n\nFocus categories: ${(mine_spec.focus_categories || ['implementation', 'debugging', 'testing']).join(', ')}` }
{ role: 'system', content: 'Extract durable learning candidates only when supported by evidence. Output one candidate per line as memory_type: concise summary. Valid memory_type values: project_rule, toolchain_rule, skill_update, debug_experience. Do not promote or archive memories.' },
{ role: 'user', content: `Evidence refs:\n${evidence_refs.join('\n')}\n\nTask ids: ${task_ids.join(', ') || '(none)'}\nFocus categories: ${focus.join(', ')}` }
]
try {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 })
const lines = (analysis.content || '').split('\n').filter(l => l.includes(':'))
for (const line of lines) {
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.2 })
for (const line of (analysis.content || '').split('\n')) {
const colon_idx = line.indexOf(':')
if (colon_idx > 0) {
const category = line.slice(0, colon_idx).trim().toLowerCase()
const pattern = line.slice(colon_idx + 1).trim()
if (pattern.length > 5) {
result.entries.push({
category,
pattern,
source_task_id: mine_spec.task_ids[0] || '',
description: pattern
})
}
}
if (colon_idx <= 0) continue
const category = line.slice(0, colon_idx).trim().toLowerCase()
const memory_type = MEMORY_TYPES.has(category) ? category : 'project_rule'
const pattern = line.slice(colon_idx + 1).trim()
if (pattern.length < 8) continue
result.entries.push({ category: memory_type, pattern, source_task_id: task_ids[0] || '', description: pattern })
}
} catch {
// LLM unavailable — extract basic patterns from task metadata
result.entries.push({
category: 'execution',
pattern: 'Tasks completed via Executor→LLM→Tool loop',
source_task_id: mine_spec.task_ids[0] || '',
description: 'Standard execution pattern for code changes'
category: 'project_rule',
pattern: `Review evidence before promoting memory from ${evidence_refs[0] || task_ids[0]}`,
source_task_id: task_ids[0] || '',
description: 'LLM unavailable; created a conservative candidate that requires human/runtime review before promotion.',
})
}
if (result.entries.length === 0 && evidence_refs.length > 0) {
const category = focus.find((item) => MEMORY_TYPES.has(item)) || 'project_rule'
result.entries.push({
category,
pattern: `Review evidence before promoting memory from ${evidence_refs[0]}`,
source_task_id: task_ids[0] || '',
description: 'Created a conservative candidate because no structured LLM-supported pattern was returned.',
})
}
for (const entry of result.entries) {
const candidate_id = `mem_${crypto.randomUUID()}`
this.runtime.emit('memory.candidate.created', {
event_id: `evt_${candidate_id}`,
candidate_id,
source_ref: {
entity_type: entry.source_task_id ? 'task' : 'evidence',
entity_id: entry.source_task_id || evidence_refs[0] || '',
},
memory_type: entry.category,
summary: entry.pattern,
evidence_refs,
})
}
if (result.entries.length === 0) {
result.status = 'no_patterns'
result.summary = `No patterns extracted from ${mine_spec.task_ids.length} tasks`
result.summary = `No supported memory candidates extracted from ${task_ids.length} tasks`
} else {
result.status = 'completed'
result.summary = `Mined ${result.entries.length} patterns from ${mine_spec.task_ids.length} tasks`
result.summary = `Created ${result.entries.length} memory candidates from ${task_ids.length} tasks`
}
this.runtime.checkpoint('mining_completed', { patterns_found: result.entries.length })
this.runtime.checkpoint('experience_mining_completed', { candidates: result.entries.length })
return result
} catch (error) {

View File

@@ -31,7 +31,7 @@ export class ReviewerRole {
const result: ReviewerResult = { status: 'pass', findings: [], summary: '' }
try {
this.runtime.emit('review.started', { task_id: review_spec.task_id })
this.runtime.checkpoint('review_started', { task_id: review_spec.task_id })
for (const file of review_spec.change_files) {
try {