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%
7.0 KB · 219 lines typescript
Raw Blame History
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