feat: run Crestodian in TUI shell

This commit is contained in:
Peter Steinberger
2026-04-25 10:59:26 +01:00
parent 9fe35a0c62
commit 385da2db60
9 changed files with 545 additions and 151 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt.
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna.
- Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna.

View File

@@ -17,7 +17,9 @@ Running `openclaw crestodian` starts the same helper explicitly.
## What Crestodian shows
On startup, Crestodian prints a compact system overview:
On startup, interactive Crestodian opens the same TUI shell used by
`openclaw tui`, with a Crestodian chat backend. The chat log starts with a
compact system overview:
- config path and validity
- configured agents and the default agent
@@ -30,7 +32,9 @@ On startup, Crestodian prints a compact system overview:
- gateway reachability
- the immediate recommended next step
It does not dump secrets or load plugin CLI commands just to start.
It does not dump secrets or load plugin CLI commands just to start. The TUI
still provides the normal header, chat log, status line, footer, autocomplete,
and editor controls.
Crestodian uses the same OpenClaw reference discovery as regular agents. In a Git checkout,
it points itself at local `docs/` and the local source tree. In an npm package install, it
@@ -51,7 +55,7 @@ openclaw crestodian --message "set default model openai/gpt-5.5" --yes
openclaw onboard --modern
```
Inside the interactive prompt:
Inside the Crestodian TUI:
```text
status

View File

@@ -47,6 +47,7 @@ Notes:
- `openclaw chat` and `openclaw terminal` are aliases for `openclaw tui --local`.
- `--local` cannot be combined with `--url`, `--token`, or `--password`.
- Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable.
- `openclaw` and `openclaw crestodian` also use this TUI shell, with Crestodian as the local setup and repair chat backend.
## What you see

View File

@@ -68,4 +68,27 @@ describe("runCrestodian", () => {
expect(planner).not.toHaveBeenCalled();
expect(lines.join("\n")).toContain("Default model:");
});
it("starts interactive Crestodian in the TUI shell", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-tui-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
const { runtime, lines } = createRuntime();
const runInteractiveTui = vi.fn(async () => {});
await runCrestodian(
{
input: { isTTY: true } as unknown as NodeJS.ReadableStream,
output: { isTTY: true } as unknown as NodeJS.WritableStream,
runInteractiveTui,
},
runtime,
);
expect(runInteractiveTui).toHaveBeenCalledWith(
expect.objectContaining({ runInteractiveTui }),
runtime,
);
expect(lines.join("\n")).not.toContain("Say: status");
});
});

View File

