import crypto from "node:crypto"; import { resolveAgentDir, 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, DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runWithModelFallback } from "../agents/model-fallback.js"; import { buildAllowedModelSet, modelKey, resolveConfiguredModelRef, resolveThinkingDefault, } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { hasNonzeroUsage } from "../agents/usage.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeThinkLevel, normalizeVerboseLevel, type ThinkLevel, type VerboseLevel, } from "../auto-reply/thinking.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, resolveAgentIdFromSessionKey, resolveSessionFilePath, resolveSessionKey, resolveStorePath, type SessionEntry, saveSessionStore, } from "../config/sessions.js"; import { emitAgentEvent, registerAgentRunContext, } from "../infra/agent-events.js"; import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; import { formatOutboundPayloadLog, type NormalizedOutboundPayload, normalizeOutboundPayloads, normalizeOutboundPayloadsForJson, } from "../infra/outbound/payloads.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; import { normalizeMessageProvider, resolveMessageProvider, } from "../utils/message-provider.js"; import { normalizeE164 } from "../utils.js"; type AgentCommandOpts = { message: string; to?: string; sessionId?: string; sessionKey?: string; thinking?: string; thinkingOnce?: string; verbose?: string; json?: boolean; timeout?: string; deliver?: boolean; /** Message provider context (webchat|voicewake|whatsapp|...). */ messageProvider?: string; provider?: string; // delivery provider (whatsapp|telegram|...) bestEffortDeliver?: boolean; abortSignal?: AbortSignal; lane?: string; runId?: string; extraSystemPrompt?: string; }; type SessionResolution = { sessionId: string; sessionKey?: string; sessionEntry?: SessionEntry; sessionStore?: Record; storePath: string; isNewSession: boolean; persistedThinking?: ThinkLevel; persistedVerbose?: VerboseLevel; }; function resolveSession(opts: { cfg: ClawdbotConfig; to?: string; sessionId?: string; sessionKey?: string; }): SessionResolution { const sessionCfg = opts.cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const mainKey = sessionCfg?.mainKey ?? "main"; const idleMinutes = Math.max( sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1, ); const idleMs = idleMinutes * 60_000; const explicitSessionKey = opts.sessionKey?.trim(); const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey); const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId, }); const sessionStore = loadSessionStore(storePath); const now = Date.now(); const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined; let sessionKey: string | undefined = explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined); let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; // If a session id was provided, prefer to re-use its entry (by id) even when no key was derived. if ( !explicitSessionKey && opts.sessionId && (!sessionEntry || sessionEntry.sessionId !== opts.sessionId) ) { const foundKey = Object.keys(sessionStore).find( (key) => sessionStore[key]?.sessionId === opts.sessionId, ); if (foundKey) { sessionKey = sessionKey ?? foundKey; sessionEntry = sessionStore[foundKey]; } } const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs; const sessionId = opts.sessionId?.trim() || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID(); const isNewSession = !fresh && !opts.sessionId; const persistedThinking = fresh && sessionEntry?.thinkingLevel ? normalizeThinkLevel(sessionEntry.thinkingLevel) : undefined; const persistedVerbose = fresh && sessionEntry?.verboseLevel ? normalizeVerboseLevel(sessionEntry.verboseLevel) : undefined; return { sessionId, sessionKey, sessionEntry, sessionStore, storePath, isNewSession, persistedThinking, persistedVerbose, }; } export async function agentCommand( opts: AgentCommandOpts, runtime: RuntimeEnv = defaultRuntime, deps: CliDeps = createDefaultDeps(), ) { const body = (opts.message ?? "").trim(); if (!body) throw new Error("Message (--message) is required"); if (!opts.to && !opts.sessionId && !opts.sessionKey) { throw new Error("Pass --to or --session-id to choose a session"); } const cfg = loadConfig(); const agentCfg = cfg.agent; const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim()); const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId); const agentDir = resolveAgentDir(cfg, sessionAgentId); const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !cfg.agent?.skipBootstrap, }); const workspaceDir = workspace.dir; const allowFrom = (cfg.whatsapp?.allowFrom ?? []) .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); const thinkOverride = normalizeThinkLevel(opts.thinking); const thinkOnce = normalizeThinkLevel(opts.thinkingOnce); if (opts.thinking && !thinkOverride) { throw new Error( "Invalid thinking level. Use one of: off, minimal, low, medium, high.", ); } if (opts.thinkingOnce && !thinkOnce) { throw new Error( "Invalid one-shot thinking level. Use one of: off, minimal, low, medium, high.", ); } const verboseOverride = normalizeVerboseLevel(opts.verbose); if (opts.verbose && !verboseOverride) { throw new Error('Invalid verbose level. Use "on" or "off".'); } const timeoutSecondsRaw = opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) : undefined; if ( timeoutSecondsRaw !== undefined && (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) ) { throw new Error("--timeout must be a positive integer (seconds)"); } const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideSeconds: timeoutSecondsRaw, }); const sessionResolution = resolveSession({ cfg, to: opts.to, sessionId: opts.sessionId, sessionKey: opts.sessionKey, }); const { sessionId, sessionKey, sessionEntry: resolvedSessionEntry, sessionStore, storePath, isNewSession, persistedThinking, persistedVerbose, } = sessionResolution; let sessionEntry = resolvedSessionEntry; const runId = opts.runId?.trim() || sessionId; if (sessionKey) { registerAgentRunContext(runId, { sessionKey }); } if (opts.deliver === true) { const sendPolicy = resolveSendPolicy({ cfg, entry: sessionEntry, sessionKey, provider: sessionEntry?.provider, chatType: sessionEntry?.chatType, }); if (sendPolicy === "deny") { throw new Error("send blocked by session policy"); } } let resolvedThinkLevel = thinkOnce ?? thinkOverride ?? persistedThinking ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined); const resolvedVerboseLevel = verboseOverride ?? persistedVerbose ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) : sessionEntry?.skillsSnapshot; if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { const current = sessionEntry ?? { sessionId, updatedAt: Date.now(), }; const next: SessionEntry = { ...current, sessionId, updatedAt: Date.now(), skillsSnapshot, }; sessionStore[sessionKey] = next; await saveSessionStore(storePath, sessionStore); sessionEntry = next; } // Persist explicit /command overrides to the session store when we have a key. if (sessionStore && sessionKey) { const entry = sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() }; const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() }; if (thinkOverride) { if (thinkOverride === "off") delete next.thinkingLevel; else next.thinkingLevel = thinkOverride; } if (verboseOverride) { if (verboseOverride === "off") delete next.verboseLevel; else next.verboseLevel = verboseOverride; } sessionStore[sessionKey] = next; await saveSessionStore(storePath, sessionStore); } const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); let provider = defaultProvider; let model = defaultModel; const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; const hasStoredOverride = Boolean( sessionEntry?.modelOverride || sessionEntry?.providerOverride, ); const needsModelCatalog = hasAllowlist || hasStoredOverride; let allowedModelKeys = new Set(); let allowedModelCatalog: Awaited> = []; let modelCatalog: Awaited> | null = null; if (needsModelCatalog) { modelCatalog = await loadModelCatalog({ config: cfg }); const allowed = buildAllowedModelSet({ cfg, catalog: modelCatalog, defaultProvider, defaultModel, }); allowedModelKeys = allowed.allowedKeys; allowedModelCatalog = allowed.allowedCatalog; } if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { const key = modelKey(overrideProvider, overrideModel); if ( overrideProvider !== "claude-cli" && allowedModelKeys.size > 0 && !allowedModelKeys.has(key) ) { delete sessionEntry.providerOverride; delete sessionEntry.modelOverride; sessionEntry.updatedAt = Date.now(); sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); } } } const storedProviderOverride = sessionEntry?.providerOverride?.trim(); const storedModelOverride = sessionEntry?.modelOverride?.trim(); if (storedModelOverride) { const candidateProvider = storedProviderOverride || defaultProvider; const key = modelKey(candidateProvider, storedModelOverride); if ( candidateProvider === "claude-cli" || allowedModelKeys.size === 0 || allowedModelKeys.has(key) ) { provider = candidateProvider; model = storedModelOverride; } } if (sessionEntry?.authProfileOverride) { const store = ensureAuthProfileStore(); const profile = store.profiles[sessionEntry.authProfileOverride]; if (!profile || profile.provider !== provider) { delete sessionEntry.authProfileOverride; sessionEntry.updatedAt = Date.now(); if (sessionStore && sessionKey) { sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); } } } if (!resolvedThinkLevel) { let catalogForThinking = modelCatalog ?? allowedModelCatalog; if (!catalogForThinking || catalogForThinking.length === 0) { modelCatalog = await loadModelCatalog({ config: cfg }); catalogForThinking = modelCatalog; } resolvedThinkLevel = resolveThinkingDefault({ cfg, provider, model, catalog: catalogForThinking, }); } const sessionFile = resolveSessionFilePath(sessionId, sessionEntry); const startedAt = Date.now(); let lifecycleEnded = false; let result: Awaited>; let fallbackProvider = provider; let fallbackModel = model; const claudeSessionId = sessionEntry?.claudeCliSessionId?.trim(); try { const messageProvider = resolveMessageProvider( opts.messageProvider, opts.provider, ); const fallbackResult = await runWithModelFallback({ cfg, provider, model, 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, claudeSessionId, }); } return runEmbeddedPiAgent({ sessionId, sessionKey, messageProvider, sessionFile, workspaceDir, config: cfg, skillsSnapshot, prompt: body, provider: providerOverride, model: modelOverride, authProfileId: sessionEntry?.authProfileOverride, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, timeoutMs, runId, lane: opts.lane, abortSignal: opts.abortSignal, extraSystemPrompt: opts.extraSystemPrompt, agentDir, onAgentEvent: (evt) => { if ( evt.stream === "lifecycle" && typeof evt.data?.phase === "string" && (evt.data.phase === "end" || evt.data.phase === "error") ) { lifecycleEnded = true; } emitAgentEvent({ runId, stream: evt.stream, data: evt.data, }); }, }); }, }); result = fallbackResult.result; fallbackProvider = fallbackResult.provider; fallbackModel = fallbackResult.model; if (!lifecycleEnded) { emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", startedAt, endedAt: Date.now(), aborted: result.meta.aborted ?? false, }, }); } } catch (err) { if (!lifecycleEnded) { emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "error", startedAt, endedAt: Date.now(), error: String(err), }, }); } throw err; } // Update token+model fields in the session store. if (sessionStore && sessionKey) { const usage = result.meta.agentMeta?.usage; const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? model; const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? provider; const contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; const entry = sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now(), }; const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now(), modelProvider: providerUsed, 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; const output = usage.output ?? 0; const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); next.inputTokens = input; next.outputTokens = output; next.totalTokens = promptTokens > 0 ? promptTokens : (usage.total ?? input); } sessionStore[sessionKey] = next; await saveSessionStore(storePath, sessionStore); } const payloads = result.payloads ?? []; const deliver = opts.deliver === true; const bestEffortDeliver = opts.bestEffortDeliver === true; const deliveryProvider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; const logDeliveryError = (err: unknown) => { const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; runtime.error?.(message); if (!runtime.error) runtime.log(message); }; const isDeliveryProviderKnown = deliveryProvider === "whatsapp" || deliveryProvider === "telegram" || deliveryProvider === "discord" || deliveryProvider === "slack" || deliveryProvider === "signal" || deliveryProvider === "imessage" || deliveryProvider === "webchat"; const resolvedTarget = deliver && isDeliveryProviderKnown ? resolveOutboundTarget({ provider: deliveryProvider, to: opts.to, allowFrom, }) : null; const deliveryTarget = resolvedTarget?.ok ? resolvedTarget.to : undefined; if (deliver) { if (!isDeliveryProviderKnown) { const err = new Error(`Unknown provider: ${deliveryProvider}`); if (!bestEffortDeliver) throw err; logDeliveryError(err); } else if (resolvedTarget && !resolvedTarget.ok) { if (!bestEffortDeliver) throw resolvedTarget.error; logDeliveryError(resolvedTarget.error); } } const normalizedPayloads = normalizeOutboundPayloadsForJson(payloads); if (opts.json) { runtime.log( JSON.stringify( buildOutboundResultEnvelope({ payloads: normalizedPayloads, meta: result.meta, }), null, 2, ), ); if (!deliver) { return { payloads: normalizedPayloads, meta: result.meta }; } } if (payloads.length === 0) { runtime.log("No reply from agent."); return { payloads: [], meta: result.meta }; } const deliveryPayloads = normalizeOutboundPayloads(payloads); const logPayload = (payload: NormalizedOutboundPayload) => { if (opts.json) return; const output = formatOutboundPayloadLog(payload); if (output) runtime.log(output); }; if (!deliver) { for (const payload of deliveryPayloads) { logPayload(payload); } } if ( deliver && (deliveryProvider === "whatsapp" || deliveryProvider === "telegram" || deliveryProvider === "discord" || deliveryProvider === "slack" || deliveryProvider === "signal" || deliveryProvider === "imessage") ) { if (deliveryTarget) { await deliverOutboundPayloads({ cfg, provider: deliveryProvider, to: deliveryTarget, payloads: deliveryPayloads, bestEffort: bestEffortDeliver, onError: (err) => logDeliveryError(err), onPayload: logPayload, deps: { sendWhatsApp: deps.sendMessageWhatsApp, sendTelegram: deps.sendMessageTelegram, sendDiscord: deps.sendMessageDiscord, sendSlack: deps.sendMessageSlack, sendSignal: deps.sendMessageSignal, sendIMessage: deps.sendMessageIMessage, }, }); } } return { payloads: normalizedPayloads, meta: result.meta }; }