From 05b76281f7ecaf2af005467637c800340b33baf3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 4 Dec 2025 17:53:37 +0000 Subject: [PATCH] CLI: add agent command for direct agent runs --- README.md | 4 + docs/agent-send.md | 56 +++++ src/auto-reply/reply.ts | 42 +--- src/auto-reply/thinking.ts | 41 ++++ src/cli/program.ts | 60 ++++++ src/commands/agent.test.ts | 174 +++++++++++++++ src/commands/agent.ts | 423 +++++++++++++++++++++++++++++++++++++ 7 files changed, 764 insertions(+), 36 deletions(-) create mode 100644 docs/agent-send.md create mode 100644 src/auto-reply/thinking.ts create mode 100644 src/commands/agent.test.ts create mode 100644 src/commands/agent.ts diff --git a/README.md b/README.md index a765993b5..6ffb6c401 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ clawdis login # Send a message clawdis send --to +1234567890 --message "Hello from the CLAWDIS!" +# Talk directly to the agent (no WhatsApp send) +clawdis agent --to +1234567890 --message "Ship checklist" --thinking high + # Start the relay clawdis relay --verbose ``` @@ -119,6 +122,7 @@ clawdis relay --provider twilio |---------|-------------| | `clawdis login` | Link WhatsApp Web via QR | | `clawdis send` | Send a message | +| `clawdis agent` | Talk directly to the agent (no WhatsApp send) | | `clawdis relay` | Start auto-reply loop | | `clawdis status` | Show recent messages | | `clawdis heartbeat` | Trigger a heartbeat | diff --git a/docs/agent-send.md b/docs/agent-send.md new file mode 100644 index 000000000..45e8edd22 --- /dev/null +++ b/docs/agent-send.md @@ -0,0 +1,56 @@ +# Plan: `warelay agent` (direct-to-agent invocation) + +Goal: Add a CLI subcommand that talks directly to the configured agent/command runner (no WhatsApp send), while reusing the same session handling and config warelay already uses for auto-replies. + +## Why +- Sometimes we want to poke the agent directly (same prompt templates/sessions) without sending a WhatsApp message. +- Current flows (`send`, relay, directives) always route through WhatsApp or add wrapping text; we need a clean “talk to agent now” tool. + +## Behavior +- Command: `warelay agent` +- Required: `--message ` +- Session selection: + - If `--session-id` given, use it. + - Else if `--to ` given, derive session key like auto-reply (`per-sender`, same normalization) and load/create session id from `session store` path in config. + - Else error (“need --to or --session-id”). +- Runs the same external command as auto-reply: `inbound.reply.command` from config (honors `reply.session` options: sendSystemOnce, sendSystemOnce=false, typing, timeouts, etc.). +- Uses the same templating rules for Body as command mode, but **skips** WhatsApp-specific wrappers (group intro, media hints). Keep session intro/bodyPrefix if sendSystemOnce is false, otherwise follow session config. +- Thinking/verbose: + - Accept flags `--thinking ` and `--verbose `. + - Persist into session store (like directive-only flow) and inject into the command invocation. +- Output: + - Default: print the agent’s text reply to stdout. + - `--json` flag: print full payload (text, any media URL, timing). +- Does **not** send anything to WhatsApp; purely local agent run. + +## Flags (proposed) +- `--message, -m ` (required) +- `--to, -t ` (derive session) +- `--session-id ` (override) +- `--thinking ` +- `--verbose ` +- `--json` (structured output) +- `--timeout ` (override command timeout) + +## Implementation steps +1) CLI: + - Add subcommand in `src/cli/program.ts`. + - Wire options, setVerbose, createDefaultDeps. +2) Command handler (new file `src/commands/agent.ts`): + - Load config. + - Resolve session store + session id (reuse `deriveSessionKey`, `loadSessionStore`, `saveSessionStore`). + - Apply thinking/verbose overrides and persist to session entry. + - Build command body (no WhatsApp wrappers; honor sessionIntro/bodyPrefix as per config). + - Call `runCommandWithTimeout` (same as auto-reply) and parse response (reuse splitter for MEDIA, etc.). + - Return text (and mediaUrl) to stdout / JSON. +3) Share logic: + - Extract helper(s) from `auto-reply/reply.ts` if needed (session + thinking persistence) to avoid duplication. +4) Tests: + - Unit tests for handler: session creation, thinking persistence, resume with `--session-id`, JSON output. + - Snapshot of command args to ensure no WhatsApp wrappers. +5) Docs: + - Add usage examples to CLI help and README. + +## Out of scope (for now) +- Chat directives `/cmd` in WhatsApp. (Can reuse the same handler later.) +- Media input/attachments. Start text-only; extend later if needed. diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index a78cad5e8..9e222b9bf 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -26,6 +26,12 @@ import { } from "./templating.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; +import { + normalizeThinkLevel, + normalizeVerboseLevel, + type ThinkLevel, + type VerboseLevel, +} from "./thinking.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; @@ -34,42 +40,6 @@ const TWILIO_TEXT_LIMIT = 1600; const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]); const ABORT_MEMORY = new Map(); -type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; -type VerboseLevel = "off" | "on"; - -function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined { - if (!raw) return undefined; - const key = raw.toLowerCase(); - if (["off"].includes(key)) return "off"; - if (["min", "minimal"].includes(key)) return "minimal"; - if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) - return "low"; - if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) - return "medium"; - if ( - [ - "high", - "ultra", - "ultrathink", - "think-hard", - "thinkhardest", - "highest", - "max", - ].includes(key) - ) - return "high"; - if (["think"].includes(key)) return "minimal"; - return undefined; -} - -function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undefined { - if (!raw) return undefined; - const key = raw.toLowerCase(); - if (["off", "false", "no", "0"].includes(key)) return "off"; - if (["on", "full", "true", "yes", "1"].includes(key)) return "on"; - return undefined; -} - function extractThinkDirective(body?: string): { cleaned: string; thinkLevel?: ThinkLevel; diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts new file mode 100644 index 000000000..60b95a17b --- /dev/null +++ b/src/auto-reply/thinking.ts @@ -0,0 +1,41 @@ +export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; +export type VerboseLevel = "off" | "on"; + +// Normalize user-provided thinking level strings to the canonical enum. +export function normalizeThinkLevel( + raw?: string | null, +): ThinkLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off"].includes(key)) return "off"; + if (["min", "minimal"].includes(key)) return "minimal"; + if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) + return "low"; + if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) + return "medium"; + if ( + [ + "high", + "ultra", + "ultrathink", + "think-hard", + "thinkhardest", + "highest", + "max", + ].includes(key) + ) + return "high"; + if (["think"].includes(key)) return "minimal"; + return undefined; +} + +// Normalize verbose flags used to toggle agent verbosity. +export function normalizeVerboseLevel( + raw?: string | null, +): VerboseLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off", "false", "no", "0"].includes(key)) return "off"; + if (["on", "full", "true", "yes", "1"].includes(key)) return "on"; + return undefined; +} diff --git a/src/cli/program.ts b/src/cli/program.ts index b6547a9a5..111ec46ca 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; +import { agentCommand } from "../commands/agent.js"; import { sendCommand } from "../commands/send.js"; import { statusCommand } from "../commands/status.js"; import { webhookCommand } from "../commands/webhook.js"; @@ -94,6 +95,10 @@ export function buildProgram() { "warelay status --limit 10 --lookback 60 --json", "Show last 10 messages from the past hour as JSON.", ], + [ + 'warelay agent --to +15551234567 --message "Run summary" --thinking high', + "Talk directly to the agent using the same session handling, no WhatsApp send.", + ], ] as const; const fmtExamples = examples @@ -178,6 +183,61 @@ Examples: } }); + program + .command("agent") + .description( + "Talk directly to the configured agent (no WhatsApp send, reuses sessions)", + ) + .requiredOption("-m, --message ", "Message body for the agent") + .option( + "-t, --to ", + "Recipient number in E.164 used to derive the session key", + ) + .option("--session-id ", "Use an explicit session id") + .option( + "--thinking ", + "Thinking level: off | minimal | low | medium | high", + ) + .option("--verbose ", "Persist agent verbose level for the session") + .option( + "--deliver", + "Send the agent's reply back to WhatsApp (requires --to)", + false, + ) + .option( + "--provider ", + "Provider to deliver via when using --deliver (auto | web | twilio)", + "auto", + ) + .option("--json", "Output result as JSON", false) + .option( + "--timeout ", + "Override agent command timeout (seconds, default 600 or config value)", + ) + .addHelpText( + "after", + ` +Examples: + warelay agent --to +15551234567 --message "status update" + warelay agent --session-id 1234 --message "Summarize inbox" --thinking medium + warelay agent --to +15551234567 --message "Trace logs" --verbose on --json + warelay agent --to +15551234567 --message "Summon reply" --deliver --provider web +`, + ) + .action(async (opts) => { + const verboseLevel = + typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : ""; + setVerbose(verboseLevel === "on"); + // Build default deps (keeps parity with other commands; future-proofing). + void createDefaultDeps(); + try { + await agentCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + program .command("heartbeat") .description( diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts new file mode 100644 index 000000000..ad65771f5 --- /dev/null +++ b/src/commands/agent.test.ts @@ -0,0 +1,174 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; +import * as commandReply from "../auto-reply/command-reply.js"; +import type { WarelayConfig } from "../config/config.js"; +import * as configModule from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentCommand } from "./agent.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +const runReplySpy = vi.spyOn(commandReply, "runCommandReply"); +const configSpy = vi.spyOn(configModule, "loadConfig"); + +function makeStorePath() { + return path.join( + os.tmpdir(), + `warelay-agent-test-${Date.now()}-${Math.random()}.json`, + ); +} + +function mockConfig( + storePath: string, + replyOverrides?: Partial["reply"]>, +) { + configSpy.mockReturnValue({ + inbound: { + reply: { + mode: "command", + command: ["echo", "{{Body}}"], + session: { + store: storePath, + sendSystemOnce: false, + }, + ...replyOverrides, + }, + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + runReplySpy.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 5 }, + }); +}); + +describe("agentCommand", () => { + it("creates a session entry when deriving from --to", async () => { + const store = makeStorePath(); + mockConfig(store); + + await agentCommand({ message: "hello", to: "+1555" }, runtime); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { sessionId: string } + >; + const entry = Object.values(saved)[0]; + expect(entry.sessionId).toBeTruthy(); + }); + + it("persists thinking and verbose overrides", async () => { + const store = makeStorePath(); + mockConfig(store); + + await agentCommand( + { message: "hi", to: "+1222", thinking: "high", verbose: "on" }, + runtime, + ); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { thinkingLevel?: string; verboseLevel?: string } + >; + const entry = Object.values(saved)[0]; + expect(entry.thinkingLevel).toBe("high"); + expect(entry.verboseLevel).toBe("on"); + + const callArgs = runReplySpy.mock.calls.at(-1)?.[0]; + expect(callArgs?.thinkLevel).toBe("high"); + expect(callArgs?.verboseLevel).toBe("on"); + }); + + it("resumes when session-id is provided", async () => { + const store = makeStorePath(); + fs.mkdirSync(path.dirname(store), { recursive: true }); + fs.writeFileSync( + store, + JSON.stringify( + { + foo: { + sessionId: "session-123", + updatedAt: Date.now(), + systemSent: true, + }, + }, + null, + 2, + ), + ); + mockConfig(store); + + await agentCommand( + { message: "resume me", sessionId: "session-123" }, + runtime, + ); + + const callArgs = runReplySpy.mock.calls.at(-1)?.[0]; + expect(callArgs?.isNewSession).toBe(false); + expect(callArgs?.templatingCtx.SessionId).toBe("session-123"); + }); + + it("prints JSON payload when requested", async () => { + runReplySpy.mockResolvedValue({ + payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], + meta: { durationMs: 42 }, + }); + const store = makeStorePath(); + mockConfig(store); + + await agentCommand({ message: "hi", to: "+1999", json: true }, runtime); + + const logged = (runtime.log as MockInstance).mock.calls.at( + -1, + )?.[0] as string; + const parsed = JSON.parse(logged) as { + payloads: Array<{ text: string; mediaUrl?: string }>; + meta: { durationMs: number }; + }; + expect(parsed.payloads[0].text).toBe("json-reply"); + expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg"); + expect(parsed.meta.durationMs).toBe(42); + }); + + it("builds command body without WhatsApp wrappers", async () => { + const store = makeStorePath(); + mockConfig(store, { + mode: "command", + command: ["echo", "{{Body}}"], + session: { + store, + sendSystemOnce: false, + sessionIntro: "Intro {{SessionId}}", + }, + bodyPrefix: "[pfx] ", + }); + + await agentCommand({ message: "ping", to: "+1333" }, runtime); + + const callArgs = runReplySpy.mock.calls.at(-1)?.[0]; + const body = callArgs?.templatingCtx.Body as string; + expect(body.startsWith("Intro")).toBe(true); + expect(body).toContain("[pfx] ping"); + expect(body).not.toContain("WhatsApp"); + expect(body).not.toContain("MEDIA:"); + }); +}); diff --git a/src/commands/agent.ts b/src/commands/agent.ts new file mode 100644 index 000000000..43f306250 --- /dev/null +++ b/src/commands/agent.ts @@ -0,0 +1,423 @@ +import crypto from "node:crypto"; + +import { runCommandReply } from "../auto-reply/command-reply.js"; +import { + applyTemplate, + type MsgContext, + type TemplateContext, +} from "../auto-reply/templating.js"; +import { + normalizeThinkLevel, + normalizeVerboseLevel, + type ThinkLevel, + type VerboseLevel, +} from "../auto-reply/thinking.js"; +import { chunkText } from "../auto-reply/chunk.js"; +import { createDefaultDeps, type CliDeps } from "../cli/deps.js"; +import { loadConfig, type WarelayConfig } from "../config/config.js"; +import { + DEFAULT_IDLE_MINUTES, + deriveSessionKey, + loadSessionStore, + resolveStorePath, + type SessionEntry, + saveSessionStore, +} from "../config/sessions.js"; +import { ensureTwilioEnv } from "../env.js"; +import { pickProvider } from "../provider-web.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import type { Provider } from "../utils.js"; +import { sendViaIpc } from "../web/ipc.js"; + +type AgentCommandOpts = { + message: string; + to?: string; + sessionId?: string; + thinking?: string; + verbose?: string; + json?: boolean; + timeout?: string; + deliver?: boolean; + provider?: Provider | "auto"; +}; + +type SessionResolution = { + sessionId: string; + sessionKey?: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; + storePath?: string; + isNewSession: boolean; + systemSent: boolean; + persistedThinking?: ThinkLevel; + persistedVerbose?: VerboseLevel; +}; + +function assertCommandConfig(cfg: WarelayConfig) { + const reply = cfg.inbound?.reply; + if (!reply || reply.mode !== "command" || !reply.command?.length) { + throw new Error( + "Configure inbound.reply.mode=command with reply.command before using `warelay agent`.", + ); + } + return reply as NonNullable< + NonNullable["reply"] + > & { mode: "command"; command: string[] }; +} + +function resolveSession(opts: { + to?: string; + sessionId?: string; + replyCfg: NonNullable["reply"]>; +}): SessionResolution { + const sessionCfg = opts.replyCfg?.session; + const scope = sessionCfg?.scope ?? "per-sender"; + const idleMinutes = Math.max( + sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, + 1, + ); + const idleMs = idleMinutes * 60_000; + const storePath = sessionCfg ? resolveStorePath(sessionCfg.store) : undefined; + const sessionStore = storePath ? loadSessionStore(storePath) : undefined; + const now = Date.now(); + + let sessionKey: string | undefined = + sessionStore && opts.to + ? deriveSessionKey(scope, { From: opts.to } as MsgContext) + : undefined; + let sessionEntry = + sessionKey && sessionStore ? sessionStore[sessionKey] : undefined; + + // If a session id was provided, prefer to re-use its entry (by id) even when no key was derived. + if ( + sessionStore && + 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]; + } + } + + let sessionId = opts.sessionId?.trim() || sessionEntry?.sessionId; + let isNewSession = false; + let systemSent = sessionEntry?.systemSent ?? false; + + if (!opts.sessionId) { + const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs; + if (!sessionEntry || !fresh) { + sessionId = sessionId ?? crypto.randomUUID(); + isNewSession = true; + systemSent = false; + if (sessionCfg && sessionStore && sessionKey) { + sessionEntry = { + sessionId, + updatedAt: now, + abortedLastRun: sessionEntry?.abortedLastRun, + }; + } + } + } else { + sessionId = sessionId ?? crypto.randomUUID(); + isNewSession = false; + if (!sessionEntry && sessionCfg && sessionStore && sessionKey) { + sessionEntry = { + sessionId, + updatedAt: now, + }; + } + } + + const persistedThinking = + !isNewSession && sessionEntry + ? normalizeThinkLevel(sessionEntry.thinkingLevel) + : undefined; + const persistedVerbose = + !isNewSession && sessionEntry + ? normalizeVerboseLevel(sessionEntry.verboseLevel) + : undefined; + + return { + sessionId: sessionId ?? crypto.randomUUID(), + sessionKey, + sessionEntry, + sessionStore, + storePath, + isNewSession, + systemSent, + 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) { + throw new Error("Pass --to or --session-id to choose a session"); + } + if (opts.deliver && !opts.to) { + throw new Error("Delivering to WhatsApp requires --to "); + } + + const cfg = loadConfig(); + const replyCfg = assertCommandConfig(cfg); + const sessionCfg = replyCfg.session; + + const thinkOverride = normalizeThinkLevel(opts.thinking); + if (opts.thinking && !thinkOverride) { + throw new Error( + "Invalid 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) + : (replyCfg.timeoutSeconds ?? 600); + const timeoutSeconds = Math.max(timeoutSecondsRaw, 1); + if (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) { + throw new Error("--timeout must be a positive integer (seconds)"); + } + const timeoutMs = timeoutSeconds * 1000; + + const sessionResolution = resolveSession({ + to: opts.to, + sessionId: opts.sessionId, + replyCfg, + }); + const { + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + isNewSession, + systemSent: initialSystemSent, + persistedThinking, + persistedVerbose, + } = sessionResolution; + + let systemSent = initialSystemSent; + const sendSystemOnce = sessionCfg?.sendSystemOnce === true; + const isFirstTurnInSession = isNewSession || !systemSent; + + // Merge thinking/verbose levels: flag override > persisted > defaults. + const resolvedThinkLevel: ThinkLevel | undefined = + thinkOverride ?? + persistedThinking ?? + (replyCfg.thinkingDefault as ThinkLevel | undefined); + const resolvedVerboseLevel: VerboseLevel | undefined = + verboseOverride ?? + persistedVerbose ?? + (replyCfg.verboseDefault as VerboseLevel | undefined); + + // Persist overrides into the session store (mirrors directive-only flow). + if (sessionStore && sessionEntry && sessionKey && storePath) { + sessionEntry.updatedAt = Date.now(); + if (thinkOverride) { + if (thinkOverride === "off") { + delete sessionEntry.thinkingLevel; + } else { + sessionEntry.thinkingLevel = thinkOverride; + } + } else if (isNewSession) { + delete sessionEntry.thinkingLevel; + } + + if (verboseOverride) { + if (verboseOverride === "off") { + delete sessionEntry.verboseLevel; + } else { + sessionEntry.verboseLevel = verboseOverride; + } + } else if (isNewSession) { + delete sessionEntry.verboseLevel; + } + + if (sendSystemOnce && isFirstTurnInSession) { + sessionEntry.systemSent = true; + systemSent = true; + } + + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + + const baseCtx: TemplateContext = { + Body: body, + BodyStripped: body, + From: opts.to, + SessionId: sessionId, + IsNewSession: isNewSession ? "true" : "false", + }; + + const sessionIntro = + isFirstTurnInSession && sessionCfg?.sessionIntro + ? applyTemplate(sessionCfg.sessionIntro, baseCtx) + : ""; + const bodyPrefix = replyCfg.bodyPrefix + ? applyTemplate(replyCfg.bodyPrefix, baseCtx) + : ""; + + let commandBody = body; + if (!sendSystemOnce || isFirstTurnInSession) { + commandBody = bodyPrefix ? `${bodyPrefix}${commandBody}` : commandBody; + } + if (sessionIntro) { + commandBody = `${sessionIntro}\n\n${commandBody}`; + } + + const templatingCtx: TemplateContext = { + ...baseCtx, + Body: commandBody, + BodyStripped: commandBody, + }; + + const result = await runCommandReply({ + reply: { ...replyCfg, mode: "command" }, + templatingCtx, + sendSystemOnce, + isNewSession, + isFirstTurnInSession, + systemSent, + timeoutMs, + timeoutSeconds, + commandRunner: runCommandWithTimeout, + thinkLevel: resolvedThinkLevel, + verboseLevel: resolvedVerboseLevel, + }); + + // If the agent returned a new session id, persist it. + const returnedSessionId = result.meta.agentMeta?.sessionId; + if ( + returnedSessionId && + returnedSessionId !== sessionId && + sessionStore && + sessionEntry && + sessionKey && + storePath + ) { + sessionEntry.sessionId = returnedSessionId; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + + const payloads = result.payloads ?? []; + if (opts.json) { + const normalizedPayloads = payloads.map((p) => ({ + text: p.text ?? "", + mediaUrl: p.mediaUrl ?? null, + mediaUrls: p.mediaUrls ?? (p.mediaUrl ? [p.mediaUrl] : undefined), + })); + runtime.log( + JSON.stringify( + { + payloads: normalizedPayloads, + meta: result.meta, + }, + null, + 2, + ), + ); + return; + } + + if (payloads.length === 0) { + runtime.log("No reply from agent."); + return; + } + + const deliver = opts.deliver === true; + let provider: Provider | "auto" | undefined = opts.provider ?? "auto"; + if (deliver) { + provider = + provider === "twilio" + ? "twilio" + : await pickProvider((provider ?? "auto") as Provider | "auto"); + if (provider === "twilio") ensureTwilioEnv(); + } + + for (const payload of payloads) { + const lines: string[] = []; + if (payload.text) lines.push(payload.text.trimEnd()); + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + for (const url of mediaList) { + lines.push(`MEDIA:${url}`); + } + runtime.log(lines.join("\n")); + + if (deliver && opts.to) { + const text = payload.text ?? ""; + const media = mediaList; + if (provider === "web") { + // Prefer IPC to reuse the running relay; fall back to direct web send. + let sentViaIpc = false; + const ipcResult = await sendViaIpc(opts.to, text, media[0]); + if (ipcResult) { + sentViaIpc = ipcResult.success; + if (ipcResult.success && media.length > 1) { + for (const extra of media.slice(1)) { + await sendViaIpc(opts.to, "", extra); + } + } + } + if (!sentViaIpc) { + if (text || media.length === 0) { + await deps.sendMessageWeb(opts.to, text, { + verbose: false, + mediaUrl: media[0], + }); + } + for (const extra of media.slice(1)) { + await deps.sendMessageWeb(opts.to, "", { + verbose: false, + mediaUrl: extra, + }); + } + } + } else { + const chunks = chunkText(text, 1600); + const resolvedMedia = await Promise.all( + media.map((m) => + deps.resolveTwilioMediaUrl(m, { serveMedia: false, runtime }), + ), + ); + const firstMedia = resolvedMedia[0]; + if (chunks.length === 0) chunks.push(""); + for (let i = 0; i < chunks.length; i++) { + const bodyChunk = chunks[i]; + const attach = i === 0 ? firstMedia : undefined; + await deps.sendMessage( + opts.to, + bodyChunk, + { mediaUrl: attach }, + runtime, + ); + } + if (resolvedMedia.length > 1) { + for (const extra of resolvedMedia.slice(1)) { + await deps.sendMessage(opts.to, "", { mediaUrl: extra }, runtime); + } + } + } + } + } +}