From d8f1124d594f687a730332bf7900bd1bdca35744 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 23:31:25 +0000 Subject: [PATCH] feat: add CLI backend fallback --- docs/gateway/cli-backends.md | 190 ++++++ docs/gateway/configuration.md | 40 ++ docs/testing.md | 25 + pnpm-lock.yaml | 10 +- src/agents/claude-cli-runner.ts | 427 +----------- src/agents/cli-backends.ts | 110 +++ src/agents/cli-runner.ts | 633 ++++++++++++++++++ src/agents/cli-session.ts | 33 + src/agents/model-selection.ts | 15 +- .../reply/agent-runner.claude-cli.test.ts | 10 +- src/auto-reply/reply/agent-runner.ts | 57 +- src/commands/agent.ts | 21 +- src/config/schema.ts | 3 + src/config/sessions.ts | 1 + src/config/types.ts | 41 ++ src/config/zod-schema.ts | 28 + src/cron/isolated-agent.ts | 19 +- src/gateway/gateway-cli-backend.live.test.ts | 257 +++++++ 18 files changed, 1448 insertions(+), 472 deletions(-) create mode 100644 docs/gateway/cli-backends.md create mode 100644 src/agents/cli-backends.ts create mode 100644 src/agents/cli-runner.ts create mode 100644 src/agents/cli-session.ts create mode 100644 src/gateway/gateway-cli-backend.live.test.ts diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md new file mode 100644 index 000000000..daab7ba83 --- /dev/null +++ b/docs/gateway/cli-backends.md @@ -0,0 +1,190 @@ +--- +summary: "CLI backends: text-only fallback via local AI CLIs" +read_when: + - You want a reliable fallback when API providers fail + - You are running Claude CLI or other local AI CLIs and want to reuse them + - You need a text-only, tool-free path that still supports sessions and images +--- +# CLI backends (fallback runtime) + +Clawdbot can run **local AI CLIs** as a **text-only fallback** when API providers are down, +rate-limited, or temporarily misbehaving. This is intentionally conservative: + +- **Tools are disabled** (no tool calls). +- **Text in → text out** (reliable). +- **Sessions are supported** (so follow-up turns stay coherent). +- **Images can be passed through** if the CLI accepts image paths. + +This is designed as a **safety net** rather than a primary path. Use it when you +want “always works” text responses without relying on external APIs. + +## Beginner-friendly quick start + +You can use Claude CLI **without any config** (Clawdbot ships a built-in default): + +```bash +clawdbot agent --message "hi" --model claude-cli/opus-4.5 +``` + +If your gateway runs under launchd/systemd and PATH is minimal, add just the +command path: + +```json5 +{ + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "/opt/homebrew/bin/claude" + } + } + } + } +} +``` + +That’s it. No keys, no extra auth config needed beyond the CLI itself. + +## Using it as a fallback + +Add a CLI backend to your fallback list so it only runs when primary models fail: + +```json5 +{ + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: [ + "claude-cli/opus-4.5" + ] + }, + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "claude-cli/opus-4.5": {} + } + } + } +} +``` + +Notes: +- If you use `agents.defaults.models` (allowlist), you must include `claude-cli/...`. +- If the primary provider fails (auth, rate limits, timeouts), Clawdbot will + try the CLI backend next. + +## Configuration overview + +All CLI backends live under: + +``` +agents.defaults.cliBackends +``` + +Each entry is keyed by a **provider id** (e.g. `claude-cli`, `my-cli`). +The provider id becomes the left side of your model ref: + +``` +/ +``` + +### Example configuration + +```json5 +{ + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "/opt/homebrew/bin/claude" + }, + "my-cli": { + command: "my-cli", + args: ["--json"], + output: "json", + input: "arg", + modelArg: "--model", + modelAliases: { + "claude-opus-4-5": "opus", + "claude-sonnet-4-5": "sonnet" + }, + sessionArg: "--session", + sessionMode: "existing", + sessionIdFields: ["session_id", "conversation_id"], + systemPromptArg: "--system", + systemPromptWhen: "first", + imageArg: "--image", + imageMode: "repeat", + serialize: true + } + } + } + } +} +``` + +## How it works + +1) **Selects a backend** based on the provider prefix (`claude-cli/...`). +2) **Builds a system prompt** using the same Clawdbot prompt + workspace context. +3) **Executes the CLI** with a session id (if supported) so history stays consistent. +4) **Parses output** (JSON or plain text) and returns the final text. +5) **Persists session ids** per backend, so follow-ups reuse the same CLI session. + +## Sessions + +- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`). +- `sessionMode`: + - `always`: always send a session id (new UUID if none stored). + - `existing`: only send a session id if one was stored before. + - `none`: never send a session id. + +## Images (pass-through) + +If your CLI accepts image paths, set `imageArg`: + +```json5 +imageArg: "--image", +imageMode: "repeat" +``` + +Clawdbot will write base64 images to temp files and pass their paths. +If `imageArg` is missing and images are present, the CLI backend will fail fast +(so fallback continues to the next provider). + +## Inputs / outputs + +- `output: "json"` (default) tries to parse JSON and extract text + session id. +- `output: "text"` treats stdout as the final response. + +Input modes: +- `input: "arg"` (default) passes the prompt as the last CLI arg. +- `input: "stdin"` sends the prompt via stdin. +- If the prompt is very long and `maxPromptArgChars` is set, stdin is used. + +## Defaults (built-in) + +Clawdbot ships a default for `claude-cli`: + +- `command: "claude"` +- `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]` +- `modelArg: "--model"` +- `systemPromptArg: "--append-system-prompt"` +- `sessionArg: "--session-id"` +- `systemPromptWhen: "first"` +- `sessionMode: "always"` + +Override only if needed (common: absolute `command` path). + +## Limitations + +- **No tools** (tool calls are disabled by design). +- **No streaming** (CLI output is collected then returned). +- **Structured outputs** depend on the CLI’s JSON format. + +## Troubleshooting + +- **CLI not found**: set `command` to a full path. +- **Wrong model name**: use `modelAliases` to map `provider/model` → CLI model. +- **No session continuity**: ensure `sessionArg` is set and `sessionMode` is not `none`. +- **Images ignored**: set `imageArg` (and verify CLI supports file paths). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index dcba68f95..e88b70926 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1038,6 +1038,46 @@ is already present in `agents.defaults.models`: If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override). +#### `agents.defaults.cliBackends` (CLI fallback) + +Optional CLI backends for text-only fallback runs (no tool calls). These are useful as a +backup path when API providers fail. Image pass-through is supported when you configure +an `imageArg` that accepts file paths. + +Notes: +- CLI backends are **text-first**; tools are always disabled. +- Sessions are supported when `sessionArg` is set; session ids are persisted per backend. +- For `claude-cli`, defaults are wired in. Override the command path if PATH is minimal + (launchd/systemd). + +Example: + +```json5 +{ + agents: { + defaults: { + cliBackends: { + "claude-cli": { + command: "/opt/homebrew/bin/claude" + }, + "my-cli": { + command: "my-cli", + args: ["--json"], + output: "json", + modelArg: "--model", + sessionArg: "--session", + sessionMode: "existing", + systemPromptArg: "--system", + systemPromptWhen: "first", + imageArg: "--image", + imageMode: "repeat" + } + } + } + } +} +``` + ```json5 { agents: { diff --git a/docs/testing.md b/docs/testing.md index 2a52b72d8..e0c3a870c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -162,6 +162,31 @@ clawdbot models auth paste-token --provider anthropic --profile-id anthropic:set CLAWDBOT_LIVE_TEST=1 CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-token-test pnpm test:live src/agents/anthropic.setup-token.live.test.ts ``` +## Live: CLI backend smoke (Claude CLI or other local CLIs) + +- Test: `src/gateway/gateway-cli-backend.live.test.ts` +- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config. +- Enable: + - `CLAWDBOT_LIVE_TEST=1` or `LIVE=1` + - `CLAWDBOT_LIVE_CLI_BACKEND=1` +- Defaults: + - Model: `claude-cli/claude-sonnet-4-5` + - Command: `claude` + - Args: `["-p","--output-format","json","--dangerously-skip-permissions"]` +- Overrides (optional): + - `CLAWDBOT_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-5"` + - `CLAWDBOT_LIVE_CLI_BACKEND_COMMAND="/full/path/to/claude"` + - `CLAWDBOT_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json","--permission-mode","bypassPermissions"]'` + - `CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV='["ANTHROPIC_API_KEY"]'` + +Example: + +```bash +CLAWDBOT_LIVE_TEST=1 CLAWDBOT_LIVE_CLI_BACKEND=1 \ + CLAWDBOT_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-sonnet-4-5" \ + pnpm test:live src/gateway/gateway-cli-backend.live.test.ts +``` + ### Recommended live recipes Narrow, explicit allowlists are fastest and least flaky: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d826333..96ea38d3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: '@mariozechner/pi-ai@0.42.2': - hash: d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97 + hash: 626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a path: patches/@mariozechner__pi-ai@0.42.2.patch importers: @@ -36,7 +36,7 @@ importers: version: 0.42.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': specifier: ^0.42.2 - version: 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5) + version: 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': specifier: ^0.42.2 version: 0.42.2(ws@8.19.0)(zod@4.3.5) @@ -3777,7 +3777,7 @@ snapshots: '@mariozechner/pi-agent-core@0.42.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.42.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -3787,7 +3787,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3811,7 +3811,7 @@ snapshots: dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/pi-agent-core': 0.42.2(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.42.2(patch_hash=d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.42.2(patch_hash=626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.42.2 chalk: 5.6.2 cli-highlight: 2.1.11 diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts index b56afd360..23e129334 100644 --- a/src/agents/claude-cli-runner.ts +++ b/src/agents/claude-cli-runner.ts @@ -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>(); - -function enqueueClaudeCliRun( - key: string, - task: () => Promise, -): Promise { - 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 = {}; - 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): 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 { - 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 { - 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, ``); - 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] = ``; - } - 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 { - 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"; diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts new file mode 100644 index 000000000..3fc90b917 --- /dev/null +++ b/src/agents/cli-backends.ts @@ -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 = { + 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, + 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 { + const ids = new Set([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 } }; +} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts new file mode 100644 index 000000000..f3d6b8a8d --- /dev/null +++ b/src/agents/cli-runner.ts @@ -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>(); + +function enqueueCliRun(key: string, task: () => Promise): Promise { + 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 = {}; + 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): 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 { + 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, + 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 }> { + 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 { + 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) | 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, + ``, + ); + 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, ""); + i += 1; + continue; + } + logArgs.push(arg); + } + if (argsPrompt) { + const promptIndex = logArgs.indexOf(argsPrompt); + if (promptIndex >= 0) { + logArgs[promptIndex] = ``; + } + } + 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 { + 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, + }); +} diff --git a/src/agents/cli-session.ts b/src/agents/cli-session.ts new file mode 100644 index 000000000..dd5ab54f4 --- /dev/null +++ b/src/agents/cli-session.ts @@ -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; + } +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 56616ca08..408326e03 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner.claude-cli.test.ts b/src/auto-reply/reply/agent-runner.claude-cli.test.ts index f47bea7dd..d306824c7 100644 --- a/src/auto-reply/reply/agent-runner.claude-cli.test.ts +++ b/src/auto-reply/reply/agent-runner.claude-cli.test.ts @@ -6,7 +6,7 @@ import type { FollowupRun, QueueSettings } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; const runEmbeddedPiAgentMock = vi.fn(); -const runClaudeCliAgentMock = vi.fn(); +const runCliAgentMock = vi.fn(); vi.mock("../../agents/model-fallback.js", () => ({ runWithModelFallback: async ({ @@ -29,8 +29,8 @@ vi.mock("../../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), })); -vi.mock("../../agents/claude-cli-runner.js", () => ({ - runClaudeCliAgent: (params: unknown) => runClaudeCliAgentMock(params), +vi.mock("../../agents/cli-runner.js", () => ({ + runCliAgent: (params: unknown) => runCliAgentMock(params), })); vi.mock("./queue.js", async () => { @@ -112,7 +112,7 @@ describe("runReplyAgent claude-cli routing", () => { const phase = evt.data?.phase; if (typeof phase === "string") lifecyclePhases.push(phase); }); - runClaudeCliAgentMock.mockResolvedValueOnce({ + runCliAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], meta: { agentMeta: { @@ -126,7 +126,7 @@ describe("runReplyAgent claude-cli routing", () => { unsubscribe(); randomSpy.mockRestore(); - expect(runClaudeCliAgentMock).toHaveBeenCalledTimes(1); + expect(runCliAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); expect(lifecyclePhases).toEqual(["start", "end"]); expect(result).toMatchObject({ text: "ok" }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 09d8772c2..1222e5c33 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1,10 +1,12 @@ import crypto from "node:crypto"; import fs from "node:fs"; -import { runClaudeCliAgent } from "../../agents/claude-cli-runner.js"; +import { runCliAgent } from "../../agents/cli-runner.js"; +import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { isCliProvider } from "../../agents/model-selection.js"; import { queueEmbeddedPiMessage, runEmbeddedPiAgent, @@ -376,7 +378,7 @@ export async function runReplyAgent(params: { provider: followupRun.run.provider, model: followupRun.run.model, run: (provider, model) => { - if (provider === "claude-cli") { + if (isCliProvider(provider, followupRun.run.config)) { const startedAt = Date.now(); emitAgentEvent({ runId, @@ -386,7 +388,8 @@ export async function runReplyAgent(params: { startedAt, }, }); - return runClaudeCliAgent({ + const cliSessionId = getCliSessionId(sessionEntry, provider); + return runCliAgent({ sessionId: followupRun.run.sessionId, sessionKey, sessionFile: followupRun.run.sessionFile, @@ -400,8 +403,7 @@ export async function runReplyAgent(params: { runId, extraSystemPrompt: followupRun.run.extraSystemPrompt, ownerNumbers: followupRun.run.ownerNumbers, - claudeSessionId: - sessionEntry?.claudeCliSessionId?.trim() || undefined, + cliSessionId, }) .then((result) => { emitAgentEvent({ @@ -815,10 +817,9 @@ export async function runReplyAgent(params: { runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider; - const cliSessionId = - providerUsed === "claude-cli" - ? runResult.meta.agentMeta?.sessionId?.trim() - : undefined; + const cliSessionId = isCliProvider(providerUsed, cfg) + ? runResult.meta.agentMeta?.sessionId?.trim() + : undefined; const contextTokensUsed = agentCfgContextTokens ?? lookupContextTokens(modelUsed) ?? @@ -836,7 +837,7 @@ export async function runReplyAgent(params: { const output = usage.output ?? 0; const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - return { + const patch: Partial = { inputTokens: input, outputTokens: output, totalTokens: @@ -845,8 +846,17 @@ export async function runReplyAgent(params: { model: modelUsed, contextTokens: contextTokensUsed ?? entry.contextTokens, updatedAt: Date.now(), - claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId, }; + if (cliSessionId) { + const nextEntry = { ...entry, ...patch }; + setCliSessionId(nextEntry, providerUsed, cliSessionId); + return { + ...patch, + cliSessionIds: nextEntry.cliSessionIds, + claudeCliSessionId: nextEntry.claudeCliSessionId, + }; + } + return patch; }, }); } catch (err) { @@ -857,13 +867,24 @@ export async function runReplyAgent(params: { await updateSessionStoreEntry({ storePath, sessionKey, - update: async (entry) => ({ - modelProvider: providerUsed ?? entry.modelProvider, - model: modelUsed ?? entry.model, - contextTokens: contextTokensUsed ?? entry.contextTokens, - claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId, - updatedAt: Date.now(), - }), + update: async (entry) => { + const patch: Partial = { + modelProvider: providerUsed ?? entry.modelProvider, + model: modelUsed ?? entry.model, + contextTokens: contextTokensUsed ?? entry.contextTokens, + updatedAt: Date.now(), + }; + if (cliSessionId) { + const nextEntry = { ...entry, ...patch }; + setCliSessionId(nextEntry, providerUsed, cliSessionId); + return { + ...patch, + cliSessionIds: nextEntry.cliSessionIds, + claudeCliSessionId: nextEntry.claudeCliSessionId, + }; + } + return patch; + }, }); } catch (err) { logVerbose(`failed to persist model/context update: ${String(err)}`); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index cf7eb6ab7..b7d2ea448 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -4,7 +4,8 @@ import { resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { runClaudeCliAgent } from "../agents/claude-cli-runner.js"; +import { runCliAgent } from "../agents/cli-runner.js"; +import { getCliSessionId, setCliSessionId } from "../agents/cli-session.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -15,6 +16,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runWithModelFallback } from "../agents/model-fallback.js"; import { buildAllowedModelSet, + isCliProvider, modelKey, resolveConfiguredModelRef, resolveThinkingDefault, @@ -361,7 +363,7 @@ export async function agentCommand( if (overrideModel) { const key = modelKey(overrideProvider, overrideModel); if ( - overrideProvider !== "claude-cli" && + !isCliProvider(overrideProvider, cfg) && allowedModelKeys.size > 0 && !allowedModelKeys.has(key) ) { @@ -380,7 +382,7 @@ export async function agentCommand( const candidateProvider = storedProviderOverride || defaultProvider; const key = modelKey(candidateProvider, storedModelOverride); if ( - candidateProvider === "claude-cli" || + isCliProvider(candidateProvider, cfg) || allowedModelKeys.size === 0 || allowedModelKeys.has(key) ) { @@ -422,7 +424,6 @@ export async function agentCommand( let result: Awaited>; let fallbackProvider = provider; let fallbackModel = model; - const claudeSessionId = sessionEntry?.claudeCliSessionId?.trim(); try { const messageProvider = resolveMessageProvider( opts.messageProvider, @@ -433,8 +434,9 @@ export async function agentCommand( provider, model, run: (providerOverride, modelOverride) => { - if (providerOverride === "claude-cli") { - return runClaudeCliAgent({ + if (isCliProvider(providerOverride, cfg)) { + const cliSessionId = getCliSessionId(sessionEntry, providerOverride); + return runCliAgent({ sessionId, sessionKey, sessionFile, @@ -447,7 +449,8 @@ export async function agentCommand( timeoutMs, runId, extraSystemPrompt: opts.extraSystemPrompt, - claudeSessionId, + cliSessionId, + images: opts.images, }); } return runEmbeddedPiAgent({ @@ -542,9 +545,9 @@ export async function agentCommand( model: modelUsed, contextTokens, }; - if (providerUsed === "claude-cli") { + if (isCliProvider(providerUsed, cfg)) { const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); - if (cliSessionId) next.claudeCliSessionId = cliSessionId; + if (cliSessionId) setCliSessionId(next, providerUsed, cliSessionId); } next.abortedLastRun = result.meta.aborted ?? false; if (hasNonzeroUsage(usage)) { diff --git a/src/config/schema.ts b/src/config/schema.ts index 94aad9b3c..889a54d86 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -112,6 +112,7 @@ const FIELD_LABELS: Record = { "agents.defaults.humanDelay.mode": "Human Delay Mode", "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.cliBackends": "CLI Backends", "commands.native": "Native Commands", "commands.text": "Text Commands", "commands.restart": "Allow Restart", @@ -191,6 +192,8 @@ const FIELD_HELP: Record = { "Optional image model (provider/model) used when the primary model lacks image input.", "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "agents.defaults.cliBackends": + "Optional CLI backends for text-only fallback (claude-cli, etc.).", "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', "agents.defaults.humanDelay.minMs": diff --git a/src/config/sessions.ts b/src/config/sessions.ts index d4d638179..c8ea348c8 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -113,6 +113,7 @@ export type SessionEntry = { model?: string; contextTokens?: number; compactionCount?: number; + cliSessionIds?: Record; claudeCliSessionId?: string; label?: string; displayName?: string; diff --git a/src/config/types.ts b/src/config/types.ts index d0b009afc..1b95e7ff7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1355,6 +1355,45 @@ export type AgentContextPruningConfig = { }; }; +export type CliBackendConfig = { + /** CLI command to execute (absolute path or on PATH). */ + command: string; + /** Base args applied to every invocation. */ + args?: string[]; + /** Output parsing mode (default: json). */ + output?: "json" | "text"; + /** Prompt input mode (default: arg). */ + input?: "arg" | "stdin"; + /** Max prompt length for arg mode (if exceeded, stdin is used). */ + maxPromptArgChars?: number; + /** Extra env vars injected for this CLI. */ + env?: Record; + /** Env vars to remove before launching this CLI. */ + clearEnv?: string[]; + /** Flag used to pass model id (e.g. --model). */ + modelArg?: string; + /** Model aliases mapping (config model id → CLI model id). */ + modelAliases?: Record; + /** Flag used to pass session id (e.g. --session-id). */ + sessionArg?: string; + /** When to pass session ids. */ + sessionMode?: "always" | "existing" | "none"; + /** JSON fields to read session id from (in order). */ + sessionIdFields?: string[]; + /** Flag used to pass system prompt. */ + systemPromptArg?: string; + /** System prompt behavior (append vs replace). */ + systemPromptMode?: "append" | "replace"; + /** When to send system prompt. */ + systemPromptWhen?: "first" | "always" | "never"; + /** Flag used to pass image paths. */ + imageArg?: string; + /** How to pass multiple images. */ + imageMode?: "repeat" | "list"; + /** Serialize runs for this CLI. */ + serialize?: boolean; +}; + export type AgentDefaultsConfig = { /** Primary model and fallbacks (provider/model). */ model?: AgentModelListConfig; @@ -1370,6 +1409,8 @@ export type AgentDefaultsConfig = { userTimezone?: string; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; + /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ + cliBackends?: Record; /** Opt-in: prune old tool results from the LLM context to reduce token usage. */ contextPruning?: AgentContextPruningConfig; /** Default thinking level when no /think directive is present. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index eb5d0a1e0..71cf2f404 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -124,6 +124,33 @@ const HumanDelaySchema = z.object({ maxMs: z.number().int().nonnegative().optional(), }); +const CliBackendSchema = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + output: z.union([z.literal("json"), z.literal("text")]).optional(), + input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), + maxPromptArgChars: z.number().int().positive().optional(), + env: z.record(z.string(), z.string()).optional(), + clearEnv: z.array(z.string()).optional(), + modelArg: z.string().optional(), + modelAliases: z.record(z.string(), z.string()).optional(), + sessionArg: z.string().optional(), + sessionMode: z + .union([z.literal("always"), z.literal("existing"), z.literal("none")]) + .optional(), + sessionIdFields: z.array(z.string()).optional(), + systemPromptArg: z.string().optional(), + systemPromptMode: z + .union([z.literal("append"), z.literal("replace")]) + .optional(), + systemPromptWhen: z + .union([z.literal("first"), z.literal("always"), z.literal("never")]) + .optional(), + imageArg: z.string().optional(), + imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), + serialize: z.boolean().optional(), +}); + const normalizeAllowFrom = (values?: Array): string[] => (values ?? []).map((v) => String(v).trim()).filter(Boolean); @@ -1037,6 +1064,7 @@ const AgentDefaultsSchema = z skipBootstrap: z.boolean().optional(), userTimezone: z.string().optional(), contextTokens: z.number().int().positive().optional(), + cliBackends: z.record(z.string(), CliBackendSchema).optional(), contextPruning: z .object({ mode: z diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index ebb83aeb1..017e33092 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; -import { runClaudeCliAgent } from "../agents/claude-cli-runner.js"; +import { runCliAgent } from "../agents/cli-runner.js"; +import { getCliSessionId, setCliSessionId } from "../agents/cli-session.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -10,6 +11,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runWithModelFallback } from "../agents/model-fallback.js"; import { getModelRefStatus, + isCliProvider, resolveAllowedModelRef, resolveConfiguredModelRef, resolveHooksGmailModel, @@ -444,14 +446,17 @@ export async function runCronIsolatedAgentTurn(params: { verboseLevel: resolvedVerboseLevel, }); const messageProvider = resolvedDelivery.provider; - const claudeSessionId = cronSession.sessionEntry.claudeCliSessionId?.trim(); const fallbackResult = await runWithModelFallback({ cfg: params.cfg, provider, model, run: (providerOverride, modelOverride) => { - if (providerOverride === "claude-cli") { - return runClaudeCliAgent({ + if (isCliProvider(providerOverride, params.cfg)) { + const cliSessionId = getCliSessionId( + cronSession.sessionEntry, + providerOverride, + ); + return runCliAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: params.sessionKey, sessionFile, @@ -463,7 +468,7 @@ export async function runCronIsolatedAgentTurn(params: { thinkLevel, timeoutMs, runId: cronSession.sessionEntry.sessionId, - claudeSessionId, + cliSessionId, }); } return runEmbeddedPiAgent({ @@ -508,10 +513,10 @@ export async function runCronIsolatedAgentTurn(params: { cronSession.sessionEntry.modelProvider = providerUsed; cronSession.sessionEntry.model = modelUsed; cronSession.sessionEntry.contextTokens = contextTokens; - if (providerUsed === "claude-cli") { + if (isCliProvider(providerUsed, params.cfg)) { const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim(); if (cliSessionId) { - cronSession.sessionEntry.claudeCliSessionId = cliSessionId; + setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId); } } if (hasNonzeroUsage(usage)) { diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts new file mode 100644 index 000000000..a1219449d --- /dev/null +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -0,0 +1,257 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { createServer } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { parseModelRef } from "../agents/model-selection.js"; +import { loadConfig } from "../config/config.js"; +import { GatewayClient } from "./client.js"; +import { startGatewayServer } from "./server.js"; + +const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; +const CLI_LIVE = process.env.CLAWDBOT_LIVE_CLI_BACKEND === "1"; +const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; + +const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5"; +const DEFAULT_ARGS = [ + "-p", + "--output-format", + "json", + "--dangerously-skip-permissions", +]; +const DEFAULT_CLEAR_ENV: string[] = []; + +function extractPayloadText(result: unknown): string { + const record = result as Record; + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((p) => + p && typeof p === "object" + ? (p as Record).text + : undefined, + ) + .filter((t): t is string => typeof t === "string" && t.trim().length > 0); + return texts.join("\n").trim(); +} + +function parseJsonStringArray( + name: string, + raw?: string, +): string[] | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + const parsed = JSON.parse(trimmed); + if ( + !Array.isArray(parsed) || + !parsed.every((entry) => typeof entry === "string") + ) { + throw new Error(`${name} must be a JSON array of strings.`); + } + return parsed; +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const srv = createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (!addr || typeof addr === "string") { + srv.close(); + reject(new Error("failed to acquire free port")); + return; + } + const port = addr.port; + srv.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +async function isPortFree(port: number): Promise { + if (!Number.isFinite(port) || port <= 0 || port > 65535) return false; + return await new Promise((resolve) => { + const srv = createServer(); + srv.once("error", () => resolve(false)); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve(true)); + }); + }); +} + +async function getFreeGatewayPort(): Promise { + for (let attempt = 0; attempt < 25; attempt += 1) { + const port = await getFreePort(); + const candidates = [port, port + 1, port + 2, port + 4]; + const ok = ( + await Promise.all(candidates.map((candidate) => isPortFree(candidate))) + ).every(Boolean); + if (ok) return port; + } + throw new Error("failed to acquire a free gateway port block"); +} + +async function connectClient(params: { url: string; token: string }) { + return await new Promise((resolve, reject) => { + let settled = false; + const stop = (err?: Error, client?: GatewayClient) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) reject(err); + else resolve(client as GatewayClient); + }; + const client = new GatewayClient({ + url: params.url, + token: params.token, + clientName: "vitest-live-cli-backend", + clientVersion: "dev", + mode: "test", + onHelloOk: () => stop(undefined, client), + onConnectError: (err) => stop(err), + onClose: (code, reason) => + stop(new Error(`gateway closed during connect (${code}): ${reason}`)), + }); + const timer = setTimeout( + () => stop(new Error("gateway connect timeout")), + 10_000, + ); + timer.unref(); + client.start(); + }); +} + +describeLive("gateway live (cli backend)", () => { + it("runs the agent pipeline against the local CLI backend", async () => { + const previous = { + configPath: process.env.CLAWDBOT_CONFIG_PATH, + token: process.env.CLAWDBOT_GATEWAY_TOKEN, + skipProviders: process.env.CLAWDBOT_SKIP_PROVIDERS, + skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER, + skipCron: process.env.CLAWDBOT_SKIP_CRON, + skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST, + }; + + process.env.CLAWDBOT_SKIP_PROVIDERS = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CRON = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; + + const token = `test-${randomUUID()}`; + process.env.CLAWDBOT_GATEWAY_TOKEN = token; + + const rawModel = + process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL; + const parsed = parseModelRef(rawModel, "claude-cli"); + if (!parsed || parsed.provider !== "claude-cli") { + throw new Error( + `CLAWDBOT_LIVE_CLI_BACKEND_MODEL must resolve to a claude-cli model. Got: ${rawModel}`, + ); + } + const modelKey = `${parsed.provider}/${parsed.model}`; + + const cliCommand = + process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ?? "claude"; + const cliArgs = + parseJsonStringArray( + "CLAWDBOT_LIVE_CLI_BACKEND_ARGS", + process.env.CLAWDBOT_LIVE_CLI_BACKEND_ARGS, + ) ?? DEFAULT_ARGS; + const cliClearEnv = + parseJsonStringArray( + "CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV", + process.env.CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV, + ) ?? DEFAULT_CLEAR_ENV; + + const cfg = loadConfig(); + const existingBackends = cfg.agents?.defaults?.cliBackends ?? {}; + const nextCfg = { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { primary: modelKey }, + models: { + [modelKey]: {}, + }, + cliBackends: { + ...existingBackends, + "claude-cli": { + command: cliCommand, + args: cliArgs, + clearEnv: cliClearEnv, + }, + }, + sandbox: { mode: "off" }, + }, + }, + }; + + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-live-cli-"), + ); + const tempConfigPath = path.join(tempDir, "clawdbot.json"); + await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); + process.env.CLAWDBOT_CONFIG_PATH = tempConfigPath; + + const port = await getFreeGatewayPort(); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + const client = await connectClient({ + url: `ws://127.0.0.1:${port}`, + token, + }); + + try { + const sessionKey = "agent:dev:live-cli-backend"; + const runId = randomUUID(); + const nonce = randomBytes(3).toString("hex").toUpperCase(); + const payload = await client.request>( + "agent", + { + sessionKey, + idempotencyKey: `idem-${runId}`, + message: `Reply with exactly: CLI backend OK ${nonce}.`, + deliver: false, + }, + { expectFinal: true }, + ); + if (payload?.status !== "ok") { + throw new Error(`agent status=${String(payload?.status)}`); + } + const text = extractPayloadText(payload?.result); + expect(text).toContain(`CLI backend OK ${nonce}.`); + } finally { + client.stop(); + await server.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + if (previous.configPath === undefined) + delete process.env.CLAWDBOT_CONFIG_PATH; + else process.env.CLAWDBOT_CONFIG_PATH = previous.configPath; + if (previous.token === undefined) + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + else process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token; + if (previous.skipProviders === undefined) + delete process.env.CLAWDBOT_SKIP_PROVIDERS; + else process.env.CLAWDBOT_SKIP_PROVIDERS = previous.skipProviders; + if (previous.skipGmail === undefined) + delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; + else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail; + if (previous.skipCron === undefined) + delete process.env.CLAWDBOT_SKIP_CRON; + else process.env.CLAWDBOT_SKIP_CRON = previous.skipCron; + if (previous.skipCanvas === undefined) + delete process.env.CLAWDBOT_SKIP_CANVAS_HOST; + else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas; + } + }, 60_000); +});