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

# KHAELOR V1 — Event Model

Status: Phase 1 deliverable — the complete typed event vocabulary. Normative for docs/ARCHITECTURE.md and all Phase 2–6 implementation. 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).

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.


# 1. Design Rules

  1. 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).
  2. 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).
  3. Rendering never lives on events. Payloads are domain data; presentation is keyed by type inside the TUI (OpenHands visualize anti-pattern excluded, ADR-4).
  4. Every event carries sessionId — the cheap seam subagents-as-child-sessions depends on (ADR-16).
  5. 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.
  6. Honest data only. Usage, cost, exit codes, and check results come from real sources; no event may carry fabricated numbers (Absolute Rule #4).
  7. Naming: past-tense facts (ToolCompleted), not commands. Commands are KernelCommands (ARCHITECTURE.md §2.2), which cause events but are not events.

# 2. Envelopes

ts
// shared/events.ts

/** Durable envelope — one JSONL line per event. */
export interface Durable<T extends string, P> {
  v: 1;                    // schema version of this event line (§5.4)
  id: string;              // ULID — globally unique, time-ordered
  sessionId: string;
  seq: number;             // monotonic per session, gapless, assigned at append time
  ts: number;              // epoch milliseconds
  parentId?: string;       // reserved, always absent in V1 (ADR-3: linear log, tree-ready)
  type: T;
  payload: P;
}

/** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */
export interface Ephemeral<T extends string, P> {
  id: string;              // ULID (correlation/debugging)
  sessionId: string;
  ts: number;
  type: T;
  payload: P;
}

/** What producers hand to the bus; envelope fields are assigned by the store/bus. */
export type DurableEventInput = { type: DurableEvent["type"]; payload: DurableEvent["payload"] };

# 3. Catalog Summary — DURABLE vs EPHEMERAL

# Event Class Rationale
1 SessionStarted D Anchors the log: cwd, model, config snapshot. Required by every projection.
2 SessionResumed D Audit trail; marks replay boundaries; records model/tool-set verification (resume contract, ADR-3).
3 SessionRenamed D /rename must survive restart; metadata is a projection (Rule: derivable from the log).
4 ModelChanged D LLM history and cost projections must know which model produced which turns; starts a new cache lineage (ADR-7).
5 BaselineRecorded D Attribution (KHAELOR's changes vs user's) must survive resume (ADR-15).
6 UserMessageCreated D Conversation truth; LLM history input.
7 SteeringQueued D Queued instructions must survive a crash before injection (ADR-11); UI shows Queued instruction after resume.
8 SteeringInjected D The injection point alters LLM history; replay must reproduce identical bytes (ADR-7).
9 Interrupted D deriveNext consumes it from state; explains truncated turns on replay (ADR-11).
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).
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).
12 ModelThinkingDelta E Same as #11.
13 ModelTextBlockCompleted D The durable record of assistant text — byte-exact for LLM history replay (ADR-7).
14 ModelThinkingBlockCompleted D Thinking blocks (+ signature) must be replayed byte-exact in assistant turns during tool loops — API requirement; hence durable.
15 ToolCallStarted E "Model began emitting a tool_use block" — UI hint only; input not yet complete. Durable record is #17.
16 ToolInputDelta E Streaming partial JSON input; UI preview only.
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).
18 ModelResponseCompleted D Stop reason + real usage — the only source of cost/compaction accounting (Rule #4, ADR-6/10).
19 ModelRequestFailed D Typed error class; context-overflow drives reactive compaction on the next derivation, so it must be state (ADR-6/10).
20 PermissionRequested D Pending approval = persisted unanswered event; approvals survive restarts (OpenHands pattern, ADR-9).
21 PermissionGranted D Consent record + scope (once/always); audit.
22 PermissionDenied D Denial + feedback text that becomes the model-facing observation (ADR-9).
23 ToolApproved D Execution authorization (auto-allow or granted); separates policy outcome from execution start.
24 ToolStarted D Execution actually began; duration accounting; distinguishes "approved but crashed before running" on replay.
25 ToolOutput E Live output chunks. The budgeted result is durably recorded by #26/#27; persisting raw chunks would duplicate it unbounded (ADR-8).
26 ToolCompleted D The tool_result content the model saw — byte-exact for LLM history (ADR-7); includes spill path + UI meta.
27 ToolFailed D Error tool_result (is_error) the model saw; error kind for diagnostics.
28 ToolCancelled D Synthetic cancelled tool_result — keeps tool_use/tool_result pairing protocol-valid across interrupt/crash (ADR-11, §6.5).
29 FileRead D Tiny payload; feeds recency/frecency, /context file list, and external-modification detection. Cheap and useful ⇒ durable.
30 FileModified D Change set, diff stats, capped diff — powers /diff, attribution, and the verification gate after resume (ADR-12/15).
31 ProcessStarted D Process registry state; checkpoint field running_processes derives from it (ADR-6).
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.
33 ProcessExited D Terminal state + cause; resume renders processes as exited.
34 ContextPruned D Deterministic replay: the exact pruned toolUseIds must be re-applied byte-identically on every rebuild (ADR-6/7).
35 ContextCompacted D Compaction-as-event: checkpoint + cut range, re-applied deterministically; doubles as a replay snapshot (ADR-6).
36 VerificationRequested D Gate attempts are budgeted (≤2) — the count must be derivable from state; preserves the withheld candidate (ADR-12).
37 TaskCompleted D Carries CompletionEvidence — the rigorous completion record (CLAUDE.md §17).
38 TaskFailed D Terminal failure + typed reason.

32 durable, 6 ephemeral. Anything not in this table is not an event.


# 4. Type Definitions (normative)

ts
// ───────────────────────── shared payload types ─────────────────────────

export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal";

export interface ModelUsage {                    // real API fields only
  inputTokens: number;
  outputTokens: number;
  cacheReadTokens: number;
  cacheWriteTokens: number;
}

export interface DiffStats { added: number; removed: number; }

export interface CheckResult {
  command: string;
  exitCode: number;
  summary: string;                               // e.g. "148 passed", "typecheck passed"
  durationMs: number;
}

export interface CompletionEvidence {            // CLAUDE.md §17
  objective: string;
  changedFiles: string[];
  checks: CheckResult[];
  unresolvedIssues: string[];
}

export interface GitBaseline {
  branch: string;
  dirtyFiles: string[];
  untrackedFiles: string[];
  diffHash: string;                              // hash of `git diff` output at capture time
}

export type ModelErrorKind =
  | "retryable" | "context-overflow" | "auth" | "invalid-request" | "cancelled";

export type ToolName =
  | "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";

// ─────────────────────── session lifecycle (durable) ───────────────────────

export type SessionStarted = Durable<"session.started", {
  title: string;
  projectHash: string;
  workingDirectory: string;
  gitBranch: string | null;
  model: string;
  auxModel: string;
  khaelorVersion: string;
}>;

export type SessionResumed = Durable<"session.resumed", {
  khaelorVersion: string;
  replayedSeq: number;                 // highest seq replayed
  model: string;                       // active model after resume (may differ — swappable)
  toolNames: ToolName[];               // verified add-only vs. original set (ADR-3 resume contract)
}>;

export type SessionRenamed = Durable<"session.renamed", { title: string }>;

export type ModelChanged = Durable<"session.model-changed", {
  from: string;
  to: string;
  reason: "user" | "config";
}>;

export type BaselineRecorded = Durable<"git.baseline-recorded", {
  when: "session-start" | "pre-first-edit";
  baseline: GitBaseline;
}>;

// ───────────────────────── user input (durable) ─────────────────────────

export type UserMessageCreated = Durable<"user.message-created", {
  text: string;                        // byte-exact — enters LLM history verbatim
  mentions: { path: string; range?: { start: number; end: number } }[];
}>;

export type SteeringQueued = Durable<"user.steering-queued", {
  text: string;
}>;

export type SteeringInjected = Durable<"user.steering-injected", {
  queuedEventId: string;               // id of the SteeringQueued event
  seam: "post-tool-batch" | "pre-model-call";
  afterSeq: number;                    // injection position in history — makes replay exact (ADR-7)
}>;

export type Interrupted = Durable<"user.interrupted", {
  scope: "turn";                       // V1: Esc aborts the turn (model stream + in-flight tools)
  pendingToolUseIds: string[];         // tools that will be closed via ToolCancelled
}>;

// ──────────────────────────── model stream ────────────────────────────

export type ModelRequestStarted = Durable<"model.request-started", {
  requestId: string;                   // correlates all blocks/usage of this call
  model: string;
  purpose: "main" | "compaction" | "verification-nudge";
  contextStats: {                      // for /context history — estimates labeled as such
    estimatedInputTokens: number;
    sections: { name: string; estimatedTokens: number }[];
  };
}>;

export type ModelTextDelta = Ephemeral<"model.text-delta", {
  requestId: string;
  blockIndex: number;
  text: string;
}>;

export type ModelThinkingDelta = Ephemeral<"model.thinking-delta", {
  requestId: string;
  blockIndex: number;
  text: string;
}>;

export type ToolCallStarted = Ephemeral<"model.tool-call-started", {
  requestId: string;
  blockIndex: number;
  toolUseId: string;
  toolName: ToolName;
}>;

export type ToolInputDelta = Ephemeral<"model.tool-input-delta", {
  requestId: string;
  blockIndex: number;
  toolUseId: string;
  partialJson: string;
}>;

export type ModelTextBlockCompleted = Durable<"model.text-block-completed", {
  requestId: string;
  blockIndex: number;
  text: string;                        // byte-exact settled block
}>;

export type ModelThinkingBlockCompleted = Durable<"model.thinking-block-completed", {
  requestId: string;
  blockIndex: number;
  thinking: string;
  signature: string;                   // required for byte-exact API replay in tool loops
}>;

export type ToolRequested = Durable<"tool.requested", {
  requestId: string;
  blockIndex: number;
  toolUseId: string;                   // Anthropic tool_use id — pairing key (§6.5)
  toolName: ToolName;
  input: unknown;                      // complete parsed input — byte-exact via canonical JSON (§5.1)
}>;

export type ModelResponseCompleted = Durable<"model.response-completed", {
  requestId: string;
  stopReason: StopReason;
  usage: ModelUsage;                   // REAL API usage — sole source for cost/compaction
  durationMs: number;
}>;

export type ModelRequestFailed = Durable<"model.request-failed", {
  requestId: string;
  kind: ModelErrorKind;
  message: string;                     // redacted — never headers/keys
  status?: number;
  retriesExhausted: boolean;
}>;

// ─────────────────────── permissions (durable) ───────────────────────

export type PermissionRequested = Durable<"permission.requested", {
  permissionRequestId: string;
  toolUseId: string;
  capability: string;                  // Capability
  descriptor: string;                  // human-meaningful: "Run `npm install` in ~/dev/project"
  suggestion?: { capability: string; pattern: string };   // "always allow git push *" (ADR-9)
}>;

export type PermissionGranted = Durable<"permission.granted", {
  permissionRequestId: string;
  scope: "once" | "always-project";    // "always" also persists a config rule (ADR-9)
}>;

export type PermissionDenied = Durable<"permission.denied", {
  permissionRequestId: string;
  source: "user" | "policy" | "hardline" | "timeout";     // silence is not consent (ADR-9)
  feedback: string;                    // returned to the model as the tool observation
}>;

// ─────────────────────── tool execution (durable + one ephemeral) ───────────────────────

export type ToolApproved = Durable<"tool.approved", {
  toolUseId: string;
  via: "policy-allow" | "user-once" | "user-always" | "rule";
}>;

export type ToolStarted = Durable<"tool.started", {
  toolUseId: string;
  toolName: ToolName;
}>;

export type ToolOutput = Ephemeral<"tool.output", {
  toolUseId: string;
  chunk: string;                       // live chunk, ANSI-stripped for TUI
}>;

export type ToolCompleted = Durable<"tool.completed", {
  toolUseId: string;
  modelText: string;                   // byte-exact tool_result content (budget-truncated w/ markers)
  spillFile?: string;                  // full output under ~/.khaelor/spill/ (ADR-8)
  durationMs: number;
  ui: {                                // presentation DATA, not presentation (Rule 3)
    kind: "read" | "search" | "edit" | "exec" | "process";
    summary: string;                   // e.g. `Search "ContextEngine" · 14 matches`
    diffStats?: DiffStats;
    exitCode?: number;
    matchCount?: number;
  };
}>;

export type ToolFailed = Durable<"tool.failed", {
  toolUseId: string;
  modelText: string;                   // byte-exact is_error tool_result content
  errorKind: "invalid-input" | "not-found" | "ambiguous-edit" | "exec-error"
           | "timeout" | "permission-denied" | "internal";
  durationMs: number;
}>;

export type ToolCancelled = Durable<"tool.cancelled", {
  toolUseId: string;
  reason: "interrupted" | "resume-recovery" | "shutdown";
  modelText: string;                   // synthetic result, e.g. "[Tool execution cancelled by user]"
}>;

// ─────────────────────── file activity (durable) ───────────────────────

export type FileRead = Durable<"file.read", {
  path: string;                        // relative to workspace cwd
  range?: { start: number; end: number };
  bytes: number;
  mtimeMs: number;                     // external-modification detection on later writes
  toolUseId: string;
}>;

export type FileModified = Durable<"file.modified", {
  path: string;
  operation: "write" | "edit";
  diffStats: DiffStats;
  diff?: string;                       // unified diff, capped (default 32 KiB) with truncation marker
  toolUseId: string;
}>;

// ─────────────────────── processes (durable + one ephemeral) ───────────────────────

export type ProcessStarted = Durable<"process.started", {
  processId: string;                   // KHAELOR id (stable across PID reuse)
  pid: number;
  command: string;
  cwd: string;
  name?: string;
  toolUseId: string;
}>;

export type ProcessOutput = Ephemeral<"process.output", {
  processId: string;
  stream: "stdout" | "stderr";
  chunk: string;
}>;

export type ProcessExited = Durable<"process.exited", {
  processId: string;
  exitCode: number | null;             // null = signal-killed
  cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed";
  durationMs: number;
}>;

// ─────────────────────── context engine (durable) ───────────────────────

export type ContextPruned = Durable<"context.pruned", {
  toolUseIds: string[];                // results blanked with the FIXED placeholder string
  placeholder: string;                 // recorded so replay is byte-exact even if default changes
  tokensReclaimedEstimate: number;
}>;

export type ContextCompacted = Durable<"context.compacted", {
  checkpointYaml: string;              // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim
  cut: { fromSeq: number; toSeq: number };   // replaced range — pairing-safe boundary (§6.5)
  trigger: "proactive-token-budget" | "reactive-overflow" | "user-command";
  tokensBefore: number;                // from real usage accounting
  summaryModel: string;                // the auxModel used
}>;

// ─────────────────────── completion (durable) ───────────────────────

export type VerificationRequested = Durable<"task.verification-requested", {
  attempt: 1 | 2;
  detectedChecks: string[];            // e.g. ["npm test", "npx tsc --noEmit"]
  withheldCandidateSeq: number;        // seq of the withheld answer's final text block (ADR-12)
}>;

export type TaskCompleted = Durable<"task.completed", {
  evidence: CompletionEvidence;
}>;

export type TaskFailed = Durable<"task.failed", {
  reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned";
  detail: string;
}>;

// ───────────────────────────── unions ─────────────────────────────

export type DurableEvent =
  | SessionStarted | SessionResumed | SessionRenamed | ModelChanged | BaselineRecorded
  | UserMessageCreated | SteeringQueued | SteeringInjected | Interrupted
  | ModelRequestStarted | ModelTextBlockCompleted | ModelThinkingBlockCompleted
  | ToolRequested | ModelResponseCompleted | ModelRequestFailed
  | PermissionRequested | PermissionGranted | PermissionDenied
  | ToolApproved | ToolStarted | ToolCompleted | ToolFailed | ToolCancelled
  | FileRead | FileModified
  | ProcessStarted | ProcessExited
  | ContextPruned | ContextCompacted
  | VerificationRequested | TaskCompleted | TaskFailed;

export type EphemeralEvent =
  | ModelTextDelta | ModelThinkingDelta | ToolCallStarted | ToolInputDelta
  | ToolOutput | ProcessOutput;

export type KhaelorEvent = DurableEvent | EphemeralEvent;

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


# 5. JSONL Serialization

# 5.1 Format

  • One durable event per line: JSON.stringify(envelope) + "\n". No pretty-printing, no BOM, UTF-8.
  • 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).
  • Strings are stored verbatim (JSON escaping only). No compression in V1; logs are line-greppable by design.