@@ -1,24 +1,14 @@
import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
import readline from "node:readline/promises";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import {
planCrestodianCommand,
type CrestodianAssistantPlan,
type CrestodianAssistantPlanner,
} from "./assistant.js";
import type { CrestodianAssistantPlanner } from "./assistant.js";
import { resolveCrestodianOperation } from "./dialogue.js";
import {
executeCrestodianOperation,
describeCrestodianPersistentOperation,
isPersistentCrestodianOperation,
parseCrestodianOperation,
type CrestodianCommandDeps,
type CrestodianOperation,
} from "./operations.js";
import {
formatCrestodianOverview,
loadCrestodianOverview,
type CrestodianOverview,
} from "./overview.js";
import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js";
import { runCrestodianTui } from "./tui-backend.js";
export type RunCrestodianOptions = {
message?: string;
@@ -29,16 +19,9 @@ export type RunCrestodianOptions = {
planWithAssistant?: CrestodianAssistantPlanner;
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
runInteractiveTui?: typeof runCrestodianTui;
};
function approvalQuestion(operation: CrestodianOperation): string {
return `Apply this operation: ${describeCrestodianPersistentOperation(operation)}?`;
}
function isYes(input: string): boolean {
return /^(y|yes|apply|do it|approved?)$/i.test(input.trim());
}
async function runOneShot(
input: string,
runtime: RuntimeEnv,
@@ -51,84 +34,20 @@ async function runOneShot(
});
}
async function runResolvedOperation(
operation: CrestodianOperation,
runtime: RuntimeEnv,
opts: RunCrestodianOptions,
): Promise<{ exitsInteractive: boolean; nextInput?: string }> {
const result = await executeCrestodianOperation(operation, runtime, {
approved: opts.yes === true || !isPersistentCrestodianOperation(operation),
deps: opts.deps,
});
return {
exitsInteractive: result.exitsInteractive === true,
nextInput: result.nextInput,
};
}
async function resolveCrestodianOperation(
input: string,
runtime: RuntimeEnv,
opts: RunCrestodianOptions,
): Promise<CrestodianOperation> {
const operation = parseCrestodianOperation(input);
if (!shouldAskAssistant(input, operation)) {
return operation;
}
const overview = await loadCrestodianOverview();
const planner = opts.planWithAssistant ?? planCrestodianCommand;
const plan = await planner({ input, overview });
if (!plan) {
return operation;
}
const planned = parseCrestodianOperation(plan.command);
if (planned.kind === "none") {
return operation;
}
logAssistantPlan(runtime, plan, overview);
return planned;
}
function shouldAskAssistant(input: string, operation: CrestodianOperation): boolean {
if (operation.kind !== "none") {
return false;
}
const trimmed = input.trim().toLowerCase();
if (!trimmed || trimmed === "quit" || trimmed === "exit") {
return false;
}
return true;
}
function logAssistantPlan(
runtime: RuntimeEnv,
plan: CrestodianAssistantPlan,
overview: CrestodianOverview,
): void {
const modelLabel = plan.modelLabel ?? overview.defaultModel ?? "configured model";
runtime.log(`[crestodian] planner: ${modelLabel}`);
if (plan.reply) {
runtime.log(plan.reply);
}
runtime.log(`[crestodian] interpreted: ${plan.command}`);
}
export async function runCrestodian(
opts: RunCrestodianOptions = {},
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const overview = await loadCrestodianOverview();
if (opts.json) {
const overview = await loadCrestodianOverview();
writeRuntimeJson(runtime, overview);
return;
}
runtime.log(formatCrestodianOverview(overview));
runtime.log("");
runtime.log(
"Say: status, doctor, health, gateway status, restart gateway, agents, models, set default model <provider/model>, talk to agent, audit, or quit.",
);
if (opts.message?.trim()) {
const overview = await loadCrestodianOverview();
runtime.log(formatCrestodianOverview(overview));
runtime.log("");
await runOneShot(opts.message, runtime, opts);
return;
}
@@ -143,51 +62,6 @@ export async function runCrestodian(
return;
}
const rl = readline.createInterface({ input, output });
let pending: CrestodianOperation | null = null;
try {
for (;;) {
const answer = await rl.question("crestodian> ");
if (pending) {
if (isYes(answer)) {
const result = await executeCrestodianOperation(pending, runtime, {
approved: true,
deps: opts.deps,
});
pending = null;
if (result.exitsInteractive) {
break;
}
continue;
}
runtime.log("Skipped. No barnacles on config today.");
pending = null;
continue;
}
const operation = await resolveCrestodianOperation(answer, runtime, opts);
if (isPersistentCrestodianOperation(operation) && !opts.yes) {
runtime.log(approvalQuestion(operation));
pending = operation;
continue;
}
const result = await runResolvedOperation(operation, runtime, opts);
if (result.exitsInteractive) {
break;
}
if (result.nextInput?.trim()) {
const followUp = await resolveCrestodianOperation(result.nextInput, runtime, opts);
if (isPersistentCrestodianOperation(followUp) && !opts.yes) {
runtime.log(approvalQuestion(followUp));
pending = followUp;
continue;
}
const followUpResult = await runResolvedOperation(followUp, runtime, opts);
if (followUpResult.exitsInteractive) {
break;
}
}
}
} finally {
rl.close();
}
const runInteractiveTui = opts.runInteractiveTui ?? runCrestodianTui;
await runInteractiveTui(opts, runtime);
}

View File

@@ -0,0 +1,71 @@
import type { RuntimeEnv } from "../runtime.js";
import {
planCrestodianCommand,
type CrestodianAssistantPlan,
type CrestodianAssistantPlanner,
} from "./assistant.js";
import {
describeCrestodianPersistentOperation,
parseCrestodianOperation,
type CrestodianOperation,
} from "./operations.js";
import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js";
export type CrestodianDialogueOptions = {
planWithAssistant?: CrestodianAssistantPlanner;
};
export function approvalQuestion(operation: CrestodianOperation): string {
return `Apply this operation: ${describeCrestodianPersistentOperation(operation)}?`;
}
export function isYes(input: string): boolean {
return /^(y|yes|apply|do it|approved?)$/i.test(input.trim());
}
export async function resolveCrestodianOperation(
input: string,
runtime: RuntimeEnv,
opts: CrestodianDialogueOptions,
): Promise<CrestodianOperation> {
const operation = parseCrestodianOperation(input);
if (!shouldAskAssistant(input, operation)) {
return operation;
}
const overview = await loadCrestodianOverview();
const planner = opts.planWithAssistant ?? planCrestodianCommand;
const plan = await planner({ input, overview });
if (!plan) {
return operation;
}
const planned = parseCrestodianOperation(plan.command);
if (planned.kind === "none") {
return operation;
}
logAssistantPlan(runtime, plan, overview);
return planned;
}
function shouldAskAssistant(input: string, operation: CrestodianOperation): boolean {
if (operation.kind !== "none") {
return false;
}
const trimmed = input.trim().toLowerCase();
if (!trimmed || trimmed === "quit" || trimmed === "exit") {
return false;
}
return true;
}
function logAssistantPlan(
runtime: RuntimeEnv,
plan: CrestodianAssistantPlan,
overview: CrestodianOverview,
): void {
const modelLabel = plan.modelLabel ?? overview.defaultModel ?? "configured model";
runtime.log(`[crestodian] planner: ${modelLabel}`);
if (plan.reply) {
runtime.log(plan.reply);
}
runtime.log(`[crestodian] interpreted: ${plan.command}`);
}

