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

# KHAELOR V1 — System Architecture

Status: Phase 1 deliverable — the definitive system design for KHAELOR V1. Inputs: CLAUDE.md (product spec), docs/research/KHAELOR_ARCHITECTURE_DECISIONS.md (ADR-1…17, binding), docs/research/COMPARATIVE_ARCHITECTURE.md (evidence). Companion: docs/EVENT_MODEL.md (the complete typed event vocabulary — normative for every event named here).

Phase 2 implementation starts from this document. Where this document and an ADR disagree, the ADR wins and this document must be corrected.


# 1. Overview

KHAELOR V1 is a single-process, terminal-native, Anthropic-only agent (ADR-1, ADR-17). Its architecture rests on five load-bearing decisions:

  1. 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.
  2. 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.
  3. 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.
  4. Prompt-cache byte-stability as an invariant (ADR-7). History is never rewritten; compaction — recorded as an event — is the sole sanctioned break.
  5. 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).

# 1.1 System diagram (refined from CLAUDE.md §4)

text
                            ┌─────────────────────────────┐
                            │         KHAELOR TUI         │
                            │  composer · timeline · bars │
                            └────┬───────────────▲────────┘
                    KernelCommand│               │ coalesced events (~16 ms)
                                 │               │
   ┌─────────────────────────────▼───────────────┴─────────────────────────────┐
   │                              EVENT BUS (typed, in-process)                │
   │        durable events ──► SessionStore (JSONL append, write-ahead)        │
   │        ephemeral deltas ──► TUI coalescer only                            │
   └────────────▲───────────────────▲──────────────────────▲───────────────────┘
                │                   │                      │
        ┌───────┴────────┐  ┌───────┴────────┐   ┌─────────┴─────────┐
        │ Session Engine │  │  Agent Kernel  │   │  Process Manager  │
        │ log·projections│◄─┤  (state-derived│   │ (background procs)│
        │ resume=replay  │  │   loop, ADR-2) │   └─────────▲─────────┘
        └───────▲────────┘  └───┬───┬───┬────┘             │
                │               │   │   │                  │
                │      ┌────────┘   │   └─────────┐        │
                │      ▼            ▼             ▼        │
                │ ┌──────────┐ ┌──────────┐ ┌───────────┐  │
                │ │ Context  │ │  Model   │ │   Tool    │──┘
                │ │ Engine   │ │ Runtime  │ │  Runtime  │──► Permissions
                │ │ (ADR-6/7)│ │ (ADR-10) │ │  (ADR-8)  │    (ADR-9)
                │ └────┬─────┘ └────┬─────┘ └─────┬─────┘
                │      │            │             │
                └──────┤       Anthropic API      ▼
                       ▼        (official SDK) ┌───────────┐
                ┌─────────────┐                │ Workspace │──► files · processes
                │ Repository  │───────────────►│ (ADR-13)  │
                │ Intelligence│                └───────────┘
                └─────────────┘

Reading the diagram:

  • The TUI never calls the engine directly. It emits KernelCommands (submit message, interrupt, approve permission, run slash command) and consumes events. Nothing imports the TUI (see §2).
  • 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).
  • 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.

# 2. Module Map and Dependency Rules

# 2.1 Dependency direction rules (normative)

Modules 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.

text
Layer 0   shared                          (imports nothing)
Layer 1   config · workspace · anthropic · session
Layer 2   permissions · repository · tools · context
Layer 3   agent
Layer 4   tui
Layer 5   cli                             (composition root)