# 5.2 Append and atomicity rules

  1. The store holds one file descriptor per open session, opened with O_APPEND.
  2. Each event is written as one write() call of one complete line (single-writer process ⇒ a line is never interleaved).
  3. Appends are serialized through a per-session write queue; seq is assigned at enqueue and is gapless.
  4. 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.
  5. 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.
  6. The log is never rewritten, truncated (except §5.3 recovery), or compacted in place. /clear starts a new session file.
  7. 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).

# 5.3 Corruption recovery (truncated last line)

A crash can leave at most one incomplete final line (consequence of rules 2–3). On open():

  1. Read the file; if the last line lacks a trailing \n or fails JSON.parse or fails envelope validation, it is a torn write.
  2. 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.
  3. Log a warning to ~/.khaelor/logs/; never to the TUI.
  4. 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).
  5. After truncation, resume recovery runs (§6.5): dangling tool_use blocks are closed with ToolCancelled{reason:"resume-recovery"}.

# 5.4 Versioning

  • 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).
  • 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.
  • Additive payload changes (new optional field) do not bump v; readers must tolerate unknown fields. Renames/semantic changes bump v and ship a migration.
  • 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).

# 6. Projections

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

# 6.1 Timeline (conversation state → TUI view-model)

Fold rules:

  • UserMessageCreated → user message item. SteeringQueuedQueued instruction chip; SteeringInjected re-parents the chip to its injection point.
  • ModelTextBlockCompleted / ModelThinkingBlockCompleted → assistant text / collapsed thinking items, grouped per requestId.
  • 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.
  • PermissionRequested without a matching PermissionGranted/Denied → active permission panel (this is how a pending approval survives restart).
  • ProcessStarted/ProcessExited/processes list state.
  • ContextCompacted → subtle — context compacted — divider; checkpoint inspectable via /context.
  • Interrupted → turn marked interrupted. TaskCompleted → completion summary from evidence.
  • 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.

