KHAELOR V1 — Event Model
Status: Phase 1 deliverable — the complete typed event vocabulary. Normative for
docs/ARCHITECTURE.mdand 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
- 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). - 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). - Rendering never lives on events. Payloads are domain data; presentation is keyed by
typeinside the TUI (OpenHandsvisualizeanti-pattern excluded, ADR-4). - Every event carries
sessionId— the cheap seam subagents-as-child-sessions depends on (ADR-16). - 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.
- Honest data only. Usage, cost, exit codes, and check results come from real sources; no event may carry fabricated numbers (Absolute Rule #4).
- Naming: past-tense facts (
ToolCompleted), not commands. Commands areKernelCommands (ARCHITECTURE.md §2.2), which cause events but are not events.
2. Envelopes
// 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)
// ───────────────────────── 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.inputis 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
- The store holds one file descriptor per open session, opened with
O_APPEND. - Each event is written as one
write()call of one complete line (single-writer process ⇒ a line is never interleaved). - Appends are serialized through a per-session write queue;
seqis assigned at enqueue and is gapless. - 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. - Flush/fsync policy:
fsyncat loop boundaries — after each tool result batch, afterModelResponseCompleted, afterContextCompacted, and on shutdown (mini'sfinallydiscipline, ADR-3). Between boundaries, OS buffering is accepted. - The log is never rewritten, truncated (except §5.3 recovery), or compacted in place.
/clearstarts a new session file. meta.jsonsidecars 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():
- Read the file; if the last line lacks a trailing
\nor failsJSON.parseor fails envelope validation, it is a torn write. - Truncate the file to the end of the last valid line (atomic:
ftruncateat the computed byte offset), after copying the torn bytes to<session-id>.jsonl.tornfor diagnostics. - Log a warning to
~/.khaelor/logs/; never to the TUI. - 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).
- After truncation, resume recovery runs (§6.5): dangling
tool_useblocks are closed withToolCancelled{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) → lineat 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 bumpvand ship a migration. - Unknown
typeat 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 acritical: trueenvelope 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.SteeringQueued→Queued instructionchip;SteeringInjectedre-parents the chip to its injection point.ModelTextBlockCompleted/ModelThinkingBlockCompleted→ assistant text / collapsed thinking items, grouped perrequestId.ToolRequested→ collapsed tool card (▸ Read src/kernel/agent.ts);ToolStarted/ToolCompleted/ToolFailed/ToolCancelledupdate its status/summary fromuimetadata;FileModifiedattaches diff stats + expandable diff.PermissionRequestedwithout a matchingPermissionGranted/Denied→ active permission panel (this is how a pending approval survives restart).ProcessStarted/ProcessExited→/processeslist state.ContextCompacted→ subtle— context compacted —divider; checkpoint inspectable via/context.Interrupted→ turn marked interrupted.TaskCompleted→ completion summary fromevidence.- 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:
UserMessageCreated.text→{role:"user", content:[{type:"text", ...}]}.- Per
requestId:ModelThinkingBlockCompleted(+signature),ModelTextBlockCompleted, andToolRequestedassemble into one{role:"assistant"}message, ordered byblockIndex. ToolCompleted.modelText/ToolFailed.modelText(is_error) /ToolCancelled.modelText→tool_resultblocks in a{role:"user"}message, in the order thetool_useblocks appeared.SteeringInjected→ the queued text is appended as an additional text block inside the tool-result user message atafterSeq(pre-model-call seam: appended to the last user message) — never a new bare user message mid-alternation (ADR-11).ContextPruned→ for each listedtoolUseId, thetool_resultcontent is replaced by the recordedplaceholderstring. Deterministic: same event ⇒ same bytes, every rebuild.ContextCompacted→ all messages derived from events withcut.fromSeq ≤ seq ≤ cut.toSeqare replaced by a single synthetic user message containingcheckpointYaml(a fixed template wrapper). Multiple compactions apply inseqorder; 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).ModelChangedmarks a cache-lineage boundary (no message mutation).- 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:
- Runtime (ADR-11): interrupt closes every in-flight or pending
toolUseIdwithToolCancelledbefore the turn ends. - Resume recovery: after replay (and §5.3 truncation), any
ToolRequestedlacking a terminal event (ToolCompleted/ToolFailed/ToolCancelled) gets a syntheticToolCancelled{reason:"resume-recovery"}appended durably at resume time — recovery is itself an event, so the next replay needs no recovery. - Compaction cuts:
ContextCompacted.cut.toSeqmay only land where everytool_useatseq ≤ toSeqhas itstool_resultatseq ≤ toSeq(OpenHands'manipulation_indicesdiscipline, 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).
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:
- 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.
- 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.
- Ordering with durables: when a durable event arrives that settles a streaming key (e.g.
ModelTextBlockCompletedfor a block with buffered deltas), the coalescer flushes immediately — buffered deltas for that key are delivered in the same frame, before the durable event indurables. The TUI thus always sees deltas-then-settlement, never settlement-then-stale-deltas. - 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.
- 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.
- Interrupt flushes:
Interruptedforces an immediate flush so the UI freezes at the last real state.
8. Event Bus API
// 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
seqorder 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)
- Round-trip: every event type serializes → parses → deep-equals (property test over the union).
- Byte-stability: LlmHistory rebuilt from a log equals the recorded request bytes, including after
ContextPruned/ContextCompactedandSteeringInjected(ADR-7). - Pairing invariant: fuzzed interrupt/crash points never yield a dangling
tool_useafter resume recovery (§6.5). - Torn-write recovery: truncating a log at every byte offset of the final line still opens cleanly (§5.3).
- Coalescer: delta storms produce ≤1 frame per 16 ms; settlement ordering (deltas before durable) holds under race.
- Version tolerance: unknown optional fields and unknown non-critical event types don't break replay (§5.4).
Author: Simon-Pierre Boucher · contact@spboucher.ai