feat: add CLI backend fallback

This commit is contained in:
Peter Steinberger
2026-01-10 23:31:25 +00:00
parent 07be761779
commit d8f1124d59
18 changed files with 1448 additions and 472 deletions

View File

@@ -1,426 +1 @@
import crypto from "node:crypto";
import os from "node:os";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
buildBootstrapContextFiles,
classifyFailoverReason,
type EmbeddedContextFile,
isFailoverErrorMessage,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import {
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles,
} from "./workspace.js";
const log = createSubsystemLogger("agent/claude-cli");
const CLAUDE_CLI_QUEUE_KEY = "global";
const CLAUDE_CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function enqueueClaudeCliRun<T>(
key: string,
task: () => Promise<T>,
): Promise<T> {
const prior = CLAUDE_CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
const tracked = chained.finally(() => {
if (CLAUDE_CLI_RUN_QUEUE.get(key) === tracked) {
CLAUDE_CLI_RUN_QUEUE.delete(key);
}
});
CLAUDE_CLI_RUN_QUEUE.set(key, tracked);
return chained;
}
type ClaudeCliUsage = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
type ClaudeCliOutput = {
text: string;
sessionId?: string;
usage?: ClaudeCliUsage;
};
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeClaudeSessionId(raw?: string): string {
const trimmed = raw?.trim();
if (trimmed && UUID_RE.test(trimmed)) return trimmed;
return crypto.randomUUID();
}
function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(
new Date(),
);
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (
!map.weekday ||
!map.year ||
!map.month ||
!map.day ||
!map.hour ||
!map.minute
) {
return undefined;
}
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
if (!model) continue;
const alias = String(
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
).trim();
if (!alias) continue;
entries.push({ alias, model });
}
return entries
.sort((a, b) => a.alias.localeCompare(b.alias))
.map((entry) => `- ${entry.alias}: ${entry.model}`);
}
function buildSystemPrompt(params: {
workspaceDir: string;
config?: ClawdbotConfig;
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
ownerNumbers?: string[];
heartbeatPrompt?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: params.modelDisplay,
},
toolNames: params.tools.map((tool) => tool.name),
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
contextFiles: params.contextFiles,
});
}
function normalizeClaudeCliModel(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed) return "opus";
const lower = trimmed.toLowerCase();
if (lower.startsWith("opus")) return "opus";
if (lower.startsWith("sonnet")) return "sonnet";
if (lower.startsWith("haiku")) return "haiku";
return trimmed;
}
function toUsage(raw: Record<string, unknown>): ClaudeCliUsage | undefined {
const pick = (key: string) =>
typeof raw[key] === "number" && raw[key] > 0
? (raw[key] as number)
: undefined;
const input = pick("input_tokens") ?? pick("inputTokens");
const output = pick("output_tokens") ?? pick("outputTokens");
const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead");
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
const total = pick("total_tokens") ?? pick("total");
if (!input && !output && !cacheRead && !cacheWrite && !total)
return undefined;
return { input, output, cacheRead, cacheWrite, total };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function collectText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.map((entry) => collectText(entry)).join("");
}
if (!isRecord(value)) return "";
if (typeof value.text === "string") return value.text;
if (typeof value.content === "string") return value.content;
if (Array.isArray(value.content)) {
return value.content.map((entry) => collectText(entry)).join("");
}
if (isRecord(value.message)) return collectText(value.message);
return "";
}
function parseClaudeCliJson(raw: string): ClaudeCliOutput | null {
const trimmed = raw.trim();
if (!trimmed) return null;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
const sessionId =
(typeof parsed.session_id === "string" && parsed.session_id) ||
(typeof parsed.sessionId === "string" && parsed.sessionId) ||
(typeof parsed.conversation_id === "string" && parsed.conversation_id) ||
undefined;
const usage = isRecord(parsed.usage) ? toUsage(parsed.usage) : undefined;
const text =
collectText(parsed.message) ||
collectText(parsed.content) ||
collectText(parsed.result) ||
collectText(parsed);
return { text: text.trim(), sessionId, usage };
}
async function runClaudeCliOnce(params: {
prompt: string;
workspaceDir: string;
modelId: string;
systemPrompt: string;
timeoutMs: number;
sessionId: string;
}): Promise<ClaudeCliOutput> {
const args = [
"-p",
"--output-format",
"json",
"--model",
normalizeClaudeCliModel(params.modelId),
"--append-system-prompt",
params.systemPrompt,
"--dangerously-skip-permissions",
"--session-id",
params.sessionId,
];
args.push(params.prompt);
log.info(
`claude-cli exec: model=${normalizeClaudeCliModel(params.modelId)} promptChars=${params.prompt.length} systemPromptChars=${params.systemPrompt.length}`,
);
if (process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1") {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--append-system-prompt") {
logArgs.push(arg, `<systemPrompt:${params.systemPrompt.length} chars>`);
i += 1;
continue;
}
if (arg === "--session-id") {
logArgs.push(arg, args[i + 1] ?? "");
i += 1;
continue;
}
logArgs.push(arg);
}
const promptIndex = logArgs.indexOf(params.prompt);
if (promptIndex >= 0) {
logArgs[promptIndex] = `<prompt:${params.prompt.length} chars>`;
}
log.info(`claude-cli argv: claude ${logArgs.join(" ")}`);
}
const result = await runCommandWithTimeout(["claude", ...args], {
timeoutMs: params.timeoutMs,
cwd: params.workspaceDir,
env: (() => {
const next = { ...process.env };
delete next.ANTHROPIC_API_KEY;
return next;
})(),
});
if (process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1") {
const stdoutDump = result.stdout.trim();
const stderrDump = result.stderr.trim();
if (stdoutDump) {
log.info(`claude-cli stdout:\n${stdoutDump}`);
}
if (stderrDump) {
log.info(`claude-cli stderr:\n${stderrDump}`);
}
}
const stdout = result.stdout.trim();
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
if (shouldLogVerbose()) {
if (stdout) {
log.debug(`claude-cli stdout:\n${stdout}`);
}
if (result.stderr.trim()) {
log.debug(`claude-cli stderr:\n${result.stderr.trim()}`);
}
}
if (result.code !== 0) {
const err = result.stderr.trim() || stdout || "Claude CLI failed.";
if (isFailoverErrorMessage(err)) {
const reason = classifyFailoverReason(err) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(err, {
reason,
provider: "claude-cli",
model: params.modelId,
status,
});
}
throw new Error(err);
}
const parsed = parseClaudeCliJson(stdout);
const output = parsed ?? { text: stdout };
if (logOutputText) {
const text = output.text?.trim();
if (text) {
log.info(`claude-cli output:\n${text}`);
}
}
return output;
}
export async function runClaudeCliAgent(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config?: ClawdbotConfig;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
claudeSessionId?: string;
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
const modelId = (params.model ?? "opus").trim() || "opus";
const modelDisplay = `${params.provider ?? "claude-cli"}/${modelId}`;
const extraSystemPrompt = [
params.extraSystemPrompt?.trim(),
"Tools are disabled in this session. Do not call tools.",
]
.filter(Boolean)
.join("\n");
const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(workspaceDir),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const heartbeatPrompt =
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
)
: undefined;
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
heartbeatPrompt,
tools: [],
contextFiles,
modelDisplay,
});
const claudeSessionId = normalizeClaudeSessionId(params.claudeSessionId);
const output = await enqueueClaudeCliRun(CLAUDE_CLI_QUEUE_KEY, () =>
runClaudeCliOnce({
prompt: params.prompt,
workspaceDir,
modelId,
systemPrompt,
timeoutMs: params.timeoutMs,
sessionId: claudeSessionId,
}),
);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: output.sessionId ?? claudeSessionId,
provider: params.provider ?? "claude-cli",
model: modelId,
usage: output.usage,
},
},
};
}
export { runClaudeCliAgent, runCliAgent } from "./cli-runner.js";

