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).
|
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
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
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
|
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
|
### Recommended live recipes
|
||||||
|
|
||||||
Narrow, explicit allowlists are fastest and least flaky:
|
Narrow, explicit allowlists are fastest and least flaky:
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -9,7 +9,7 @@ overrides:
|
|||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
'@mariozechner/pi-ai@0.42.2':
|
'@mariozechner/pi-ai@0.42.2':
|
||||||
hash: d8d7c692952a4064a56dc4c67818939d17a48886eb355867de2bcc9118915c97
|
hash: 626a71b0c497d0d6200d4fe689ecf8e9261ee8f6b478df1913691d5f85fb8a6a
|
||||||
path: patches/@mariozechner__pi-ai@0.42.2.patch
|
path: patches/@mariozechner__pi-ai@0.42.2.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@@ -36,7 +36,7 @@ importers:
|
|||||||
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.42.2
|
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':
|
'@mariozechner/pi-coding-agent':
|
||||||
specifier: ^0.42.2
|
specifier: ^0.42.2
|
||||||
version: 0.42.2(ws@8.19.0)(zod@4.3.5)
|
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)':
|
'@mariozechner/pi-agent-core@0.42.2(ws@8.19.0)(zod@4.3.5)':
|
||||||
dependencies:
|
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
|
'@mariozechner/pi-tui': 0.42.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
@@ -3787,7 +3787,7 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- 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:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
|
||||||
'@google/genai': 1.34.0
|
'@google/genai': 1.34.0
|
||||||
@@ -3811,7 +3811,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/clipboard': 0.3.0
|
'@mariozechner/clipboard': 0.3.0
|
||||||
'@mariozechner/pi-agent-core': 0.42.2(ws@8.19.0)(zod@4.3.5)
|
'@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
|
'@mariozechner/pi-tui': 0.42.2
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
|
|||||||
@@ -1,426 +1 @@
|
|||||||
import crypto from "node:crypto";
|
export { runClaudeCliAgent, runCliAgent } from "./cli-runner.js";
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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;
|
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 {
|
function normalizeAnthropicModelId(model: string): string {
|
||||||
const trimmed = model.trim();
|
const trimmed = model.trim();
|
||||||
if (!trimmed) return trimmed;
|
if (!trimmed) return trimmed;
|
||||||
@@ -173,7 +182,9 @@ export function buildAllowedModelSet(params: {
|
|||||||
const parsed = parseModelRef(String(raw), params.defaultProvider);
|
const parsed = parseModelRef(String(raw), params.defaultProvider);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
const key = modelKey(parsed.provider, parsed.model);
|
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);
|
allowedKeys.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +197,7 @@ export function buildAllowedModelSet(params: {
|
|||||||
allowedKeys.has(modelKey(entry.provider, entry.id)),
|
allowedKeys.has(modelKey(entry.provider, entry.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allowedCatalog.length === 0) {
|
if (allowedCatalog.length === 0 && allowedKeys.size === 0) {
|
||||||
if (defaultKey) catalogKeys.add(defaultKey);
|
if (defaultKey) catalogKeys.add(defaultKey);
|
||||||
return {
|
return {
|
||||||
allowAny: true,
|
allowAny: true,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { FollowupRun, QueueSettings } from "./queue.js";
|
|||||||
import { createMockTypingController } from "./test-helpers.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
const runEmbeddedPiAgentMock = vi.fn();
|
const runEmbeddedPiAgentMock = vi.fn();
|
||||||
const runClaudeCliAgentMock = vi.fn();
|
const runCliAgentMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../agents/model-fallback.js", () => ({
|
vi.mock("../../agents/model-fallback.js", () => ({
|
||||||
runWithModelFallback: async ({
|
runWithModelFallback: async ({
|
||||||
@@ -29,8 +29,8 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
|||||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../agents/claude-cli-runner.js", () => ({
|
vi.mock("../../agents/cli-runner.js", () => ({
|
||||||
runClaudeCliAgent: (params: unknown) => runClaudeCliAgentMock(params),
|
runCliAgent: (params: unknown) => runCliAgentMock(params),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./queue.js", async () => {
|
vi.mock("./queue.js", async () => {
|
||||||
@@ -112,7 +112,7 @@ describe("runReplyAgent claude-cli routing", () => {
|
|||||||
const phase = evt.data?.phase;
|
const phase = evt.data?.phase;
|
||||||
if (typeof phase === "string") lifecyclePhases.push(phase);
|
if (typeof phase === "string") lifecyclePhases.push(phase);
|
||||||
});
|
});
|
||||||
runClaudeCliAgentMock.mockResolvedValueOnce({
|
runCliAgentMock.mockResolvedValueOnce({
|
||||||
payloads: [{ text: "ok" }],
|
payloads: [{ text: "ok" }],
|
||||||
meta: {
|
meta: {
|
||||||
agentMeta: {
|
agentMeta: {
|
||||||
@@ -126,7 +126,7 @@ describe("runReplyAgent claude-cli routing", () => {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
randomSpy.mockRestore();
|
randomSpy.mockRestore();
|
||||||
|
|
||||||
expect(runClaudeCliAgentMock).toHaveBeenCalledTimes(1);
|
expect(runCliAgentMock).toHaveBeenCalledTimes(1);
|
||||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||||
expect(lifecyclePhases).toEqual(["start", "end"]);
|
expect(lifecyclePhases).toEqual(["start", "end"]);
|
||||||
expect(result).toMatchObject({ text: "ok" });
|
expect(result).toMatchObject({ text: "ok" });
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
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 { lookupContextTokens } from "../../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||||
|
import { isCliProvider } from "../../agents/model-selection.js";
|
||||||
import {
|
import {
|
||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
runEmbeddedPiAgent,
|
runEmbeddedPiAgent,
|
||||||
@@ -376,7 +378,7 @@ export async function runReplyAgent(params: {
|
|||||||
provider: followupRun.run.provider,
|
provider: followupRun.run.provider,
|
||||||
model: followupRun.run.model,
|
model: followupRun.run.model,
|
||||||
run: (provider, model) => {
|
run: (provider, model) => {
|
||||||
if (provider === "claude-cli") {
|
if (isCliProvider(provider, followupRun.run.config)) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
@@ -386,7 +388,8 @@ export async function runReplyAgent(params: {
|
|||||||
startedAt,
|
startedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return runClaudeCliAgent({
|
const cliSessionId = getCliSessionId(sessionEntry, provider);
|
||||||
|
return runCliAgent({
|
||||||
sessionId: followupRun.run.sessionId,
|
sessionId: followupRun.run.sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionFile: followupRun.run.sessionFile,
|
sessionFile: followupRun.run.sessionFile,
|
||||||
@@ -400,8 +403,7 @@ export async function runReplyAgent(params: {
|
|||||||
runId,
|
runId,
|
||||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||||
ownerNumbers: followupRun.run.ownerNumbers,
|
ownerNumbers: followupRun.run.ownerNumbers,
|
||||||
claudeSessionId:
|
cliSessionId,
|
||||||
sessionEntry?.claudeCliSessionId?.trim() || undefined,
|
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
@@ -815,10 +817,9 @@ export async function runReplyAgent(params: {
|
|||||||
runResult.meta.agentMeta?.provider ??
|
runResult.meta.agentMeta?.provider ??
|
||||||
fallbackProvider ??
|
fallbackProvider ??
|
||||||
followupRun.run.provider;
|
followupRun.run.provider;
|
||||||
const cliSessionId =
|
const cliSessionId = isCliProvider(providerUsed, cfg)
|
||||||
providerUsed === "claude-cli"
|
? runResult.meta.agentMeta?.sessionId?.trim()
|
||||||
? runResult.meta.agentMeta?.sessionId?.trim()
|
: undefined;
|
||||||
: undefined;
|
|
||||||
const contextTokensUsed =
|
const contextTokensUsed =
|
||||||
agentCfgContextTokens ??
|
agentCfgContextTokens ??
|
||||||
lookupContextTokens(modelUsed) ??
|
lookupContextTokens(modelUsed) ??
|
||||||
@@ -836,7 +837,7 @@ export async function runReplyAgent(params: {
|
|||||||
const output = usage.output ?? 0;
|
const output = usage.output ?? 0;
|
||||||
const promptTokens =
|
const promptTokens =
|
||||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||||
return {
|
const patch: Partial<SessionEntry> = {
|
||||||
inputTokens: input,
|
inputTokens: input,
|
||||||
outputTokens: output,
|
outputTokens: output,
|
||||||
totalTokens:
|
totalTokens:
|
||||||
@@ -845,8 +846,17 @@ export async function runReplyAgent(params: {
|
|||||||
model: modelUsed,
|
model: modelUsed,
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
updatedAt: Date.now(),
|
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) {
|
} catch (err) {
|
||||||
@@ -857,13 +867,24 @@ export async function runReplyAgent(params: {
|
|||||||
await updateSessionStoreEntry({
|
await updateSessionStoreEntry({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
update: async (entry) => ({
|
update: async (entry) => {
|
||||||
modelProvider: providerUsed ?? entry.modelProvider,
|
const patch: Partial<SessionEntry> = {
|
||||||
model: modelUsed ?? entry.model,
|
modelProvider: providerUsed ?? entry.modelProvider,
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
model: modelUsed ?? entry.model,
|
||||||
claudeCliSessionId: cliSessionId ?? entry.claudeCliSessionId,
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
updatedAt: Date.now(),
|
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) {
|
} catch (err) {
|
||||||
logVerbose(`failed to persist model/context update: ${String(err)}`);
|
logVerbose(`failed to persist model/context update: ${String(err)}`);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
resolveAgentWorkspaceDir,
|
resolveAgentWorkspaceDir,
|
||||||
} from "../agents/agent-scope.js";
|
} from "../agents/agent-scope.js";
|
||||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.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 { lookupContextTokens } from "../agents/context.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONTEXT_TOKENS,
|
DEFAULT_CONTEXT_TOKENS,
|
||||||
@@ -15,6 +16,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
|
|||||||
import { runWithModelFallback } from "../agents/model-fallback.js";
|
import { runWithModelFallback } from "../agents/model-fallback.js";
|
||||||
import {
|
import {
|
||||||
buildAllowedModelSet,
|
buildAllowedModelSet,
|
||||||
|
isCliProvider,
|
||||||
modelKey,
|
modelKey,
|
||||||
resolveConfiguredModelRef,
|
resolveConfiguredModelRef,
|
||||||
resolveThinkingDefault,
|
resolveThinkingDefault,
|
||||||
@@ -361,7 +363,7 @@ export async function agentCommand(
|
|||||||
if (overrideModel) {
|
if (overrideModel) {
|
||||||
const key = modelKey(overrideProvider, overrideModel);
|
const key = modelKey(overrideProvider, overrideModel);
|
||||||
if (
|
if (
|
||||||
overrideProvider !== "claude-cli" &&
|
!isCliProvider(overrideProvider, cfg) &&
|
||||||
allowedModelKeys.size > 0 &&
|
allowedModelKeys.size > 0 &&
|
||||||
!allowedModelKeys.has(key)
|
!allowedModelKeys.has(key)
|
||||||
) {
|
) {
|
||||||
@@ -380,7 +382,7 @@ export async function agentCommand(
|
|||||||
const candidateProvider = storedProviderOverride || defaultProvider;
|
const candidateProvider = storedProviderOverride || defaultProvider;
|
||||||
const key = modelKey(candidateProvider, storedModelOverride);
|
const key = modelKey(candidateProvider, storedModelOverride);
|
||||||
if (
|
if (
|
||||||
candidateProvider === "claude-cli" ||
|
isCliProvider(candidateProvider, cfg) ||
|
||||||
allowedModelKeys.size === 0 ||
|
allowedModelKeys.size === 0 ||
|
||||||
allowedModelKeys.has(key)
|
allowedModelKeys.has(key)
|
||||||
) {
|
) {
|
||||||
@@ -422,7 +424,6 @@ export async function agentCommand(
|
|||||||
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||||
let fallbackProvider = provider;
|
let fallbackProvider = provider;
|
||||||
let fallbackModel = model;
|
let fallbackModel = model;
|
||||||
const claudeSessionId = sessionEntry?.claudeCliSessionId?.trim();
|
|
||||||
try {
|
try {
|
||||||
const messageProvider = resolveMessageProvider(
|
const messageProvider = resolveMessageProvider(
|
||||||
opts.messageProvider,
|
opts.messageProvider,
|
||||||
@@ -433,8 +434,9 @@ export async function agentCommand(
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
run: (providerOverride, modelOverride) => {
|
run: (providerOverride, modelOverride) => {
|
||||||
if (providerOverride === "claude-cli") {
|
if (isCliProvider(providerOverride, cfg)) {
|
||||||
return runClaudeCliAgent({
|
const cliSessionId = getCliSessionId(sessionEntry, providerOverride);
|
||||||
|
return runCliAgent({
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
@@ -447,7 +449,8 @@ export async function agentCommand(
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
runId,
|
runId,
|
||||||
extraSystemPrompt: opts.extraSystemPrompt,
|
extraSystemPrompt: opts.extraSystemPrompt,
|
||||||
claudeSessionId,
|
cliSessionId,
|
||||||
|
images: opts.images,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return runEmbeddedPiAgent({
|
return runEmbeddedPiAgent({
|
||||||
@@ -542,9 +545,9 @@ export async function agentCommand(
|
|||||||
model: modelUsed,
|
model: modelUsed,
|
||||||
contextTokens,
|
contextTokens,
|
||||||
};
|
};
|
||||||
if (providerUsed === "claude-cli") {
|
if (isCliProvider(providerUsed, cfg)) {
|
||||||
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
|
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
|
||||||
if (cliSessionId) next.claudeCliSessionId = cliSessionId;
|
if (cliSessionId) setCliSessionId(next, providerUsed, cliSessionId);
|
||||||
}
|
}
|
||||||
next.abortedLastRun = result.meta.aborted ?? false;
|
next.abortedLastRun = result.meta.aborted ?? false;
|
||||||
if (hasNonzeroUsage(usage)) {
|
if (hasNonzeroUsage(usage)) {
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"agents.defaults.humanDelay.mode": "Human Delay Mode",
|
"agents.defaults.humanDelay.mode": "Human Delay Mode",
|
||||||
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
|
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
|
||||||
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
||||||
|
"agents.defaults.cliBackends": "CLI Backends",
|
||||||
"commands.native": "Native Commands",
|
"commands.native": "Native Commands",
|
||||||
"commands.text": "Text Commands",
|
"commands.text": "Text Commands",
|
||||||
"commands.restart": "Allow Restart",
|
"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.",
|
"Optional image model (provider/model) used when the primary model lacks image input.",
|
||||||
"agents.defaults.imageModel.fallbacks":
|
"agents.defaults.imageModel.fallbacks":
|
||||||
"Ordered fallback image models (provider/model).",
|
"Ordered fallback image models (provider/model).",
|
||||||
|
"agents.defaults.cliBackends":
|
||||||
|
"Optional CLI backends for text-only fallback (claude-cli, etc.).",
|
||||||
"agents.defaults.humanDelay.mode":
|
"agents.defaults.humanDelay.mode":
|
||||||
'Delay style for block replies ("off", "natural", "custom").',
|
'Delay style for block replies ("off", "natural", "custom").',
|
||||||
"agents.defaults.humanDelay.minMs":
|
"agents.defaults.humanDelay.minMs":
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export type SessionEntry = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
compactionCount?: number;
|
compactionCount?: number;
|
||||||
|
cliSessionIds?: Record<string, string>;
|
||||||
claudeCliSessionId?: string;
|
claudeCliSessionId?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
displayName?: 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 = {
|
export type AgentDefaultsConfig = {
|
||||||
/** Primary model and fallbacks (provider/model). */
|
/** Primary model and fallbacks (provider/model). */
|
||||||
model?: AgentModelListConfig;
|
model?: AgentModelListConfig;
|
||||||
@@ -1370,6 +1409,8 @@ export type AgentDefaultsConfig = {
|
|||||||
userTimezone?: string;
|
userTimezone?: string;
|
||||||
/** Optional display-only context window override (used for % in status UIs). */
|
/** Optional display-only context window override (used for % in status UIs). */
|
||||||
contextTokens?: number;
|
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. */
|
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
|
||||||
contextPruning?: AgentContextPruningConfig;
|
contextPruning?: AgentContextPruningConfig;
|
||||||
/** Default thinking level when no /think directive is present. */
|
/** Default thinking level when no /think directive is present. */
|
||||||
|
|||||||
@@ -124,6 +124,33 @@ const HumanDelaySchema = z.object({
|
|||||||
maxMs: z.number().int().nonnegative().optional(),
|
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[] =>
|
const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
|
||||||
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
|
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -1037,6 +1064,7 @@ const AgentDefaultsSchema = z
|
|||||||
skipBootstrap: z.boolean().optional(),
|
skipBootstrap: z.boolean().optional(),
|
||||||
userTimezone: z.string().optional(),
|
userTimezone: z.string().optional(),
|
||||||
contextTokens: z.number().int().positive().optional(),
|
contextTokens: z.number().int().positive().optional(),
|
||||||
|
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
|
||||||
contextPruning: z
|
contextPruning: z
|
||||||
.object({
|
.object({
|
||||||
mode: z
|
mode: z
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
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 { lookupContextTokens } from "../agents/context.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONTEXT_TOKENS,
|
DEFAULT_CONTEXT_TOKENS,
|
||||||
@@ -10,6 +11,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
|
|||||||
import { runWithModelFallback } from "../agents/model-fallback.js";
|
import { runWithModelFallback } from "../agents/model-fallback.js";
|
||||||
import {
|
import {
|
||||||
getModelRefStatus,
|
getModelRefStatus,
|
||||||
|
isCliProvider,
|
||||||
resolveAllowedModelRef,
|
resolveAllowedModelRef,
|
||||||
resolveConfiguredModelRef,
|
resolveConfiguredModelRef,
|
||||||
resolveHooksGmailModel,
|
resolveHooksGmailModel,
|
||||||
@@ -444,14 +446,17 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
verboseLevel: resolvedVerboseLevel,
|
verboseLevel: resolvedVerboseLevel,
|
||||||
});
|
});
|
||||||
const messageProvider = resolvedDelivery.provider;
|
const messageProvider = resolvedDelivery.provider;
|
||||||
const claudeSessionId = cronSession.sessionEntry.claudeCliSessionId?.trim();
|
|
||||||
const fallbackResult = await runWithModelFallback({
|
const fallbackResult = await runWithModelFallback({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
run: (providerOverride, modelOverride) => {
|
run: (providerOverride, modelOverride) => {
|
||||||
if (providerOverride === "claude-cli") {
|
if (isCliProvider(providerOverride, params.cfg)) {
|
||||||
return runClaudeCliAgent({
|
const cliSessionId = getCliSessionId(
|
||||||
|
cronSession.sessionEntry,
|
||||||
|
providerOverride,
|
||||||
|
);
|
||||||
|
return runCliAgent({
|
||||||
sessionId: cronSession.sessionEntry.sessionId,
|
sessionId: cronSession.sessionEntry.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
@@ -463,7 +468,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
thinkLevel,
|
thinkLevel,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
runId: cronSession.sessionEntry.sessionId,
|
runId: cronSession.sessionEntry.sessionId,
|
||||||
claudeSessionId,
|
cliSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return runEmbeddedPiAgent({
|
return runEmbeddedPiAgent({
|
||||||
@@ -508,10 +513,10 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
cronSession.sessionEntry.modelProvider = providerUsed;
|
cronSession.sessionEntry.modelProvider = providerUsed;
|
||||||
cronSession.sessionEntry.model = modelUsed;
|
cronSession.sessionEntry.model = modelUsed;
|
||||||
cronSession.sessionEntry.contextTokens = contextTokens;
|
cronSession.sessionEntry.contextTokens = contextTokens;
|
||||||
if (providerUsed === "claude-cli") {
|
if (isCliProvider(providerUsed, params.cfg)) {
|
||||||
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
||||||
if (cliSessionId) {
|
if (cliSessionId) {
|
||||||
cronSession.sessionEntry.claudeCliSessionId = cliSessionId;
|
setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasNonzeroUsage(usage)) {
|
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