Rules, explicitly:

  1. 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.
  2. Nothing imports tui except cli. The TUI is a pure consumer of events and producer of KernelCommands.
  3. 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.
  4. 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.
  5. 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/).
  6. anthropic imports shared only (plus the @anthropic-ai/sdk package). Config values are passed in; it never reads config or sessions.
  7. session imports shared only. It owns the JSONL store and all projections.
  8. context may import session (projection types), repository, anthropic (the ModelClient interface, for the auxiliary compaction model), and shared.
  9. agent may import anthropic, tools, context, session, permissions, workspace, and shared. It is the only module that wires services together below cli.
  10. cli may import everything. It is the composition root: it constructs the bus, store, workspace, runtimes, kernel, and TUI, and wires KernelCommand dispatch.
  11. No module imports another module's internals — only its index.ts barrel. Deep imports fail lint.
  12. No cycles, ever. A cycle is a build failure, not a warning.

# 2.2 Module responsibilities and public interfaces

Interface 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.

# src/shared/ — Layer 0

Event vocabulary and envelopes, event bus, kernel commands, shared result/error types, logging, ids (ULID), token/byte utilities. No business logic.

ts
// shared/events.ts — full definitions in EVENT_MODEL.md
export type DurableEvent = /* discriminated union, EVENT_MODEL.md §4 */;
export type EphemeralEvent = /* discriminated union, EVENT_MODEL.md §4 */;
export type KhaelorEvent = DurableEvent | EphemeralEvent;

export interface EventBus {
  publishDurable(e: DurableEventInput): DurableEvent;   // append-then-publish; assigns seq/id/ts
  publishEphemeral(e: EphemeralEvent): void;
  on<T extends KhaelorEvent["type"]>(
    type: T,
    handler: (e: Extract<KhaelorEvent, { type: T }>) => void,
  ): Unsubscribe;
  onAny(handler: (e: KhaelorEvent) => void): Unsubscribe;
}

// shared/commands.ts — the TUI→engine channel
export type KernelCommand =
  | { kind: "submit-message"; text: string; mentions: FileMention[] }
  | { kind: "steer"; text: string }
  | { kind: "interrupt" }
  | { kind: "permission-response"; requestId: string; decision: "once" | "always" | "deny" }
  | { kind: "slash"; command: string; args: string }
  | { kind: "shell"; command: string }          // "!git status" composer shortcut
  | { kind: "quit" };

export type Unsubscribe = () => void;
export type Result<T, E = KhaelorError> = { ok: true; value: T } | { ok: false; error: E };

# src/config/ — Layer 1

Schema, 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.

ts
export interface KhaelorConfig {
  model: string;                       // Anthropic model id — never hard-coded lists (CLAUDE.md §6)
  auxModel: string;                    // cheaper model for compaction summaries (ADR-10)
  thinking: "off" | "adaptive" | "always";
  maxOutputTokens: number;
  permissions: Record<string, PermissionAction>;   // capability → allow|ask|deny
  theme: ThemeConfig;
}
export function loadConfig(argv: ParsedArgs, cwd: string): Promise<ResolvedConfig>;
export function discoverInstructions(cwd: string): Promise<InstructionFile[]>; // ordered, global → nested
export function persistPermissionGrant(scope: "project", rule: PermissionRule): Promise<void>; // ADR-9

# src/workspace/ — Layer 1

The world seam (ADR-13). Exactly four methods plus process spawning support used by the Process Manager. LocalWorkspace is the only V1 implementation.

ts
export interface Workspace {
  cwd(): string;
  readFile(path: string): Promise<string>;
  writeFile(path: string, content: string): Promise<void>;   // atomic: tmp + rename
  exec(command: Command): Promise<ProcessResult>;
}
export interface Command {
  cmd: string;                       // run via user shell for `bash` tool
  cwd?: string;
  timeoutMs: number;                 // hard ceiling; ADR-8: long commands redirect to `process`
  env?: Record<string, string>;
  signal?: AbortSignal;
}
export interface ProcessResult {
  exitCode: number | null;           // null = killed by timeout/signal
  stdout: string; stderr: string;
  durationMs: number;
  truncated: boolean;
}
export class LocalWorkspace implements Workspace { /* only module touching node:fs/child_process */ }
// Tool-side helpers PARAMETERIZED BY Workspace (never fattening the interface — ADR-13):
export function statFile(ws: Workspace, path: string): Promise<FileStat>;
export function fileExists(ws: Workspace, path: string): Promise<boolean>;

