spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/cli/engine.ts4 * Description: Composition root for the agent engine — session log, bus, workspace, tools, permissions, context, model, kernel.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { homedir } from "node:os";11import { join } from "node:path";12import { AnthropicModelClient } from "../anthropic/index.js";13import type { ThinkingConfig } from "../anthropic/index.js";14import {15 AgentKernel,16 EventLogSession,17 InterruptionController,18 SteeringQueue,19 ToolExecutor,20 VerificationGate,21 recoverDanglingOnResume,22} from "../agent/index.js";23import type { TurnOutcome } from "../agent/index.js";24import { persistPermissionGrant } from "../config/index.js";25import type { ResolvedConfig } from "../config/index.js";26import {27 ContextBudget,28 KhaelorContextEngine,29 buildSystemPrompt,30 discoverProjectInstructions,31} from "../context/index.js";32import {33 PermissionService,34 normalizePermissionsSection,35} from "../permissions/index.js";36import type { PermissionAsker, PermissionRule } from "../permissions/index.js";37import { GitService } from "../repository/index.js";38import { SessionEventBus, SessionLog, defaultSessionsDir } from "../session/index.js";39import type { DurableEventInput, ToolName } from "../session/index.js";40import { createDefaultToolRegistry } from "../tools/index.js";41import type { ToolRegistry } from "../tools/index.js";42import {43 InMemoryFileTimeRegistry,44 LocalProcessManager,45 LocalWorkspace,46} from "../workspace/index.js";47import type { ManagedProcess, ProcessEventSink } from "../workspace/index.js";48import { KHAELOR_VERSION } from "./args.js";49import type { FileLogger } from "./logger.js";50import { projectHash } from "./sessions.js";5152// ───────────────────────── session context ─────────────────────────5354export interface SessionContext {55 sessionId: string;56 hash: string;57 log: SessionLog;58 bus: SessionEventBus;59 session: EventLogSession;60 resumed: boolean;61}6263export interface OpenSessionOptions {64 cwd: string;65 sessionsDir?: string;66 /** Resume this session id; omitted → create a fresh session. */67 resumeId?: string;68 logger: FileLogger;69}7071/**72 * Create or resume the session log and wire the write-ahead bus + the73 * kernel's event view. Resume recovery (synthetic ToolCancelled for dangling74 * tool_use) is recorded durably here, at open time (EVENT_MODEL.md §6.5.2).75 */76export async function openSessionContext(options: OpenSessionOptions): Promise<SessionContext> {77 const sessionsDir = options.sessionsDir ?? defaultSessionsDir();78 const hash = projectHash(options.cwd);79 const log =80 options.resumeId !== undefined81 ? await SessionLog.open({ projectHash: hash, sessionId: options.resumeId, sessionsDir })82 : await SessionLog.create({ projectHash: hash, sessionsDir });83 if (log.recovery !== null) {84 options.logger.log("warn", "session log torn-line recovery", {85 tornFile: log.recovery.tornFile,86 truncatedTo: log.recovery.truncatedTo,87 });88 }89 const bus = new SessionEventBus({90 sessionId: log.sessionId,91 appender: log,92 onHandlerError: (error, event) => {93 options.logger.error("event handler failed", {94 eventType: event.type,95 error: error instanceof Error ? error.message : String(error),96 });97 },98 });99 const session = new EventLogSession({100 sessionId: log.sessionId,101 bus,102 replayed: log.replayedEvents,103 });104 return {105 sessionId: log.sessionId,106 hash,107 log,108 bus,109 session,110 resumed: options.resumeId !== undefined,111 };112}113114// ───────────────────────── the engine ─────────────────────────115116/** Inputs to the byte-stable system prompt — frozen at assembly (ADR-7). */117export interface SystemPromptArgs {118 workingDirectory: string;119 toolNames: readonly string[];120 instructions: Parameters<typeof buildSystemPrompt>[0]["instructions"];121}122123export interface AssembleEngineOptions {124 config: ResolvedConfig;125 cwd: string;126 context: SessionContext;127 /** TUI permission panel callback; absent → non-interactive (asks resolve deny). */128 asker?: PermissionAsker;129 logger: FileLogger;130 /** User dir for KHAELOR.md discovery. Default ~/.khaelor. */131 userDir?: string;132 /** Data root for spill/process logs. Default ~/.khaelor. */133 dataDir?: string;134}135136/**137 * Everything the CLI drives: the wired services plus a single-turn runner.138 * Built per session; `switchModel` rebuilds only the model-scoped pieces139 * (budget + context engine — a new prompt-cache lineage, ADR-7 rule 4).140 */141export class Engine {142 readonly session: EventLogSession;143 readonly log: SessionLog;144 readonly bus: SessionEventBus;145 readonly workspace: LocalWorkspace;146 readonly processes: LocalProcessManager;147 readonly git: GitService;148 readonly registry: ToolRegistry;149 readonly permissions: PermissionService;150 readonly steering: SteeringQueue;151 readonly interruption: InterruptionController;152 readonly verifier: VerificationGate;153 readonly modelClient: AnthropicModelClient;154 readonly executor: ToolExecutor;155 readonly logger: FileLogger;156157 contextEngine: KhaelorContextEngine;158 budget: ContextBudget;159 model: string;160161 readonly #config: ResolvedConfig;162 readonly #systemArgs: SystemPromptArgs;163 readonly #bridge: ProcessBridge;164 #turnActive = false;165 #shutdownStarted = false;166167 constructor(args: {168 config: ResolvedConfig;169 context: SessionContext;170 workspace: LocalWorkspace;171 processes: LocalProcessManager;172 git: GitService;173 registry: ToolRegistry;174 permissions: PermissionService;175 modelClient: AnthropicModelClient;176 executor: ToolExecutor;177 contextEngine: KhaelorContextEngine;178 budget: ContextBudget;179 logger: FileLogger;180 systemArgs: SystemPromptArgs;181 bridge: ProcessBridge;182 }) {183 this.#config = args.config;184 this.session = args.context.session;185 this.log = args.context.log;186 this.bus = args.context.bus;187 this.workspace = args.workspace;188 this.processes = args.processes;189 this.git = args.git;190 this.registry = args.registry;191 this.permissions = args.permissions;192 this.modelClient = args.modelClient;193 this.executor = args.executor;194 this.contextEngine = args.contextEngine;195 this.budget = args.budget;196 this.logger = args.logger;197 this.model = args.config.model;198 this.#systemArgs = args.systemArgs;199 this.#bridge = args.bridge;200 this.steering = new SteeringQueue(this.session);201 this.interruption = new InterruptionController(this.session);202 this.verifier = new VerificationGate({203 workspace: this.workspace,204 attributor: { attributeChanges: () => this.git.attributeChanges() },205 });206 }207208 get turnActive(): boolean {209 return this.#turnActive;210 }211212 /** Run one agent turn over the recorded state. Never runs two concurrently. */213 async runTurn(): Promise<TurnOutcome> {214 if (this.#turnActive) return { kind: "idle" };215 this.#turnActive = true;216 try {217 const kernel = new AgentKernel({218 session: this.session,219 context: this.contextEngine,220 model: this.modelClient,221 executor: this.executor,222 verifier: this.verifier,223 steering: this.steering,224 interruption: this.interruption,225 compaction: this.budget,226 });227 return await kernel.runTurn();228 } finally {229 this.#turnActive = false;230 }231 }232233 /** Switch the main model mid-session — durable ModelChanged + fresh budget/engine. */234 switchModel(to: string, reason: "user" | "config"): void {235 if (to === this.model) return;236 const from = this.model;237 this.model = to;238 this.budget = new ContextBudget({239 model: to,240 reservedOutputTokens: this.#config.maxOutputTokens,241 });242 this.contextEngine = buildContextEngine({243 config: this.#config,244 model: to,245 modelClient: this.modelClient,246 budget: this.budget,247 registry: this.registry,248 systemArgs: this.#systemArgs,249 });250 this.session.publishDurable({251 type: "session.model-changed",252 payload: { from, to, reason },253 });254 }255256 /** Record the git baseline (session start) when inside a repository. */257 async captureBaseline(): Promise<void> {258 const result = await this.git.recordBaseline("session-start");259 if (result.kind === "ok") {260 this.session.publishDurable({261 type: "git.baseline-recorded",262 payload: { when: "session-start", baseline: result.value },263 });264 } else if (result.kind === "error") {265 this.logger.log("warn", "git baseline capture failed", { message: result.message });266 }267 }268269 /** Orderly shutdown: stop background processes, flush + close the log. */270 async shutdown(): Promise<void> {271 if (this.#shutdownStarted) return;272 this.#shutdownStarted = true;273 this.#bridge.shuttingDown = true;274 try {275 await this.processes.stopAll();276 } catch (error) {277 this.logger.error("stopAll failed during shutdown", {278 error: error instanceof Error ? error.message : String(error),279 });280 }281 try {282 await this.log.close();283 } catch (error) {284 this.logger.error("session log close failed", {285 error: error instanceof Error ? error.message : String(error),286 });287 }288 }289}290291// ───────────────────────── assembly ─────────────────────────292293function thinkingFor(config: ResolvedConfig): ThinkingConfig | undefined {294 // "adaptive"/"always" → omit: current Anthropic models run adaptive thinking295 // by default and reject fixed budgets. "off" → explicit disabled.296 return config.thinking === "off" ? { mode: "disabled" } : undefined;297}298299function buildContextEngine(args: {300 config: ResolvedConfig;301 model: string;302 modelClient: AnthropicModelClient;303 budget: ContextBudget;304 registry: ToolRegistry;305 systemArgs: SystemPromptArgs;306}): KhaelorContextEngine {307 const thinking = thinkingFor(args.config);308 return new KhaelorContextEngine({309 model: args.model,310 auxModel: args.config.auxModel,311 maxOutputTokens: args.config.maxOutputTokens,312 systemTiers: buildSystemPrompt(args.systemArgs),313 tools: args.registry.list().map((tool) => ({314 name: tool.name,315 description: tool.description,316 inputSchema: tool.inputSchema as unknown as Record<string, unknown>,317 })),318 modelClient: args.modelClient,319 budget: args.budget,320 ...(thinking !== undefined ? { thinking } : {}),321 });322}323324/** Bridge ProcessManager lifecycle to durable events; tracks the owning tool call. */325class ProcessBridge implements ProcessEventSink {326 currentToolUseId = "";327 shuttingDown = false;328 readonly #session: EventLogSession;329330 constructor(session: EventLogSession) {331 this.#session = session;332 }333334 onProcessStarted(process: ManagedProcess): void {335 this.#session.publishDurable({336 type: "process.started",337 payload: {338 processId: process.id,339 pid: process.pid,340 command: process.command,341 cwd: process.cwd,342 toolUseId: this.currentToolUseId,343 },344 });345 }346347 onProcessExited(process: ManagedProcess): void {348 const cause =349 this.shuttingDown350 ? "khaelor-shutdown"351 : process.status === "failed"352 ? "crashed"353 : process.status === "stopped"354 ? "stopped-by-tool"355 : "exited";356 this.#session.publishDurable({357 type: "process.exited",358 payload: {359 processId: process.id,360 exitCode: process.exitCode,361 cause,362 durationMs: Math.max(0, Date.now() - process.startedAt),363 },364 });365 }366}367368/**369 * Wire the full engine around an open session context. Publishes the370 * SessionStarted / SessionResumed event and performs resume recovery.371 */372export async function assembleEngine(options: AssembleEngineOptions): Promise<Engine> {373 const { config, cwd, context } = options;374 const dataDir = options.dataDir ?? join(homedir(), ".khaelor");375 const userDir = options.userDir ?? join(homedir(), ".khaelor");376377 const workspace = new LocalWorkspace(cwd);378 const fileTimes = new InMemoryFileTimeRegistry();379 const git = new GitService(workspace);380 const registry = createDefaultToolRegistry();381 const toolNames = registry.list().map((tool) => tool.name);382383 const bridge = new ProcessBridge(context.session);384 context.bus.on("tool.started", (event) => {385 bridge.currentToolUseId = event.payload.toolUseId;386 });387 const processes = new LocalProcessManager({388 logDir: join(dataDir, "process-logs", context.sessionId),389 eventSink: bridge,390 });391392 // Config permissions (shorthand, nested, and rules forms) become project-layer rules.393 let projectRules: readonly PermissionRule[] = [];394 const normalized = normalizePermissionsSection(config.permissions, "project");395 if (normalized.ok) {396 projectRules = normalized.value;397 } else {398 options.logger.log("warn", "invalid permissions config ignored", {399 message: normalized.error.message,400 });401 }402 const permissions = new PermissionService({403 rules: { project: projectRules },404 ...(options.asker !== undefined ? { asker: options.asker } : {}),405 // "Always allow in this project" grants persist to .khaelor/config.json406 // (PERMISSION_MODEL.md §6.2) — they are ordinary rules on the next load.407 persister: {408 persist: (rule) => persistPermissionGrant(rule, { projectDir: cwd }),409 },410 publish: (event) => {411 context.session.publishDurable(event as DurableEventInput);412 },413 });414415 const executor = new ToolExecutor({416 session: context.session,417 registry,418 permissions,419 workspace,420 fileTimes,421 processes,422 spillDir: join(dataDir, "spill"),423 home: homedir(),424 });425426 const instructions = await discoverProjectInstructions(workspace, { userDir });427 const systemArgs = { workingDirectory: cwd, toolNames, instructions };428429 const modelClient = new AnthropicModelClient({ apiKey: config.apiKey ?? "" });430 const budget = new ContextBudget({431 model: config.model,432 reservedOutputTokens: config.maxOutputTokens,433 });434 const contextEngine = buildContextEngine({435 config,436 model: config.model,437 modelClient,438 budget,439 registry,440 systemArgs,441 });442443 const engine = new Engine({444 config,445 context,446 workspace,447 processes,448 git,449 registry,450 permissions,451 modelClient,452 executor,453 contextEngine,454 budget,455 logger: options.logger,456 systemArgs,457 bridge,458 });459460 // Session lifecycle event + resume recovery — recorded before any turn runs.461 if (context.resumed) {462 context.session.publishDurable({463 type: "session.resumed",464 payload: {465 khaelorVersion: KHAELOR_VERSION,466 replayedSeq: context.log.seqCursor - 1,467 model: config.model,468 toolNames: toolNames as ToolName[],469 },470 });471 recoverDanglingOnResume(context.session);472 } else {473 const branch = await git.currentBranch();474 context.session.publishDurable({475 type: "session.started",476 payload: {477 title: "",478 projectHash: context.hash,479 workingDirectory: cwd,480 gitBranch: branch.kind === "ok" ? branch.value : null,481 model: config.model,482 auxModel: config.auxModel,483 khaelorVersion: KHAELOR_VERSION,484 },485 });486 }487488 return engine;489}490