View File

@@ -0,0 +1,52 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
runTui: vi.fn(async (_opts: unknown) => ({ exitReason: "exit" as const })),
}));
vi.mock("../tui/tui.js", () => ({
runTui: mocks.runTui,
}));
import { runCrestodianTui } from "./tui-backend.js";
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: (code) => {
throw new Error(`exit ${code}`);
},
};
}
describe("runCrestodianTui", () => {
afterEach(() => {
vi.unstubAllEnvs();
mocks.runTui.mockClear();
});
it("runs Crestodian inside the shared TUI shell", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-tui-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json"));
await runCrestodianTui({}, createRuntime());
expect(mocks.runTui).toHaveBeenCalledWith(
expect.objectContaining({
local: true,
session: "agent:crestodian:main",
historyLimit: 200,
config: {},
title: "openclaw crestodian",
}),
);
const callOptions = mocks.runTui.mock.calls[0]?.[0] as { backend?: unknown } | undefined;
expect(callOptions?.backend).toBeTruthy();
});
});

View File

@@ -0,0 +1,355 @@
import { randomUUID } from "node:crypto";
import type { SessionsPatchParams, SessionsPatchResult } from "../gateway/protocol/index.js";
import { buildAgentMainSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import type {
ChatSendOptions,
TuiAgentsList,
TuiBackend,
TuiEvent,
TuiModelChoice,
TuiSessionList,
} from "../tui/tui-backend.js";
import { runTui } from "../tui/tui.js";
import type { CrestodianAssistantPlanner } from "./assistant.js";
import { approvalQuestion, isYes, resolveCrestodianOperation } from "./dialogue.js";
import {
executeCrestodianOperation,
isPersistentCrestodianOperation,
type CrestodianCommandDeps,
type CrestodianOperation,
} from "./operations.js";
import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js";
export type CrestodianTuiOptions = {
yes?: boolean;
deps?: CrestodianCommandDeps;
planWithAssistant?: CrestodianAssistantPlanner;
};
type CrestodianHistoryMessage = {
role: "assistant" | "user";
content: Array<{ type: "text"; text: string }>;
timestamp: number;
};
type CaptureRuntime = RuntimeEnv & {
read: () => string;
};
const CRESTODIAN_AGENT_ID = "crestodian";
const CRESTODIAN_SESSION_KEY = buildAgentMainSessionKey({ agentId: CRESTODIAN_AGENT_ID });
function createCaptureRuntime(): CaptureRuntime {
const lines: string[] = [];
return {
log: (...args) => lines.push(args.join(" ")),
error: (...args) => lines.push(args.join(" ")),
exit: (code) => {
throw new Error(`Crestodian operation exited with code ${String(code)}`);
},
read: () => lines.join("\n").trim(),
};
}
function message(role: "assistant" | "user", text: string): CrestodianHistoryMessage {
return {
role,
content: [{ type: "text", text }],
timestamp: Date.now(),
};
}
function splitModelRef(ref: string | undefined): { provider?: string; model?: string } {
const trimmed = ref?.trim();
if (!trimmed) {
return {};
}
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash >= trimmed.length - 1) {
return { model: trimmed };
}
return {
provider: trimmed.slice(0, slash),
model: trimmed.slice(slash + 1),
};
}
function crestodianWelcome(overviewText: string): string {
return [
overviewText,
"",
"Say: status, doctor, health, gateway status, restart gateway, agents, models, set default model <provider/model>, talk to agent, audit, or quit.",
].join("\n");
}
class CrestodianTuiBackend implements TuiBackend {
readonly connection = { url: "crestodian local" };
onEvent?: (evt: TuiEvent) => void;
onConnected?: () => void;
onDisconnected?: (reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
private seq = 0;
private pending: CrestodianOperation | null = null;
private handoff: CrestodianOperation | null = null;
private requestExit: (() => void) | null = null;
private readonly messages: CrestodianHistoryMessage[] = [];
constructor(
private readonly opts: CrestodianTuiOptions,
welcome: string,
) {
this.messages.push(message("assistant", welcome));
}
setRequestExitHandler(handler: () => void): void {
this.requestExit = handler;
}
consumeHandoff(): CrestodianOperation | null {
const handoff = this.handoff;
this.handoff = null;
return handoff;
}
start(): void {
queueMicrotask(() => {
this.onConnected?.();
});
}
stop(): void {
// The enclosing TUI owns terminal shutdown; Crestodian has no transport to close.
}
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
const runId = opts.runId ?? randomUUID();
const text = opts.message.trim();
this.messages.push(message("user", opts.message));
void this.respond(runId, opts.sessionKey, text);
return { runId };
}
async abortChat(): Promise<{ ok: boolean; aborted: boolean }> {
return { ok: true, aborted: false };
}
async loadHistory(): Promise<{
sessionId: string;
messages: CrestodianHistoryMessage[];
thinkingLevel: string;
verboseLevel: string;
}> {
return {
sessionId: "crestodian",
messages: this.messages,
thinkingLevel: "off",
verboseLevel: "off",
};
}
async listSessions(): Promise<TuiSessionList> {
const overview = await loadCrestodianOverview();
const model = splitModelRef(overview.defaultModel);
return {
ts: Date.now(),
path: "crestodian",
count: 1,
defaults: {
model: model.model ?? null,
modelProvider: model.provider ?? null,
contextTokens: null,
},
sessions: [
{
key: CRESTODIAN_SESSION_KEY,
sessionId: "crestodian",
displayName: "Crestodian",
updatedAt: Date.now(),
thinkingLevel: "off",
verboseLevel: "off",
model: model.model,
modelProvider: model.provider,
},
],
};
}
async listAgents(): Promise<TuiAgentsList> {
return {
defaultId: CRESTODIAN_AGENT_ID,
mainKey: "main",
scope: "per-sender",
agents: [{ id: CRESTODIAN_AGENT_ID, name: "Crestodian" }],
};
}
async patchSession(opts: SessionsPatchParams): Promise<SessionsPatchResult> {
const model = splitModelRef(typeof opts.model === "string" ? opts.model : undefined);
return {
ok: true,
path: "crestodian",
key: CRESTODIAN_SESSION_KEY,
entry: {
sessionId: "crestodian",
displayName: "Crestodian",
updatedAt: Date.now(),
...(model.model ? { model: model.model } : {}),
...(model.provider ? { modelProvider: model.provider } : {}),
},
resolved: {
modelProvider: model.provider,
model: model.model,
},
};
}
async resetSession(): Promise<{ ok: boolean }> {
this.pending = null;
const overview = await loadCrestodianOverview();
this.messages.splice(
0,
this.messages.length,
message("assistant", crestodianWelcome(formatCrestodianOverview(overview))),
);
return { ok: true };
}
async getGatewayStatus(): Promise<string> {
const overview = await loadCrestodianOverview();
return overview.gateway.reachable ? "Gateway reachable" : "Gateway unreachable";
}
async listModels(): Promise<TuiModelChoice[]> {
return [];
}
private nextSeq(): number {
this.seq += 1;
return this.seq;
}
private emit(event: string, payload: unknown): void {
this.onEvent?.({
event,
payload,
seq: this.nextSeq(),
});
}
private emitFinal(runId: string, sessionKey: string, text: string): void {
const assistant = message(
"assistant",
text || "Crestodian listened and found nothing to change.",
);
this.messages.push(assistant);
this.emit("chat", {
runId,
sessionKey,
state: "final",
message: assistant,
});
}
private emitError(runId: string, sessionKey: string, error: unknown): void {
const errorMessage = error instanceof Error ? error.message : String(error);
this.emit("chat", {
runId,
sessionKey,
state: "error",
errorMessage,
});
}
private async respond(runId: string, sessionKey: string, text: string): Promise<void> {
try {
const reply = await this.resolveReply(text);
this.emitFinal(runId, sessionKey, reply);
} catch (error) {
this.emitError(runId, sessionKey, error);
}
}
private async resolveReply(text: string): Promise<string> {
if (this.pending) {
if (isYes(text)) {
const pending = this.pending;
this.pending = null;
const capture = createCaptureRuntime();
await executeCrestodianOperation(pending, capture, {
approved: true,
deps: this.opts.deps,
});
return capture.read() || "Applied. Audit entry written.";
}
this.pending = null;
return "Skipped. No barnacles on config today.";
}
const capture = createCaptureRuntime();
const operation = await resolveCrestodianOperation(text, capture, this.opts);
if (operation.kind === "open-tui") {
this.handoff = operation;
queueMicrotask(() => this.requestExit?.());
return "Opening your normal agent TUI. Use /crestodian there to come back.";
}
if (isPersistentCrestodianOperation(operation) && !this.opts.yes) {
this.pending = operation;
await executeCrestodianOperation(operation, capture, {
approved: false,
deps: this.opts.deps,
});
return [capture.read(), approvalQuestion(operation)].filter(Boolean).join("\n\n");
}
await executeCrestodianOperation(operation, capture, {
approved: this.opts.yes === true || !isPersistentCrestodianOperation(operation),
deps: this.opts.deps,
});
const reply = capture.read();
if (operation.kind === "none" && reply.includes("Bye.")) {
queueMicrotask(() => this.requestExit?.());
}
return reply;
}
}
export async function runCrestodianTui(
opts: CrestodianTuiOptions,
runtime: RuntimeEnv,
): Promise<void> {
let nextInput: string | undefined;
for (;;) {
const overview = await loadCrestodianOverview();
const backend = new CrestodianTuiBackend(
opts,
crestodianWelcome(formatCrestodianOverview(overview)),
);
await runTui({
local: true,
session: CRESTODIAN_SESSION_KEY,
historyLimit: 200,
backend,
config: {},
title: "openclaw crestodian",
...(nextInput ? { message: nextInput } : {}),
});
const handoff = backend.consumeHandoff();
if (!handoff) {
return;
}
const result = await executeCrestodianOperation(handoff, runtime, {
approved: true,
deps: opts.deps,
});
nextInput = result.nextInput;
if (!nextInput?.trim()) {
return;
}
}
}