# src/anthropic/ — Layer 1

ModelClient over the official SDK (ADR-10). Streaming, typed errors, retry/backoff, cancellation, honest usage. See §7.

# src/session/ — Layer 1

Event log store (JSONL), projections, resume-by-replay, session metadata, checkpoint types. See §6.

# src/permissions/ — Layer 2

Capability policy and evaluator (ADR-9).

ts
export type Capability =
  | "file.read" | "file.write.project" | "process.execute"
  | "network.access" | "git.modify" | "filesystem.outsideProject";
export type PermissionAction = "allow" | "ask" | "deny";
export interface PermissionRule { capability: Capability; pattern: string; action: PermissionAction; }

export interface PermissionRequest {
  capability: Capability;
  descriptor: string;               // e.g. "bash: npm install", "write: src/agent/kernel.ts"
  toolUseId: string;
  suggestion?: PermissionRule;      // "always allow `git push *`" — omitted when the command
}                                   // contains shell operators (Hermes guard, ADR-9)

export interface PermissionEvaluator {
  evaluate(req: PermissionRequest): PermissionAction;   // last-match-wins over rules + hardline floor
  addGrant(rule: PermissionRule): Promise<void>;        // persists via config (ADR-9)
}

The 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).

# src/repository/ — Layer 2

Repository 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.

ts
export interface RepositoryIndex {
  fileMap(opts?: { maxEntries?: number }): Promise<RepoFileEntry[]>;   // respects .gitignore/.khaelorignore
  gitStatus(): Promise<GitStatus>;                                     // branch, dirty, untracked
  gitDiff(paths?: string[]): Promise<GitDiff>;
  recordBaseline(when: "session-start" | "pre-first-edit"): Promise<GitBaseline>;  // ADR-15
  attributeChanges(): Promise<{ khaelor: string[]; preExisting: string[] }>;
  search(query: RipgrepQuery): Promise<SearchResult>;                  // bounded, structured
  findFiles(fuzzy: string, limit: number): Promise<RankedFile[]>;      // frecency-ranked
  noteAccess(path: string): void;                                      // feeds frecency + recency
}

# src/tools/ — Layer 2

The 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.

ts
export interface ToolDefinition<In> {
  name: ToolName;
  description: string;                       // guidance prose lives here, not in params
  inputSchema: JSONSchema;                   // ≤5 properties
  capability: (input: In, ctx: ToolContext) => Capability;   // drives permission evaluation
  execute: (input: In, ctx: ToolContext) => Promise<ToolResult>;
}
export interface ToolContext {
  workspace: Workspace;
  repo: RepositoryIndex;
  processes: ProcessManager;
  signal: AbortSignal;                       // turn-scoped; aborts in-flight tools (ADR-11)
  emitOutput: (chunk: string) => void;       // → ephemeral ToolOutput events
  budget: OutputBudget;                      // per-result + per-turn caps, spill-to-file (ADR-8)
}
export interface ToolResult {
  modelText: string;               // budgeted head/tail-truncated text with omission markers
  isError: boolean;
  spillFile?: string;              // full output path under ~/.khaelor/spill/, model can read/grep it
  ui: ToolUiMeta;                  // diffStats, matchCount, exitCode… for collapsed rendering
  filesModified?: FileModification[];
}
export interface ToolRegistry {
  definitions(): ToolDefinition<unknown>[];  // data-driven — the ADR-16 seam
  get(name: string): ToolDefinition<unknown> | undefined;
}
export interface ProcessManager {            // model-facing via the `process` tool (ADR-8)
  start(cmd: string, opts: { cwd?: string; name?: string }): ManagedProcess;
  list(): ManagedProcessInfo[];
  read(id: string, opts?: { offset?: number; limit?: number }): ProcessReadResult; // rolling 200K buffer
  write(id: string, input: string): void;
  stop(id: string): Promise<void>;           // process-group kill (mini hygiene, ADR-8)
  stopAll(): Promise<void>;                   // shutdown only — NOT on interrupt (ADR-11)
}

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.