# 6.2 LlmHistory (Anthropic messages[]) — byte-stable

The most invariant-critical projection. Fold rules, in seq order:

  1. UserMessageCreated.text{role:"user", content:[{type:"text", ...}]}.
  2. Per requestId: ModelThinkingBlockCompleted (+signature), ModelTextBlockCompleted, and ToolRequested assemble into one {role:"assistant"} message, ordered by blockIndex.
  3. ToolCompleted.modelText / ToolFailed.modelText (is_error) / ToolCancelled.modelTexttool_result blocks in a {role:"user"} message, in the order the tool_use blocks appeared.
  4. 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).
  5. ContextPruned → for each listed toolUseId, the tool_result content is replaced by the recorded placeholder string. Deterministic: same event ⇒ same bytes, every rebuild.
  6. 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).
  7. ModelChanged marks a cache-lineage boundary (no message mutation).
  8. 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.

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

# 6.3 UsageTotals (cost counters)

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

# 6.4 FileChangeSet

Fold: 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.

# 6.5 Pairing safety (tool_use / tool_result)

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.

Enforcement, at three points:

  1. Runtime (ADR-11): interrupt closes every in-flight or pending toolUseId with ToolCancelled before the turn ends.
  2. 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.
  3. 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).

# 7. Delta Coalescing Contract (TUI, ~16 ms)

