SPB Git

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%
44.4 KB · 777 lines markdown
Rendered Raw Blame History
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