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%
34.6 KB · 608 lines markdown
Rendered Raw Blame History
1<!--2KHAELOR3File: docs/EVENT_MODEL.md4Author: Simon-Pierre Boucher5Contact: contact@spboucher.ai6-->78# KHAELOR V1 — Event Model910> **Status:** Phase 1 deliverable — the complete typed event vocabulary. **Normative** for `docs/ARCHITECTURE.md` and all Phase 2–6 implementation.11> **Inputs:** CLAUDE.md §7 (base vocabulary), ADR-3 (event-sourced JSONL), ADR-4 (typed bus, durable/ephemeral split, 16 ms coalescing), ADR-5 (streaming), ADR-6/7 (compaction-as-event, byte-stability), ADR-11 (interruption/steering), ADR-12 (completion), ADR-15 (git baseline).12>13> The event log is the source of truth for session replay and resume (CLAUDE.md §7). Everything else — UI state, LLM message history, cost, metadata — is a projection.1415---1617## 1. Design Rules18191. **Two classes of event.** **DURABLE** events are appended to the session JSONL *before* being published on the bus (write-ahead), and carry a per-session monotonic `seq`. **EPHEMERAL** events (streaming deltas) flow to the TUI only and are never persisted (ADR-4).202. **The completed block is the durable record.** Deltas are lossy by design; when a stream closes a block, the settled content is recorded durably (`ModelTextBlockCompleted`, `ToolRequested`, …). A crash mid-block loses at most the in-flight block — OpenCode behaves identically (ADR-4).213. **Rendering never lives on events.** Payloads are domain data; presentation is keyed by `type` inside the TUI (OpenHands `visualize` anti-pattern excluded, ADR-4).224. **Every event carries `sessionId`** — the cheap seam subagents-as-child-sessions depends on (ADR-16).235. **Payloads are bounded.** Anything that could be huge (tool output, diffs) is stored budget-truncated with an explicit marker, with full content spilled to a file path (ADR-8). The log never stores unbounded blobs.246. **Honest data only.** Usage, cost, exit codes, and check results come from real sources; no event may carry fabricated numbers (Absolute Rule #4).257. **Naming:** past-tense facts (`ToolCompleted`), not commands. Commands are `KernelCommand`s (`ARCHITECTURE.md §2.2`), which *cause* events but are not events.2627---2829## 2. Envelopes3031```ts32// shared/events.ts3334/** Durable envelope — one JSONL line per event. */35export interface Durable<T extends string, P> {36  v: 1;                    // schema version of this event line (§5.4)37  id: string;              // ULID — globally unique, time-ordered38  sessionId: string;39  seq: number;             // monotonic per session, gapless, assigned at append time40  ts: number;              // epoch milliseconds41  parentId?: string;       // reserved, always absent in V1 (ADR-3: linear log, tree-ready)42  type: T;43  payload: P;44}4546/** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */47export interface Ephemeral<T extends string, P> {48  id: string;              // ULID (correlation/debugging)49  sessionId: string;50  ts: number;51  type: T;52  payload: P;53}5455/** What producers hand to the bus; envelope fields are assigned by the store/bus. */56export type DurableEventInput = { type: DurableEvent["type"]; payload: DurableEvent["payload"] };57```5859---6061## 3. Catalog Summary — DURABLE vs EPHEMERAL6263| # | Event | Class | Rationale |64|---|---|---|---|65| 1 | `SessionStarted` | **D** | Anchors the log: cwd, model, config snapshot. Required by every projection. |66| 2 | `SessionResumed` | **D** | Audit trail; marks replay boundaries; records model/tool-set verification (resume contract, ADR-3). |67| 3 | `SessionRenamed` | **D** | `/rename` must survive restart; metadata is a projection (Rule: derivable from the log). |68| 4 | `ModelChanged` | **D** | LLM history and cost projections must know which model produced which turns; starts a new cache lineage (ADR-7). |69| 5 | `BaselineRecorded` | **D** | Attribution (KHAELOR's changes vs user's) must survive resume (ADR-15). |70| 6 | `UserMessageCreated` | **D** | Conversation truth; LLM history input. |71| 7 | `SteeringQueued` | **D** | Queued instructions must survive a crash before injection (ADR-11); UI shows `Queued instruction` after resume. |72| 8 | `SteeringInjected` | **D** | The injection point alters LLM history; replay must reproduce identical bytes (ADR-7). |73| 9 | `Interrupted` | **D** | `deriveNext` consumes it from state; explains truncated turns on replay (ADR-11). |74| 10 | `ModelRequestStarted` | **D** | Correlates blocks/usage to a request; records model id + context stats for `/context` history. Small payload — never the full prompt (rebuildable by projection). |75| 11 | `ModelTextDelta` | **E** | Pure streaming UX; settled text is durably recorded by #13. Persisting deltas would bloat the log for zero replay value (ADR-4). |76| 12 | `ModelThinkingDelta` | **E** | Same as #11. |77| 13 | `ModelTextBlockCompleted` | **D** | The durable record of assistant text — byte-exact for LLM history replay (ADR-7). |78| 14 | `ModelThinkingBlockCompleted` | **D** | Thinking blocks (+ signature) must be replayed byte-exact in assistant turns during tool loops — API requirement; hence durable. |79| 15 | `ToolCallStarted` | **E** | "Model began emitting a tool_use block" — UI hint only; input not yet complete. Durable record is #17. |80| 16 | `ToolInputDelta` | **E** | Streaming partial JSON input; UI preview only. |81| 17 | `ToolRequested` | **D** | The complete `tool_use` block (id, name, input) — doubles as the durable assistant-block record and the pending-work marker `deriveNext` consumes (ADR-2). |82| 18 | `ModelResponseCompleted` | **D** | Stop reason + **real usage** — the only source of cost/compaction accounting (Rule #4, ADR-6/10). |83| 19 | `ModelRequestFailed` | **D** | Typed error class; `context-overflow` drives reactive compaction on the *next* derivation, so it must be state (ADR-6/10). |84| 20 | `PermissionRequested` | **D** | Pending approval = persisted unanswered event; approvals survive restarts (OpenHands pattern, ADR-9). |85| 21 | `PermissionGranted` | **D** | Consent record + scope (`once`/`always`); audit. |86| 22 | `PermissionDenied` | **D** | Denial + feedback text that becomes the model-facing observation (ADR-9). |87| 23 | `ToolApproved` | **D** | Execution authorization (auto-allow or granted); separates policy outcome from execution start. |88| 24 | `ToolStarted` | **D** | Execution actually began; duration accounting; distinguishes "approved but crashed before running" on replay. |89| 25 | `ToolOutput` | **E** | Live output chunks. The budgeted result is durably recorded by #26/#27; persisting raw chunks would duplicate it unbounded (ADR-8). |90| 26 | `ToolCompleted` | **D** | The `tool_result` content the model saw — byte-exact for LLM history (ADR-7); includes spill path + UI meta. |91| 27 | `ToolFailed` | **D** | Error `tool_result` (is_error) the model saw; error kind for diagnostics. |92| 28 | `ToolCancelled` | **D** | Synthetic cancelled `tool_result` — keeps tool_use/tool_result pairing protocol-valid across interrupt/crash (ADR-11, §6.5). |93| 29 | `FileRead` | **D** | Tiny payload; feeds recency/frecency, `/context` file list, and external-modification detection. Cheap and useful ⇒ durable. |94| 30 | `FileModified` | **D** | Change set, diff stats, capped diff — powers `/diff`, attribution, and the verification gate after resume (ADR-12/15). |95| 31 | `ProcessStarted` | **D** | Process registry state; checkpoint field `running_processes` derives from it (ADR-6). |96| 32 | `ProcessOutput` | **E** | Rolling in-memory buffer (200K) is the read model; processes die with the KHAELOR process, so persisted output has no replay value. Model access is via `process.read`, recorded as that tool's result. |97| 33 | `ProcessExited` | **D** | Terminal state + cause; resume renders processes as exited. |98| 34 | `ContextPruned` | **D** | Deterministic replay: the exact pruned toolUseIds must be re-applied byte-identically on every rebuild (ADR-6/7). |99| 35 | `ContextCompacted` | **D** | Compaction-as-event: checkpoint + cut range, re-applied deterministically; doubles as a replay snapshot (ADR-6). |100| 36 | `VerificationRequested` | **D** | Gate attempts are budgeted (≤2) — the count must be derivable from state; preserves the withheld candidate (ADR-12). |101| 37 | `TaskCompleted` | **D** | Carries `CompletionEvidence` — the rigorous completion record (CLAUDE.md §17). |102| 38 | `TaskFailed` | **D** | Terminal failure + typed reason. |103104**32 durable, 6 ephemeral.** Anything not in this table is not an event.105106---107108## 4. Type Definitions (normative)109110```ts111// ───────────────────────── shared payload types ─────────────────────────112113export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal";114115export interface ModelUsage {                    // real API fields only116  inputTokens: number;117  outputTokens: number;118  cacheReadTokens: number;119  cacheWriteTokens: number;120}121122export interface DiffStats { added: number; removed: number; }123124export interface CheckResult {125  command: string;126  exitCode: number;127  summary: string;                               // e.g. "148 passed", "typecheck passed"128  durationMs: number;129}130131export interface CompletionEvidence {            // CLAUDE.md §17132  objective: string;133  changedFiles: string[];134  checks: CheckResult[];135  unresolvedIssues: string[];136}137138export interface GitBaseline {139  branch: string;140  dirtyFiles: string[];141  untrackedFiles: string[];142  diffHash: string;                              // hash of `git diff` output at capture time143}144145export type ModelErrorKind =146  | "retryable" | "context-overflow" | "auth" | "invalid-request" | "cancelled";147148export type ToolName =149  | "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";150151// ─────────────────────── session lifecycle (durable) ───────────────────────152153export type SessionStarted = Durable<"session.started", {154  title: string;155  projectHash: string;156  workingDirectory: string;157  gitBranch: string | null;158  model: string;159  auxModel: string;160  khaelorVersion: string;161}>;162163export type SessionResumed = Durable<"session.resumed", {164  khaelorVersion: string;165  replayedSeq: number;                 // highest seq replayed166  model: string;                       // active model after resume (may differ — swappable)167  toolNames: ToolName[];               // verified add-only vs. original set (ADR-3 resume contract)168}>;169170export type SessionRenamed = Durable<"session.renamed", { title: string }>;171172export type ModelChanged = Durable<"session.model-changed", {173  from: string;174  to: string;175  reason: "user" | "config";176}>;177178export type BaselineRecorded = Durable<"git.baseline-recorded", {179  when: "session-start" | "pre-first-edit";180  baseline: GitBaseline;181}>;182183// ───────────────────────── user input (durable) ─────────────────────────184185export type UserMessageCreated = Durable<"user.message-created", {186  text: string;                        // byte-exact — enters LLM history verbatim187  mentions: { path: string; range?: { start: number; end: number } }[];188}>;189190export type SteeringQueued = Durable<"user.steering-queued", {191  text: string;192}>;193194export type SteeringInjected = Durable<"user.steering-injected", {195  queuedEventId: string;               // id of the SteeringQueued event196  seam: "post-tool-batch" | "pre-model-call";197  afterSeq: number;                    // injection position in history — makes replay exact (ADR-7)198}>;199200export type Interrupted = Durable<"user.interrupted", {201  scope: "turn";                       // V1: Esc aborts the turn (model stream + in-flight tools)202  pendingToolUseIds: string[];         // tools that will be closed via ToolCancelled203}>;204205// ──────────────────────────── model stream ────────────────────────────206207export type ModelRequestStarted = Durable<"model.request-started", {208  requestId: string;                   // correlates all blocks/usage of this call209  model: string;210  purpose: "main" | "compaction" | "verification-nudge";211  contextStats: {                      // for /context history — estimates labeled as such212    estimatedInputTokens: number;213    sections: { name: string; estimatedTokens: number }[];214  };215}>;216217export type ModelTextDelta = Ephemeral<"model.text-delta", {218  requestId: string;219  blockIndex: number;220  text: string;221}>;222223export type ModelThinkingDelta = Ephemeral<"model.thinking-delta", {224  requestId: string;225  blockIndex: number;226  text: string;227}>;228229export type ToolCallStarted = Ephemeral<"model.tool-call-started", {230  requestId: string;231  blockIndex: number;232  toolUseId: string;233  toolName: ToolName;234}>;235236export type ToolInputDelta = Ephemeral<"model.tool-input-delta", {237  requestId: string;238  blockIndex: number;239  toolUseId: string;240  partialJson: string;241}>;242243export type ModelTextBlockCompleted = Durable<"model.text-block-completed", {244  requestId: string;245  blockIndex: number;246  text: string;                        // byte-exact settled block247}>;248249export type ModelThinkingBlockCompleted = Durable<"model.thinking-block-completed", {250  requestId: string;251  blockIndex: number;252  thinking: string;253  signature: string;                   // required for byte-exact API replay in tool loops254}>;255256export type ToolRequested = Durable<"tool.requested", {257  requestId: string;258  blockIndex: number;259  toolUseId: string;                   // Anthropic tool_use id — pairing key (§6.5)260  toolName: ToolName;261  input: unknown;                      // complete parsed input — byte-exact via canonical JSON (§5.1)262}>;263264export type ModelResponseCompleted = Durable<"model.response-completed", {265  requestId: string;266  stopReason: StopReason;267  usage: ModelUsage;                   // REAL API usage — sole source for cost/compaction268  durationMs: number;269}>;270271export type ModelRequestFailed = Durable<"model.request-failed", {272  requestId: string;273  kind: ModelErrorKind;274  message: string;                     // redacted — never headers/keys275  status?: number;276  retriesExhausted: boolean;277}>;278279// ─────────────────────── permissions (durable) ───────────────────────280281export type PermissionRequested = Durable<"permission.requested", {282  permissionRequestId: string;283  toolUseId: string;284  capability: string;                  // Capability285  descriptor: string;                  // human-meaningful: "Run `npm install` in ~/dev/project"286  suggestion?: { capability: string; pattern: string };   // "always allow git push *" (ADR-9)287}>;288289export type PermissionGranted = Durable<"permission.granted", {290  permissionRequestId: string;291  scope: "once" | "always-project";    // "always" also persists a config rule (ADR-9)292}>;293294export type PermissionDenied = Durable<"permission.denied", {295  permissionRequestId: string;296  source: "user" | "policy" | "hardline" | "timeout";     // silence is not consent (ADR-9)297  feedback: string;                    // returned to the model as the tool observation298}>;299300// ─────────────────────── tool execution (durable + one ephemeral) ───────────────────────301302export type ToolApproved = Durable<"tool.approved", {303  toolUseId: string;304  via: "policy-allow" | "user-once" | "user-always" | "rule";305}>;306307export type ToolStarted = Durable<"tool.started", {308  toolUseId: string;309  toolName: ToolName;310}>;311312export type ToolOutput = Ephemeral<"tool.output", {313  toolUseId: string;314  chunk: string;                       // live chunk, ANSI-stripped for TUI315}>;316317export type ToolCompleted = Durable<"tool.completed", {318  toolUseId: string;319  modelText: string;                   // byte-exact tool_result content (budget-truncated w/ markers)320  spillFile?: string;                  // full output under ~/.khaelor/spill/ (ADR-8)321  durationMs: number;322  ui: {                                // presentation DATA, not presentation (Rule 3)323    kind: "read" | "search" | "edit" | "exec" | "process";324    summary: string;                   // e.g. `Search "ContextEngine" · 14 matches`325    diffStats?: DiffStats;326    exitCode?: number;327    matchCount?: number;328  };329}>;330331export type ToolFailed = Durable<"tool.failed", {332  toolUseId: string;333  modelText: string;                   // byte-exact is_error tool_result content334  errorKind: "invalid-input" | "not-found" | "ambiguous-edit" | "exec-error"335           | "timeout" | "permission-denied" | "internal";336  durationMs: number;337}>;338339export type ToolCancelled = Durable<"tool.cancelled", {340  toolUseId: string;341  reason: "interrupted" | "resume-recovery" | "shutdown";342  modelText: string;                   // synthetic result, e.g. "[Tool execution cancelled by user]"343}>;344345// ─────────────────────── file activity (durable) ───────────────────────346347export type FileRead = Durable<"file.read", {348  path: string;                        // relative to workspace cwd349  range?: { start: number; end: number };350  bytes: number;351  mtimeMs: number;                     // external-modification detection on later writes352  toolUseId: string;353}>;354355export type FileModified = Durable<"file.modified", {356  path: string;357  operation: "write" | "edit";358  diffStats: DiffStats;359  diff?: string;                       // unified diff, capped (default 32 KiB) with truncation marker360  toolUseId: string;361}>;362363// ─────────────────────── processes (durable + one ephemeral) ───────────────────────364365export type ProcessStarted = Durable<"process.started", {366  processId: string;                   // KHAELOR id (stable across PID reuse)367  pid: number;368  command: string;369  cwd: string;370  name?: string;371  toolUseId: string;372}>;373374export type ProcessOutput = Ephemeral<"process.output", {375  processId: string;376  stream: "stdout" | "stderr";377  chunk: string;378}>;379380export type ProcessExited = Durable<"process.exited", {381  processId: string;382  exitCode: number | null;             // null = signal-killed383  cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed";384  durationMs: number;385}>;386387// ─────────────────────── context engine (durable) ───────────────────────388389export type ContextPruned = Durable<"context.pruned", {390  toolUseIds: string[];                // results blanked with the FIXED placeholder string391  placeholder: string;                 // recorded so replay is byte-exact even if default changes392  tokensReclaimedEstimate: number;393}>;394395export type ContextCompacted = Durable<"context.compacted", {396  checkpointYaml: string;              // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim397  cut: { fromSeq: number; toSeq: number };   // replaced range — pairing-safe boundary (§6.5)398  trigger: "proactive-token-budget" | "reactive-overflow" | "user-command";399  tokensBefore: number;                // from real usage accounting400  summaryModel: string;                // the auxModel used401}>;402403// ─────────────────────── completion (durable) ───────────────────────404405export type VerificationRequested = Durable<"task.verification-requested", {406  attempt: 1 | 2;407  detectedChecks: string[];            // e.g. ["npm test", "npx tsc --noEmit"]408  withheldCandidateSeq: number;        // seq of the withheld answer's final text block (ADR-12)409}>;410411export type TaskCompleted = Durable<"task.completed", {412  evidence: CompletionEvidence;413}>;414415export type TaskFailed = Durable<"task.failed", {416  reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned";417  detail: string;418}>;419420// ───────────────────────────── unions ─────────────────────────────421422export type DurableEvent =423  | SessionStarted | SessionResumed | SessionRenamed | ModelChanged | BaselineRecorded424  | UserMessageCreated | SteeringQueued | SteeringInjected | Interrupted425  | ModelRequestStarted | ModelTextBlockCompleted | ModelThinkingBlockCompleted426  | ToolRequested | ModelResponseCompleted | ModelRequestFailed427  | PermissionRequested | PermissionGranted | PermissionDenied428  | ToolApproved | ToolStarted | ToolCompleted | ToolFailed | ToolCancelled429  | FileRead | FileModified430  | ProcessStarted | ProcessExited431  | ContextPruned | ContextCompacted432  | VerificationRequested | TaskCompleted | TaskFailed;433434export type EphemeralEvent =435  | ModelTextDelta | ModelThinkingDelta | ToolCallStarted | ToolInputDelta436  | ToolOutput | ProcessOutput;437438export type KhaelorEvent = DurableEvent | EphemeralEvent;439```440441Type-string convention: `domain.past-tense-fact` (`tool.completed`). The TypeScript alias names (PascalCase) match CLAUDE.md §7's vocabulary; the wire `type` strings are the namespaced forms above.442443---444445## 5. JSONL Serialization446447### 5.1 Format448449- One durable event per line: `JSON.stringify(envelope)` + `"\n"`. No pretty-printing, no BOM, UTF-8.450- **Canonical JSON for byte-stability-sensitive payloads:** object keys serialized in a fixed order (envelope: `v,id,sessionId,seq,ts,type,payload`; payloads: schema field order). `ToolRequested.input` is stored via a canonical-JSON stringify so the LLM-history projection reproduces identical bytes on every rebuild (ADR-7).451- Strings are stored verbatim (JSON escaping only). No compression in V1; logs are line-greppable by design.452453### 5.2 Append and atomicity rules4544551. The store holds one file descriptor per open session, opened with `O_APPEND`.4562. Each event is written as **one `write()` call of one complete line** (single-writer process ⇒ a line is never interleaved).4573. Appends are serialized through a per-session write queue; `seq` is assigned at enqueue and is gapless.4584. **Write-ahead:** `publishDurable()` resolves the append (buffered write accepted by the OS) *before* the bus delivers the event to subscribers. Projections can therefore never observe an event the log doesn't contain.4595. **Flush/fsync policy:** `fsync` at loop boundaries — after each tool result batch, after `ModelResponseCompleted`, after `ContextCompacted`, and on shutdown (mini's `finally` discipline, ADR-3). Between boundaries, OS buffering is accepted.4606. The log is **never rewritten, truncated (except §5.3 recovery), or compacted in place**. `/clear` starts a new session file.4617. `meta.json` sidecars are projection caches written atomically (tmp + rename) and are always rebuildable from the log — never a second source of truth (ADR-3 concern, accepted).462463### 5.3 Corruption recovery (truncated last line)464465A crash can leave at most one incomplete final line (consequence of rules 2–3). On `open()`:4664671. Read the file; if the last line lacks a trailing `\n` **or** fails `JSON.parse` **or** fails envelope validation, it is a torn write.4682. Truncate the file to the end of the last valid line (atomic: `ftruncate` at the computed byte offset), after copying the torn bytes to `<session-id>.jsonl.torn` for diagnostics.4693. Log a warning to `~/.khaelor/logs/`; never to the TUI.4704. Corruption anywhere *other* than the final line indicates external interference: the session opens read-only for inspection and resume is refused with an actionable message (no silent repair of user data).4715. After truncation, resume recovery runs (§6.5): dangling `tool_use` blocks are closed with `ToolCancelled{reason:"resume-recovery"}`.472473### 5.4 Versioning474475- Every line carries `v` (schema version), per-line — a log may legitimately contain mixed versions after an upgrade (ADR-3: version events from day one; never a v1/v2 dual architecture).476- **Reads migrate, writes are current:** the store applies pure upgrade functions `migrate_v1_to_v2(line) → line` at read time; new events are always written at the current version. Log files are never rewritten in place.477- Additive payload changes (new optional field) do **not** bump `v`; readers must tolerate unknown fields. Renames/semantic changes bump `v` and ship a migration.478- Unknown `type` at read time (from a newer KHAELOR): the event is preserved and surfaced as an opaque timeline item; projections that don't recognize it skip it. Resume is refused only if an unknown event is *load-bearing* (declared via a `critical: true` envelope extension reserved for future use).479480---481482## 6. Projections483484All projections are folds: `state = events.reduce(apply, initial)`. The same reducer maintains live in-memory state (as events are published) and rebuilds on resume (as events are replayed) — one code path, two feeds (ADR-2/3). Each projection tolerates deletion of its cache and rebuilds from the log.485486### 6.1 Timeline (conversation state → TUI view-model)487488Fold rules:489490- `UserMessageCreated` → user message item. `SteeringQueued``Queued instruction` chip; `SteeringInjected` re-parents the chip to its injection point.491- `ModelTextBlockCompleted` / `ModelThinkingBlockCompleted` → assistant text / collapsed thinking items, grouped per `requestId`.492- `ToolRequested` → collapsed tool card (`▸ Read src/kernel/agent.ts`); `ToolStarted`/`ToolCompleted`/`ToolFailed`/`ToolCancelled` update its status/summary from `ui` metadata; `FileModified` attaches diff stats + expandable diff.493- `PermissionRequested` without a matching `PermissionGranted/Denied` → active permission panel (this is how a pending approval survives restart).494- `ProcessStarted`/`ProcessExited``/processes` list state.495- `ContextCompacted` → subtle `— context compacted —` divider; checkpoint inspectable via `/context`.496- `Interrupted` → turn marked interrupted. `TaskCompleted` → completion summary from `evidence`.497- **Live streaming state is layered on top** by the coalescer (§7): ephemeral deltas mutate only the *live tail* of the view-model and are discarded once the corresponding durable settled event arrives.498499### 6.2 LlmHistory (Anthropic `messages[]`) — byte-stable500501The most invariant-critical projection. Fold rules, in `seq` order:5025031. `UserMessageCreated.text``{role:"user", content:[{type:"text", ...}]}`.5042. Per `requestId`: `ModelThinkingBlockCompleted` (+signature), `ModelTextBlockCompleted`, and `ToolRequested` assemble into one `{role:"assistant"}` message, **ordered by `blockIndex`**.5053. `ToolCompleted.modelText` / `ToolFailed.modelText` (is_error) / `ToolCancelled.modelText``tool_result` blocks in a `{role:"user"}` message, in the order the `tool_use` blocks appeared.5064. `SteeringInjected` → the queued text is appended as an additional text block **inside the tool-result user message at `afterSeq`** (pre-model-call seam: appended to the last user message) — never a new bare user message mid-alternation (ADR-11).5075. `ContextPruned` → for each listed `toolUseId`, the `tool_result` content is replaced by the recorded `placeholder` string. Deterministic: same event ⇒ same bytes, every rebuild.5086. `ContextCompacted` → all messages derived from events with `cut.fromSeq ≤ seq ≤ cut.toSeq` are replaced by a single synthetic user message containing `checkpointYaml` (a fixed template wrapper). Multiple compactions apply in `seq` order; a later compaction may consume an earlier checkpoint message. Replay-deterministic by construction — the checkpoint text and cut range live in the event, not in engine code (ADR-6).5097. `ModelChanged` marks a cache-lineage boundary (no message mutation).5108. Volatile per-turn context (git status, process list, mention contents) is **not** in this projection — the Context Engine attaches it to the API copy of the current message only (ADR-7). LlmHistory is exactly the stable replayable prefix.511512**Byte-stability contract:** rebuilding LlmHistory from the log at any time yields byte-identical message content to what was previously sent (the ADR-7 test: serialize → compare).513514### 6.3 UsageTotals (cost counters)515516Fold: sum `ModelResponseCompleted.usage` fields, keyed by model id (main vs aux priced separately). Cost = tokens × configured pricing table. Incremental counters cached in `meta.json` are a pure cache (OpenCode's trick as projection, never truth — ADR-3). `/cost` and the status bar read this projection; if any usage field is absent, the display says so — nothing is estimated silently (Absolute Rule #4).517518### 6.4 FileChangeSet519520Fold: `BaselineRecorded` fixes the attribution baseline; `FileModified` accumulates `{path → {operations, cumulative diffStats, toolUseIds}}`; `FileRead.mtimeMs` feeds external-modification warnings. Consumers: `/diff`, the verification gate (`needsVerification`: code files changed since last `CheckResult` evidence, documentation-only filtered), and attribution (`khaelor` vs `preExisting` — ADR-15). A `ContextCompacted` never erases this projection — file changes remain first-class even when their conversational context is summarized.521522### 6.5 Pairing safety (tool_use / tool_result)523524**Invariant:** in LlmHistory, every `tool_use` block has exactly one `tool_result`, and the Anthropic role alternation is valid — at all times, including mid-crash and post-compaction.525526Enforcement, at three points:5275281. **Runtime (ADR-11):** interrupt closes every in-flight or pending `toolUseId` with `ToolCancelled` before the turn ends.5292. **Resume recovery:** after replay (and §5.3 truncation), any `ToolRequested` lacking a terminal event (`ToolCompleted`/`ToolFailed`/`ToolCancelled`) gets a synthetic `ToolCancelled{reason:"resume-recovery"}` **appended durably at resume time** — recovery is itself an event, so the next replay needs no recovery.5303. **Compaction cuts:** `ContextCompacted.cut.toSeq` may only land where every `tool_use` at `seq ≤ toSeq` has its `tool_result` at `seq ≤ toSeq` (OpenHands' `manipulation_indices` discipline, ADR-6). The Context Engine computes candidate cut points from the projection; the store validates the invariant before appending the event (violation = internal error, refused).531532---533534## 7. Delta Coalescing Contract (TUI, ~16 ms)535536The bus delivers ephemeral events synchronously; the TUI's `Coalescer` is the single buffering point (ADR-4, ADR-14).537538```ts539export interface CoalescedFrame {540  textAppends: Map<BlockKey, string>;        // concatenated ModelTextDelta / ModelThinkingDelta541  toolInputPreviews: Map<string, string>;    // latest accumulated partial JSON per toolUseId542  toolOutputAppends: Map<string, string>;    // concatenated ToolOutput per toolUseId543  processOutputAppends: Map<string, string>; // concatenated ProcessOutput per processId544  durables: DurableEvent[];                  // durable events in this window, in seq order545}546export interface Coalescer {547  subscribe(onFrame: (f: CoalescedFrame) => void): Unsubscribe;548}549```550551Contract:5525531. **Flush cadence:** a frame is flushed at most once per ~16 ms window (timer armed on first buffered event; nothing buffered ⇒ no timer, no idle wake-ups). One render per frame regardless of event rate.5542. **Coalescing is concatenation** for text-like deltas (order-preserving per block/tool/process key); consecutive deltas for the same key collapse into one string append.5553. **Ordering with durables:** when a durable event arrives that *settles* a streaming key (e.g. `ModelTextBlockCompleted` for a block with buffered deltas), the coalescer **flushes immediately** — buffered deltas for that key are delivered in the same frame, *before* the durable event in `durables`. The TUI thus always sees deltas-then-settlement, never settlement-then-stale-deltas.5564. **Settlement replaces:** on receiving the settled block, the TUI discards its accumulated delta string for that key and renders the durable content (byte-authoritative). Deltas are UX, never truth.5575. **Backpressure/caps:** buffered append strings are capped per key per frame (default 16 KiB); overflow within a window truncates the *visual* preview with a marker — the durable settled event restores full fidelity. The live-render region caps (ADR-14) apply downstream.5586. **Interrupt flushes:** `Interrupted` forces an immediate flush so the UI freezes at the last real state.559560---561562## 8. Event Bus API563564```ts565// shared/bus.ts566export interface EventBus {567  /** Append (write-ahead, §5.2) then publish. Assigns v/id/sessionId/seq/ts.568   *  Returns the full envelope. Throws only on unrecoverable store failure569   *  (disk full is classified and surfaced actionably). */570  publishDurable(input: DurableEventInput): DurableEvent;571572  /** Fire-and-forget to live subscribers. Never persisted. */573  publishEphemeral(input: EphemeralEventInput): void;574575  /** Typed subscription — handler parameter narrows by `type`. */576  on<T extends KhaelorEvent["type"]>(577    type: T,578    handler: (e: Extract<KhaelorEvent, { type: T }>) => void,579  ): Unsubscribe;580581  /** Wildcard (projections, logging, coalescer). */582  onAny(handler: (e: KhaelorEvent) => void): Unsubscribe;583}584```585586Semantics and backpressure notes:587588- **In-process only** (ADR-17). The bus vocabulary is the system's only "API"; if the engine later moves to a `worker_thread`, this interface is what crosses the boundary.589- **Synchronous fan-out, in `seq` order per session.** Handlers must be fast and non-throwing; a handler exception is caught, logged, and never blocks other subscribers or the kernel. Handlers needing async work schedule it — they do not make the bus async.590- **No internal queues:** the coalescer (§7) is the only sanctioned buffering point; projection folds are O(small) per event by design. If a subscriber is measurably slow, fix the subscriber — not the bus (measure first, CLAUDE.md §18).591- **Replay uses the same reducers, not the bus:** `SessionStore.open()` feeds replayed events directly to projection reducers; live subscribers (TUI, logging) receive only live events plus the rebuilt state handed to them at attach time. This keeps "replay" from re-triggering side effects.592- Subscriptions return `Unsubscribe`; the TUI and kernel release all subscriptions on shutdown (leak check in tests).593594---595596## 9. Test Obligations (Phase 2–6 definition of done)5975981. **Round-trip:** every event type serializes → parses → deep-equals (property test over the union).5992. **Byte-stability:** LlmHistory rebuilt from a log equals the recorded request bytes, including after `ContextPruned`/`ContextCompacted` and `SteeringInjected` (ADR-7).6003. **Pairing invariant:** fuzzed interrupt/crash points never yield a dangling `tool_use` after resume recovery (§6.5).6014. **Torn-write recovery:** truncating a log at every byte offset of the final line still opens cleanly (§5.3).6025. **Coalescer:** delta storms produce ≤1 frame per 16 ms; settlement ordering (deltas before durable) holds under race.6036. **Version tolerance:** unknown optional fields and unknown non-critical event types don't break replay (§5.4).604605---606607*Author: Simon-Pierre Boucher · contact@spboucher.ai*608