/** * KHAELOR * File: src/cli/engine.ts * Description: Composition root for the agent engine — session log, bus, workspace, tools, permissions, context, model, kernel. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { homedir } from "node:os"; import { join } from "node:path"; import { AnthropicModelClient } from "../anthropic/index.js"; import type { ThinkingConfig } from "../anthropic/index.js"; import { AgentKernel, EventLogSession, InterruptionController, SteeringQueue, ToolExecutor, VerificationGate, recoverDanglingOnResume, } from "../agent/index.js"; import type { TurnOutcome } from "../agent/index.js"; import { persistPermissionGrant } from "../config/index.js"; import type { ResolvedConfig } from "../config/index.js"; import { ContextBudget, KhaelorContextEngine, buildSystemPrompt, discoverProjectInstructions, } from "../context/index.js"; import { MEMORY_FILE } from "../memory/index.js"; import { PermissionService, normalizePermissionsSection, } from "../permissions/index.js"; import type { PermissionAsker, PermissionRule } from "../permissions/index.js"; import { PhaseService } from "../phases/index.js"; import type { PhaseApprovalAsker } from "../phases/index.js"; import { RepoGraphService } from "../repograph/index.js"; import { GitService } from "../repository/index.js"; import { SessionEventBus, SessionLog, defaultSessionsDir } from "../session/index.js"; import type { DurableEventInput, ToolName } from "../session/index.js"; import { createDefaultToolRegistry } from "../tools/index.js"; import type { ToolRegistry } from "../tools/index.js"; import { VerifyRunner, loadVerifyConfig } from "../verify/index.js"; import { InMemoryFileTimeRegistry, LocalProcessManager, LocalWorkspace, } from "../workspace/index.js"; import type { ManagedProcess, ProcessEventSink } from "../workspace/index.js"; import { KHAELOR_VERSION } from "./args.js"; import type { FileLogger } from "./logger.js"; import { projectHash } from "./sessions.js"; // ───────────────────────── session context ───────────────────────── export interface SessionContext { sessionId: string; hash: string; log: SessionLog; bus: SessionEventBus; session: EventLogSession; resumed: boolean; } export interface OpenSessionOptions { cwd: string; sessionsDir?: string; /** Resume this session id; omitted → create a fresh session. */ resumeId?: string; logger: FileLogger; } /** * Create or resume the session log and wire the write-ahead bus + the * kernel's event view. Resume recovery (synthetic ToolCancelled for dangling * tool_use) is recorded durably here, at open time (EVENT_MODEL.md §6.5.2). */ export async function openSessionContext(options: OpenSessionOptions): Promise { const sessionsDir = options.sessionsDir ?? defaultSessionsDir(); const hash = projectHash(options.cwd); const log = options.resumeId !== undefined ? await SessionLog.open({ projectHash: hash, sessionId: options.resumeId, sessionsDir }) : await SessionLog.create({ projectHash: hash, sessionsDir }); if (log.recovery !== null) { options.logger.log("warn", "session log torn-line recovery", { tornFile: log.recovery.tornFile, truncatedTo: log.recovery.truncatedTo, }); } const bus = new SessionEventBus({ sessionId: log.sessionId, appender: log, onHandlerError: (error, event) => { options.logger.error("event handler failed", { eventType: event.type, error: error instanceof Error ? error.message : String(error), }); }, }); const session = new EventLogSession({ sessionId: log.sessionId, bus, replayed: log.replayedEvents, }); return { sessionId: log.sessionId, hash, log, bus, session, resumed: options.resumeId !== undefined, }; } // ───────────────────────── the engine ───────────────────────── /** Inputs to the byte-stable system prompt — frozen at assembly (ADR-7). */ export interface SystemPromptArgs { workingDirectory: string; toolNames: readonly string[]; instructions: Parameters[0]["instructions"]; } export interface AssembleEngineOptions { config: ResolvedConfig; cwd: string; context: SessionContext; /** TUI permission panel callback; absent → non-interactive (asks resolve deny). */ asker?: PermissionAsker; /** TUI design-approval panel (strict gate mode); absent → large designs stay pending. */ designAsker?: PhaseApprovalAsker; logger: FileLogger; /** User dir for KHAELOR.md discovery. Default ~/.khaelor. */ userDir?: string; /** Data root for spill/process logs. Default ~/.khaelor. */ dataDir?: string; } /** * Everything the CLI drives: the wired services plus a single-turn runner. * Built per session; `switchModel` rebuilds only the model-scoped pieces * (budget + context engine — a new prompt-cache lineage, ADR-7 rule 4). */ export class Engine { readonly session: EventLogSession; readonly log: SessionLog; readonly bus: SessionEventBus; readonly workspace: LocalWorkspace; readonly processes: LocalProcessManager; readonly git: GitService; readonly registry: ToolRegistry; readonly permissions: PermissionService; readonly steering: SteeringQueue; readonly interruption: InterruptionController; readonly verifier: VerificationGate; readonly modelClient: AnthropicModelClient; readonly executor: ToolExecutor; readonly logger: FileLogger; readonly phases: PhaseService; readonly repograph: RepoGraphService; readonly verifyRunner: VerifyRunner; contextEngine: KhaelorContextEngine; budget: ContextBudget; model: string; readonly #config: ResolvedConfig; readonly #systemArgs: SystemPromptArgs; readonly #bridge: ProcessBridge; #turnActive = false; #shutdownStarted = false; constructor(args: { config: ResolvedConfig; context: SessionContext; workspace: LocalWorkspace; processes: LocalProcessManager; git: GitService; registry: ToolRegistry; permissions: PermissionService; modelClient: AnthropicModelClient; executor: ToolExecutor; contextEngine: KhaelorContextEngine; budget: ContextBudget; logger: FileLogger; systemArgs: SystemPromptArgs; bridge: ProcessBridge; phases: PhaseService; repograph: RepoGraphService; verifyRunner: VerifyRunner; }) { this.#config = args.config; this.session = args.context.session; this.log = args.context.log; this.bus = args.context.bus; this.workspace = args.workspace; this.processes = args.processes; this.git = args.git; this.registry = args.registry; this.permissions = args.permissions; this.modelClient = args.modelClient; this.executor = args.executor; this.contextEngine = args.contextEngine; this.budget = args.budget; this.logger = args.logger; this.model = args.config.model; this.#systemArgs = args.systemArgs; this.#bridge = args.bridge; this.phases = args.phases; this.repograph = args.repograph; this.verifyRunner = args.verifyRunner; this.steering = new SteeringQueue(this.session); this.interruption = new InterruptionController(this.session); this.verifier = new VerificationGate({ workspace: this.workspace, attributor: { attributeChanges: () => this.git.attributeChanges() }, }); } get turnActive(): boolean { return this.#turnActive; } /** Run one agent turn over the recorded state. Never runs two concurrently. */ async runTurn(): Promise { if (this.#turnActive) return { kind: "idle" }; this.#turnActive = true; try { const kernel = new AgentKernel({ session: this.session, context: this.contextEngine, model: this.modelClient, executor: this.executor, verifier: this.verifier, steering: this.steering, interruption: this.interruption, compaction: this.budget, }); return await kernel.runTurn(); } finally { this.#turnActive = false; } } /** Switch the main model mid-session — durable ModelChanged + fresh budget/engine. */ switchModel(to: string, reason: "user" | "config"): void { if (to === this.model) return; const from = this.model; this.model = to; this.budget = new ContextBudget({ model: to, reservedOutputTokens: this.#config.maxOutputTokens, }); this.contextEngine = buildContextEngine({ config: this.#config, model: to, modelClient: this.modelClient, budget: this.budget, registry: this.registry, systemArgs: this.#systemArgs, }); this.session.publishDurable({ type: "session.model-changed", payload: { from, to, reason }, }); } /** Record the git baseline (session start) when inside a repository. */ async captureBaseline(): Promise { const result = await this.git.recordBaseline("session-start"); if (result.kind === "ok") { this.session.publishDurable({ type: "git.baseline-recorded", payload: { when: "session-start", baseline: result.value }, }); } else if (result.kind === "error") { this.logger.log("warn", "git baseline capture failed", { message: result.message }); } } /** Orderly shutdown: stop background processes, flush + close the log. */ async shutdown(): Promise { if (this.#shutdownStarted) return; this.#shutdownStarted = true; this.#bridge.shuttingDown = true; try { await this.processes.stopAll(); } catch (error) { this.logger.error("stopAll failed during shutdown", { error: error instanceof Error ? error.message : String(error), }); } try { await this.log.close(); } catch (error) { this.logger.error("session log close failed", { error: error instanceof Error ? error.message : String(error), }); } } } // ───────────────────────── assembly ───────────────────────── function thinkingFor(config: ResolvedConfig): ThinkingConfig | undefined { // "adaptive"/"always" → omit: current Anthropic models run adaptive thinking // by default and reject fixed budgets. "off" → explicit disabled. return config.thinking === "off" ? { mode: "disabled" } : undefined; } function buildContextEngine(args: { config: ResolvedConfig; model: string; modelClient: AnthropicModelClient; budget: ContextBudget; registry: ToolRegistry; systemArgs: SystemPromptArgs; }): KhaelorContextEngine { const thinking = thinkingFor(args.config); return new KhaelorContextEngine({ model: args.model, auxModel: args.config.auxModel, maxOutputTokens: args.config.maxOutputTokens, systemTiers: buildSystemPrompt(args.systemArgs), tools: args.registry.list().map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema as unknown as Record, })), modelClient: args.modelClient, budget: args.budget, ...(thinking !== undefined ? { thinking } : {}), }); } /** Bridge ProcessManager lifecycle to durable events; tracks the owning tool call. */ class ProcessBridge implements ProcessEventSink { currentToolUseId = ""; shuttingDown = false; readonly #session: EventLogSession; constructor(session: EventLogSession) { this.#session = session; } onProcessStarted(process: ManagedProcess): void { this.#session.publishDurable({ type: "process.started", payload: { processId: process.id, pid: process.pid, command: process.command, cwd: process.cwd, toolUseId: this.currentToolUseId, }, }); } onProcessExited(process: ManagedProcess): void { const cause = this.shuttingDown ? "khaelor-shutdown" : process.status === "failed" ? "crashed" : process.status === "stopped" ? "stopped-by-tool" : "exited"; this.#session.publishDurable({ type: "process.exited", payload: { processId: process.id, exitCode: process.exitCode, cause, durationMs: Math.max(0, Date.now() - process.startedAt), }, }); } } /** * Wire the full engine around an open session context. Publishes the * SessionStarted / SessionResumed event and performs resume recovery. */ export async function assembleEngine(options: AssembleEngineOptions): Promise { const { config, cwd, context } = options; const dataDir = options.dataDir ?? join(homedir(), ".khaelor"); const userDir = options.userDir ?? join(homedir(), ".khaelor"); const workspace = new LocalWorkspace(cwd); const fileTimes = new InMemoryFileTimeRegistry(); const git = new GitService(workspace); const registry = createDefaultToolRegistry(); const toolNames = registry.list().map((tool) => tool.name); const bridge = new ProcessBridge(context.session); context.bus.on("tool.started", (event) => { bridge.currentToolUseId = event.payload.toolUseId; }); const processes = new LocalProcessManager({ logDir: join(dataDir, "process-logs", context.sessionId), eventSink: bridge, }); // Config permissions (shorthand, nested, and rules forms) become project-layer rules. let projectRules: readonly PermissionRule[] = []; const normalized = normalizePermissionsSection(config.permissions, "project"); if (normalized.ok) { projectRules = normalized.value; } else { options.logger.log("warn", "invalid permissions config ignored", { message: normalized.error.message, }); } const permissions = new PermissionService({ rules: { project: projectRules }, ...(options.asker !== undefined ? { asker: options.asker } : {}), // "Always allow in this project" grants persist to .khaelor/config.json // (PERMISSION_MODEL.md §6.2) — they are ordinary rules on the next load. persister: { persist: (rule) => persistPermissionGrant(rule, { projectDir: cwd }), }, publish: (event) => { context.session.publishDurable(event as DurableEventInput); }, }); // ── v2 services around the kernel: phases, semantic index, native verify ── const phases = new PhaseService({ session: context.session, config: { mode: config.gate.mode, autoApprove: { ...config.gate.autoApprove } }, projectRoot: cwd, ...(options.designAsker !== undefined ? { asker: options.designAsker } : {}), }); const repograph = new RepoGraphService({ workspace }); // Background warm-up (visible cost stays out of the first tool call). void repograph.ensureIndexed().catch((error: unknown) => { options.logger.log("warn", "repograph initial index failed", { error: String(error) }); }); const verifyConfig = await loadVerifyConfig(workspace); const verifyRunner = new VerifyRunner({ workspace, session: context.session, config: verifyConfig, }); const executor = new ToolExecutor({ session: context.session, registry, permissions, workspace, fileTimes, processes, spillDir: join(dataDir, "spill"), home: homedir(), phases, repograph, verify: verifyRunner, }); const instructions = await discoverProjectInstructions(workspace, { userDir }); // Project memory (v2 §5): auto-maintained facts join the instruction tier. try { const memoryPath = join(cwd, MEMORY_FILE); const memoryContent = await workspace.readFile(memoryPath); if (memoryContent.trim().length > 0) { instructions.push({ path: memoryPath, scope: "project", content: memoryContent }); } } catch { // no project memory yet } const systemArgs = { workingDirectory: cwd, toolNames, instructions }; const modelClient = new AnthropicModelClient({ apiKey: config.apiKey ?? "" }); const budget = new ContextBudget({ model: config.model, reservedOutputTokens: config.maxOutputTokens, }); const contextEngine = buildContextEngine({ config, model: config.model, modelClient, budget, registry, systemArgs, }); const engine = new Engine({ config, context, workspace, processes, git, registry, permissions, modelClient, executor, contextEngine, budget, logger: options.logger, systemArgs, bridge, phases, repograph, verifyRunner, }); // Session lifecycle event + resume recovery — recorded before any turn runs. if (context.resumed) { context.session.publishDurable({ type: "session.resumed", payload: { khaelorVersion: KHAELOR_VERSION, replayedSeq: context.log.seqCursor - 1, model: config.model, toolNames: toolNames as ToolName[], }, }); recoverDanglingOnResume(context.session); } else { const branch = await git.currentBranch(); context.session.publishDurable({ type: "session.started", payload: { title: "", projectHash: context.hash, workingDirectory: cwd, gitBranch: branch.kind === "ok" ? branch.value : null, model: config.model, auxModel: config.auxModel, khaelorVersion: KHAELOR_VERSION, }, }); // Gated sessions open in understand (v2 §1). phases.ensureStarted(); } return engine; }