mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
feat: run Crestodian in TUI shell
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
71
src/crestodian/dialogue.ts
Normal file
71
src/crestodian/dialogue.ts
Normal 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}`);
|
||||
}
|
||||
52
src/crestodian/tui-backend.test.ts
Normal file
52
src/crestodian/tui-backend.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
355
src/crestodian/tui-backend.ts
Normal file
355
src/crestodian/tui-backend.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user