# src/context/ — Layer 2

The Context Engine (ADR-6/7). See §8.

# src/agent/ — Layer 3

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.

# src/tui/ — Layer 4

Terminal 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:

ts
export interface TuiApp {
  start(io: { events: EventBus; dispatch: (c: KernelCommand) => void; config: ResolvedConfig }): Promise<void>;
  stop(): Promise<void>;
}

Internally, 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.

# src/cli/ — Layer 5

Entry point, argv parsing, config resolution, composition root, lifecycle (signals, terminal setup/teardown, crash-safe restore), lazy-import discipline (§13), khaelor --debug logging switch.


# 3. Data & Control Flow (one turn, end to end)

text
user types → composer → KernelCommand{submit-message} → cli dispatcher
  → durable UserMessageCreated appended + published
  → kernel loop wakes (it is the sole consumer of "work exists" state)
  → ContextEngine.selectContext(projection) → ModelRequest (byte-stable tiers, ADR-7)
  → ModelClient.stream(request, signal)
       deltas   → ephemeral ModelTextDelta / ModelThinkingDelta / ToolInputDelta → TUI coalescer
       settled  → durable ModelTextBlockCompleted / ModelThinkingBlockCompleted / ToolRequested
       finish   → durable ModelResponseCompleted {usage, stopReason}
  → for each ToolRequested (sequential, block order):
       Tool Runtime: capability → PermissionEvaluator
         ask → durable PermissionRequested → TUI panel → PermissionGranted/Denied
       durable ToolApproved → ToolStarted → execute (ephemeral ToolOutput chunks)
       → durable ToolCompleted | ToolFailed | ToolCancelled (+ FileModified / ProcessStarted …)
  → steering queue drained at the post-tool-batch seam → durable SteeringInjected
  → ContextEngine.onTurnComplete(usage) → maybe durable ContextPruned / ContextCompacted
  → loop re-derives: more tool calls? overflow? stop?
  → on stop with unverified code changes → verification gate (≤2 nudges) → TaskCompleted{evidence}

Every 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.


# 4. The Agent Kernel (ADR-2)

The 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.

# 4.1 Kernel state

Per ADR-2 / mini's rule ("if a field isn't consulted by the loop itself, it belongs to a service"), the kernel holds exactly:

ts
interface KernelDeps {
  session: SessionHandle;          // projection access + durable publish
  context: ContextEngine;
  model: ModelClient;
  tools: ToolRuntime;              // registry + permission gate + executor
  verifier: VerificationGate;      // ADR-12
  steering: SteeringQueue;         // ADR-11
  bus: EventBus;
}
interface KernelRunState {
  turnSignal: AbortController;     // the cancellation root for this run (ADR-11)
  budget: { iterations: number; verificationAttempts: number };
}

# 4.2 The loop (precise pseudocode)

