chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -126,18 +126,54 @@ export function e2eCommand(): void {
|
||||
// P4: Worker IPC
|
||||
{ label: 'P4: Worker IPC (test)', fn: () => runTest('P4', './packages/runtime/test/e2e/worker-fixture.test.ts ./packages/runtime/test/regression/worker-exit-code.test.ts ./packages/runtime/test/regression/worker-result-envelope.test.ts', repoRoot) },
|
||||
|
||||
// P5: C++ Toolchain
|
||||
// P4.1: Tool envelope shape (Phase 5 gate #1)
|
||||
{ label: 'P4.1: Tool envelope shape', fn: () => runTest('P4.1', './packages/runtime/test/e2e/tool-envelope-shape.test.ts', repoRoot) },
|
||||
|
||||
// P4.2: Shell.run functional (Phase 5 gate #2)
|
||||
{ label: 'P4.2: Shell.run functional', fn: () => runTest('P4.2', './packages/runtime/test/e2e/shell-run-functional.test.ts', repoRoot) },
|
||||
|
||||
// P4.3: Scheduler result status (Phase 5 gate #4)
|
||||
{ label: 'P4.3: Scheduler result status', fn: () => runTest('P4.3', './packages/runtime/test/e2e/scheduler-result-status.test.ts', repoRoot) },
|
||||
|
||||
// P4.4: Confirmation flow (Phase 5 gate #6)
|
||||
{ label: 'P4.4: Confirmation flow', fn: () => runTest('P4.4', './packages/runtime/test/e2e/confirmation-flow.test.ts', repoRoot) },
|
||||
|
||||
// P5: C++ Toolchain (FR-017: complete C++ workflow)
|
||||
{ label: 'P5: C++ Toolchain (test)', fn: () => runTest('P5', './packages/toolchain-cpp/test/', repoRoot) },
|
||||
|
||||
// P5.1: C++ build/test flow (FR-020: real C++ workflow)
|
||||
{ label: 'P5.1: C++ build/test flow', fn: () => runTest('P5.1', './packages/toolchain-cpp/test/cpp-build-flow.test.ts', repoRoot) },
|
||||
|
||||
// P6: Projection/TUI
|
||||
{ label: 'P6: Projection/TUI', fn: () => runTest('P6', './packages/runtime/test/regression/projection-store-apply.test.ts ./packages/runtime/test/regression/workspace-enum.test.ts', repoRoot) },
|
||||
|
||||
// P6.1: TUI startup (FR-020: TUI startup gate)
|
||||
{ label: 'P6.1: TUI startup', fn: () => runTest('P6.1', './packages/runtime/test/e2e/tui-startup.test.ts', repoRoot) },
|
||||
|
||||
// P6.2: Artifact/event persistence (FR-020: artifact/event persistence gate)
|
||||
{ label: 'P6.2: Artifact/event persistence', fn: () => runTest('P6.2', './packages/runtime/test/e2e/artifact-persistence.test.ts ./packages/runtime/test/e2e/event-persistence.test.ts', repoRoot) },
|
||||
|
||||
// P7: Agents
|
||||
{ label: 'P7: Agents (test)', fn: () => runTest('P7', './packages/runtime/test/e2e/direct-mode-fixture.test.ts ./packages/runtime/test/e2e/architecture-review-fixture.test.ts ./packages/runtime/test/regression/main-agent-states.test.ts', repoRoot) },
|
||||
|
||||
// P8: Full regression suite
|
||||
{ label: 'P8: Full regression suite', fn: () => runTest('P8', './packages/runtime/test/regression/', repoRoot) },
|
||||
|
||||
// FR-020: Additional release gates
|
||||
// Gate: SQLite recovery
|
||||
{ label: 'G1: SQLite recovery', fn: () => runTest('G1', './packages/runtime/test/e2e/sqlite-recovery.test.ts', repoRoot) },
|
||||
|
||||
// Gate: Project init (FR-020)
|
||||
{ label: 'G2: Project init', fn: () => runTest('G2', './packages/cli/test/init-command.test.ts', repoRoot) },
|
||||
|
||||
// Gate: Real LLM E2E (FR-020) - only runs if LLM provider configured
|
||||
{ label: 'G3: Real LLM E2E', fn: () => runTest('G3', './packages/runtime/test/e2e/llm-e2e.test.ts', repoRoot) },
|
||||
|
||||
// Gate: Child IPC (FR-020)
|
||||
{ label: 'G4: Child IPC', fn: () => runTest('G4', './packages/runtime/test/e2e/child-ipc.test.ts', repoRoot) },
|
||||
{ label: 'G5: ADR cascade invalidation (FR-007.5)', fn: () => runTest('G5', './packages/runtime/test/e2e/adr-cascade.test.ts', repoRoot) },
|
||||
{ label: 'G6: Compression validation (FR-014)', fn: () => runTest('G6', './packages/runtime/test/e2e/compression-validator.test.ts', repoRoot) },
|
||||
|
||||
// Security
|
||||
{ label: 'SEC: Command injection regression', fn: () => runTest('SEC', './packages/toolchain-cpp/test/command-injection.test.ts', repoRoot) },
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { execSync } from 'child_process'
|
||||
import { loadConfig } from '../bootstrap/loadConfig.js'
|
||||
import { ToolRegistry, createToolRegistry, register_builtin_tools } from '@aircoding/runtime'
|
||||
import type { ToolExecutionContext, ToolCall } from '@aircoding/contracts'
|
||||
@@ -16,6 +17,22 @@ export async function initCommand(project_path?: string, toolRegistry?: ToolRegi
|
||||
const project_root = project_path || process.cwd()
|
||||
console.log(`Initializing AirCoding project at ${project_root}`)
|
||||
|
||||
// FR-007.5: Ensure git repository exists for worktree isolation and rollback snapshots
|
||||
const git_dir = join(project_root, '.git')
|
||||
if (!existsSync(git_dir)) {
|
||||
try {
|
||||
execSync('git init', { cwd: project_root, stdio: 'pipe', timeout: 10000 })
|
||||
console.log(' Initialized git repository')
|
||||
// Create initial commit so worktree add and revert have a base
|
||||
execSync('git config user.email "aircoding@local"', { cwd: project_root, stdio: 'pipe' })
|
||||
execSync('git config user.name "AirCoding"', { cwd: project_root, stdio: 'pipe' })
|
||||
} catch (e: any) {
|
||||
console.warn(' Warning: git not available — worktree isolation and rollback disabled')
|
||||
}
|
||||
} else {
|
||||
console.log(' Git repository already exists')
|
||||
}
|
||||
|
||||
// Create minimal ToolRegistry if not provided (INV-3 compliance)
|
||||
let registry = toolRegistry
|
||||
if (!registry) {
|
||||
@@ -44,10 +61,15 @@ export async function initCommand(project_path?: string, toolRegistry?: ToolRegi
|
||||
// Create .air directory structure via fs.write tool (INV-3)
|
||||
const dirs = [
|
||||
join(project_root, '.air', 'shared'),
|
||||
join(project_root, '.air', 'shared', 'rules'),
|
||||
join(project_root, '.air', 'shared', 'plan'),
|
||||
join(project_root, '.air', 'local'),
|
||||
join(project_root, '.air', 'sessions'),
|
||||
join(project_root, '.air', 'logs'),
|
||||
join(project_root, '.air', 'workspaces')
|
||||
join(project_root, '.air', 'local', 'sessions'),
|
||||
join(project_root, '.air', 'local', 'logs'),
|
||||
join(project_root, '.air', 'local', 'workspaces'),
|
||||
join(project_root, '.air', 'local', 'backups'),
|
||||
join(project_root, '.air', 'local', 'tmp'),
|
||||
join(project_root, '.air', 'local', 'locks'),
|
||||
]
|
||||
|
||||
for (const dir of dirs) {
|
||||
@@ -75,11 +97,91 @@ export async function initCommand(project_path?: string, toolRegistry?: ToolRegi
|
||||
|
||||
// Write default rules via fs.write (INV-3)
|
||||
await call('fs.write', {
|
||||
path: join(project_root, '.air', 'shared', 'rules.md'),
|
||||
path: join(project_root, '.air', 'shared', 'rules', 'default.md'),
|
||||
content: '# Project Rules\n\nAdd your project-specific rules here.\n',
|
||||
create_dirs: true
|
||||
})
|
||||
|
||||
// Write permissions.yaml (architecture §6, Doctor reads this)
|
||||
await call('fs.write', {
|
||||
path: join(project_root, '.air', 'shared', 'permissions.yaml'),
|
||||
content: [
|
||||
'# AirCoding Permission Configuration',
|
||||
'# See architecture docs: security-model-v1.md',
|
||||
'',
|
||||
'default_policy: ask',
|
||||
'profiles:',
|
||||
' main_direct:',
|
||||
' allow: [fs.read, shell.run, project.scan]',
|
||||
' ask: [fs.write, fs.edit, fs.delete, git.*, cpp.*]',
|
||||
' deny: [system.*, network.*]',
|
||||
' executor:',
|
||||
' allow: [fs.*, shell.*, git.*, cpp.*, artifact.*, context.*]',
|
||||
' ask: [network.*, gui.*, debug.*]',
|
||||
' deny: [system.*, permission.*]',
|
||||
' reviewer:',
|
||||
' allow: [fs.read, project.scan, context.*]',
|
||||
' deny: [fs.write, fs.edit, fs.delete, shell.*, git.*, network.*, system.*]',
|
||||
' debugger:',
|
||||
' allow: [fs.read, shell.run, cpp.*, artifact.*, context.*, debug.*]',
|
||||
' ask: [fs.write, fs.edit]',
|
||||
' deny: [fs.delete, git.*, network.*, system.*]',
|
||||
'protect_paths:',
|
||||
' - .git/',
|
||||
' - .air/',
|
||||
' - node_modules/',
|
||||
' - credentials/',
|
||||
' - secrets/',
|
||||
' - "*.key"',
|
||||
' - "*.pem"',
|
||||
' - ".env*"',
|
||||
'build_dirs:',
|
||||
' - build/',
|
||||
' - dist/',
|
||||
' - out/',
|
||||
' - target/',
|
||||
].join('\n') + '\n',
|
||||
create_dirs: true
|
||||
})
|
||||
|
||||
// Write compaction-rules.md (architecture §6)
|
||||
await call('fs.write', {
|
||||
path: join(project_root, '.air', 'shared', 'compaction-rules.md'),
|
||||
content: [
|
||||
'# Compaction Rules',
|
||||
'',
|
||||
'## Immutable Layers',
|
||||
'- runtime_invariant (L0)',
|
||||
'- role (L1)',
|
||||
'',
|
||||
'## Thresholds',
|
||||
'- compaction_trigger: 85% token budget',
|
||||
'- min_tokens_freed: 40000',
|
||||
'',
|
||||
'## Must Preserve Patterns',
|
||||
'- File paths with extensions: .ts, .tsx, .js, .jsx, .cpp, .c, .h, .hpp, .cmake, .py, .rs, .go, .java',
|
||||
'- ADR references (adr/xxx)',
|
||||
'- FR references (FR-xxx)',
|
||||
'- Task IDs (task_xxx)',
|
||||
'- TODO/FIXME/HACK markers',
|
||||
'- Function/method references',
|
||||
'',
|
||||
'## Max Loss Threshold',
|
||||
'- 30% of MUST_PRESERVE patterns allowed',
|
||||
].join('\n') + '\n',
|
||||
create_dirs: true
|
||||
})
|
||||
|
||||
console.log('\nProject initialized successfully!')
|
||||
console.log(`Run 'air run' to start a session.`)
|
||||
|
||||
// FR-007.5: Create initial git commit so worktree and revert have a base
|
||||
if (existsSync(git_dir)) {
|
||||
try {
|
||||
execSync('git add -A', { cwd: project_root, stdio: 'pipe', timeout: 15000 })
|
||||
execSync('git commit -m "AirCoding project initialized" --allow-empty', { cwd: project_root, stdio: 'pipe', timeout: 15000 })
|
||||
} catch {
|
||||
// Git available but nothing to commit or other non-fatal issue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* RunCommand - Interactive AI coding session
|
||||
* DD §17. Full chain: input → MainAgent → Scheduler → Worker → LLM → tools → result.
|
||||
* Phase 3: All output routed through TUI status pipeline (no console.log tearing).
|
||||
*
|
||||
* @module packages/cli/src/commands/run
|
||||
*/
|
||||
@@ -9,11 +10,16 @@ import { loadConfig } from '../bootstrap/loadConfig.js'
|
||||
import { createRuntime } from '../bootstrap/createRuntime.js'
|
||||
import { initCommand } from './init.js'
|
||||
import type { TuiApp as TuiAppInstance } from '@aircoding/tui'
|
||||
import { MainAgent, eventIngestor } from '@aircoding/runtime'
|
||||
import { OpenAICompatibleAdapter } from '@aircoding/llm'
|
||||
import { existsSync } from 'fs'
|
||||
import { MainAgent, ArchitectureDesigner, eventIngestor } from '@aircoding/runtime'
|
||||
import { existsSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { Effect, Layer } from 'effect'
|
||||
import { FetchHttpClient } from 'effect/unstable/http'
|
||||
import { configure } from '../../llm/src/opencode/providers/openai-compatible.js'
|
||||
import { LLMClient } from '../../llm/src/opencode/route/client.js'
|
||||
import { request } from '../../llm/src/opencode/llm.js'
|
||||
import { RequestExecutor } from '../../llm/src/opencode/route/executor.js'
|
||||
|
||||
type TaskResultSummary = {
|
||||
title: string
|
||||
@@ -25,166 +31,197 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
const config = loadConfig(project_path)
|
||||
const project_root = config.project_root || process.cwd()
|
||||
|
||||
// Auto-init if needed
|
||||
// Auto-init if needed (pre-TUI, console is OK)
|
||||
if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) {
|
||||
console.log('Project not initialized. Running air init...\n')
|
||||
process.stderr.write('Project not initialized. Running air init...\n')
|
||||
await initCommand(project_root)
|
||||
}
|
||||
|
||||
// Create LLM provider
|
||||
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY || ''
|
||||
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
||||
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://open.bigmodel.cn/api/paas/v4'
|
||||
const model = process.env.AIRCODING_MODEL || 'glm-5.1'
|
||||
|
||||
const adapter = new OpenAICompatibleAdapter({ base_url: apiUrl, api_key: apiKey, model })
|
||||
// OpenCode provider: configure().model()
|
||||
const oc = configure({ baseURL: apiUrl, apiKey })
|
||||
const ocModel = oc.model(model)
|
||||
|
||||
// OpenCode LLM layer chain: FetchHttpClient → RequestExecutor → LLMClient
|
||||
const ocLayer = LLMClient.layer.pipe(
|
||||
Layer.provide(RequestExecutor.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
// Thin glue: complete_text() for WorkerManager, backed by OpenCode LLMClient.generate()
|
||||
const provider = {
|
||||
adapters: new Map([['openai-compatible', adapter]]),
|
||||
current_adapter: adapter,
|
||||
current_model: model,
|
||||
async complete_text(msgs: unknown[], opts: any = {}) {
|
||||
return (adapter as any).complete_text(msgs, opts)
|
||||
complete_text: async (msgs: unknown[], opts: any = {}) => {
|
||||
const messages = (msgs as any[]).map((m: any) =>
|
||||
m.role === 'system' ? { _tag: 'SystemPart', content: m.content }
|
||||
: m.role === 'assistant' ? { _tag: 'AssistantMessage', content: [{ _tag: 'TextPart', text: m.content }] }
|
||||
: { _tag: 'UserMessage', content: [{ _tag: 'TextPart', text: m.content }] }
|
||||
)
|
||||
const req = request({
|
||||
model: ocModel,
|
||||
system: opts.system,
|
||||
messages: messages as any,
|
||||
generation: { maxTokens: opts.max_tokens || 4096, temperature: opts.temperature },
|
||||
tools: opts.tools as any,
|
||||
})
|
||||
const program = Effect.gen(function* () {
|
||||
const resp = yield* LLMClient.generate(req)
|
||||
let content = ''
|
||||
const tool_calls: any[] = []
|
||||
for (const ev of resp.events) {
|
||||
const e = ev as any
|
||||
if (e._tag === 'TextPart' || e.type === 'text') { content += (e.text || '') }
|
||||
if (e._tag === 'ToolCallEvent' || e.type === 'tool_call') {
|
||||
tool_calls.push({ id: e.id, name: e.name, arguments: e.input || e.arguments || {} })
|
||||
}
|
||||
// Fallback: walk content arrays
|
||||
if (e.content && Array.isArray(e.content)) {
|
||||
for (const c of e.content) {
|
||||
if (c._tag === 'TextPart' || c.type === 'text') content += (c.text || '')
|
||||
if (c._tag === 'ToolCallEvent' || c.type === 'tool_call') {
|
||||
tool_calls.push({ id: c.id, name: c.name, arguments: c.input || c.arguments || {} })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { content, usage: resp.usage, tool_calls: tool_calls.length ? tool_calls : undefined }
|
||||
})
|
||||
return Effect.runPromise(Effect.provide(program, ocLayer))
|
||||
}
|
||||
}
|
||||
|
||||
// Create runtime and wire everything
|
||||
const runtime = await createRuntime(config)
|
||||
const app = runtime.app
|
||||
|
||||
// Wire ProviderManager into WorkerManager for llm.request IPC
|
||||
app.worker_manager.set_provider_manager(provider as any)
|
||||
app.worker_manager.set_context({
|
||||
session_id: app.session_id,
|
||||
project_id: app.project_id,
|
||||
project_root
|
||||
})
|
||||
|
||||
app.worker_manager.set_context({ session_id: app.session_id, project_id: app.project_id, project_root })
|
||||
await app.start()
|
||||
|
||||
// Create MainAgent
|
||||
const architectureDesigner = new ArchitectureDesigner(eventIngestor as any)
|
||||
const agent = new MainAgent({
|
||||
session_id: app.session_id,
|
||||
project_id: app.project_id,
|
||||
classify_mode: 'regex',
|
||||
provider_manager: provider as any,
|
||||
context_assembler: app.context_assembler,
|
||||
project_root,
|
||||
agent_id: 'main-agent' as any,
|
||||
classify_model: model
|
||||
session_id: app.session_id, project_id: app.project_id,
|
||||
classify_mode: 'llm', provider_manager: provider as any,
|
||||
context_assembler: app.context_assembler, architecture_designer: architectureDesigner,
|
||||
project_root, agent_id: 'main-agent' as any, classify_model: model,
|
||||
scheduler: app.scheduler
|
||||
})
|
||||
|
||||
await import('@aircoding/tui/preload')
|
||||
const { TuiApp } = await import('@aircoding/tui')
|
||||
|
||||
const taskResults = new Map<string, TaskResultSummary>()
|
||||
let pendingConfirmation: string | undefined
|
||||
let pendingConfirmation: { input: string; message: string } | undefined
|
||||
let unsubProjection: (() => void) | null = null
|
||||
let tui: TuiAppInstance
|
||||
let shuttingDown = false
|
||||
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
unsubProjection?.()
|
||||
app.scheduler.stop_loop()
|
||||
tui?.stop()
|
||||
await app.shutdown()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const dispatchTask = async (input: string) => {
|
||||
const dispatchTask = async (input: string): Promise<string> => {
|
||||
const taskId = `task_${randomUUID().slice(0, 8)}`
|
||||
await app.scheduler.create_tasks([{
|
||||
id: taskId,
|
||||
type: 'execute',
|
||||
title: input.slice(0, 80),
|
||||
description: input
|
||||
}])
|
||||
|
||||
console.log(`Task ${taskId} created. Dispatching worker...`)
|
||||
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 producedFiles: Array<{ path: string; size: number }> = []
|
||||
|
||||
if (resultFiles.length > 0) {
|
||||
const { statSync, existsSync: fileExists } = await import('fs')
|
||||
for (const file of resultFiles) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
if (producedFiles.length > 0) {
|
||||
console.log(' Produced files:')
|
||||
for (const file of producedFiles.slice(0, 10)) {
|
||||
console.log(` ${file.path} (${file.size}B)`)
|
||||
}
|
||||
}
|
||||
|
||||
taskResults.set(taskId, { title: input.slice(0, 80), files: producedFiles, state: finalState })
|
||||
tui.set_status(`Task ${taskId}: ${finalState}`)
|
||||
await app.scheduler.create_tasks([{ id: taskId, type: 'execute', title: input.slice(0, 80), description: input }])
|
||||
// Non-blocking: scheduler loop picks up the task, TUI updates via projection.
|
||||
// Defer to next tick so TUI's submit() cleanup (setBusy(false)) runs first,
|
||||
// then we re-assert busy state for the duration of async task execution.
|
||||
setTimeout(() => {
|
||||
tui.set_busy(true, `Running: ${input.slice(0, 50)}`)
|
||||
tui.set_status(`Dispatched: ${input.slice(0, 50)}`)
|
||||
}, 0)
|
||||
return taskId
|
||||
}
|
||||
|
||||
const handleSubmit = async (input: string) => {
|
||||
if (pendingConfirmation) {
|
||||
if (/^(y|yes|是|确认|确定)$/i.test(input)) {
|
||||
const confirmedInput = pendingConfirmation
|
||||
const { input: confirmedInput } = pendingConfirmation
|
||||
pendingConfirmation = undefined
|
||||
await agent.handle_confirmation(true)
|
||||
tui.set_status('Confirmed — dispatching')
|
||||
await dispatchTask(confirmedInput)
|
||||
} else if (/^(n|no|否|取消)$/i.test(input)) {
|
||||
pendingConfirmation = undefined
|
||||
await agent.handle_confirmation(false)
|
||||
console.log('Cancelled. No task was created.\n')
|
||||
tui.set_status('Cancelled')
|
||||
tui.set_status('Cancelled — No task was created')
|
||||
} else {
|
||||
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
|
||||
tui.set_status('Waiting for confirmation')
|
||||
tui.set_status('Please answer y/n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const classification = await agent.handle_user_message(input)
|
||||
console.log(`[${classification.action}]`)
|
||||
|
||||
// FR-014: Auto-trigger compaction
|
||||
if (agent.compaction_requested) {
|
||||
agent.compaction_requested = false
|
||||
const compactTaskId = `task_compact_${randomUUID().slice(0, 8)}`
|
||||
try {
|
||||
await app.scheduler.create_tasks([{ id: compactTaskId, type: 'compact', title: 'Auto-compaction', description: 'Context budget exceeded — compacting history.' }])
|
||||
tui.set_status('Compacting context...')
|
||||
} catch { /* advisory */ }
|
||||
}
|
||||
|
||||
if (classification.action === 'answer') {
|
||||
console.log('\n' + (classification.response || 'No response') + '\n')
|
||||
tui.set_status('Answered')
|
||||
tui.set_status(classification.response?.slice(0, 100) || 'Answered')
|
||||
} else if (classification.action === 'replan') {
|
||||
const reason = classification.reason || input
|
||||
const impact = classification.impact
|
||||
const changeId = `change_${randomUUID().slice(0, 8)}`
|
||||
try {
|
||||
await eventIngestor.ingest({
|
||||
id: `evt_${changeId}`, type: 'requirement.changed', version: 1,
|
||||
session_id: app.session_id, project_id: app.project_id,
|
||||
timestamp: new Date().toISOString(), source: { kind: 'main' },
|
||||
route: ['main_agent', 'architecture_replan'],
|
||||
payload: { change_id: changeId, origin_message_id: `msg_${randomUUID().slice(0, 8)}`, summary: reason, change_type: 'architecture', affected_refs: impact?.affected_components || [] }
|
||||
})
|
||||
} catch {}
|
||||
const adrChange = architectureDesigner.detect_adr_change(reason)
|
||||
const adrId = adrChange?.old_tech || 'unknown'
|
||||
tui.set_status(`Architecture replan: ${adrChange?.old_tech || ''} → ${adrChange?.new_tech || ''}`)
|
||||
await app.scheduler.invalidate_by_adr(adrId, reason)
|
||||
const newTaskId = `task_${randomUUID().slice(0, 8)}`
|
||||
const planDelta = architectureDesigner.create_plan_delta_for_adr_change({
|
||||
old_adr_id: adrId, new_adr_id: adrChange?.new_tech || `adr_${randomUUID().slice(0, 8)}`, reason,
|
||||
new_tasks: [{ id: newTaskId, type: 'execute', title: `Reimplement with ${adrChange?.new_tech || 'new approach'}`, description: reason }],
|
||||
})
|
||||
await app.scheduler.apply_plan_delta(planDelta.delta)
|
||||
app.scheduler.unfreeze()
|
||||
await dispatchTask(`[Replan] ${reason.slice(0, 70)}`)
|
||||
} 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')
|
||||
pendingConfirmation = { input, message: classification.response.replace(/\s*\(y\/n\)\s*$/, '') }
|
||||
tui.set_status(classification.response.slice(0, 80))
|
||||
} else {
|
||||
await dispatchTask(input)
|
||||
}
|
||||
} else {
|
||||
console.log(`Result: ${classification.response || 'Done'}`)
|
||||
tui.set_status(classification.response || 'Done')
|
||||
tui.set_status(classification.response?.slice(0, 80) || 'Done')
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePermission = async (prompt_id: string, selected_option: string) => {
|
||||
await eventIngestor.ingest({
|
||||
const { eventBus: bus } = await import('@aircoding/runtime')
|
||||
const resolutionEvent = {
|
||||
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' },
|
||||
type: 'permission.prompt.resolved' as const, version: 1,
|
||||
session_id: app.session_id, project_id: app.project_id,
|
||||
timestamp: new Date().toISOString(), source: { kind: 'main' as const, 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}`)
|
||||
payload: { prompt_id, selected_option, decision_id: `decision_${randomUUID().slice(0, 8)}`, resolved_by: 'user' },
|
||||
}
|
||||
bus.publish(resolutionEvent)
|
||||
try { await eventIngestor.ingest(resolutionEvent) } catch {}
|
||||
tui.set_status(`Permission: ${selected_option}`)
|
||||
}
|
||||
|
||||
tui = new TuiApp({
|
||||
@@ -196,100 +233,64 @@ export async function runCommand(project_path?: string): Promise<void> {
|
||||
})
|
||||
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()
|
||||
// Phase 3: Start non-blocking scheduler loop + projection subscription for real-time TUI
|
||||
const knownCompleted = new Set<string>()
|
||||
unsubProjection = runtime.projection_client.subscribe((projection) => {
|
||||
// Detect newly completed/failed tasks and update results
|
||||
for (const task of projection.tasks) {
|
||||
const key = `${task.id}:${task.status}`
|
||||
if ((task.status === 'completed' || task.status === 'failed' || task.status === 'blocked') && !knownCompleted.has(key)) {
|
||||
knownCompleted.add(key)
|
||||
// Collect result files from worker manager
|
||||
const workerResult = app.worker_manager.get_result_for_task?.(task.id)
|
||||
const resultFiles: string[] = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
|
||||
const producedFiles: Array<{ path: string; size: number }> = []
|
||||
if (resultFiles.length > 0) {
|
||||
for (const file of resultFiles) {
|
||||
if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue
|
||||
const fullPath = join(project_root, file)
|
||||
if (!existsSync(fullPath)) continue
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isFile()) producedFiles.push({ path: file, size: stat.size })
|
||||
}
|
||||
}
|
||||
taskResults.set(task.id, { title: task.title || task.id, files: producedFiles, state: task.status })
|
||||
const fileList = producedFiles.slice(0, 5).map(f => f.path).join(', ')
|
||||
const statusMsg = task.status === 'completed'
|
||||
? (producedFiles.length > 0 ? `Done: ${fileList}` : `Task ${task.id.slice(-8)} completed`)
|
||||
: `Task ${task.id.slice(-8)}: ${task.status}`
|
||||
tui.set_status(statusMsg)
|
||||
}
|
||||
}
|
||||
// When no running tasks remain, clear busy state
|
||||
const runningCount = projection.tasks.filter(t => t.status === 'running' || t.status === 'pending').length
|
||||
if (runningCount === 0) {
|
||||
tui.set_busy(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Start scheduler background loop (idempotent, yields between steps for TUI responsiveness)
|
||||
app.scheduler.start_loop()
|
||||
|
||||
// Banner goes through TUI renderer's external output mode (pre-TUI)
|
||||
tui.set_status(apiKey ? `Ready — ${model}` : 'No API key — AI disabled')
|
||||
|
||||
process.once('SIGINT', () => { void shutdown() })
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
async function handleSlashCommand(
|
||||
input: string,
|
||||
app: any,
|
||||
tui: TuiAppInstance,
|
||||
taskResults: Map<string, TaskResultSummary>,
|
||||
shutdown: () => Promise<void>,
|
||||
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')
|
||||
console.log(' /tools — List registered tools')
|
||||
console.log(' /tasks — Show task graph')
|
||||
console.log(' /results — Show produced files from completed tasks')
|
||||
console.log(' /quit — Exit AirCoding\n')
|
||||
break
|
||||
|
||||
case 'results':
|
||||
if (taskResults.size === 0) {
|
||||
console.log(' No task results yet. Submit a task first.\n')
|
||||
} else {
|
||||
console.log('')
|
||||
for (const [_taskId, result] of taskResults) {
|
||||
console.log(` Task: ${result.title} [${result.state}]`)
|
||||
if (result.files.length > 0) {
|
||||
for (const file of result.files) {
|
||||
console.log(` ${file.path} (${file.size}B)`)
|
||||
}
|
||||
} else {
|
||||
console.log(' (no files produced)')
|
||||
}
|
||||
}
|
||||
console.log('')
|
||||
}
|
||||
break
|
||||
|
||||
case 'status':
|
||||
console.log(`\n Scheduler: ${app.scheduler.get_state()}`)
|
||||
console.log(` Workers: ${app.worker_manager.list().length}`)
|
||||
console.log(` DB: ${app.db.isOpen() ? 'open' : 'closed'}\n`)
|
||||
break
|
||||
|
||||
case 'tools': {
|
||||
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 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(', ')}`)
|
||||
}
|
||||
console.log('')
|
||||
break
|
||||
}
|
||||
|
||||
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)) {
|
||||
console.log(` ${status}: ${count}`)
|
||||
}
|
||||
console.log('')
|
||||
break
|
||||
}
|
||||
|
||||
case 'quit':
|
||||
case 'exit':
|
||||
await shutdown()
|
||||
break
|
||||
|
||||
default:
|
||||
console.log(`Unknown command: ${cmd}. Try /help\n`)
|
||||
case 'help': tui.set_view('help'); break
|
||||
case 'results': tui.set_view('diff'); break
|
||||
case 'status': tui.set_view('agents'); tui.set_status(`Scheduler: ${app.scheduler.get_state()}, Workers: ${app.worker_manager.list().length}`); break
|
||||
case 'tools': tui.set_view('tools'); break
|
||||
case 'tasks': tui.set_view('tasks'); break
|
||||
case 'quit': case 'exit': await shutdown(); break
|
||||
default: tui.set_status(`Unknown: ${cmd}. Try /help`); break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* @module packages/cli
|
||||
*/
|
||||
|
||||
export { initCommand } from './commands/init.js'
|
||||
import { initCommand } from './commands/init.js'
|
||||
import { doctorCommand } from './commands/doctor.js'
|
||||
import { providerCommand } from './commands/provider.js'
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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')
|
||||
|
||||
it('prefers WorkerResult.changed_files over recent filesystem scan', () => {
|
||||
expect(source).toContain('get_result_for_task')
|
||||
expect(source).toContain('workerResult?.changed_files')
|
||||
})
|
||||
|
||||
it('filters .air internals from produced files', () => {
|
||||
expect(source).toContain("file.startsWith('.air/')")
|
||||
expect(source).toContain("e.startsWith('.')")
|
||||
})
|
||||
|
||||
it('routes destructive confirmation before slash/main dispatch', () => {
|
||||
expect(source).toContain('pendingConfirmation')
|
||||
expect(source).toContain('handle_confirmation(true)')
|
||||
expect(source).toContain('handle_confirmation(false)')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../contracts" },
|
||||
{ "path": "../runtime" },
|
||||
{ "path": "../tui" },
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../toolchain-cpp" }
|
||||
]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user