feat(agent): add claude cli runner
This commit is contained in:
332
src/agents/claude-cli-runner.ts
Normal file
332
src/agents/claude-cli-runner.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
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 { createSubsystemLogger } from "../logging.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
type EmbeddedContextFile,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||
|
||||
const log = createSubsystemLogger("agent/claude-cli");
|
||||
|
||||
type ClaudeCliUsage = {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
type ClaudeCliOutput = {
|
||||
text: string;
|
||||
sessionId?: string;
|
||||
usage?: ClaudeCliUsage;
|
||||
};
|
||||
|
||||
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?.agent?.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[];
|
||||
tools: AgentTool[];
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
modelDisplay: string;
|
||||
}) {
|
||||
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
defaultThinkLevel: params.defaultThinkLevel,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint: false,
|
||||
heartbeatPrompt: resolveHeartbeatPrompt(
|
||||
params.config?.agent?.heartbeat?.prompt,
|
||||
),
|
||||
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;
|
||||
resumeSessionId?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<ClaudeCliOutput> {
|
||||
const args = [
|
||||
"-p",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--model",
|
||||
normalizeClaudeCliModel(params.modelId),
|
||||
"--append-system-prompt",
|
||||
params.systemPrompt,
|
||||
"--dangerously-skip-permissions",
|
||||
"--permission-mode",
|
||||
"dontAsk",
|
||||
"--tools",
|
||||
"",
|
||||
];
|
||||
if (params.resumeSessionId) {
|
||||
args.push("--resume", params.resumeSessionId);
|
||||
} else if (params.sessionId) {
|
||||
args.push("--session-id", params.sessionId);
|
||||
}
|
||||
args.push(params.prompt);
|
||||
|
||||
const result = await runCommandWithTimeout(["claude", ...args], {
|
||||
timeoutMs: params.timeoutMs,
|
||||
cwd: params.workspaceDir,
|
||||
});
|
||||
const stdout = result.stdout.trim();
|
||||
if (result.code !== 0) {
|
||||
const err = result.stderr.trim() || stdout || "Claude CLI failed.";
|
||||
throw new Error(err);
|
||||
}
|
||||
const parsed = parseClaudeCliJson(stdout);
|
||||
if (parsed) return parsed;
|
||||
return { text: stdout };
|
||||
}
|
||||
|
||||
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[];
|
||||
resumeSessionId?: 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 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
workspaceDir,
|
||||
config: params.config,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
tools: [],
|
||||
contextFiles,
|
||||
modelDisplay,
|
||||
});
|
||||
|
||||
let output: ClaudeCliOutput;
|
||||
try {
|
||||
output = await runClaudeCliOnce({
|
||||
prompt: params.prompt,
|
||||
workspaceDir,
|
||||
modelId,
|
||||
systemPrompt,
|
||||
timeoutMs: params.timeoutMs,
|
||||
resumeSessionId: params.resumeSessionId,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!params.resumeSessionId) throw err;
|
||||
log.warn(
|
||||
`claude-cli resume failed for ${params.resumeSessionId}; retrying with --session-id (${params.sessionId})`,
|
||||
);
|
||||
output = await runClaudeCliOnce({
|
||||
prompt: params.prompt,
|
||||
workspaceDir,
|
||||
modelId,
|
||||
systemPrompt,
|
||||
timeoutMs: params.timeoutMs,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
const text = output.text?.trim();
|
||||
const payloads = text ? [{ text }] : undefined;
|
||||
|
||||
return {
|
||||
payloads,
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
agentMeta: {
|
||||
sessionId: output.sessionId ?? params.sessionId,
|
||||
provider: params.provider ?? "claude-cli",
|
||||
model: modelId,
|
||||
usage: output.usage,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import { runClaudeCliAgent } from "../agents/claude-cli-runner.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
@@ -347,7 +348,11 @@ export async function agentCommand(
|
||||
const overrideModel = sessionEntry.modelOverride?.trim();
|
||||
if (overrideModel) {
|
||||
const key = modelKey(overrideProvider, overrideModel);
|
||||
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
||||
if (
|
||||
overrideProvider !== "claude-cli" &&
|
||||
allowedModelKeys.size > 0 &&
|
||||
!allowedModelKeys.has(key)
|
||||
) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
@@ -362,7 +367,11 @@ export async function agentCommand(
|
||||
if (storedModelOverride) {
|
||||
const candidateProvider = storedProviderOverride || defaultProvider;
|
||||
const key = modelKey(candidateProvider, storedModelOverride);
|
||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||
if (
|
||||
candidateProvider === "claude-cli" ||
|
||||
allowedModelKeys.size === 0 ||
|
||||
allowedModelKeys.has(key)
|
||||
) {
|
||||
provider = candidateProvider;
|
||||
model = storedModelOverride;
|
||||
}
|
||||
@@ -401,6 +410,7 @@ export async function agentCommand(
|
||||
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = provider;
|
||||
let fallbackModel = model;
|
||||
const claudeResumeId = sessionEntry?.claudeCliSessionId?.trim();
|
||||
try {
|
||||
const messageProvider = resolveMessageProvider(
|
||||
opts.messageProvider,
|
||||
@@ -410,8 +420,25 @@ export async function agentCommand(
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
run: (providerOverride, modelOverride) =>
|
||||
runEmbeddedPiAgent({
|
||||
run: (providerOverride, modelOverride) => {
|
||||
if (providerOverride === "claude-cli") {
|
||||
return runClaudeCliAgent({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: body,
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
timeoutMs,
|
||||
runId,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
resumeSessionId: claudeResumeId,
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
messageProvider,
|
||||
@@ -445,7 +472,8 @@ export async function agentCommand(
|
||||
data: evt.data,
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
result = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
@@ -501,6 +529,10 @@ export async function agentCommand(
|
||||
model: modelUsed,
|
||||
contextTokens,
|
||||
};
|
||||
if (providerUsed === "claude-cli") {
|
||||
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
|
||||
if (cliSessionId) next.claudeCliSessionId = cliSessionId;
|
||||
}
|
||||
next.abortedLastRun = result.meta.aborted ?? false;
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const input = usage.input ?? 0;
|
||||
|
||||
@@ -112,6 +112,7 @@ export type SessionEntry = {
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
claudeCliSessionId?: string;
|
||||
displayName?: string;
|
||||
provider?: string;
|
||||
subject?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { runClaudeCliAgent } from "../agents/claude-cli-runner.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
@@ -422,12 +423,29 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const messageProvider = resolvedDelivery.provider;
|
||||
const claudeResumeId = cronSession.sessionEntry.claudeCliSessionId?.trim();
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: params.cfg,
|
||||
provider,
|
||||
model,
|
||||
run: (providerOverride, modelOverride) =>
|
||||
runEmbeddedPiAgent({
|
||||
run: (providerOverride, modelOverride) => {
|
||||
if (providerOverride === "claude-cli") {
|
||||
return runClaudeCliAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: params.cfg,
|
||||
prompt: commandBody,
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel,
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
resumeSessionId: claudeResumeId,
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider,
|
||||
@@ -448,7 +466,8 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
(agentCfg?.verboseDefault as "on" | "off" | undefined),
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
@@ -473,6 +492,12 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
cronSession.sessionEntry.modelProvider = providerUsed;
|
||||
cronSession.sessionEntry.model = modelUsed;
|
||||
cronSession.sessionEntry.contextTokens = contextTokens;
|
||||
if (providerUsed === "claude-cli") {
|
||||
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
||||
if (cliSessionId) {
|
||||
cronSession.sessionEntry.claudeCliSessionId = cliSessionId;
|
||||
}
|
||||
}
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
|
||||
Reference in New Issue
Block a user