ts
async function runTurn(deps: KernelDeps, run: KernelRunState): Promise<TurnOutcome> {
  while (true) {
    // 1. Re-derive what to do from RECORDED state — never from loop-local flags.
    const state = deps.session.projection();          // in-memory, event-derived (rebuilt from
    const next  = deriveNext(state, run.budget);      // JSONL only on resume — ADR-2 trade-off)

    switch (next.kind) {
      case "inject-steering": {
        // Safe seam: after tool results / before the next model call (ADR-11).
        deps.session.publish(steeringInjected(next.queued, next.seam));
        continue;
      }

      case "compact": {
        // Proactive (token budget) or reactive (overflow error recorded). ADR-6.
        const checkpoint = await deps.context.compress(state, run.turnSignal.signal);
        deps.session.publish(contextCompacted(checkpoint));   // durable; replay-deterministic
        continue;
      }

      case "call-model": {
        const ctx = await deps.context.selectContext(state);   // byte-stable tiers (ADR-7)
        try {
          for await (const ev of deps.model.stream(ctx.request, run.turnSignal.signal)) {
            reduceModelEvent(ev, deps);   // pure reducer: deltas → ephemeral publish;
          }                               // settled blocks / tool_use / usage → durable publish
        } catch (err) {
          const klass = classifyModelError(err);               // typed taxonomy (ADR-10)
          deps.session.publish(modelRequestFailed(klass));
          if (klass.kind === "context-overflow") continue;     // → deriveNext yields "compact"
          if (klass.kind === "cancelled")        continue;     // → deriveNext sees Interrupted
          if (klass.retryable) continue;                       // ModelClient already backed off
          return { kind: "failed", error: klass };             // fatal → TaskFailed recorded by caller
        }
        continue;
      }

      case "execute-tools": {
        // Sequential, block order. The runtime gates permissions, executes, and records
        // ToolApproved/Started/Completed/Failed/Cancelled + FileModified/Process* itself.
        await deps.tools.executeBatch(next.pending, run.turnSignal.signal);
        deps.context.onTurnComplete(deps.session.projection().lastUsage);  // ADR-6 observation
        continue;
      }

      case "verify": {
        // Model stopped; code changed this turn; no fresh verification evidence (ADR-12).
        if (run.budget.verificationAttempts >= 2) return { kind: "done", withheld: next.candidate };
        run.budget.verificationAttempts++;
        deps.session.publish(verificationRequested(next.detectedChecks, next.candidate));
        continue;                                              // nudge is a synthetic message → call-model
      }

      case "interrupted": return { kind: "interrupted" };      // Interrupted event is already durable
      case "done":        return { kind: "done" };
    }
  }
}

# 4.3 deriveNext — a pure function of recorded state

ts
function deriveNext(s: SessionProjection, budget: Budget): NextAction {
  if (s.interruptedSinceLastModelCall)             return { kind: "interrupted" };
  if (s.pendingToolCalls.length > 0)               return { kind: "execute-tools", pending: s.pendingToolCalls };
  if (s.queuedSteering.length > 0 && s.atSafeSeam) return { kind: "inject-steering", ... };
  if (s.contextOverflowRecorded || s.tokensUsed > s.compactionThreshold)
                                                   return { kind: "compact" };
  if (s.lastStop === "end_turn") {
    const gate = needsVerification(s);             // code changed this turn ∧ no fresh evidence,
    if (gate.required)                             //   documentation-only changes filtered (ADR-12)
                                                   return { kind: "verify", ...gate };
    return { kind: "done" };
  }
  if (isDoomLoop(s))                               return { kind: "verify-with-user" };  // 3 byte-identical
  if (budget.iterations <= 0)                      return { kind: "done" };              //   consecutive calls → ask
  return { kind: "call-model" };
}

Properties 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.

# 4.4 What is not in the kernel

Retry/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.


# 5. Session Engine (ADR-3)

# 5.1 Storage layout

text
~/.khaelor/
  sessions/<project-hash>/
    <session-id>.jsonl        # append-only durable event log — THE truth
    <session-id>.meta.json    # projection cache (title, usage, cost, updatedAt) — always rebuildable
  spill/                      # oversized tool output (size-capped)
  logs/                       # developer logs (never the TUI)

project-hash = hash of the git root (or cwd when not a repo). Session ids are ULIDs — lexicographic order = creation order.

# 5.2 The store

