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: prototypes/tui-spike/shared/demo.ts4 * Description: Shared demo model — mocked agent-turn event script (markdown stream at ~30 deltas/s,5 * tool rows, status line) plus composer state. Both candidates render this same model.6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 */1011import { EventEmitter } from "node:events";1213export interface StatusState {14 text: string;15 since: number;16}1718export interface Snapshot {19 tail: string; // unsettled markdown tail (raw)20 status: StatusState | null;21 composer: string;22 turn: number;23}2425export type KeyEvent =26 | { type: "char"; ch: string }27 | { type: "backspace" }28 | { type: "enter" }29 | { type: "esc" }30 | { type: "ctrlc" }31 | { type: "other" };3233/** Inner text of the fenced code block — used by the driver as the settled-content byte-identity probe. */34export const FENCE_TEXT = [35 "async append(event: SessionEvent): Promise<void> {",36 " for (let attempt = 0; attempt < 3; attempt++) {",37 " try {",38 " await this.journal.write(encode(event));",39 " return;",40 " } catch (err) {",41 " if (!isTransient(err)) throw err;",42 " await delay(2 ** (2 * attempt + 1));",43 " }",44 " }",45 " this.bus.emit({ type: 'WriteFailed', event });",46 "}",47].join("\n");4849/** ~2 KB of markdown-ish response text streamed each turn. */50export const MARKDOWN: string = [51 "## Session store retry logic",52 "",53 "The failure point is in `SessionStore.append` — journal writes are",54 "not retried on transient `EAGAIN`, so a busy filesystem drops the",55 "event and the session log diverges from what the user saw on",56 "screen. The fix wraps the journal write in a bounded retry loop",57 "with exponential backoff, and keeps the event log append-only.",58 "",59 "Key changes:",60 "",61 "- `append` now retries up to 3 times on `EAGAIN` / `EBUSY`",62 "- backoff is 2 ms, 8 ms, 32 ms — bounded, never user-visible",63 "- a `WriteFailed` event is emitted only after the final attempt",64 "- no partial frames are ever kept in the journal",65 "",66 "```ts",67 FENCE_TEXT,68 "```",69 "",70 "The retry loop is deliberately synchronous with the event bus:",71 "observations settle in order, and the live region never shows a",72 "frame that the journal has not accepted. Interruption is safe",73 "because a cancelled write is indistinguishable from a write that",74 "never started — the journal either has the full event or nothing.",75 "",76 "Two details worth calling out for review:",77 "",78 "1. `isTransient` treats `EAGAIN`, `EBUSY` and `EINTR` as retryable",79 "2. the backoff delays are cumulative worst-case 42 ms, well under",80 " the 100 ms budget for a settled-event flush",81 "",82 "With this in place the flaky `store.test.ts` failures stop",83 "reproducing under load, and the append path stays inside the hot",84 "loop budget. The remaining work is to surface `WriteFailed` in",85 "the status bar so a dying disk is visible before data is lost.",86 "",87].join("\n");8889const DELTA_CHARS = 8; // ~8 chars per delta90const DELTA_INTERVAL_MS = 33; // ~30 deltas/second9192const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));9394/**95 * Drives the mocked agent turn and owns composer state.96 * Emits: 'settled' (lines: string[]) — block to print once to scrollback;97 * 'dirty' () — live region must repaint.98 */99export class DemoModel extends EventEmitter {100 tail = "";101 status: StatusState | null = null;102 composer = "";103 turn = 0;104 settledCount = 0;105 private stopped = false;106107 snapshot(): Snapshot {108 return { tail: this.tail, status: this.status, composer: this.composer, turn: this.turn };109 }110111 start(): void {112 void this.loop();113 }114115 stop(): void {116 this.stopped = true;117 }118119 handleKey(k: KeyEvent): void {120 if (k.type === "char") this.composer += k.ch;121 else if (k.type === "backspace") this.composer = this.composer.slice(0, -1);122 else if (k.type === "enter") {123 if (this.composer.length > 0) {124 this.settle(["", "❯ " + this.composer, ""]);125 this.composer = "";126 }127 }128 this.emit("dirty");129 }130131 private settle(lines: string[]): void {132 this.settledCount++;133 this.emit("settled", lines);134 }135136 private setStatus(text: string | null): void {137 this.status = text === null ? null : { text, since: Date.now() };138 this.emit("dirty");139 }140141 private async loop(): Promise<void> {142 while (!this.stopped) {143 await this.runTurn();144 }145 }146147 private async runTurn(): Promise<void> {148 this.turn++;149 this.setStatus("Reading src/session/store.ts");150 await sleep(500);151 if (this.stopped) return;152 this.settle([" ▸ Read src/session/store.ts · 212 lines"]);153 await sleep(250);154 if (this.stopped) return;155 this.settle([' ▸ Search "retry" · 6 matches']);156 this.setStatus("Thinking");157 await sleep(400);158 if (this.stopped) return;159160 this.setStatus("Writing");161 for (let i = 0; i < MARKDOWN.length; i += DELTA_CHARS) {162 if (this.stopped) return;163 this.tail += MARKDOWN.slice(i, i + DELTA_CHARS);164 this.scanSettle();165 this.emit("dirty");166 if (i / DELTA_CHARS === 120) {167 // a tool call completes while text is still streaming168 this.settle([" ▸ Edit src/context/engine.ts · +31 −12"]);169 this.setStatus("Editing src/context/engine.ts");170 }171 await sleep(DELTA_INTERVAL_MS);172 }173174 // flush the remaining tail as settled175 if (this.tail.trim().length > 0) this.settle(this.tail.split("\n"));176 this.tail = "";177 this.settle([" ▸ Run npm test · passed · 4.2s", ""]);178 this.setStatus(null);179 await sleep(700);180 }181182 /**183 * Settled-block scanner (TUI_DESIGN §8): a top-level block settles at a blank line184 * outside a code fence. Only complete (newline-terminated) lines are considered.185 */186 private scanSettle(): void {187 const lines = this.tail.split("\n");188 let fence = false;189 let lastBoundary = -1;190 for (let i = 0; i < lines.length - 1; i++) {191 if (/^```/.test(lines[i].trim())) fence = !fence;192 if (!fence && lines[i].trim() === "") lastBoundary = i;193 }194 if (lastBoundary >= 0) {195 const block = lines.slice(0, lastBoundary + 1);196 if (block.some((l) => l.trim() !== "")) this.settle(block);197 this.tail = lines.slice(lastBoundary + 1).join("\n");198 }199 }200}201202/** Minimal raw-mode key decoder for the spike (arrows and other CSI input are ignored). */203export function decodeKeys(buf: Buffer): KeyEvent[] {204 const s = buf.toString("utf8");205 if (s === "\x1b") return [{ type: "esc" }];206 if (s.startsWith("\x1b")) return [{ type: "other" }];207 const out: KeyEvent[] = [];208 for (const ch of s) {209 if (ch === "\x03") out.push({ type: "ctrlc" });210 else if (ch === "\r" || ch === "\n") out.push({ type: "enter" });211 else if (ch === "\x7f" || ch === "\b") out.push({ type: "backspace" });212 else if (ch >= " ") out.push({ type: "char", ch });213 }214 return out;215}216217export const STATUS_BAR = " main +2 −0 │ claude-sonnet │ context 31% │ $0.42";218export const TAIL_CAP_LINES = 12;219