feat: add CLI backend fallback
This commit is contained in:
190
docs/gateway/cli-backends.md
Normal file
190
docs/gateway/cli-backends.md
Normal file
@@ -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:
|
||||
|
||||
```
|
||||
<provider>/<model>
|
||||
```
|
||||
|
||||
### 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).
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
110
src/agents/cli-backends.ts
Normal 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
633
src/agents/cli-runner.ts
Normal 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
33
src/agents/cli-session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<SessionEntry> = {
|
||||
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<SessionEntry> = {
|
||||
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)}`);
|
||||
|
||||
@@ -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<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
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)) {
|
||||
|
||||
@@ -112,6 +112,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -113,6 +113,7 @@ export type SessionEntry = {
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
cliSessionIds?: Record<string, string>;
|
||||
claudeCliSessionId?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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<string, string>;
|
||||
/** 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<string, CliBackendConfig>;
|
||||
/** 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. */
|
||||
|
||||
@@ -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 | number>): 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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
257
src/gateway/gateway-cli-backend.live.test.ts
Normal file
257
src/gateway/gateway-cli-backend.live.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
|
||||
const texts = payloads
|
||||
.map((p) =>
|
||||
p && typeof p === "object"
|
||||
? (p as Record<string, unknown>).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<number> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<GatewayClient>((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<Record<string, unknown>>(
|
||||
"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);
|
||||
});
|
||||
Reference in New Issue
Block a user