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%
12.6 KB · 378 lines typescript
Raw Blame History
1#!/usr/bin/env node2/**3 * KHAELOR4 * File: src/cli/main.ts5 * Description: The khaelor bin entry — argv parsing, config resolution, fast-startup interactive wiring, --print mode.6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 */1011import { realpathSync } from "node:fs";12import { homedir } from "node:os";13import { join } from "node:path";14import { pathToFileURL } from "node:url";15import { loadConfig } from "../config/index.js";16import type { ResolvedConfig } from "../config/index.js";17import { resolveContextWindow } from "../context/index.js";18import type { PermissionAnswer, PermissionAsker, PermissionPrompt } from "../permissions/index.js";19import { RepositoryMap } from "../repository/index.js";20import { defaultSessionsDir } from "../session/index.js";21import { TuiApp, fuzzyFilter } from "../tui/index.js";22import type { PaletteItem, PermissionDecision } from "../tui/index.js";23import {24  KHAELOR_VERSION,25  helpText,26  missingApiKeyMessage,27  parseArgs,28} from "./args.js";29import type { ParsedArgs } from "./args.js";30import { registerCliCommands } from "./commands.js";31import { assembleEngine, openSessionContext } from "./engine.js";32import type { Engine } from "./engine.js";33import {34  QuitGuard,35  installCrashHandlers,36  installSignalShutdown,37  quitConfirmationLines,38} from "./lifecycle.js";39import { FileLogger, defaultLogDir } from "./logger.js";40import { runPrintMode } from "./print.js";41import { renderResumedTranscript } from "./sessions.js";4243// ───────────────────────── permission bridge ─────────────────────────4445/**46 * Bridges PermissionService.ask to the TUI permission panel: the service47 * publishes PermissionRequested (the panel renders from that durable event);48 * the user's key press arrives via TuiAppActions.permission and resolves the49 * pending ask. Silence never resolves to allow.50 */51class TuiPermissionAsker implements PermissionAsker {52  #pending: { resolve: (answer: PermissionAnswer) => void; prompt: PermissionPrompt } | null =53    null;5455  ask(prompt: PermissionPrompt): Promise<PermissionAnswer> {56    return new Promise((resolve) => {57      this.#pending = { resolve, prompt };58    });59  }6061  answer(_requestId: string, decision: PermissionDecision): void {62    const pending = this.#pending;63    if (pending === null) return;64    this.#pending = null;65    if (decision === "deny") {66      pending.resolve({ kind: "deny" });67      return;68    }69    if (decision === "allow-always") {70      const pattern = pending.prompt.requests.flatMap((r) => r.alwaysPatterns)[0];71      pending.resolve(72        pattern !== undefined ? { kind: "allow-always", pattern } : { kind: "allow-once" },73      );74      return;75    }76    pending.resolve({ kind: "allow-once" });77  }78}7980// ───────────────────────── interactive session ─────────────────────────8182type SessionEnd = { kind: "quit" } | { kind: "new" } | { kind: "resume"; sessionId: string };8384interface InteractiveOptions {85  config: ResolvedConfig;86  logger: FileLogger;87  cwd: string;88  sessionsDir: string;89  resumeId?: string;90}9192function abbreviateHome(path: string): string {93  const home = homedir();94  return path.startsWith(home) ? `~${path.slice(home.length)}` : path;95}9697async function listModelsLive(config: ResolvedConfig): Promise<{ id: string; displayName?: string }[]> {98  // Lazy SDK import — never in the boot path (ARCHITECTURE.md §12.1).99  const { default: Anthropic } = await import("@anthropic-ai/sdk");100  const sdk = new Anthropic({ apiKey: config.apiKey ?? "", maxRetries: 0, timeout: 5_000 });101  const models: { id: string; displayName?: string }[] = [];102  for await (const model of sdk.models.list()) {103    models.push({ id: model.id, displayName: model.display_name });104    if (models.length >= 25) break;105  }106  return models;107}108109/** Run one interactive session; resolves with how the next iteration should start. */110async function runInteractiveSession(options: InteractiveOptions): Promise<SessionEnd> {111  const { config, logger, cwd } = options;112113  const context = await openSessionContext({114    cwd,115    sessionsDir: options.sessionsDir,116    ...(options.resumeId !== undefined ? { resumeId: options.resumeId } : {}),117    logger,118  });119120  const asker = new TuiPermissionAsker();121  const quitGuard = new QuitGuard();122123  const engineRef: { current: Engine | null } = { current: null };124  let resolveReady: (engine: Engine) => void = () => {};125  const ready = new Promise<Engine>((resolve) => {126    resolveReady = resolve;127  });128  let endSession: (end: SessionEnd) => void = () => {};129  const ended = new Promise<SessionEnd>((resolve) => {130    endSession = resolve;131  });132133  const driveTurn = (active: Engine): void => {134    void active135      .runTurn()136      .then((outcome) => {137        if (outcome.kind === "failed") {138          logger.error("turn failed", { reason: outcome.reason, detail: outcome.detail });139        }140      })141      .catch((error: unknown) => {142        const message = error instanceof Error ? error.message : String(error);143        logger.error("turn crashed", { error: message });144        app.printBlock(["", ` turn crashed: ${message}`, "   details in ~/.khaelor/logs/"]);145      });146  };147148  const runShell = async (active: Engine, command: string): Promise<void> => {149    app.printBlock(["", ` ! ${command}`]);150    try {151      const result = await active.workspace.exec({ cmd: command, timeoutMs: 30_000 });152      const output = `${result.stdout}${result.stderr}`.split("\n").filter((l) => l !== "");153      const capped = output.slice(0, 40).map((line) => `   ${line}`);154      if (output.length > 40) capped.push(`   … ${output.length - 40} more lines`);155      capped.push(`   exit ${result.exitCode ?? "killed"} · ${result.durationMs}ms`);156      app.printBlock(capped);157    } catch (error) {158      app.printBlock([`   shell failed: ${error instanceof Error ? error.message : String(error)}`]);159    }160  };161162  const handleQuit = async (): Promise<void> => {163    const active = await ready;164    const running = active.processes.list().filter((p) => p.status === "running").length;165    if (quitGuard.request(running) === "confirm") {166      app.printBlock(quitConfirmationLines(running));167      return;168    }169    endSession({ kind: "quit" });170  };171172  // One-shot composer capture — /rename consumes the next submit as its input.173  const captureNextRef: { current: ((text: string) => void) | null } = { current: null };174175  const app = new TuiApp({176    bus: context.bus,177    model: config.model,178    thinking: config.thinking,179    cwdLabel: abbreviateHome(cwd),180    contextWindow: resolveContextWindow(config.model),181    actions: {182      submit: (text, opts) => {183        if (!opts.shell && captureNextRef.current !== null) {184          const consume = captureNextRef.current;185          captureNextRef.current = null;186          consume(text);187          return;188        }189        void ready.then(async (active) => {190          if (opts.shell) {191            await runShell(active, text);192            return;193          }194          if (active.turnActive) {195            active.steering.queue(text);196            return;197          }198          active.session.publishDurable({199            type: "user.message-created",200            payload: { text, mentions: [] },201          });202          driveTurn(active);203        });204      },205      interrupt: () => {206        void ready.then((active) => {207          active.interruption.interrupt();208        });209      },210      permission: (requestId, decision) => {211        asker.answer(requestId, decision);212      },213      quit: () => {214        void handleQuit();215      },216    },217  });218219  // FIRST PAINT — everything below is lazy/background (§12.1 budget).220  await app.start();221  if (context.resumed) {222    app.printBlock(renderResumedTranscript(context.log.replayedEvents));223  }224225  const switchGuard = (next: SessionEnd): void => {226    if (engineRef.current !== null && engineRef.current.turnActive) {227      app.printBlock(["", " a turn is running — Esc to interrupt it first"]);228      return;229    }230    endSession(next);231  };232233  // Background: assemble the engine and wire the real commands.234  void (async () => {235    try {236      const assembled = await assembleEngine({237        config,238        cwd,239        context,240        asker,241        logger,242      });243      engineRef.current = assembled;244      registerCliCommands(app.commands, {245        ui: app,246        engine: () => assembled,247        config,248        cwd,249        sessionsDir: options.sessionsDir,250        projectHash: context.hash,251        actions: {252          newSession: () => switchGuard({ kind: "new" }),253          resumeSession: (sessionId) => switchGuard({ kind: "resume", sessionId }),254          quit: () => {255            void handleQuit();256          },257          captureNextSubmit: (consume) => {258            captureNextRef.current = consume;259          },260        },261        listModels: () => listModelsLive(config),262      });263      resolveReady(assembled);264265      // Deeper background: baseline, branch, repository index for @mentions.266      void assembled.captureBaseline();267      void assembled.git.currentBranch().then((branch) => {268        app.setGitBranch(branch.kind === "ok" ? branch.value : null);269      });270      void new RepositoryMap(assembled.workspace)271        .fileMap()272        .then((entries) => {273          const paths = entries.map((entry) => entry.path);274          app.setMentionProvider((query): PaletteItem[] => {275            if (query === "") return paths.slice(0, 8).map((p) => ({ id: p, label: p }));276            return fuzzyFilter(query, paths, (p) => p)277              .slice(0, 8)278              .map((ranked) => ({ id: ranked.item, label: ranked.item }));279          });280        })281        .catch((error: unknown) => {282          logger.debug("repository map unavailable", {283            error: error instanceof Error ? error.message : String(error),284          });285        });286    } catch (error) {287      const message = error instanceof Error ? error.message : String(error);288      logger.error("engine assembly failed", { error: message });289      app.printBlock(["", ` startup failed: ${message}`, "   details in ~/.khaelor/logs/"]);290    }291  })();292293  const end = await ended;294  app.stop();295  if (engineRef.current !== null) await engineRef.current.shutdown();296  else await context.log.close();297  return end;298}299300// ───────────────────────── entry ─────────────────────────301302export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {303  const args: ParsedArgs = parseArgs(argv);304  if (args.errors.length > 0) {305    process.stderr.write(`khaelor: ${args.errors.join("; ")}\n\n${helpText()}`);306    process.exitCode = 2;307    return;308  }309  if (args.help) {310    process.stdout.write(helpText());311    return;312  }313  if (args.version) {314    process.stdout.write(`khaelor ${KHAELOR_VERSION}\n`);315    return;316  }317318  const logger = new FileLogger(join(defaultLogDir(), "khaelor.log"), { debug: args.debug });319  installCrashHandlers(logger);320321  const cwd = process.cwd();322  const config = await loadConfig({323    cwd,324    flags: {325      ...(args.model !== null ? { model: args.model } : {}),326      ...(args.auxModel !== null ? { auxModel: args.auxModel } : {}),327      ...(args.thinking !== null ? { thinking: args.thinking } : {}),328      ...(args.maxOutputTokens !== null ? { maxOutputTokens: args.maxOutputTokens } : {}),329    },330  });331  if (!config.hasApiKey) {332    process.stderr.write(`${missingApiKeyMessage()}\n`);333    process.exitCode = 1;334    return;335  }336337  if (args.print !== null) {338    let finished = false;339    installSignalShutdown(async () => {340      if (!finished) logger.log("warn", "print mode interrupted by signal");341    });342    const code = await runPrintMode({ config, cwd, prompt: args.print, logger });343    finished = true;344    process.exitCode = code;345    return;346  }347348  // Interactive: sessions can chain (/new, /resume) without restarting the process.349  const sessionsDir = defaultSessionsDir();350  let resumeId: string | undefined = args.resume ?? undefined;351  for (;;) {352    const end = await runInteractiveSession({353      config,354      logger,355      cwd,356      sessionsDir,357      ...(resumeId !== undefined ? { resumeId } : {}),358    });359    if (end.kind === "quit") break;360    resumeId = end.kind === "resume" ? end.sessionId : undefined;361  }362  // Lingering handles (SDK sockets, stdin) must not hold the process open.363  process.exit(0);364}365366const entryPath = process.argv[1];367let isDirectRun = false;368if (entryPath !== undefined) {369  try {370    isDirectRun = pathToFileURL(realpathSync(entryPath)).href === import.meta.url;371  } catch {372    isDirectRun = false;373  }374}375if (isDirectRun) {376  void main();377}378