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%
1/**2 * KHAELOR3 * File: src/session/events.ts4 * Description: The complete typed event catalog — envelopes, 38 event types, durable/ephemeral split (EVENT_MODEL.md).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910/** Schema version written on every durable JSONL line (EVENT_MODEL.md §5.4). */11export const EVENT_SCHEMA_VERSION = 1 as const;12export type EventSchemaVersion = typeof EVENT_SCHEMA_VERSION;1314// ───────────────────────────── envelopes ─────────────────────────────1516/** Durable envelope — one JSONL line per event. */17export interface Durable<T extends string, P> {18 v: EventSchemaVersion; // schema version of this event line19 id: string; // ULID — globally unique, time-ordered20 sessionId: string;21 seq: number; // monotonic per session, gapless, assigned at append time22 ts: number; // epoch milliseconds23 parentId?: string; // reserved, always absent in V1 (ADR-3: linear log, tree-ready)24 type: T;25 payload: P;26}2728/** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */29export interface Ephemeral<T extends string, P> {30 id: string; // ULID (correlation/debugging)31 sessionId: string;32 ts: number;33 type: T;34 payload: P;35}3637// ───────────────────────── shared payload types ─────────────────────────3839export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal";4041export interface ModelUsage {42 // real API fields only43 inputTokens: number;44 outputTokens: number;45 cacheReadTokens: number;46 cacheWriteTokens: number;47}4849export interface DiffStats {50 added: number;51 removed: number;52}5354export interface CheckResult {55 command: string;56 exitCode: number;57 summary: string; // e.g. "148 passed", "typecheck passed"58 durationMs: number;59}6061export interface CompletionEvidence {62 // CLAUDE.md §1763 objective: string;64 changedFiles: string[];65 checks: CheckResult[];66 unresolvedIssues: string[];67}6869export interface GitBaseline {70 branch: string;71 dirtyFiles: string[];72 untrackedFiles: string[];73 diffHash: string; // hash of `git diff` output at capture time74}7576export type ModelErrorKind =77 | "retryable"78 | "context-overflow"79 | "auth"80 | "invalid-request"81 | "cancelled";8283export type ToolName = "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";8485// ─────────────────────── session lifecycle (durable) ───────────────────────8687export type SessionStarted = Durable<88 "session.started",89 {90 title: string;91 projectHash: string;92 workingDirectory: string;93 gitBranch: string | null;94 model: string;95 auxModel: string;96 khaelorVersion: string;97 }98>;99100export type SessionResumed = Durable<101 "session.resumed",102 {103 khaelorVersion: string;104 replayedSeq: number; // highest seq replayed105 model: string; // active model after resume (may differ — swappable)106 toolNames: ToolName[]; // verified add-only vs. original set (ADR-3 resume contract)107 }108>;109110export type SessionRenamed = Durable<"session.renamed", { title: string }>;111112export type ModelChanged = Durable<113 "session.model-changed",114 {115 from: string;116 to: string;117 reason: "user" | "config";118 }119>;120121export type BaselineRecorded = Durable<122 "git.baseline-recorded",123 {124 when: "session-start" | "pre-first-edit";125 baseline: GitBaseline;126 }127>;128129// ───────────────────────── user input (durable) ─────────────────────────130131export type UserMessageCreated = Durable<132 "user.message-created",133 {134 text: string; // byte-exact — enters LLM history verbatim135 mentions: { path: string; range?: { start: number; end: number } }[];136 }137>;138139export type SteeringQueued = Durable<"user.steering-queued", { text: string }>;140141export type SteeringInjected = Durable<142 "user.steering-injected",143 {144 queuedEventId: string; // id of the SteeringQueued event145 seam: "post-tool-batch" | "pre-model-call";146 afterSeq: number; // injection position in history — makes replay exact (ADR-7)147 }148>;149150export type Interrupted = Durable<151 "user.interrupted",152 {153 scope: "turn"; // V1: Esc aborts the turn (model stream + in-flight tools)154 pendingToolUseIds: string[]; // tools that will be closed via ToolCancelled155 }156>;157158// ──────────────────────────── model stream ────────────────────────────159160export type ModelRequestStarted = Durable<161 "model.request-started",162 {163 requestId: string; // correlates all blocks/usage of this call164 model: string;165 purpose: "main" | "compaction" | "verification-nudge";166 contextStats: {167 // for /context history — estimates labeled as such168 estimatedInputTokens: number;169 sections: { name: string; estimatedTokens: number }[];170 };171 }172>;173174export type ModelTextDelta = Ephemeral<175 "model.text-delta",176 {177 requestId: string;178 blockIndex: number;179 text: string;180 }181>;182183export type ModelThinkingDelta = Ephemeral<184 "model.thinking-delta",185 {186 requestId: string;187 blockIndex: number;188 text: string;189 }190>;191192export type ToolCallStarted = Ephemeral<193 "model.tool-call-started",194 {195 requestId: string;196 blockIndex: number;197 toolUseId: string;198 toolName: ToolName;199 }200>;201202export type ToolInputDelta = Ephemeral<203 "model.tool-input-delta",204 {205 requestId: string;206 blockIndex: number;207 toolUseId: string;208 partialJson: string;209 }210>;211212export type ModelTextBlockCompleted = Durable<213 "model.text-block-completed",214 {215 requestId: string;216 blockIndex: number;217 text: string; // byte-exact settled block218 }219>;220221export type ModelThinkingBlockCompleted = Durable<222 "model.thinking-block-completed",223 {224 requestId: string;225 blockIndex: number;226 thinking: string;227 signature: string; // required for byte-exact API replay in tool loops228 }229>;230231export type ToolRequested = Durable<232 "tool.requested",233 {234 requestId: string;235 blockIndex: number;236 toolUseId: string; // Anthropic tool_use id — pairing key (§6.5)237 toolName: ToolName;238 input: unknown; // complete parsed input — byte-exact via canonical JSON (§5.1)239 }240>;241242export type ModelResponseCompleted = Durable<243 "model.response-completed",244 {245 requestId: string;246 stopReason: StopReason;247 usage: ModelUsage; // REAL API usage — sole source for cost/compaction248 durationMs: number;249 }250>;251252export type ModelRequestFailed = Durable<253 "model.request-failed",254 {255 requestId: string;256 kind: ModelErrorKind;257 message: string; // redacted — never headers/keys258 status?: number;259 retriesExhausted: boolean;260 }261>;262263// ─────────────────────── permissions (durable) ───────────────────────264265export type PermissionRequested = Durable<266 "permission.requested",267 {268 permissionRequestId: string;269 toolUseId: string;270 capability: string; // Capability271 descriptor: string; // human-meaningful: "Run `npm install` in ~/dev/project"272 suggestion?: { capability: string; pattern: string }; // "always allow git push *" (ADR-9)273 }274>;275276export type PermissionGranted = Durable<277 "permission.granted",278 {279 permissionRequestId: string;280 scope: "once" | "always-project"; // "always" also persists a config rule (ADR-9)281 }282>;283284export type PermissionDenied = Durable<285 "permission.denied",286 {287 permissionRequestId: string;288 source: "user" | "policy" | "hardline" | "timeout"; // silence is not consent (ADR-9)289 feedback: string; // returned to the model as the tool observation290 }291>;292293// ─────────────── tool execution (durable + one ephemeral) ───────────────294295export type ToolApproved = Durable<296 "tool.approved",297 {298 toolUseId: string;299 via: "policy-allow" | "user-once" | "user-always" | "rule";300 }301>;302303export type ToolStarted = Durable<304 "tool.started",305 {306 toolUseId: string;307 toolName: ToolName;308 }309>;310311export type ToolOutput = Ephemeral<312 "tool.output",313 {314 toolUseId: string;315 chunk: string; // live chunk, ANSI-stripped for TUI316 }317>;318319export type ToolCompleted = Durable<320 "tool.completed",321 {322 toolUseId: string;323 modelText: string; // byte-exact tool_result content (budget-truncated w/ markers)324 spillFile?: string; // full output under ~/.khaelor/spill/ (ADR-8)325 durationMs: number;326 ui: {327 // presentation DATA, not presentation (Rule 3)328 kind: "read" | "search" | "edit" | "exec" | "process";329 summary: string; // e.g. `Search "ContextEngine" · 14 matches`330 diffStats?: DiffStats;331 exitCode?: number;332 matchCount?: number;333 };334 }335>;336337export type ToolFailed = Durable<338 "tool.failed",339 {340 toolUseId: string;341 modelText: string; // byte-exact is_error tool_result content342 errorKind:343 | "invalid-input"344 | "not-found"345 | "ambiguous-edit"346 | "exec-error"347 | "timeout"348 | "permission-denied"349 | "internal";350 durationMs: number;351 }352>;353354export type ToolCancelled = Durable<355 "tool.cancelled",356 {357 toolUseId: string;358 reason: "interrupted" | "resume-recovery" | "shutdown";359 modelText: string; // synthetic result, e.g. "[Tool execution cancelled by user]"360 }361>;362363// ─────────────────────── file activity (durable) ───────────────────────364365export type FileRead = Durable<366 "file.read",367 {368 path: string; // relative to workspace cwd369 range?: { start: number; end: number };370 bytes: number;371 mtimeMs: number; // external-modification detection on later writes372 toolUseId: string;373 }374>;375376export type FileModified = Durable<377 "file.modified",378 {379 path: string;380 operation: "write" | "edit";381 diffStats: DiffStats;382 diff?: string; // unified diff, capped (default 32 KiB) with truncation marker383 toolUseId: string;384 }385>;386387// ─────────────── processes (durable + one ephemeral) ───────────────388389export type ProcessStarted = Durable<390 "process.started",391 {392 processId: string; // KHAELOR id (stable across PID reuse)393 pid: number;394 command: string;395 cwd: string;396 name?: string;397 toolUseId: string;398 }399>;400401export type ProcessOutput = Ephemeral<402 "process.output",403 {404 processId: string;405 stream: "stdout" | "stderr";406 chunk: string;407 }408>;409410export type ProcessExited = Durable<411 "process.exited",412 {413 processId: string;414 exitCode: number | null; // null = signal-killed415 cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed";416 durationMs: number;417 }418>;419420// ─────────────────────── context engine (durable) ───────────────────────421422export type ContextPruned = Durable<423 "context.pruned",424 {425 toolUseIds: string[]; // results blanked with the FIXED placeholder string426 placeholder: string; // recorded so replay is byte-exact even if default changes427 tokensReclaimedEstimate: number;428 }429>;430431export type ContextCompacted = Durable<432 "context.compacted",433 {434 checkpointYaml: string; // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim435 cut: { fromSeq: number; toSeq: number }; // replaced range — pairing-safe boundary (§6.5)436 trigger: "proactive-token-budget" | "reactive-overflow" | "user-command";437 tokensBefore: number; // from real usage accounting438 summaryModel: string; // the auxModel used439 }440>;441442// ─────────────────────── completion (durable) ───────────────────────443444export type VerificationRequested = Durable<445 "task.verification-requested",446 {447 attempt: 1 | 2;448 detectedChecks: string[]; // e.g. ["npm test", "npx tsc --noEmit"]449 withheldCandidateSeq: number; // seq of the withheld answer's final text block (ADR-12)450 }451>;452453export type TaskCompleted = Durable<454 "task.completed",455 {456 evidence: CompletionEvidence;457 }458>;459460export type TaskFailed = Durable<461 "task.failed",462 {463 reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned";464 detail: string;465 }466>;467468// ───────────────────────────── unions ─────────────────────────────469470export type DurableEvent =471 | SessionStarted472 | SessionResumed473 | SessionRenamed474 | ModelChanged475 | BaselineRecorded476 | UserMessageCreated477 | SteeringQueued478 | SteeringInjected479 | Interrupted480 | ModelRequestStarted481 | ModelTextBlockCompleted482 | ModelThinkingBlockCompleted483 | ToolRequested484 | ModelResponseCompleted485 | ModelRequestFailed486 | PermissionRequested487 | PermissionGranted488 | PermissionDenied489 | ToolApproved490 | ToolStarted491 | ToolCompleted492 | ToolFailed493 | ToolCancelled494 | FileRead495 | FileModified496 | ProcessStarted497 | ProcessExited498 | ContextPruned499 | ContextCompacted500 | VerificationRequested501 | TaskCompleted502 | TaskFailed;503504export type EphemeralEvent =505 | ModelTextDelta506 | ModelThinkingDelta507 | ToolCallStarted508 | ToolInputDelta509 | ToolOutput510 | ProcessOutput;511512export type KhaelorEvent = DurableEvent | EphemeralEvent;513514export type DurableEventType = DurableEvent["type"];515export type EphemeralEventType = EphemeralEvent["type"];516517/** What producers hand to the bus/store; envelope fields are assigned at append time. */518export type DurableEventInput = {519 [T in DurableEventType]: { type: T; payload: Extract<DurableEvent, { type: T }>["payload"] };520}[DurableEventType];521522/** What producers hand to the bus for ephemeral publication. */523export type EphemeralEventInput = {524 [T in EphemeralEventType]: { type: T; payload: Extract<EphemeralEvent, { type: T }>["payload"] };525}[EphemeralEventType];526527// ─────────────── durable/ephemeral classification (runtime) ───────────────528529const DURABLE_TYPE_LIST = [530 "session.started",531 "session.resumed",532 "session.renamed",533 "session.model-changed",534 "git.baseline-recorded",535 "user.message-created",536 "user.steering-queued",537 "user.steering-injected",538 "user.interrupted",539 "model.request-started",540 "model.text-block-completed",541 "model.thinking-block-completed",542 "tool.requested",543 "model.response-completed",544 "model.request-failed",545 "permission.requested",546 "permission.granted",547 "permission.denied",548 "tool.approved",549 "tool.started",550 "tool.completed",551 "tool.failed",552 "tool.cancelled",553 "file.read",554 "file.modified",555 "process.started",556 "process.exited",557 "context.pruned",558 "context.compacted",559 "task.verification-requested",560 "task.completed",561 "task.failed",562] as const satisfies readonly DurableEventType[];563564const EPHEMERAL_TYPE_LIST = [565 "model.text-delta",566 "model.thinking-delta",567 "model.tool-call-started",568 "model.tool-input-delta",569 "tool.output",570 "process.output",571] as const satisfies readonly EphemeralEventType[];572573type AssertNever<T extends never> = T;574/** Compile-time exhaustiveness guards: fail to compile if a union member is missing from a list. */575export type _DurableListIsExhaustive = AssertNever<576 Exclude<DurableEventType, (typeof DURABLE_TYPE_LIST)[number]>577>;578export type _EphemeralListIsExhaustive = AssertNever<579 Exclude<EphemeralEventType, (typeof EPHEMERAL_TYPE_LIST)[number]>580>;581582/** The 32 durable event types (EVENT_MODEL.md §3). */583export const DURABLE_EVENT_TYPES: ReadonlySet<DurableEventType> = new Set(DURABLE_TYPE_LIST);584585/** The 6 ephemeral event types (EVENT_MODEL.md §3). */586export const EPHEMERAL_EVENT_TYPES: ReadonlySet<EphemeralEventType> = new Set(EPHEMERAL_TYPE_LIST);587588export function isDurableEventType(type: string): type is DurableEventType {589 return DURABLE_EVENT_TYPES.has(type as DurableEventType);590}591592export function isEphemeralEventType(type: string): type is EphemeralEventType {593 return EPHEMERAL_EVENT_TYPES.has(type as EphemeralEventType);594}595596/** Runtime classification of a full event by its `type` string. */597export function isDurableEvent(event: KhaelorEvent): event is DurableEvent {598 return isDurableEventType(event.type);599}600