ts
export interface SessionStore {
  create(meta: NewSessionMeta): Promise<SessionHandle>;
  open(sessionId: string): Promise<SessionHandle>;            // resume = replay (§5.4)
  list(projectHash: string): Promise<SessionMeta[]>;          // reads meta caches; rebuilds stale ones
}
export interface SessionHandle {
  readonly id: string;
  publish(e: DurableEventInput): DurableEvent;    // append (write-ahead) + bus publish; assigns seq
  projection(): SessionProjection;                // in-memory, maintained by the same reducer
  meta(): SessionMeta;
}
export interface SessionMeta {                    // CLAUDE.md §8
  id: string; title: string; project: string;
  createdAt: number; updatedAt: number;
  model: string;
  tokenUsage: UsageTotals;                        // incl. cache read/write — real API numbers only
  cost: number;
  gitBranch: string;
  workingDirectory: string;
}

Durability 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.

# 5.3 Projections

All state is derived. Five projections, each a fold over the durable stream (full rules in EVENT_MODEL.md §6):

Projection Consumers Notes
SessionProjection kernel (deriveNext) pending tool calls, queued steering, last stop reason, overflow flag, files changed this turn, verification evidence
LlmHistory Context Engine byte-stable Anthropic messages[]; re-applies ContextCompacted/ContextPruned deterministically
Timeline TUI view-model messages, tool cards, diffs, status
UsageTotals /cost, status bar summed from ModelResponseCompleted.usage only — never invented (Absolute Rule #4)
FileChangeSet /diff, verification gate, attribution vs. BaselineRecorded (ADR-15)

Projections are caches, never truth (ADR-3 concern): each must tolerate being stale or deleted and rebuild from the log.

# 5.4 Resume

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.

Long-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.

# 5.5 Session commands

/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).


# 6. Context Engine (ADR-6, ADR-7)

# 6.1 Interface

Hermes' verbs, exactly four:

ts
export interface ContextEngine {
  /** Assemble the model request from the projection. V1: pass-through hook for selection
   *  (no retrieval/topic routing yet — the verb exists so repository intelligence can
   *  plug in without interface change). */
  selectContext(s: SessionProjection): Promise<BuiltContext>;

  /** Summarize-compact. Emits nothing itself — returns the checkpoint the kernel records
   *  as a durable ContextCompacted event. Cuts ONLY at pairing-safe indices. */
  compress(s: SessionProjection, signal: AbortSignal): Promise<CompactionCheckpoint>;

  /** Observation hook: real usage from the last response updates budget accounting. */
  onTurnComplete(usage: ModelUsage): void;

  /** Cheap, deterministic, no-LLM: blank old tool results (protect newest N tokens).
   *  Returns the toolUseIds to prune; kernel records ContextPruned. Runs BEFORE compress. */
  pruneToolResults(s: SessionProjection): PruneDecision;
}
export interface BuiltContext {
  request: ModelRequest;            // system tiers + messages + tools + cache breakpoints
  stats: ContextStats;              // per-section token estimates → /context inspector
}

# 6.2 Triggers — token-based, from real usage only

  • 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).
  • Reactive: a context-overflow typed model error (ADR-10) records ModelRequestFailed{kind:"context-overflow"}; deriveNext routes to compact — never a blind retry.
  • Order: pruneToolResults first (cheap, deterministic — protect the newest ~40K tokens of tool output); compress only if still over budget.

# 6.3 Compaction-as-event

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).

# 6.4 Checkpoint format (structured YAML — CLAUDE.md §12)

yaml
objective: <the user's current objective, one paragraph>
completed:
  - <finished sub-goal>
current_state: <where the work stands right now>
important_files:
  - path: src/context/engine.ts
    reason: <why it matters to remaining work>
changes:
  - <file-level change made so far>
failed_attempts:
  - <approach tried and abandoned, with why>
decisions:
  - <decision taken and rationale>
running_processes:
  - id: <process id>
    command: npm run dev
    status: running
next_steps:
  - <concrete next action>
