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%
14.9 KB · 508 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/demo.ts4 * Description: Runnable mocked-session demo (`npx tsx src/tui/demo.ts`) — the Phase 2 acceptance artifact; every number5 *              on screen comes from the scripted mock events, never fabricated by the UI.6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 */1011import { SessionEventBus } from "../session/index.js";12import { ulid } from "../shared/index.js";13import { TuiApp } from "./app.js";14import type { PermissionDecision } from "./app.js";1516const AUTO = !process.stdin.isTTY || process.env["KHAELOR_DEMO_AUTO"] === "1";17const SPEED = process.env["KHAELOR_DEMO_FAST"] === "1" ? 0.2 : 1;1819const sessionId = ulid();20const bus = new SessionEventBus({ sessionId });2122// ───────────────────────── script machinery ─────────────────────────2324class ScriptCancelled extends Error {}2526let cancelled = false;27const timers = new Set<ReturnType<typeof setTimeout>>();28const sleepers = new Set<() => void>();2930function sleep(ms: number): Promise<void> {31  return new Promise((resolve) => {32    const done = (): void => {33      timers.delete(t);34      sleepers.delete(done);35      resolve();36    };37    const t = setTimeout(done, ms * SPEED);38    timers.add(t);39    sleepers.add(done);40  });41}4243function checkpoint(): void {44  if (cancelled) throw new ScriptCancelled();45}4647function cancelScript(): void {48  cancelled = true;49  for (const t of timers) clearTimeout(t);50  timers.clear();51  // Wake pending sleeps so awaiting script steps reach their checkpoint and stop.52  for (const wake of [...sleepers]) wake();53}5455// ───────────────────────── mock session driver ─────────────────────────5657let requestSeq = 0;58let toolSeq = 0;59let busyTurn = false;60const openToolUseIds = new Set<string>();61let pendingPermission: { id: string; toolUseId: string } | null = null;6263function decidePermission(id: string, decision: PermissionDecision): void {64  if (pendingPermission === null || pendingPermission.id !== id) return;65  pendingPermission = null;66  if (decision === "deny") {67    bus.publishDurable({68      type: "permission.denied",69      payload: { permissionRequestId: id, source: "user", feedback: "User denied the command." },70    });71  } else {72    bus.publishDurable({73      type: "permission.granted",74      payload: {75        permissionRequestId: id,76        scope: decision === "allow-always" ? "always-project" : "once",77      },78    });79  }80}8182const app = new TuiApp({83  bus,84  model: "claude-sonnet-4-5",85  thinking: "adaptive",86  cwdLabel: "~/dev/khaelor",87  gitBranch: "main",88  contextWindow: 200_000,89  // Demo pricing for the mocked model — inputs to the mock, clearly not real billing.90  pricing: { inputPerMTok: 3, outputPerMTok: 15, cacheReadPerMTok: 0.3, cacheWritePerMTok: 3.75 },91  mentionProvider: (query) =>92    [93      { id: "src/session/store.ts", label: "src/session/store.ts" },94      { id: "src/session/bus.ts", label: "src/session/bus.ts" },95      { id: "src/tui/app.ts", label: "src/tui/app.ts" },96      { id: "tests/session/store.test.ts", label: "tests/session/store.test.ts" },97    ].filter((i) => query === "" || i.label.includes(query)),98  actions: {99    submit(text, opts) {100      if (opts.shell) {101        // Shell execution arrives with the tool runtime (Phase 5); the mock102        // records the intent honestly instead of faking output.103        bus.publishDurable({104          type: "user.steering-queued",105          payload: { text: `(shell) ${text}` },106        });107        return;108      }109      if (busyTurn) {110        bus.publishDurable({ type: "user.steering-queued", payload: { text } });111        return;112      }113      bus.publishDurable({ type: "user.message-created", payload: { text, mentions: [] } });114      void scriptedReply();115    },116    interrupt() {117      if (!busyTurn) return;118      cancelScript();119      bus.publishDurable({120        type: "user.interrupted",121        payload: { scope: "turn", pendingToolUseIds: [...openToolUseIds] },122      });123      for (const toolUseId of openToolUseIds) {124        bus.publishDurable({125          type: "tool.cancelled",126          payload: {127            toolUseId,128            reason: "interrupted",129            modelText: "[Tool execution cancelled by user]",130          },131        });132      }133      openToolUseIds.clear();134      busyTurn = false;135    },136    permission: decidePermission,137    quit() {138      shutdown(0);139    },140  },141});142143function startRequest(purpose: "main" = "main"): string {144  requestSeq += 1;145  const requestId = `req-${requestSeq}`;146  bus.publishDurable({147    type: "model.request-started",148    payload: {149      requestId,150      model: "claude-sonnet-4-5",151      purpose,152      contextStats: { estimatedInputTokens: 4200 + requestSeq * 900, sections: [] },153    },154  });155  return requestId;156}157158async function streamText(159  requestId: string,160  blockIndex: number,161  text: string,162  onProgress?: (fraction: number) => void,163): Promise<void> {164  // ~30 deltas/s: one delta every 33 ms.165  const chunk = 7;166  for (let i = 0; i < text.length; i += chunk) {167    checkpoint();168    bus.publishEphemeral({169      type: "model.text-delta",170      payload: { requestId, blockIndex, text: text.slice(i, i + chunk) },171    });172    onProgress?.(i / text.length);173    await sleep(33);174  }175  checkpoint();176  bus.publishDurable({177    type: "model.text-block-completed",178    payload: { requestId, blockIndex, text },179  });180}181182interface ToolRun {183  requestId: string;184  name: "read" | "grep" | "glob" | "edit" | "write" | "bash" | "process";185  input: Record<string, unknown>;186  durationMs: number;187  summary: string;188  kind: "read" | "search" | "edit" | "exec" | "process";189  outputChunks?: string[];190  extraUi?: { diffStats?: { added: number; removed: number }; exitCode?: number; matchCount?: number };191}192193async function runTool(run: ToolRun): Promise<string> {194  toolSeq += 1;195  const toolUseId = `tool-${toolSeq}`;196  openToolUseIds.add(toolUseId);197  bus.publishEphemeral({198    type: "model.tool-call-started",199    payload: { requestId: run.requestId, blockIndex: toolSeq + 10, toolUseId, toolName: run.name },200  });201  bus.publishDurable({202    type: "tool.requested",203    payload: {204      requestId: run.requestId,205      blockIndex: toolSeq + 10,206      toolUseId,207      toolName: run.name,208      input: run.input,209    },210  });211  bus.publishDurable({ type: "tool.started", payload: { toolUseId, toolName: run.name } });212213  const chunks = run.outputChunks ?? [];214  const perChunk = chunks.length > 0 ? run.durationMs / chunks.length : run.durationMs;215  if (chunks.length > 0) {216    for (const chunk of chunks) {217      checkpoint();218      await sleep(perChunk);219      bus.publishEphemeral({ type: "tool.output", payload: { toolUseId, chunk } });220    }221  } else {222    await sleep(run.durationMs);223  }224  checkpoint();225226  openToolUseIds.delete(toolUseId);227  bus.publishDurable({228    type: "tool.completed",229    payload: {230      toolUseId,231      modelText: `(mock result of ${run.name})`,232      durationMs: run.durationMs,233      ui: { kind: run.kind, summary: run.summary, ...(run.extraUi ?? {}) },234    },235  });236  return toolUseId;237}238239// ───────────────────────── the scripted session ─────────────────────────240241const RESPONSE_MARKDOWN = `I found the failure point in \`SessionStore.append\` — writes are not retried on transient \`EAGAIN\`. Here is the plan:242243## Plan2442451. Wrap the journal write in a bounded retry helper2462. Keep the **fsync** on the final attempt only2473. Add a regression test for the *torn-write* recovery path248249\`\`\`ts250async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {251  let lastError: unknown;252  for (let i = 0; i < attempts; i++) {253    try {254      return await fn();255    } catch (error) {256      lastError = error;257      await delay(2 ** i * 10); // 10ms, 20ms, 40ms258    }259  }260  throw lastError;261}262\`\`\`263264| step | file | risk |265| --- | --- | --- |266| retry helper | src/shared/retry.ts | low |267| journal wiring | src/session/store.ts | medium |268| recovery test | tests/session/store.test.ts | low |269270Starting with the store change now.`;271272const FINAL_MARKDOWN = `# Done273274All checks passed. The store now retries transient write failures with exponential backoff, and the recovery path is covered by a regression test.275276\`\`\`bash277# verify locally278npm test -- tests/session/store.test.ts   # 18 tests, 0.4s279\`\`\`280281Next I would wire the same helper into the checkpoint writer — it shares the failure mode and the fix is mechanical, but it touches the compaction path so it deserves its own review.`;282283const EDIT_DIFF = `@@ -84,7 +84,9 @@ export class SessionStore {284-  private append(line: string): void {285-    this.fd.writeSync(line);286+  private async append(line: string): Promise<void> {287+    await withRetry(() => this.fd.write(line));288+    this.journalLength += 1;289   }`;290291async function mainScript(): Promise<void> {292  await sleep(700);293  checkpoint();294  busyTurn = true;295  bus.publishDurable({296    type: "user.message-created",297    payload: { text: "add retry logic to the session store, then run the tests", mentions: [] },298  });299  const req1 = startRequest();300301  // Thinking (status line reads `● Thinking · Xs` from real deltas).302  for (let i = 0; i < 8; i++) {303    checkpoint();304    bus.publishEphemeral({305      type: "model.thinking-delta",306      payload: { requestId: req1, blockIndex: 0, text: "…" },307    });308    await sleep(150);309  }310311  await runTool({312    requestId: req1,313    name: "read",314    input: { file_path: "src/session/store.ts" },315    durationMs: 420,316    summary: "Read src/session/store.ts · 212 lines",317    kind: "read",318  });319  await runTool({320    requestId: req1,321    name: "grep",322    input: { pattern: "retry" },323    durationMs: 300,324    summary: 'Search "retry" · 6 matches',325    kind: "search",326    extraUi: { matchCount: 6 },327  });328329  await streamText(req1, 1, RESPONSE_MARKDOWN);330  await sleep(300);331332  // Permission round: panel appears; Enter/A/Esc decide; auto-allow in AUTO mode.333  const permissionId = `perm-${requestSeq}`;334  pendingPermission = { id: permissionId, toolUseId: `tool-${toolSeq + 1}` };335  bus.publishDurable({336    type: "permission.requested",337    payload: {338      permissionRequestId: permissionId,339      toolUseId: `tool-${toolSeq + 1}`,340      capability: "process.execute",341      descriptor: "npm install p-retry",342      suggestion: { capability: "process.execute", pattern: "npm install *" },343    },344  });345  const waitStart = Date.now();346  while (pendingPermission !== null) {347    checkpoint();348    if (AUTO && Date.now() - waitStart > 2500 * SPEED) {349      decidePermission(permissionId, "allow-once");350      break;351    }352    await sleep(50);353  }354  await sleep(150);355  checkpoint();356357  await runTool({358    requestId: req1,359    name: "bash",360    input: { command: "npm install p-retry" },361    durationMs: 1100,362    summary: "Run npm install p-retry · exit 0 · 1.1s",363    kind: "exec",364    extraUi: { exitCode: 0 },365    outputChunks: ["added 1 package in 0.9s\n"],366  });367368  // The edit: one-liner + ✓ summary + expandable diff (`d`).369  const editToolId = await runTool({370    requestId: req1,371    name: "edit",372    input: { file_path: "src/session/store.ts" },373    durationMs: 380,374    summary: "Edit src/session/store.ts · +14 −3",375    kind: "edit",376    extraUi: { diffStats: { added: 14, removed: 3 } },377  });378  bus.publishDurable({379    type: "file.modified",380    payload: {381      path: "src/session/store.ts",382      operation: "edit",383      diffStats: { added: 14, removed: 3 },384      diff: EDIT_DIFF,385      toolUseId: editToolId,386    },387  });388  await sleep(300);389390  // npm test with streaming output; Ctrl+T expansion is exercised in AUTO mode.391  const testRun = runTool({392    requestId: req1,393    name: "bash",394    input: { command: "npm test" },395    durationMs: 2200,396    summary: "Run npm test · passed · 2.2s",397    kind: "exec",398    extraUi: { exitCode: 0 },399    outputChunks: [400      "> vitest run\n",401      " ✓ tests/session/store.test.ts (18 tests)\n",402      " ✓ tests/session/bus.test.ts (12 tests)\n",403      " ✓ tests/session/projections.test.ts (21 tests)\n",404      "Test Files  3 passed (3)\n",405      "     Tests  51 passed (51)\n",406    ],407  });408  if (AUTO) {409    await sleep(700);410    app.pressKey({ type: "ctrl", ch: "t" }); // expand the live tool tail411  }412  await testRun;413414  bus.publishDurable({415    type: "model.response-completed",416    payload: {417      requestId: req1,418      stopReason: "tool_use",419      usage: { inputTokens: 18_450, outputTokens: 1_240, cacheReadTokens: 41_020, cacheWriteTokens: 3_800 },420      durationMs: 9_400,421    },422  });423  await sleep(400);424  checkpoint();425426  // Final response — interrupted mid-stream by Esc (scripted in AUTO mode,427  // fired at a stream-progress point so it never races stream completion).428  const req2 = startRequest();429  let escSent = false;430  await streamText(req2, 0, FINAL_MARKDOWN, (fraction) => {431    if (AUTO && !escSent && fraction >= 0.45) {432      escSent = true;433      app.pressKey({ type: "esc" });434    }435  });436  bus.publishDurable({437    type: "model.response-completed",438    payload: {439      requestId: req2,440      stopReason: "end_turn",441      usage: { inputTokens: 21_300, outputTokens: 460, cacheReadTokens: 44_800, cacheWriteTokens: 0 },442      durationMs: 3_100,443    },444  });445  busyTurn = false;446}447448/** Post-script interactivity: a submitted message gets a small honest scripted reply. */449async function scriptedReply(): Promise<void> {450  busyTurn = true;451  cancelled = false;452  const requestId = startRequest();453  try {454    await sleep(400);455    await streamText(456      requestId,457      0,458      "This is a **mocked** session — the reply is scripted. The real Anthropic integration arrives in Phase 3.",459    );460    bus.publishDurable({461      type: "model.response-completed",462      payload: {463        requestId,464        stopReason: "end_turn",465        usage: { inputTokens: 900, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 0 },466        durationMs: 800,467      },468    });469  } catch (error) {470    if (!(error instanceof ScriptCancelled)) throw error;471  }472  busyTurn = false;473}474475// ───────────────────────── lifecycle ─────────────────────────476477let exiting = false;478function shutdown(code: number): void {479  if (exiting) return;480  exiting = true;481  cancelScript();482  app.stop();483  process.stdout.write("khaelor demo complete\n");484  process.exit(code);485}486487async function main(): Promise<void> {488  await app.start();489  try {490    await mainScript();491  } catch (error) {492    if (!(error instanceof ScriptCancelled)) {493      app.stop();494      throw error;495    }496  }497  if (AUTO) {498    // Headless / CI: linger briefly so the final frame settles, then exit.499    cancelled = false;500    await sleep(1200);501    shutdown(0);502  }503  // Interactive: stay alive — type, mention (@), open palettes (/, Ctrl+K),504  // expand the diff (d), inspect cost (/cost), quit with Ctrl+D or /quit.505}506507void main();508