The bus delivers ephemeral events synchronously; the TUI's Coalescer is the single buffering point (ADR-4, ADR-14).

ts
export interface CoalescedFrame {
  textAppends: Map<BlockKey, string>;        // concatenated ModelTextDelta / ModelThinkingDelta
  toolInputPreviews: Map<string, string>;    // latest accumulated partial JSON per toolUseId
  toolOutputAppends: Map<string, string>;    // concatenated ToolOutput per toolUseId
  processOutputAppends: Map<string, string>; // concatenated ProcessOutput per processId
  durables: DurableEvent[];                  // durable events in this window, in seq order
}
export interface Coalescer {
  subscribe(onFrame: (f: CoalescedFrame) => void): Unsubscribe;
}

Contract:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Interrupt flushes: Interrupted forces an immediate flush so the UI freezes at the last real state.

# 8. Event Bus API

ts
// shared/bus.ts
export interface EventBus {
  /** Append (write-ahead, §5.2) then publish. Assigns v/id/sessionId/seq/ts.
   *  Returns the full envelope. Throws only on unrecoverable store failure
   *  (disk full is classified and surfaced actionably). */
  publishDurable(input: DurableEventInput): DurableEvent;

  /** Fire-and-forget to live subscribers. Never persisted. */
  publishEphemeral(input: EphemeralEventInput): void;