raw_evidence:                       # preserved verbatim when summarization would destroy it
  - label: <e.g. failing test output>
    content: |
      <capped raw text>

failed_attempts, decisions, and running_processes exist precisely because free-text summaries destroy them (ADR-6 trade-off).

# 6.5 Prompt-cache byte-stability rules (ADR-7 — enforced by tests)

  1. 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.
  2. 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.
  3. 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.
  4. Model or instruction changes mid-session (ModelChanged) start a new cache lineage; that is accepted and visible.
  5. Cache read/write tokens surface in /cost from real usage fields — cache health is observable, and a regression is a bug.

# 7. Model Runtime (ADR-10)

ts
export interface ModelClient {
  stream(request: ModelRequest, signal: AbortSignal): AsyncIterable<ModelEvent>;
  countTokens?(request: ModelRequest): Promise<number>;      // best-effort budgeting aid
}
export interface ModelRequest {
  model: string;
  system: SystemTier[];                       // stable tiers with cache_control breakpoints
  messages: AnthropicMessage[];               // byte-stable projection (§6.5)
  tools: ToolSchema[];
  maxOutputTokens: number;
  thinking?: ThinkingConfig;
}
export type ModelEvent =
  | { type: "started"; requestId: string }
  | { type: "text-delta"; blockIndex: number; text: string }
  | { type: "thinking-delta"; blockIndex: number; text: string }
  | { type: "block-completed"; blockIndex: number; block: CompletedBlock }  // text | thinking(+signature) | tool_use
  | { type: "finished"; stopReason: StopReason; usage: ModelUsage };
export interface ModelUsage {                 // real API fields ONLY — never estimated (Rule #4)
  inputTokens: number; outputTokens: number;
  cacheReadTokens: number; cacheWriteTokens: number;
}

Error taxonomy (typed, kernel branches on it):

ts
export type ModelErrorKind =
  | "retryable"          // 429 / 5xx / network — retried inside ModelClient with jittered
                         //   exponential backoff and a retry budget; surfaced only on exhaustion
  | "context-overflow"   // routed to Context Engine (reactive compaction) — NEVER retried blindly
  | "auth"               // fatal; actionable message; never logs the key
  | "invalid-request"    // fatal; a KHAELOR bug — surfaced loudly
  | "cancelled";         // AbortSignal fired — not an error path, folds into Interrupted state
export class ModelError extends Error {
  kind: ModelErrorKind; retryable: boolean; status?: number; requestId?: string;
}

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).

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.


# 8. Interruption & Steering (ADR-11)

Interrupt (Esc):

  1. TUI dispatches KernelCommand{interrupt} → durable Interrupted event → run.turnSignal.abort().
  2. The model stream aborts; in-flight tools receive the signal, get a 250 ms grace, then are cancelled.
  3. Every dangling tool_use is closed with a synthetic cancelled tool_result — recorded as durable ToolCancelled — so history is protocol-valid at all times.
  4. Background process-managed processes are not killed (explicitly long-lived).
  5. The loop's next deriveNext observes the interrupted state and returns; the session is intact and immediately usable.

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).


# 9. Completion & Verification (ADR-12)

When the model stops (end_turn) and the FileChangeSet shows code changed this turn without fresh verification evidence, the gate:

  1. Detects relevant check commands from the repository (package scripts, test configs) — never blind full suites.
  2. Records VerificationRequested{detectedChecks, attempt} and withholds the candidate answer (preserved — budget exhaustion returns it rather than losing it).
  3. Injects a synthetic evidence-bearing nudge; max 2 attempts; documentation-only changes are filtered out.
  4. On acceptance records TaskCompleted carrying:
ts
interface CompletionEvidence {
  objective: string;
  changedFiles: string[];
  checks: CheckResult[];            // command, exitCode, summary — real results only
  unresolvedIssues: string[];
}

Completion 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).


# 10. Permissions (ADR-9) — placement summary

Evaluation 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.