View File

@@ -70,6 +70,12 @@ const OPENCLAW_DIST_ENTRY_MJS_PATH = fileURLToPath(
const OPENAI_CODEX_PROVIDER = "openai-codex";
type RunTuiOptions = TuiOptions & {
backend?: TuiBackend;
config?: OpenClawConfig;
title?: string;
};
/** Resolve the absolute path to the `codex` CLI binary, or `null` if not installed. */
export function resolveCodexCliBin(): string | null {
try {
@@ -284,9 +290,9 @@ export function resolveCtrlCAction(params: {
};
}
export async function runTui(opts: TuiOptions): Promise<TuiResult> {
const isLocalMode = opts.local === true;
const config = loadConfig();
export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
const isLocalMode = opts.local === true || opts.backend !== undefined;
const config = opts.config ?? loadConfig();
const initialSessionInput = (opts.session ?? "").trim();
let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope;
let sessionMainKey = normalizeMainKey(config.session?.mainKey);
@@ -496,13 +502,15 @@ export async function runTui(opts: TuiOptions): Promise<TuiResult> {
localBtwRunIds.clear();
};
const client: TuiBackend = opts.local
? new EmbeddedTuiBackend()
: await GatewayChatClient.connect({
url: opts.url,
token: opts.token,
password: opts.password,
});
const client: TuiBackend = opts.backend
? opts.backend
: opts.local
? new EmbeddedTuiBackend()
: await GatewayChatClient.connect({
url: opts.url,
token: opts.token,
password: opts.password,
});
const previousConsoleSubsystemFilter = isLocalMode
? loggingState.consoleSubsystemFilter
? [...loggingState.consoleSubsystemFilter]
@@ -577,9 +585,10 @@ export async function runTui(opts: TuiOptions): Promise<TuiResult> {
const updateHeader = () => {
const sessionLabel = formatSessionKey(currentSessionKey);
const agentLabel = formatAgentLabel(currentAgentId);
const title = opts.title ?? "openclaw tui";
header.setText(
theme.header(
`openclaw tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`,
`${title} - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`,
),
);
};
@@ -923,6 +932,10 @@ export async function runTui(opts: TuiOptions): Promise<TuiResult> {
finishTui?.();
});
};
const exitAwareClient = client as TuiBackend & {
setRequestExitHandler?: (handler: () => void) => void;
};
exitAwareClient.setRequestExitHandler?.(() => requestExit());
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
createCommandHandlers({