110
src/agents/cli-backends.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { normalizeProviderId } from "./model-selection.js";
export type ResolvedCliBackend = {
id: string;
config: CliBackendConfig;
};
const CLAUDE_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
command: "claude",
args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"],
output: "json",
input: "arg",
modelArg: "--model",
modelAliases: CLAUDE_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
clearEnv: ["ANTHROPIC_API_KEY"],
serialize: true,
};
function normalizeBackendKey(key: string): string {
return normalizeProviderId(key);
}
function pickBackendConfig(
config: Record<string, CliBackendConfig>,
normalizedId: string,
): CliBackendConfig | undefined {
for (const [key, entry] of Object.entries(config)) {
if (normalizeBackendKey(key) === normalizedId) return entry;
}
return undefined;
}
function mergeBackendConfig(
base: CliBackendConfig,
override?: CliBackendConfig,
): CliBackendConfig {
if (!override) return { ...base };
return {
...base,
...override,
args: override.args ?? base.args,
env: { ...base.env, ...override.env },
modelAliases: { ...base.modelAliases, ...override.modelAliases },
clearEnv: Array.from(
new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]),
),
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
};
}
export function resolveCliBackendIds(cfg?: ClawdbotConfig): Set<string> {
const ids = new Set<string>([normalizeBackendKey("claude-cli")]);
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
for (const key of Object.keys(configured)) {
ids.add(normalizeBackendKey(key));
}
return ids;
}
export function resolveCliBackendConfig(
provider: string,
cfg?: ClawdbotConfig,
): ResolvedCliBackend | null {
const normalized = normalizeBackendKey(provider);
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
const override = pickBackendConfig(configured, normalized);
if (normalized === "claude-cli") {
const merged = mergeBackendConfig(DEFAULT_CLAUDE_BACKEND, override);
const command = merged.command?.trim();
if (!command) return null;
return { id: normalized, config: { ...merged, command } };
}
if (!override) return null;
const command = override.command?.trim();
if (!command) return null;
return { id: normalized, config: { ...override, command } };
}