# 11. Concurrency Model

One Node process, one event loop (ADR-1, ADR-17). Concurrency is structured async, not threads.

Concurrent at any moment:

Activity Mechanism Notes
Model stream consumption async iterable one at a time per session
Tool execution async, sequential within a batch in V1 parallel read-only tools = post-V1 optimization, measured first
Background processes child processes + stream I/O rolling buffers; outlive turns, not the process
TUI input handling stdin events always responsive — never awaited behind engine work
TUI rendering 16 ms coalescer flush bounded live region keeps render work small
Log appends serialized per-session write queue write-ahead of publish

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.

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.

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).


# 12. Startup Sequence & Performance Budget

# 12.1 Cold-start sequence

text
t0    node boots; cli entry parses argv (no framework, hand-rolled — zero-dep parse)
t1    load config files (2 small JSON reads) + env; NO dotenv autoload; no network
t2    initialize terminal + render the startup shell (header, prompt) — FIRST PAINT
      ── everything below is lazy / background ──
bg    git status + branch (async → fills status bar when ready)
bg    session store init (create/open lazily on first message or /resume)
bg    instruction-file discovery (KHAELOR.md etc.)
lazy  @anthropic-ai/sdk        — imported on first model call
lazy  markdown/highlight/diff  — imported on first render that needs them
lazy  ripgrep spawn            — on first grep/glob/@-mention

The Anthropic key is validated on first use, not at startup (a missing key renders an actionable inline message, not a boot failure).

# 12.2 Budgets (measured in CI where practical; ADR-1 lazy-import discipline)

Metric Budget
Cold start → first paint, interactive prompt < 150 ms
Keystroke → echo (input latency) < 16 ms
Render frame during full-speed token stream < 16 ms (one flush per frame)
Tool dispatch overhead (gate + record, excl. tool work) < 5 ms
Session resume, 10K durable events < 500 ms
Memory, 4-hour session bounded (capped live region, virtualized scrollback, rolling process buffers)

A 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).


# 13. V1 Non-Goals and Extension Seams (ADR-16, ADR-17)

Explicitly not in V1 — with the seam that keeps each addable without kernel rewrites. Boundaries only; no premature abstraction beyond what is listed.

Post-V1 capability V1 seam (already present) What is NOT built now
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
Memory ContextEngine.selectContext is the injection point; frozen-snapshot rule inherited from ADR-7 no providers, no background review, no curation
Skills instruction-file discovery + data-driven registry no skill format, loading, or self-generation
MCP ToolDefinition/ToolRegistry are plain data no client, transport, or config surface
Multi-provider ModelClient is the single boundary no adapter framework, no second implementation
Docker/SSH remote 4-method Workspace; strategy will be "run the core remotely", not per-syscall proxying no Workspace implementations beyond local
Shadow-git snapshots / revert baselines + snapshot hashes representable as events no shadow repo machinery
Session branch/rewind linear log; envelope reserves parentId? no tree invariants
Daemon / IDE / khaelor serve the typed bus vocabulary is the only "API"; engine↔TUI boundary is command/event only no server, sockets, or RPC
worker_thread engine split clean bus boundary (§11) not until Phase 8 measurements demand it

Also not V1: OpenAI/Gemini/OpenRouter/local models, browser automation, computer vision, cloud execution, web UI, multi-user, plugins, marketplace (CLAUDE.md §23).


# 14. Cross-References

  • docs/EVENT_MODEL.md — normative event vocabulary, JSONL format, projection rules, coalescing contract, bus API.
  • docs/research/KHAELOR_ARCHITECTURE_DECISIONS.md — ADR-1…17 (binding rationale).
  • Phase 1 remaining deliverables: TUI_DESIGN.md (incl. the ADR-14 framework spike report), TOOL_PROTOCOL.md, PERMISSION_MODEL.md.

Author: Simon-Pierre Boucher · contact@spboucher.ai