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<!--2KHAELOR3File: docs/ARCHITECTURE.md4Author: Simon-Pierre Boucher5Contact: contact@spboucher.ai6-->78# KHAELOR V1 — System Architecture910> **Status:** Phase 1 deliverable — the definitive system design for KHAELOR V1.11> **Inputs:** `CLAUDE.md` (product spec), `docs/research/KHAELOR_ARCHITECTURE_DECISIONS.md` (ADR-1…17, binding), `docs/research/COMPARATIVE_ARCHITECTURE.md` (evidence).12> **Companion:** `docs/EVENT_MODEL.md` (the complete typed event vocabulary — normative for every event named here).13>14> Phase 2 implementation starts from this document. Where this document and an ADR disagree, the ADR wins and this document must be corrected.1516---1718## 1. Overview1920KHAELOR V1 is a single-process, terminal-native, Anthropic-only agent (ADR-1, ADR-17). Its architecture rests on five load-bearing decisions:21221. **A tiny, state-derived kernel** (ADR-2). The loop re-derives "what next" from recorded session state each iteration. The kernel coordinates; services own everything else.232. **An event-sourced session** (ADR-3, ADR-4). One append-only JSONL file per session is the sole source of truth. UI state, LLM message history, cost, and metadata are all projections. Resume = replay.243. **First-class streaming** (ADR-5). Everything is an event; deltas flow ephemerally to the TUI, completed blocks flow durably to the log. There is no non-streaming path.254. **Prompt-cache byte-stability as an invariant** (ADR-7). History is never rewritten; compaction — recorded as an event — is the sole sanctioned break.265. **One world seam** (ADR-13). Tools act on the world only through `Workspace`. No `node:fs` / `node:child_process` outside `src/workspace/` (and the narrow, listed exemptions).2728### 1.1 System diagram (refined from CLAUDE.md §4)2930```31 ┌─────────────────────────────┐32 │ KHAELOR TUI │33 │ composer · timeline · bars │34 └────┬───────────────▲────────┘35 KernelCommand│ │ coalesced events (~16 ms)36 │ │37 ┌─────────────────────────────▼───────────────┴─────────────────────────────┐38 │ EVENT BUS (typed, in-process) │39 │ durable events ──► SessionStore (JSONL append, write-ahead) │40 │ ephemeral deltas ──► TUI coalescer only │41 └────────────▲───────────────────▲──────────────────────▲───────────────────┘42 │ │ │43 ┌───────┴────────┐ ┌───────┴────────┐ ┌─────────┴─────────┐44 │ Session Engine │ │ Agent Kernel │ │ Process Manager │45 │ log·projections│◄─┤ (state-derived│ │ (background procs)│46 │ resume=replay │ │ loop, ADR-2) │ └─────────▲─────────┘47 └───────▲────────┘ └───┬───┬───┬────┘ │48 │ │ │ │ │49 │ ┌────────┘ │ └─────────┐ │50 │ ▼ ▼ ▼ │51 │ ┌──────────┐ ┌──────────┐ ┌───────────┐ │52 │ │ Context │ │ Model │ │ Tool │──┘53 │ │ Engine │ │ Runtime │ │ Runtime │──► Permissions54 │ │ (ADR-6/7)│ │ (ADR-10) │ │ (ADR-8) │ (ADR-9)55 │ └────┬─────┘ └────┬─────┘ └─────┬─────┘56 │ │ │ │57 └──────┤ Anthropic API ▼58 ▼ (official SDK) ┌───────────┐59 ┌─────────────┐ │ Workspace │──► files · processes60 │ Repository │───────────────►│ (ADR-13) │61 │ Intelligence│ └───────────┘62 └─────────────┘63```6465Reading the diagram:6667- The **TUI never calls the engine directly.** It emits `KernelCommand`s (submit message, interrupt, approve permission, run slash command) and consumes events. Nothing imports the TUI (see §2).68- The **event bus is the only "API"** (ADR-17). Durable events are appended to the session log *before* being published (write-ahead, see `EVENT_MODEL.md §5`).69- The **kernel** touches exactly five services: Context Engine, Model Runtime, Tool Runtime, Session Engine (via its projection handle), and the verification gate. Permissions, workspace, and repository intelligence sit behind those services.7071---7273## 2. Module Map and Dependency Rules7475### 2.1 Dependency direction rules (normative)7677Modules are arranged in strict layers. **An arrow means "may import"; anything not listed is forbidden.** ESLint (`import/no-restricted-paths` or `eslint-plugin-boundaries`) enforces this in CI alongside the header check.7879```80Layer 0 shared (imports nothing)81Layer 1 config · workspace · anthropic · session82Layer 2 permissions · repository · tools · context83Layer 3 agent84Layer 4 tui85Layer 5 cli (composition root)86```8788Rules, explicitly:89901. **`shared` imports nothing** (except `node:` builtins for pure utilities). The event vocabulary, envelope types, the `EventBus` interface, `KernelCommand`, `Result`, and error base types live here — this is what keeps the graph acyclic, since every layer speaks in these types.912. **Nothing imports `tui` except `cli`.** The TUI is a pure consumer of events and producer of `KernelCommand`s.923. **`tui` may import only `shared` and `config`** (theme/keybinding types). It renders from its own view-model, built by folding events (see `EVENT_MODEL.md §6.1`). It never imports `agent`, `session`, `tools`, or `anthropic`.934. **`tools` may import `workspace`, `repository`, and `shared` only.** Tools never check permissions themselves (the Tool Runtime gates them, §5.4) and never touch Node fs/process globals.945. **`workspace` is the only module that imports `node:fs` and `node:child_process`.** Exemptions (each individually listed in the lint rule config): `session/store` (its own log file I/O), `config/loader` (config file I/O), `cli` (bootstrap), `shared/logging` (log files under `~/.khaelor/logs/`).956. **`anthropic` imports `shared` only** (plus the `@anthropic-ai/sdk` package). Config values are passed in; it never reads config or sessions.967. **`session` imports `shared` only.** It owns the JSONL store and all projections.978. **`context` may import `session` (projection types), `repository`, `anthropic` (the `ModelClient` interface, for the auxiliary compaction model), and `shared`.**989. **`agent` may import `anthropic`, `tools`, `context`, `session`, `permissions`, `workspace`, and `shared`.** It is the only module that wires services together below `cli`.9910. **`cli` may import everything.** It is the composition root: it constructs the bus, store, workspace, runtimes, kernel, and TUI, and wires `KernelCommand` dispatch.10011. **No module imports another module's internals** — only its `index.ts` barrel. Deep imports fail lint.10112. **No cycles, ever.** A cycle is a build failure, not a warning.102103### 2.2 Module responsibilities and public interfaces104105Interface sketches below are the *public barrel* of each module — real, compilable-looking signatures that Phase 2–5 implement. Event types referenced (e.g. `DurableEvent`) are defined normatively in `EVENT_MODEL.md`.106107#### `src/shared/` — Layer 0108109Event vocabulary and envelopes, event bus, kernel commands, shared result/error types, logging, ids (ULID), token/byte utilities. **No business logic.**110111```ts112// shared/events.ts — full definitions in EVENT_MODEL.md113export type DurableEvent = /* discriminated union, EVENT_MODEL.md §4 */;114export type EphemeralEvent = /* discriminated union, EVENT_MODEL.md §4 */;115export type KhaelorEvent = DurableEvent | EphemeralEvent;116117export interface EventBus {118 publishDurable(e: DurableEventInput): DurableEvent; // append-then-publish; assigns seq/id/ts119 publishEphemeral(e: EphemeralEvent): void;120 on<T extends KhaelorEvent["type"]>(121 type: T,122 handler: (e: Extract<KhaelorEvent, { type: T }>) => void,123 ): Unsubscribe;124 onAny(handler: (e: KhaelorEvent) => void): Unsubscribe;125}126127// shared/commands.ts — the TUI→engine channel128export type KernelCommand =129 | { kind: "submit-message"; text: string; mentions: FileMention[] }130 | { kind: "steer"; text: string }131 | { kind: "interrupt" }132 | { kind: "permission-response"; requestId: string; decision: "once" | "always" | "deny" }133 | { kind: "slash"; command: string; args: string }134 | { kind: "shell"; command: string } // "!git status" composer shortcut135 | { kind: "quit" };136137export type Unsubscribe = () => void;138export type Result<T, E = KhaelorError> = { ok: true; value: T } | { ok: false; error: E };139```140141#### `src/config/` — Layer 1142143Schema, loading, precedence merge (CLI flags → `.khaelor/config.json` → `~/.khaelor/config.json` → env → defaults), secret hygiene (never log/echo keys), `KHAELOR.md` / `CLAUDE.md` / `AGENTS.md` instruction-file discovery with precedence.144145```ts146export interface KhaelorConfig {147 model: string; // Anthropic model id — never hard-coded lists (CLAUDE.md §6)148 auxModel: string; // cheaper model for compaction summaries (ADR-10)149 thinking: "off" | "adaptive" | "always";150 maxOutputTokens: number;151 permissions: Record<string, PermissionAction>; // capability → allow|ask|deny152 theme: ThemeConfig;153}154export function loadConfig(argv: ParsedArgs, cwd: string): Promise<ResolvedConfig>;155export function discoverInstructions(cwd: string): Promise<InstructionFile[]>; // ordered, global → nested156export function persistPermissionGrant(scope: "project", rule: PermissionRule): Promise<void>; // ADR-9157```158159#### `src/workspace/` — Layer 1160161The world seam (ADR-13). Exactly four methods plus process spawning support used by the Process Manager. `LocalWorkspace` is the only V1 implementation.162163```ts164export interface Workspace {165 cwd(): string;166 readFile(path: string): Promise<string>;167 writeFile(path: string, content: string): Promise<void>; // atomic: tmp + rename168 exec(command: Command): Promise<ProcessResult>;169}170export interface Command {171 cmd: string; // run via user shell for `bash` tool172 cwd?: string;173 timeoutMs: number; // hard ceiling; ADR-8: long commands redirect to `process`174 env?: Record<string, string>;175 signal?: AbortSignal;176}177export interface ProcessResult {178 exitCode: number | null; // null = killed by timeout/signal179 stdout: string; stderr: string;180 durationMs: number;181 truncated: boolean;182}183export class LocalWorkspace implements Workspace { /* only module touching node:fs/child_process */ }184// Tool-side helpers PARAMETERIZED BY Workspace (never fattening the interface — ADR-13):185export function statFile(ws: Workspace, path: string): Promise<FileStat>;186export function fileExists(ws: Workspace, path: string): Promise<boolean>;187```188189#### `src/anthropic/` — Layer 1190191`ModelClient` over the official SDK (ADR-10). Streaming, typed errors, retry/backoff, cancellation, honest usage. See §7.192193#### `src/session/` — Layer 1194195Event log store (JSONL), projections, resume-by-replay, session metadata, checkpoint types. See §6.196197#### `src/permissions/` — Layer 2198199Capability policy and evaluator (ADR-9).200201```ts202export type Capability =203 | "file.read" | "file.write.project" | "process.execute"204 | "network.access" | "git.modify" | "filesystem.outsideProject";205export type PermissionAction = "allow" | "ask" | "deny";206export interface PermissionRule { capability: Capability; pattern: string; action: PermissionAction; }207208export interface PermissionRequest {209 capability: Capability;210 descriptor: string; // e.g. "bash: npm install", "write: src/agent/kernel.ts"211 toolUseId: string;212 suggestion?: PermissionRule; // "always allow `git push *`" — omitted when the command213} // contains shell operators (Hermes guard, ADR-9)214215export interface PermissionEvaluator {216 evaluate(req: PermissionRequest): PermissionAction; // last-match-wins over rules + hardline floor217 addGrant(rule: PermissionRule): Promise<void>; // persists via config (ADR-9)218}219```220221The evaluator is deterministic: a small unbypassable deny floor (checked against de-obfuscated command variants) → configured rules, last match wins with wildcards → default `ask` for unmatched destructive capabilities. Denials produce feedback text delivered to the model as an observation (ADR-9, rejection-with-feedback).222223#### `src/repository/` — Layer 2224225Repository intelligence, exposed **only** through the Context Engine (CLAUDE.md §11) and the tools that wrap it. V1: filesystem map, git status/diff/baseline, ripgrep search, frecency-ranked file finding for `@` mentions. No AST/embeddings/symbol index.226227```ts228export interface RepositoryIndex {229 fileMap(opts?: { maxEntries?: number }): Promise<RepoFileEntry[]>; // respects .gitignore/.khaelorignore230 gitStatus(): Promise<GitStatus>; // branch, dirty, untracked231 gitDiff(paths?: string[]): Promise<GitDiff>;232 recordBaseline(when: "session-start" | "pre-first-edit"): Promise<GitBaseline>; // ADR-15233 attributeChanges(): Promise<{ khaelor: string[]; preExisting: string[] }>;234 search(query: RipgrepQuery): Promise<SearchResult>; // bounded, structured235 findFiles(fuzzy: string, limit: number): Promise<RankedFile[]>; // frecency-ranked236 noteAccess(path: string): void; // feeds frecency + recency237}238```239240#### `src/tools/` — Layer 2241242The seven primitives (ADR-8): `read · write · edit · grep · glob · bash · process`, plus the registry and the process manager. Each tool: ≤5 parameters, rich description, structured result with model-facing text and UI-facing metadata.243244```ts245export interface ToolDefinition<In> {246 name: ToolName;247 description: string; // guidance prose lives here, not in params248 inputSchema: JSONSchema; // ≤5 properties249 capability: (input: In, ctx: ToolContext) => Capability; // drives permission evaluation250 execute: (input: In, ctx: ToolContext) => Promise<ToolResult>;251}252export interface ToolContext {253 workspace: Workspace;254 repo: RepositoryIndex;255 processes: ProcessManager;256 signal: AbortSignal; // turn-scoped; aborts in-flight tools (ADR-11)257 emitOutput: (chunk: string) => void; // → ephemeral ToolOutput events258 budget: OutputBudget; // per-result + per-turn caps, spill-to-file (ADR-8)259}260export interface ToolResult {261 modelText: string; // budgeted head/tail-truncated text with omission markers262 isError: boolean;263 spillFile?: string; // full output path under ~/.khaelor/spill/, model can read/grep it264 ui: ToolUiMeta; // diffStats, matchCount, exitCode… for collapsed rendering265 filesModified?: FileModification[];266}267export interface ToolRegistry {268 definitions(): ToolDefinition<unknown>[]; // data-driven — the ADR-16 seam269 get(name: string): ToolDefinition<unknown> | undefined;270}271export interface ProcessManager { // model-facing via the `process` tool (ADR-8)272 start(cmd: string, opts: { cwd?: string; name?: string }): ManagedProcess;273 list(): ManagedProcessInfo[];274 read(id: string, opts?: { offset?: number; limit?: number }): ProcessReadResult; // rolling 200K buffer275 write(id: string, input: string): void;276 stop(id: string): Promise<void>; // process-group kill (mini hygiene, ADR-8)277 stopAll(): Promise<void>; // shutdown only — NOT on interrupt (ADR-11)278}279```280281`edit` implements the replacer cascade (exact → line-trimmed → block-anchor → whitespace-normalized → indentation-flexible → escape-normalized → trimmed-boundary → context-aware → multi-occurrence) with uniqueness and disproportionate-match guards, CRLF/BOM preservation, atomic writes, and OpenHands-grade failure messages (line-number hints, "maybe you meant", post-edit snippet). Every successful `write`/`edit` emits a durable `FileModified` event carrying diff stats and a capped unified diff.282283#### `src/context/` — Layer 2284285The Context Engine (ADR-6/7). See §8.286287#### `src/agent/` — Layer 3288289`AgentKernel` (the loop), `deriveNext` (pure state → decision), the stream-event reducer, the Tool Runtime (permission gate + execution + observation recording), the verification gate (ADR-12), steering queue, interruption controller. See §5.290291#### `src/tui/` — Layer 4292293Terminal UI: app shell, timeline, composer, markdown renderer (settled-block streaming), diff viewer, tool views, palettes, status bar, permission panel, coalescer. Framework selected by the Phase 1 spike (ADR-14); this module's *external contract* is framework-independent:294295```ts296export interface TuiApp {297 start(io: { events: EventBus; dispatch: (c: KernelCommand) => void; config: ResolvedConfig }): Promise<void>;298 stop(): Promise<void>;299}300```301302Internally, a `Coalescer` batches ephemeral deltas at ~16 ms (contract in `EVENT_MODEL.md §7`); the view-model is a fold over events; rendering uses settled-block incremental markdown with a bounded live region (hard caps on live-region chars/lines). No 100-message scrollback cliff.303304#### `src/cli/` — Layer 5305306Entry point, argv parsing, config resolution, composition root, lifecycle (signals, terminal setup/teardown, crash-safe restore), lazy-import discipline (§13), `khaelor --debug` logging switch.307308---309310## 3. Data & Control Flow (one turn, end to end)311312```313user types → composer → KernelCommand{submit-message} → cli dispatcher314 → durable UserMessageCreated appended + published315 → kernel loop wakes (it is the sole consumer of "work exists" state)316 → ContextEngine.selectContext(projection) → ModelRequest (byte-stable tiers, ADR-7)317 → ModelClient.stream(request, signal)318 deltas → ephemeral ModelTextDelta / ModelThinkingDelta / ToolInputDelta → TUI coalescer319 settled → durable ModelTextBlockCompleted / ModelThinkingBlockCompleted / ToolRequested320 finish → durable ModelResponseCompleted {usage, stopReason}321 → for each ToolRequested (sequential, block order):322 Tool Runtime: capability → PermissionEvaluator323 ask → durable PermissionRequested → TUI panel → PermissionGranted/Denied324 durable ToolApproved → ToolStarted → execute (ephemeral ToolOutput chunks)325 → durable ToolCompleted | ToolFailed | ToolCancelled (+ FileModified / ProcessStarted …)326 → steering queue drained at the post-tool-batch seam → durable SteeringInjected327 → ContextEngine.onTurnComplete(usage) → maybe durable ContextPruned / ContextCompacted328 → loop re-derives: more tool calls? overflow? stop?329 → on stop with unverified code changes → verification gate (≤2 nudges) → TaskCompleted{evidence}330```331332Every durable event is appended to the JSONL **before** subscribers see it. The TUI, the session store, and the LLM history projection all consume the same stream — streaming, history, and state cannot diverge.333334---335336## 4. The Agent Kernel (ADR-2)337338The kernel is a small loop that re-derives "what next" from recorded session state each iteration. Exit conditions derive from state, never from in-memory flags. Target size: a few hundred lines including the reducer — anything larger is carrying non-kernel work.339340### 4.1 Kernel state341342Per ADR-2 / mini's rule ("if a field isn't consulted by the loop itself, it belongs to a service"), the kernel holds exactly:343344```ts345interface KernelDeps {346 session: SessionHandle; // projection access + durable publish347 context: ContextEngine;348 model: ModelClient;349 tools: ToolRuntime; // registry + permission gate + executor350 verifier: VerificationGate; // ADR-12351 steering: SteeringQueue; // ADR-11352 bus: EventBus;353}354interface KernelRunState {355 turnSignal: AbortController; // the cancellation root for this run (ADR-11)356 budget: { iterations: number; verificationAttempts: number };357}358```359360### 4.2 The loop (precise pseudocode)361362```ts363async function runTurn(deps: KernelDeps, run: KernelRunState): Promise<TurnOutcome> {364 while (true) {365 // 1. Re-derive what to do from RECORDED state — never from loop-local flags.366 const state = deps.session.projection(); // in-memory, event-derived (rebuilt from367 const next = deriveNext(state, run.budget); // JSONL only on resume — ADR-2 trade-off)368369 switch (next.kind) {370 case "inject-steering": {371 // Safe seam: after tool results / before the next model call (ADR-11).372 deps.session.publish(steeringInjected(next.queued, next.seam));373 continue;374 }375376 case "compact": {377 // Proactive (token budget) or reactive (overflow error recorded). ADR-6.378 const checkpoint = await deps.context.compress(state, run.turnSignal.signal);379 deps.session.publish(contextCompacted(checkpoint)); // durable; replay-deterministic380 continue;381 }382383 case "call-model": {384 const ctx = await deps.context.selectContext(state); // byte-stable tiers (ADR-7)385 try {386 for await (const ev of deps.model.stream(ctx.request, run.turnSignal.signal)) {387 reduceModelEvent(ev, deps); // pure reducer: deltas → ephemeral publish;388 } // settled blocks / tool_use / usage → durable publish389 } catch (err) {390 const klass = classifyModelError(err); // typed taxonomy (ADR-10)391 deps.session.publish(modelRequestFailed(klass));392 if (klass.kind === "context-overflow") continue; // → deriveNext yields "compact"393 if (klass.kind === "cancelled") continue; // → deriveNext sees Interrupted394 if (klass.retryable) continue; // ModelClient already backed off395 return { kind: "failed", error: klass }; // fatal → TaskFailed recorded by caller396 }397 continue;398 }399400 case "execute-tools": {401 // Sequential, block order. The runtime gates permissions, executes, and records402 // ToolApproved/Started/Completed/Failed/Cancelled + FileModified/Process* itself.403 await deps.tools.executeBatch(next.pending, run.turnSignal.signal);404 deps.context.onTurnComplete(deps.session.projection().lastUsage); // ADR-6 observation405 continue;406 }407408 case "verify": {409 // Model stopped; code changed this turn; no fresh verification evidence (ADR-12).410 if (run.budget.verificationAttempts >= 2) return { kind: "done", withheld: next.candidate };411 run.budget.verificationAttempts++;412 deps.session.publish(verificationRequested(next.detectedChecks, next.candidate));413 continue; // nudge is a synthetic message → call-model414 }415416 case "interrupted": return { kind: "interrupted" }; // Interrupted event is already durable417 case "done": return { kind: "done" };418 }419 }420}421```422423### 4.3 `deriveNext` — a pure function of recorded state424425```ts426function deriveNext(s: SessionProjection, budget: Budget): NextAction {427 if (s.interruptedSinceLastModelCall) return { kind: "interrupted" };428 if (s.pendingToolCalls.length > 0) return { kind: "execute-tools", pending: s.pendingToolCalls };429 if (s.queuedSteering.length > 0 && s.atSafeSeam) return { kind: "inject-steering", ... };430 if (s.contextOverflowRecorded || s.tokensUsed > s.compactionThreshold)431 return { kind: "compact" };432 if (s.lastStop === "end_turn") {433 const gate = needsVerification(s); // code changed this turn ∧ no fresh evidence,434 if (gate.required) // documentation-only changes filtered (ADR-12)435 return { kind: "verify", ...gate };436 return { kind: "done" };437 }438 if (isDoomLoop(s)) return { kind: "verify-with-user" }; // 3 byte-identical439 if (budget.iterations <= 0) return { kind: "done" }; // consecutive calls → ask440 return { kind: "call-model" };441}442```443444Properties this buys (OpenCode evidence, ADR-2): a crashed process resumes mid-conversation because nothing lives only in loop-local variables; steering and interruption are just state the next iteration observes; compaction is a task the loop derives, not a side effect buried in a handler. The stream reducer (`reduceModelEvent`) is a separate pure component from the loop.445446### 4.4 What is *not* in the kernel447448Retry/backoff (Model Runtime), permission evaluation (Tool Runtime → Permissions), output budgeting (tools), compaction algorithm (Context Engine), rendering (TUI), persistence mechanics (Session Store), git baselines (Repository). The doom-loop check and the verification gate are the only kernel-adjacent policies, and both are pure functions over the projection.449450---451452## 5. Session Engine (ADR-3)453454### 5.1 Storage layout455456```457~/.khaelor/458 sessions/<project-hash>/459 <session-id>.jsonl # append-only durable event log — THE truth460 <session-id>.meta.json # projection cache (title, usage, cost, updatedAt) — always rebuildable461 spill/ # oversized tool output (size-capped)462 logs/ # developer logs (never the TUI)463```464465`project-hash` = hash of the git root (or cwd when not a repo). Session ids are ULIDs — lexicographic order = creation order.466467### 5.2 The store468469```ts470export interface SessionStore {471 create(meta: NewSessionMeta): Promise<SessionHandle>;472 open(sessionId: string): Promise<SessionHandle>; // resume = replay (§5.4)473 list(projectHash: string): Promise<SessionMeta[]>; // reads meta caches; rebuilds stale ones474}475export interface SessionHandle {476 readonly id: string;477 publish(e: DurableEventInput): DurableEvent; // append (write-ahead) + bus publish; assigns seq478 projection(): SessionProjection; // in-memory, maintained by the same reducer479 meta(): SessionMeta;480}481export interface SessionMeta { // CLAUDE.md §8482 id: string; title: string; project: string;483 createdAt: number; updatedAt: number;484 model: string;485 tokenUsage: UsageTotals; // incl. cache read/write — real API numbers only486 cost: number;487 gitBranch: string;488 workingDirectory: string;489}490```491492Durability discipline (mini's `finally` rule, ADR-3): the log is appended at every loop boundary — after every durable event, before the bus publishes it. Append atomicity, fsync policy, corruption recovery (truncated last line), and the versioning strategy are specified normatively in `EVENT_MODEL.md §5`.493494### 5.3 Projections495496All state is derived. Five projections, each a fold over the durable stream (full rules in `EVENT_MODEL.md §6`):497498| Projection | Consumers | Notes |499|---|---|---|500| `SessionProjection` | kernel (`deriveNext`) | pending tool calls, queued steering, last stop reason, overflow flag, files changed this turn, verification evidence |501| `LlmHistory` | Context Engine | byte-stable Anthropic `messages[]`; re-applies `ContextCompacted`/`ContextPruned` deterministically |502| `Timeline` | TUI view-model | messages, tool cards, diffs, status |503| `UsageTotals` | `/cost`, status bar | summed from `ModelResponseCompleted.usage` only — never invented (Absolute Rule #4) |504| `FileChangeSet` | `/diff`, verification gate, attribution | vs. `BaselineRecorded` (ADR-15) |505506Projections are caches, never truth (ADR-3 concern): each must tolerate being stale or deleted and rebuild from the log.507508### 5.4 Resume509510`open()` replays the JSONL through the same reducer that maintains live projections. Resume contract (OpenHands-derived, ADR-3): tools add-only, model swappable. On replay, any `ToolRequested` without a terminal result gets a synthetic durable `ToolCancelled` appended **at resume time** so the LLM history is protocol-valid (`EVENT_MODEL.md §6.5`). Background processes do not survive the process; `ProcessExited{cause:"khaelor-shutdown"}` is recorded at shutdown, and resume renders them as exited.511512Long-session replay cost is bounded by `ContextCompacted` events, which act as natural snapshots for `LlmHistory`; if `Timeline` replay is ever measured slow, periodic snapshot events are the sanctioned fix — not a database.513514### 5.5 Session commands515516`/sessions` (list via meta caches) · `/resume` · `/new` · `/rename` (durable `SessionRenamed`) · `/clear` (new session; never truncates a log). `/branch` and `/rewind` are post-V1; the envelope reserves `parentId?` (ADR-3).517518---519520## 6. Context Engine (ADR-6, ADR-7)521522### 6.1 Interface523524Hermes' verbs, exactly four:525526```ts527export interface ContextEngine {528 /** Assemble the model request from the projection. V1: pass-through hook for selection529 * (no retrieval/topic routing yet — the verb exists so repository intelligence can530 * plug in without interface change). */531 selectContext(s: SessionProjection): Promise<BuiltContext>;532533 /** Summarize-compact. Emits nothing itself — returns the checkpoint the kernel records534 * as a durable ContextCompacted event. Cuts ONLY at pairing-safe indices. */535 compress(s: SessionProjection, signal: AbortSignal): Promise<CompactionCheckpoint>;536537 /** Observation hook: real usage from the last response updates budget accounting. */538 onTurnComplete(usage: ModelUsage): void;539540 /** Cheap, deterministic, no-LLM: blank old tool results (protect newest N tokens).541 * Returns the toolUseIds to prune; kernel records ContextPruned. Runs BEFORE compress. */542 pruneToolResults(s: SessionProjection): PruneDecision;543}544export interface BuiltContext {545 request: ModelRequest; // system tiers + messages + tools + cache breakpoints546 stats: ContextStats; // per-section token estimates → /context inspector547}548```549550### 6.2 Triggers — token-based, from real usage only551552- **Proactive:** after each response, `onTurnComplete` compares `usage.inputTokens + usage.outputTokens` against `usableWindow = modelWindow − reservedOutput − compactionBuffer`. Crossing the threshold sets the projection's compaction flag; `deriveNext` yields `compact` at the next safe boundary. Never event-count triggers (OpenHands' named weakness).553- **Reactive:** a `context-overflow` typed model error (ADR-10) records `ModelRequestFailed{kind:"context-overflow"}`; `deriveNext` routes to `compact` — never a blind retry.554- **Order:** `pruneToolResults` first (cheap, deterministic — protect the newest ~40K tokens of tool output); `compress` only if still over budget.555556### 6.3 Compaction-as-event557558`ContextCompacted` is a durable event carrying the checkpoint and the exact cut range. `LlmHistory` re-applies it deterministically on every rebuild — replay-safe, inspectable (`/context`, `/compact`), and itself re-compactable later. Cut indices are chosen only where every `tool_use` before the cut has its `tool_result` before the cut (pairing safety — `EVENT_MODEL.md §6.5`). Compaction summaries route to the configured `auxModel` (ADR-10).559560### 6.4 Checkpoint format (structured YAML — CLAUDE.md §12)561562```yaml563objective: <the user's current objective, one paragraph>564completed:565 - <finished sub-goal>566current_state: <where the work stands right now>567important_files:568 - path: src/context/engine.ts569 reason: <why it matters to remaining work>570changes:571 - <file-level change made so far>572failed_attempts:573 - <approach tried and abandoned, with why>574decisions:575 - <decision taken and rationale>576running_processes:577 - id: <process id>578 command: npm run dev579 status: running580next_steps:581 - <concrete next action>582raw_evidence: # preserved verbatim when summarization would destroy it583 - label: <e.g. failing test output>584 content: |585 <capped raw text>586```587588`failed_attempts`, `decisions`, and `running_processes` exist precisely because free-text summaries destroy them (ADR-6 trade-off).589590### 6.5 Prompt-cache byte-stability rules (ADR-7 — enforced by tests)5915921. **The system prompt is built once per session** in stable tiers: `[identity/behavior] → [tool guidance] → [project instructions]`. It is never re-rendered mid-session. `cache_control` breakpoints are placed deliberately at tier ends.5932. **History is never rewritten.** Assistant blocks, tool results, and user messages are replayed byte-exact. `ContextCompacted` (and deterministic `ContextPruned` with a fixed placeholder string) are the only sanctioned breaks — both durable events, so every rebuild produces identical bytes.5943. **Volatile context** (timestamps, git status, running-process lists, per-turn repository context) is injected **only into the API copy of the current user message**, never interleaved into history, never in the cached system tiers.5954. **Model or instruction changes** mid-session (`ModelChanged`) start a new cache lineage; that is accepted and visible.5965. Cache read/write tokens surface in `/cost` from real usage fields — cache health is observable, and a regression is a bug.597598---599600## 7. Model Runtime (ADR-10)601602```ts603export interface ModelClient {604 stream(request: ModelRequest, signal: AbortSignal): AsyncIterable<ModelEvent>;605 countTokens?(request: ModelRequest): Promise<number>; // best-effort budgeting aid606}607export interface ModelRequest {608 model: string;609 system: SystemTier[]; // stable tiers with cache_control breakpoints610 messages: AnthropicMessage[]; // byte-stable projection (§6.5)611 tools: ToolSchema[];612 maxOutputTokens: number;613 thinking?: ThinkingConfig;614}615export type ModelEvent =616 | { type: "started"; requestId: string }617 | { type: "text-delta"; blockIndex: number; text: string }618 | { type: "thinking-delta"; blockIndex: number; text: string }619 | { type: "block-completed"; blockIndex: number; block: CompletedBlock } // text | thinking(+signature) | tool_use620 | { type: "finished"; stopReason: StopReason; usage: ModelUsage };621export interface ModelUsage { // real API fields ONLY — never estimated (Rule #4)622 inputTokens: number; outputTokens: number;623 cacheReadTokens: number; cacheWriteTokens: number;624}625```626627**Error taxonomy** (typed, kernel branches on it):628629```ts630export type ModelErrorKind =631 | "retryable" // 429 / 5xx / network — retried inside ModelClient with jittered632 // exponential backoff and a retry budget; surfaced only on exhaustion633 | "context-overflow" // routed to Context Engine (reactive compaction) — NEVER retried blindly634 | "auth" // fatal; actionable message; never logs the key635 | "invalid-request" // fatal; a KHAELOR bug — surfaced loudly636 | "cancelled"; // AbortSignal fired — not an error path, folds into Interrupted state637export class ModelError extends Error {638 kind: ModelErrorKind; retryable: boolean; status?: number; requestId?: string;639}640```641642**Cancellation** is AbortSignal end to end: turn controller → SDK request → the async iterable throws `ModelError{kind:"cancelled"}`. No thread flags, no scattered checks (ADR-11).643644**Accounting:** `finished.usage` is the only source for `/cost` and compaction budgets. Two configured model ids share this one interface: `model` (main) and `auxModel` (compaction) — no provider framework, no second implementation.645646---647648## 8. Interruption & Steering (ADR-11)649650**Interrupt (`Esc`):**6516521. TUI dispatches `KernelCommand{interrupt}` → durable `Interrupted` event → `run.turnSignal.abort()`.6532. The model stream aborts; in-flight tools receive the signal, get a 250 ms grace, then are cancelled.6543. Every dangling `tool_use` is closed with a synthetic cancelled `tool_result` — recorded as durable `ToolCancelled` — so history is **protocol-valid at all times**.6554. Background `process`-managed processes are **not** killed (explicitly long-lived).6565. The loop's next `deriveNext` observes the interrupted state and returns; the session is intact and immediately usable.657658**Steering:** text typed while the agent works becomes a durable `SteeringQueued` event, shown as `Queued instruction`. The queue drains at exactly two seams — post-tool-batch and pre-model-call — recorded as `SteeringInjected` and appended into the API-copy content alongside tool results (never breaking role alternation). A long uninterrupted text stream cannot be steered until it completes or is cancelled — accepted price (ADR-11).659660---661662## 9. Completion & Verification (ADR-12)663664When the model stops (`end_turn`) and the `FileChangeSet` shows code changed this turn without fresh verification evidence, the gate:6656661. Detects relevant check commands from the repository (package scripts, test configs) — never blind full suites.6672. Records `VerificationRequested{detectedChecks, attempt}` and **withholds the candidate answer** (preserved — budget exhaustion returns it rather than losing it).6683. Injects a synthetic evidence-bearing nudge; max 2 attempts; documentation-only changes are filtered out.6694. On acceptance records `TaskCompleted` carrying:670671```ts672interface CompletionEvidence {673 objective: string;674 changedFiles: string[];675 checks: CheckResult[]; // command, exitCode, summary — real results only676 unresolvedIssues: string[];677}678```679680Completion is inferred from stop-without-tool-calls plus this gate (no finish tool in V1); if dogfooding shows ambiguity, an explicit finish signal is addable without kernel changes (ADR-12 concern, recorded).681682---683684## 10. Permissions (ADR-9) — placement summary685686Evaluation happens in exactly one place: the Tool Runtime, between `ToolRequested` and `ToolStarted`. Flow: capability derivation from tool input → hardline deny floor → last-match-wins rules → `allow` (durable `ToolApproved`) / `ask` (durable `PermissionRequested` → inline panel → `PermissionGranted{scope}` or `PermissionDenied`) / `deny`. "Always allow" persists a scoped rule to project config **and** records the grant event. Denial text returns to the model as the tool result observation (course correction, not dead end). "Silence is not consent": an unanswered request never auto-approves. Full model in the Phase 1 `PERMISSION_MODEL.md`.687688---689690## 11. Concurrency Model691692**One Node process, one event loop** (ADR-1, ADR-17). Concurrency is structured async, not threads.693694Concurrent at any moment:695696| Activity | Mechanism | Notes |697|---|---|---|698| Model stream consumption | async iterable | one at a time per session |699| Tool execution | async, **sequential within a batch** in V1 | parallel read-only tools = post-V1 optimization, measured first |700| Background processes | child processes + stream I/O | rolling buffers; outlive turns, not the process |701| TUI input handling | stdin events | always responsive — never awaited behind engine work |702| TUI rendering | 16 ms coalescer flush | bounded live region keeps render work small |703| Log appends | serialized per-session write queue | write-ahead of publish |704705**Cancellation tree:** `session controller → turn controller → { model request, each tool execution }`. `Esc` aborts the turn controller only. `SIGINT`/quit aborts the session controller, stops background processes (`stopAll`), flushes the log, restores the terminal.706707**Recorded risk (ADR-17):** render work and tool I/O share the event loop. Mitigations: bounded live region + coalescing keep render slices small; the bus boundary is clean so moving the engine into a `worker_thread` later is a packaging change. If Phase 8 measurements show contention, that is the sanctioned escape hatch — measure first.708709**Ordering guarantee:** for a given session, durable events are appended and published in `seq` order; ephemeral deltas for a block are always delivered to the TUI before the durable event that settles that block (`EVENT_MODEL.md §7`).710711---712713## 12. Startup Sequence & Performance Budget714715### 12.1 Cold-start sequence716717```718t0 node boots; cli entry parses argv (no framework, hand-rolled — zero-dep parse)719t1 load config files (2 small JSON reads) + env; NO dotenv autoload; no network720t2 initialize terminal + render the startup shell (header, prompt) — FIRST PAINT721 ── everything below is lazy / background ──722bg git status + branch (async → fills status bar when ready)723bg session store init (create/open lazily on first message or /resume)724bg instruction-file discovery (KHAELOR.md etc.)725lazy @anthropic-ai/sdk — imported on first model call726lazy markdown/highlight/diff — imported on first render that needs them727lazy ripgrep spawn — on first grep/glob/@-mention728```729730The Anthropic key is validated on first use, not at startup (a missing key renders an actionable inline message, not a boot failure).731732### 12.2 Budgets (measured in CI where practical; ADR-1 lazy-import discipline)733734| Metric | Budget |735|---|---|736| Cold start → first paint, interactive prompt | **< 150 ms** |737| Keystroke → echo (input latency) | **< 16 ms** |738| Render frame during full-speed token stream | **< 16 ms** (one flush per frame) |739| Tool dispatch overhead (gate + record, excl. tool work) | < 5 ms |740| Session resume, 10K durable events | < 500 ms |741| Memory, 4-hour session | bounded (capped live region, virtualized scrollback, rolling process buffers) |742743A startup benchmark script and an import-graph check (no heavy module in the boot path) are part of Phase 2's definition of done. No spinner-driven UX: status lines show real activity (`● Reading src/session/store.ts`), never fabricated progress (Absolute Rule #4).744745---746747## 13. V1 Non-Goals and Extension Seams (ADR-16, ADR-17)748749Explicitly **not** in V1 — with the seam that keeps each addable without kernel rewrites. Boundaries only; no premature abstraction beyond what is listed.750751| Post-V1 capability | V1 seam (already present) | What is NOT built now |752|---|---|---|753| Subagents | every event carries `sessionId`; tool registry is data-driven; child = new session with derived permissions | no task tool, no depth/budget machinery, no child UI |754| Memory | `ContextEngine.selectContext` is the injection point; frozen-snapshot rule inherited from ADR-7 | no providers, no background review, no curation |755| Skills | instruction-file discovery + data-driven registry | no skill format, loading, or self-generation |756| MCP | `ToolDefinition`/`ToolRegistry` are plain data | no client, transport, or config surface |757| Multi-provider | `ModelClient` is the single boundary | no adapter framework, no second implementation |758| Docker/SSH remote | 4-method `Workspace`; strategy will be "run the core remotely", not per-syscall proxying | no `Workspace` implementations beyond local |759| Shadow-git snapshots / revert | baselines + snapshot hashes representable as events | no shadow repo machinery |760| Session branch/rewind | linear log; envelope reserves `parentId?` | no tree invariants |761| Daemon / IDE / `khaelor serve` | the typed bus vocabulary is the only "API"; engine↔TUI boundary is command/event only | no server, sockets, or RPC |762| `worker_thread` engine split | clean bus boundary (§11) | not until Phase 8 measurements demand it |763764Also not V1: OpenAI/Gemini/OpenRouter/local models, browser automation, computer vision, cloud execution, web UI, multi-user, plugins, marketplace (CLAUDE.md §23).765766---767768## 14. Cross-References769770- `docs/EVENT_MODEL.md` — normative event vocabulary, JSONL format, projection rules, coalescing contract, bus API.771- `docs/research/KHAELOR_ARCHITECTURE_DECISIONS.md` — ADR-1…17 (binding rationale).772- Phase 1 remaining deliverables: `TUI_DESIGN.md` (incl. the ADR-14 framework spike report), `TOOL_PROTOCOL.md`, `PERMISSION_MODEL.md`.773774---775776*Author: Simon-Pierre Boucher · contact@spboucher.ai*777