633
src/agents/cli-runner.ts Normal file
View File

@@ -0,0 +1,633 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
buildBootstrapContextFiles,
classifyFailoverReason,
type EmbeddedContextFile,
isFailoverErrorMessage,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import {
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles,
} from "./workspace.js";
const log = createSubsystemLogger("agent/claude-cli");
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
const tracked = chained.finally(() => {
if (CLI_RUN_QUEUE.get(key) === tracked) {
CLI_RUN_QUEUE.delete(key);
}
});
CLI_RUN_QUEUE.set(key, tracked);
return chained;
}
type CliUsage = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
type CliOutput = {
text: string;
sessionId?: string;
usage?: CliUsage;
};
function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(
new Date(),
);
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (
!map.weekday ||
!map.year ||
!map.month ||
!map.day ||
!map.hour ||
!map.minute
) {
return undefined;
}
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
if (!model) continue;
const alias = String(
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
).trim();
if (!alias) continue;
entries.push({ alias, model });
}
return entries
.sort((a, b) => a.alias.localeCompare(b.alias))
.map((entry) => `- ${entry.alias}: ${entry.model}`);
}
function buildSystemPrompt(params: {
workspaceDir: string;
config?: ClawdbotConfig;
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
ownerNumbers?: string[];
heartbeatPrompt?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: params.modelDisplay,
},
toolNames: params.tools.map((tool) => tool.name),
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
contextFiles: params.contextFiles,
});
}
function normalizeCliModel(modelId: string, backend: CliBackendConfig): string {
const trimmed = modelId.trim();
if (!trimmed) return trimmed;
const direct = backend.modelAliases?.[trimmed];
if (direct) return direct;
const lower = trimmed.toLowerCase();
const mapped = backend.modelAliases?.[lower];
if (mapped) return mapped;
return trimmed;
}
function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
const pick = (key: string) =>
typeof raw[key] === "number" && raw[key] > 0
? (raw[key] as number)
: undefined;
const input = pick("input_tokens") ?? pick("inputTokens");
const output = pick("output_tokens") ?? pick("outputTokens");
const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead");
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
const total = pick("total_tokens") ?? pick("total");
if (!input && !output && !cacheRead && !cacheWrite && !total)
return undefined;
return { input, output, cacheRead, cacheWrite, total };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function collectText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.map((entry) => collectText(entry)).join("");
}
if (!isRecord(value)) return "";
if (typeof value.text === "string") return value.text;
if (typeof value.content === "string") return value.content;
if (Array.isArray(value.content)) {
return value.content.map((entry) => collectText(entry)).join("");
}
if (isRecord(value.message)) return collectText(value.message);
return "";
}
function pickSessionId(
parsed: Record<string, unknown>,
backend: CliBackendConfig,
): string | undefined {
const fields = backend.sessionIdFields ?? [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
];
for (const field of fields) {
const value = parsed[field];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function parseCliJson(
raw: string,
backend: CliBackendConfig,
): CliOutput | null {
const trimmed = raw.trim();
if (!trimmed) return null;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
const sessionId = pickSessionId(parsed, backend);
const usage = isRecord(parsed.usage) ? toUsage(parsed.usage) : undefined;
const text =
collectText(parsed.message) ||
collectText(parsed.content) ||
collectText(parsed.result) ||
collectText(parsed);
return { text: text.trim(), sessionId, usage };
}
function resolveSystemPromptUsage(params: {
backend: CliBackendConfig;
isNewSession: boolean;
systemPrompt?: string;
}): string | null {
const systemPrompt = params.systemPrompt?.trim();
if (!systemPrompt) return null;
const when = params.backend.systemPromptWhen ?? "first";
if (when === "never") return null;
if (when === "first" && !params.isNewSession) return null;
if (!params.backend.systemPromptArg?.trim()) return null;
return systemPrompt;
}
function resolveSessionIdToSend(params: {
backend: CliBackendConfig;
cliSessionId?: string;
}): { sessionId?: string; isNew: boolean } {
const mode = params.backend.sessionMode ?? "always";
const existing = params.cliSessionId?.trim();
if (mode === "none") return { sessionId: undefined, isNew: !existing };
if (mode === "existing") return { sessionId: existing, isNew: !existing };
if (existing) return { sessionId: existing, isNew: false };
return { sessionId: crypto.randomUUID(), isNew: true };
}
function resolvePromptInput(params: {
backend: CliBackendConfig;
prompt: string;
}): { argsPrompt?: string; stdin?: string } {
const inputMode = params.backend.input ?? "arg";
if (inputMode === "stdin") {
return { stdin: params.prompt };
}
if (
params.backend.maxPromptArgChars &&
params.prompt.length > params.backend.maxPromptArgChars
) {
return { stdin: params.prompt };
}
return { argsPrompt: params.prompt };
}
function resolveImageExtension(mimeType: string): string {
const normalized = mimeType.toLowerCase();
if (normalized.includes("png")) return "png";
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("gif")) return "gif";
if (normalized.includes("webp")) return "webp";
return "bin";
}
async function writeCliImages(
images: ImageContent[],
): Promise<{ paths: string[]; cleanup: () => Promise<void> }> {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-cli-images-"),
);
const paths: string[] = [];
for (let i = 0; i < images.length; i += 1) {
const image = images[i];
const ext = resolveImageExtension(image.mimeType);
const filePath = path.join(tempDir, `image-${i + 1}.${ext}`);
const buffer = Buffer.from(image.data, "base64");
await fs.writeFile(filePath, buffer, { mode: 0o600 });
paths.push(filePath);
}
const cleanup = async () => {
await fs.rm(tempDir, { recursive: true, force: true });
};
return { paths, cleanup };
}
function buildCliArgs(params: {
backend: CliBackendConfig;
modelId: string;
sessionId?: string;
systemPrompt?: string | null;
imagePaths?: string[];
promptArg?: string;
}): string[] {
const args: string[] = [...(params.backend.args ?? [])];
if (params.backend.modelArg && params.modelId) {
args.push(params.backend.modelArg, params.modelId);
}
if (params.systemPrompt && params.backend.systemPromptArg) {
args.push(params.backend.systemPromptArg, params.systemPrompt);
}
if (params.sessionId && params.backend.sessionArg) {
args.push(params.backend.sessionArg, params.sessionId);
}
if (params.imagePaths && params.imagePaths.length > 0) {
const mode = params.backend.imageMode ?? "repeat";
const imageArg = params.backend.imageArg;
if (imageArg) {
if (mode === "list") {
args.push(imageArg, params.imagePaths.join(","));
} else {
for (const imagePath of params.imagePaths) {
args.push(imageArg, imagePath);
}
}
}
}
if (params.promptArg !== undefined) {
args.push(params.promptArg);
}
return args;
}
export async function runCliAgent(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config?: ClawdbotConfig;
prompt: string;
provider: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
cliSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
const backendResolved = resolveCliBackendConfig(
params.provider,
params.config,
);
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
const backend = backendResolved.config;
const modelId = (params.model ?? "default").trim() || "default";
const normalizedModel = normalizeCliModel(modelId, backend);
const modelDisplay = `${params.provider}/${modelId}`;
const extraSystemPrompt = [
params.extraSystemPrompt?.trim(),
"Tools are disabled in this session. Do not call tools.",
]
.filter(Boolean)
.join("\n");
const bootstrapFiles = filterBootstrapFilesForSession(
await loadWorkspaceBootstrapFiles(workspaceDir),
params.sessionKey ?? params.sessionId,
);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const heartbeatPrompt =
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
)
: undefined;
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
heartbeatPrompt,
tools: [],
contextFiles,
modelDisplay,
});
const { sessionId: cliSessionIdToSend, isNew } = resolveSessionIdToSend({
backend,
cliSessionId: params.cliSessionId,
});
const sessionIdSent =
backend.sessionArg && cliSessionIdToSend ? cliSessionIdToSend : undefined;
const systemPromptArg = resolveSystemPromptUsage({
backend,
isNewSession: isNew,
systemPrompt,
});
let imagePaths: string[] | undefined;
let cleanupImages: (() => Promise<void>) | undefined;
if (params.images && params.images.length > 0) {
if (!backend.imageArg) {
throw new FailoverError("CLI backend does not support images.", {
reason: "format",
provider: params.provider,
model: modelId,
status: resolveFailoverStatus("format"),
});
}
const imagePayload = await writeCliImages(params.images);
imagePaths = imagePayload.paths;
cleanupImages = imagePayload.cleanup;
}
const { argsPrompt, stdin } = resolvePromptInput({
backend,
prompt: params.prompt,
});
const args = buildCliArgs({
backend,
modelId: normalizedModel,
sessionId: cliSessionIdToSend,
systemPrompt: systemPromptArg,
imagePaths,
promptArg: argsPrompt,
});
const serialize = backend.serialize ?? true;
const queueKey = serialize
? backendResolved.id
: `${backendResolved.id}:${params.runId}`;
try {
const output = await enqueueCliRun(queueKey, async () => {
log.info(
`cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`,
);
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
if (logOutputText) {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
if (arg === backend.systemPromptArg) {
const systemPromptValue = args[i + 1] ?? "";
logArgs.push(
arg,
`<systemPrompt:${systemPromptValue.length} chars>`,
);
i += 1;
continue;
}
if (arg === backend.sessionArg) {
logArgs.push(arg, args[i + 1] ?? "");
i += 1;
continue;
}
if (arg === backend.modelArg) {
logArgs.push(arg, args[i + 1] ?? "");
i += 1;
continue;
}
if (arg === backend.imageArg) {
logArgs.push(arg, "<image>");
i += 1;
continue;
}
logArgs.push(arg);
}
if (argsPrompt) {
const promptIndex = logArgs.indexOf(argsPrompt);
if (promptIndex >= 0) {
logArgs[promptIndex] = `<prompt:${argsPrompt.length} chars>`;
}
}
log.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`);
}
const env = (() => {
const next = { ...process.env, ...backend.env };
for (const key of backend.clearEnv ?? []) {
delete next[key];
}
return next;
})();
const result = await runCommandWithTimeout([backend.command, ...args], {
timeoutMs: params.timeoutMs,
cwd: workspaceDir,
env,
...(stdin ? { input: stdin } : {}),
});
const stdout = result.stdout.trim();
const stderr = result.stderr.trim();
if (logOutputText) {
if (stdout) log.info(`cli stdout:\n${stdout}`);
if (stderr) log.info(`cli stderr:\n${stderr}`);
}
if (shouldLogVerbose()) {
if (stdout) log.debug(`cli stdout:\n${stdout}`);
if (stderr) log.debug(`cli stderr:\n${stderr}`);
}
if (result.code !== 0) {
const err = stderr || stdout || "CLI failed.";
const reason = classifyFailoverReason(err) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(err, {
reason,
provider: params.provider,
model: modelId,
status,
});
}
if (backend.output === "text") {
return { text: stdout, sessionId: undefined };
}
const parsed = parseCliJson(stdout, backend);
return parsed ?? { text: stdout };
});
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId,
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
} catch (err) {
if (err instanceof FailoverError) throw err;
const message = err instanceof Error ? err.message : String(err);
if (isFailoverErrorMessage(message)) {
const reason = classifyFailoverReason(message) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(message, {
reason,
provider: params.provider,
model: modelId,
status,
});
}
throw err;
} finally {
if (cleanupImages) {
await cleanupImages();
}
}
}
export async function runClaudeCliAgent(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config?: ClawdbotConfig;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
claudeSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
return runCliAgent({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.config,
prompt: params.prompt,
provider: params.provider ?? "claude-cli",
model: params.model ?? "opus",
thinkLevel: params.thinkLevel,
timeoutMs: params.timeoutMs,
runId: params.runId,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
cliSessionId: params.claudeSessionId,
images: params.images,
});
}

33
src/agents/cli-session.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { SessionEntry } from "../config/sessions.js";
import { normalizeProviderId } from "./model-selection.js";
export function getCliSessionId(
entry: SessionEntry | undefined,
provider: string,
): string | undefined {
if (!entry) return undefined;
const normalized = normalizeProviderId(provider);
const fromMap = entry.cliSessionIds?.[normalized];
if (fromMap?.trim()) return fromMap.trim();
if (normalized === "claude-cli") {
const legacy = entry.claudeCliSessionId?.trim();
if (legacy) return legacy;
}
return undefined;
}
export function setCliSessionId(
entry: SessionEntry,
provider: string,
sessionId: string,
): void {
const normalized = normalizeProviderId(provider);
const trimmed = sessionId.trim();
if (!trimmed) return;
const existing = entry.cliSessionIds ?? {};
entry.cliSessionIds = { ...existing };
entry.cliSessionIds[normalized] = trimmed;
if (normalized === "claude-cli") {
entry.claudeCliSessionId = trimmed;
}
}

View File

@@ -28,6 +28,15 @@ export function normalizeProviderId(provider: string): string {
return normalized;
}
export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
const normalized = normalizeProviderId(provider);
if (normalized === "claude-cli") return true;
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
return Object.keys(backends).some(
(key) => normalizeProviderId(key) === normalized,
);
}
function normalizeAnthropicModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) return trimmed;
@@ -173,7 +182,9 @@ export function buildAllowedModelSet(params: {
const parsed = parseModelRef(String(raw), params.defaultProvider);
if (!parsed) continue;
const key = modelKey(parsed.provider, parsed.model);
if (catalogKeys.has(key)) {
if (isCliProvider(parsed.provider, params.cfg)) {
allowedKeys.add(key);
} else if (catalogKeys.has(key)) {
allowedKeys.add(key);
}
}
@@ -186,7 +197,7 @@ export function buildAllowedModelSet(params: {
allowedKeys.has(modelKey(entry.provider, entry.id)),
);
if (allowedCatalog.length === 0) {
if (allowedCatalog.length === 0 && allowedKeys.size === 0) {
if (defaultKey) catalogKeys.add(defaultKey);
return {
allowAny: true,