  /** Typed subscription — handler parameter narrows by `type`. */
  on<T extends KhaelorEvent["type"]>(
    type: T,
    handler: (e: Extract<KhaelorEvent, { type: T }>) => void,
  ): Unsubscribe;

  /** Wildcard (projections, logging, coalescer). */
  onAny(handler: (e: KhaelorEvent) => void): Unsubscribe;
}

Semantics and backpressure notes:

  • 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.
  • 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.
  • 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).
  • 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.
  • Subscriptions return Unsubscribe; the TUI and kernel release all subscriptions on shutdown (leak check in tests).

# 9. Test Obligations (Phase 2–6 definition of done)

  1. Round-trip: every event type serializes → parses → deep-equals (property test over the union).
  2. Byte-stability: LlmHistory rebuilt from a log equals the recorded request bytes, including after ContextPruned/ContextCompacted and SteeringInjected (ADR-7).
  3. Pairing invariant: fuzzed interrupt/crash points never yield a dangling tool_use after resume recovery (§6.5).
  4. Torn-write recovery: truncating a log at every byte offset of the final line still opens cleanly (§5.3).
  5. Coalescer: delta storms produce ≤1 frame per 16 ms; settlement ordering (deltas before durable) holds under race.
  6. Version tolerance: unknown optional fields and unknown non-critical event types don't break replay (§5.4).

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