From 9a8fe4d683b3d54c89636d98c43980b2c365d1f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 04:52:37 +0100 Subject: [PATCH] feat(agent): add claude cli runner --- CHANGELOG.md | 1 + src/agents/claude-cli-runner.ts | 332 ++++++++++++++++++++++++++++++++ src/commands/agent.ts | 42 +++- src/config/sessions.ts | 1 + src/cron/isolated-agent.ts | 31 ++- 5 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 src/agents/claude-cli-runner.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b4cb5af..bb7f9e096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Debugging: add raw model stream logging flags and document gateway watch mode. +- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts new file mode 100644 index 000000000..29eb7b13f --- /dev/null +++ b/src/agents/claude-cli-runner.ts @@ -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 = {}; + 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): ClaudeCliUsage | undefined { + const pick = (key: string) => + typeof raw[key] === "number" && raw[key] > 0 + ? (raw[key] as number) + : undefined; + const input = pick("input_tokens") ?? pick("inputTokens"); + const output = pick("output_tokens") ?? pick("outputTokens"); + const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead"); + const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite"); + const total = pick("total_tokens") ?? pick("total"); + if (!input && !output && !cacheRead && !cacheWrite && !total) + return undefined; + return { input, output, cacheRead, cacheWrite, total }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function collectText(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map((entry) => collectText(entry)).join(""); + } + if (!isRecord(value)) return ""; + if (typeof value.text === "string") return value.text; + if (typeof value.content === "string") return value.content; + if (Array.isArray(value.content)) { + return value.content.map((entry) => collectText(entry)).join(""); + } + if (isRecord(value.message)) return collectText(value.message); + return ""; +} + +function parseClaudeCliJson(raw: string): ClaudeCliOutput | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + const sessionId = + (typeof parsed.session_id === "string" && parsed.session_id) || + (typeof parsed.sessionId === "string" && parsed.sessionId) || + (typeof parsed.conversation_id === "string" && parsed.conversation_id) || + undefined; + const usage = isRecord(parsed.usage) ? toUsage(parsed.usage) : undefined; + const text = + collectText(parsed.message) || + collectText(parsed.content) || + collectText(parsed.result) || + collectText(parsed); + return { text: text.trim(), sessionId, usage }; +} + +async function runClaudeCliOnce(params: { + prompt: string; + workspaceDir: string; + modelId: string; + systemPrompt: string; + timeoutMs: number; + resumeSessionId?: string; + sessionId?: string; +}): Promise { + 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 { + 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, + }, + }, + }; +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index da3902a1a..5f6d25b9e 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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>; 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; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 6dfa7b3be..dfeda9e98 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -112,6 +112,7 @@ export type SessionEntry = { model?: string; contextTokens?: number; compactionCount?: number; + claudeCliSessionId?: string; displayName?: string; provider?: string; subject?: string; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index a4f7be945..a4db61028 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -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;