diff --git a/.agent-core/agent-core.jsonc b/.agent-core/agent-core.jsonc index e9e1df068b9..2819c66bd9f 100644 --- a/.agent-core/agent-core.jsonc +++ b/.agent-core/agent-core.jsonc @@ -7,17 +7,17 @@ "type": "remote", "url": "https://mcp.context7.com/mcp" }, - "personas-memory": { + "memory": { "type": "local", "command": ["bun", "run", "/home/artur/.local/src/agent-core/src/mcp/servers/memory.ts"], "enabled": true }, - "personas-calendar": { + "calendar": { "type": "local", "command": ["bun", "run", "/home/artur/.local/src/agent-core/src/mcp/servers/calendar.ts"], "enabled": true }, - "personas-portfolio": { + "portfolio": { "type": "local", "command": ["bun", "run", "/home/artur/.local/src/agent-core/src/mcp/servers/portfolio.ts"], "enabled": true diff --git a/.agent-core/tool/canvas.ts b/.agent-core/tool/canvas.ts index e97385dd609..55f01bc5743 100644 --- a/.agent-core/tool/canvas.ts +++ b/.agent-core/tool/canvas.ts @@ -38,8 +38,6 @@ Examples: config: tool.schema.string().describe("JSON configuration for the canvas content"), }, async execute(args) { - const { requestDaemon } = await import("../../../src/daemon/ipc-client.js") - let config: Record try { config = JSON.parse(args.config) @@ -47,22 +45,18 @@ Examples: return `Invalid JSON config: ${args.config}` } - try { - const result = await requestDaemon<{ paneId: string; id: string; kind: string }>( - "canvas:spawn", - { kind: args.kind, id: args.id, config } - ) - return `Canvas "${args.id}" (${args.kind}) displayed in pane ${result.paneId}. + // Canvas daemon integration not yet implemented + // For now, return the content as formatted text + const title = (config.title as string) || args.id + const content = (config.content as string) || JSON.stringify(config, null, 2) -Content: -${JSON.stringify(config, null, 2)}` - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - return `Failed to spawn canvas: ${msg} + return `=== ${title} === +(Canvas type: ${args.kind}) -Note: Canvas requires the agent-core daemon to be running. -Start it with: agent-core daemon` - } +${content} + +--- +Note: WezTerm canvas panes are not yet implemented. Content displayed inline.` }, }) @@ -78,8 +72,6 @@ Examples: config: tool.schema.string().describe("New JSON configuration"), }, async execute(args) { - const { requestDaemon } = await import("../../../src/daemon/ipc-client.js") - let config: Record try { config = JSON.parse(args.config) @@ -87,16 +79,11 @@ Examples: return `Invalid JSON config: ${args.config}` } - try { - await requestDaemon("canvas:update", { id: args.id, config }) - return `Canvas "${args.id}" updated. + // Canvas daemon integration not yet implemented + return `Canvas "${args.id}" update requested (not yet implemented). New content: ${JSON.stringify(config, null, 2)}` - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - return `Failed to update canvas: ${msg}` - } }, }) @@ -107,15 +94,8 @@ export const canvasClose = tool({ id: tool.schema.string().describe("Canvas identifier to close"), }, async execute(args) { - const { requestDaemon } = await import("../../../src/daemon/ipc-client.js") - - try { - await requestDaemon("canvas:close", { id: args.id }) - return `Canvas "${args.id}" closed.` - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - return `Failed to close canvas: ${msg}` - } + // Canvas daemon integration not yet implemented + return `Canvas "${args.id}" close requested (not yet implemented).` }, }) @@ -124,29 +104,7 @@ export const canvasList = tool({ description: `List all active canvases.`, args: {}, async execute() { - const { requestDaemon } = await import("../../../src/daemon/ipc-client.js") - - try { - const canvases = await requestDaemon< - Array<{ - id: string - kind: string - paneId: string - createdAt: number - }> - >("canvas:list", {}) - - if (canvases.length === 0) { - return "No active canvases." - } - - const list = canvases.map((c) => `- ${c.id} (${c.kind}) in pane ${c.paneId}`).join("\n") - - return `${canvases.length} active canvas(es): -${list}` - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - return `Failed to list canvases: ${msg}` - } + // Canvas daemon integration not yet implemented + return `Canvas listing not yet implemented. WezTerm canvas panes are a planned feature.` }, }) diff --git a/.agent-core/tool/zee-messaging.ts b/.agent-core/tool/zee-messaging.ts index 08e310471a9..12d0a95bf9d 100644 --- a/.agent-core/tool/zee-messaging.ts +++ b/.agent-core/tool/zee-messaging.ts @@ -10,19 +10,19 @@ export default tool({ description: `Send messages via WhatsApp or Telegram gateways. Channels: -- **whatsapp**: Zee's WhatsApp gateway (requires active daemon with --whatsapp) -- **telegram**: Stanley/Johny Telegram bots (requires active daemon with --telegram-*) +- **whatsapp**: Zee's WhatsApp gateway (requires agent-core daemon with gateway enabled) +- **telegram**: Telegram bots (requires agent-core daemon with gateway enabled) WhatsApp: -- \`to\`: Chat ID (from incoming message context, e.g., "1234567890@c.us") +- to: E164 phone (e.g., "+1555...") or chat JID (e.g., "1234567890@c.us" or "...@g.us") - Only Zee can send via WhatsApp Telegram: -- \`to\`: Numeric chat ID (from incoming message context) -- \`persona\`: Which bot to use - "stanley" (default) or "johny" +- to: Chat ID (numeric) or @username +- persona: Which bot/account to use - "stanley" (default) or "johny" Examples: -- WhatsApp: { channel: "whatsapp", to: "1234567890@c.us", message: "Hello!" } +- WhatsApp: { channel: "whatsapp", to: "+15551234567", message: "Hello!" } - Telegram via Stanley: { channel: "telegram", to: "123456789", message: "Market update!", persona: "stanley" }`, args: { channel: tool.schema @@ -38,9 +38,11 @@ Examples: async execute(args) { const { channel, to, message, persona } = args - // Get daemon port from environment or default - const daemonPort = process.env.AGENT_CORE_DAEMON_PORT || "3456" - const baseUrl = `http://127.0.0.1:${daemonPort}` + const rawBaseUrl = + process.env.AGENT_CORE_URL || + process.env.AGENT_CORE_DAEMON_URL || + `http://127.0.0.1:${process.env.AGENT_CORE_PORT || "3210"}` + const baseUrl = rawBaseUrl.replace(/\/$/, "") try { if (channel === "whatsapp") { @@ -56,9 +58,9 @@ Examples: return `Failed to send WhatsApp message: ${error} Troubleshooting: -- Ensure daemon is running with --whatsapp flag -- Check WhatsApp connection status -- Verify chatId format (e.g., "1234567890@c.us")` +- Ensure \`agent-core daemon\` is running +- Check \`agent-core debug status\` shows Gateway: Active +- Verify recipient format (E164 like "+1555..." or JID like "1234567890@c.us")` } const result = await response.json() @@ -91,8 +93,8 @@ Chat ID must be a numeric value (e.g., 123456789).` return `Failed to send Telegram message via ${selectedPersona}: ${error} Troubleshooting: -- Ensure daemon is running with --telegram-${selectedPersona}-token flag -- Check bot connection status +- Ensure \`agent-core daemon\` is running +- Check \`agent-core debug status\` shows Gateway: Active - Verify chatId is numeric` } diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000000..063f5d0c477 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,33 @@ +# Testing + +## Automated (Non-UI) + +Run from repo root: + +```bash +cd packages/agent-core +bun test +bun run typecheck +``` + +## Manual (TUI / UI) + +Run from repo root: + +```bash +cd packages/agent-core +bun dev +``` + +Smoke checklist: + +- TUI launches and renders without crashing. +- `Ctrl+X H` toggles `HOLD`/`RELEASE` mode. +- `Ctrl+T` cycles model variants (for models that define variants). +- Provider dialog accepts an API key and shows success toast. + +## Latest Run (2026-01-17) + +- `cd packages/agent-core && bun test` (pass) +- `cd packages/agent-core && bun run typecheck` (pass) +- `cd packages/agent-core && bun dev` (launched TUI) diff --git a/packages/agent-core/src/cli/cmd/auth.ts b/packages/agent-core/src/cli/cmd/auth.ts index 387bbb6c7ea..1bab3523840 100644 --- a/packages/agent-core/src/cli/cmd/auth.ts +++ b/packages/agent-core/src/cli/cmd/auth.ts @@ -228,28 +228,37 @@ export const AuthLoginCommand = cmd({ async fn() { UI.empty() prompts.intro("Add credential") - if (args.url) { - const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Bun.spawn({ - cmd: wellknown.auth.command, - stdout: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - prompts.log.error("Failed") + const rawInput = typeof args.url === "string" ? args.url.trim() : "" + let providerArg: string | undefined + if (rawInput) { + try { + const url = new URL(rawInput) + const wellknown = await fetch(`${url.toString().replace(/\/$/, "")}/.well-known/opencode`).then( + (x) => x.json() as any, + ) + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Bun.spawn({ + cmd: wellknown.auth.command, + stdout: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + const token = await new Response(proc.stdout).text() + await Auth.set(url.toString(), { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), + }) + prompts.log.success("Logged into " + url.toString()) prompts.outro("Done") return + } catch { + providerArg = rawInput } - const token = await new Response(proc.stdout).text() - await Auth.set(args.url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + args.url) - prompts.outro("Done") - return } await ModelsDev.refresh().catch(() => {}) @@ -277,35 +286,51 @@ export const AuthLoginCommand = cmd({ openrouter: 5, vercel: 6, } - let provider = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, + let provider = providerArg ?? "" + if (!provider) { + const selected = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: { + opencode: "recommended", + anthropic: "Claude Max or API key", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], + })), ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - anthropic: "Claude Max or API key", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), - ), - { - value: "other", - label: "Other", - }, - ], - }) + { + value: "other", + label: "Other", + }, + ], + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + provider = selected as string + } - if (prompts.isCancel(provider)) throw new UI.CancelledError() + const knownProvider = provider in providers + if (!knownProvider && provider !== "other") { + provider = provider.replace(/^@ai-sdk\//, "") + const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) + if (handled) return + } + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in agent-core.json, check the docs for examples.`, + ) + } const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { @@ -314,13 +339,12 @@ export const AuthLoginCommand = cmd({ } if (provider === "other") { - provider = await prompts.text({ + const entered = await prompts.text({ message: "Enter provider id", validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), }) - if (prompts.isCancel(provider)) throw new UI.CancelledError() - provider = provider.replace(/^@ai-sdk\//, "") - if (prompts.isCancel(provider)) throw new UI.CancelledError() + if (prompts.isCancel(entered)) throw new UI.CancelledError() + provider = entered.replace(/^@ai-sdk\//, "") // Check if a plugin provides auth for this custom provider const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) diff --git a/packages/agent-core/src/cli/cmd/daemon.ts b/packages/agent-core/src/cli/cmd/daemon.ts index b0e1d1d1723..76cf0ee0579 100644 --- a/packages/agent-core/src/cli/cmd/daemon.ts +++ b/packages/agent-core/src/cli/cmd/daemon.ts @@ -27,6 +27,7 @@ export namespace Daemon { const STATE_DIR = path.join(Global.Path.state, "daemon") const PID_FILE = path.join(STATE_DIR, "daemon.pid") const LOCK_FILE = path.join(STATE_DIR, "daemon.lock") + let lockHandle: fs.FileHandle | null = null export interface DaemonState { pid: number @@ -95,6 +96,60 @@ export namespace Daemon { } } + async function readLockFile(): Promise<{ pid?: number; startTime?: number } | null> { + try { + const content = await fs.readFile(LOCK_FILE, "utf-8") + return JSON.parse(content) + } catch { + return null + } + } + + export async function acquireLock() { + await ensureStateDir() + try { + lockHandle = await fs.open(LOCK_FILE, "wx") + await lockHandle.writeFile(JSON.stringify({ pid: process.pid, startTime: Date.now() }, null, 2)) + return + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : "" + if (code !== "EEXIST") throw error + } + + const existing = await readLockFile() + if (existing?.pid) { + try { + process.kill(existing.pid, 0) + throw new Error(`Daemon is already running (PID: ${existing.pid})`) + } catch { + // Stale lock + } + } + + await checkAndCleanStaleLock() + lockHandle = await fs.open(LOCK_FILE, "wx") + await lockHandle.writeFile(JSON.stringify({ pid: process.pid, startTime: Date.now() }, null, 2)) + } + + export async function releaseLock() { + try { + if (lockHandle) { + await lockHandle.close() + } + } catch { + // Ignore lock close errors + } finally { + lockHandle = null + } + + try { + await fs.unlink(LOCK_FILE) + log.info("removed lock file", { path: LOCK_FILE }) + } catch { + // Ignore if file doesn't exist + } + } + export async function restoreSessionsWithTodos(directory: string) { log.info("checking for sessions with incomplete todos", { directory }) @@ -801,6 +856,14 @@ export const DaemonCommand = cmd({ } } + try { + await Daemon.acquireLock() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + UI.error(`Failed to acquire daemon lock: ${message}`) + process.exit(1) + } + const opts = await resolveNetworkOptions(args) const directory = args.directory as string @@ -812,7 +875,15 @@ export const DaemonCommand = cmd({ }) // Start the server - const server = Server.listen(opts) + let server: ReturnType + try { + server = Server.listen(opts) + } catch (error) { + await Daemon.releaseLock() + const message = error instanceof Error ? error.message : String(error) + UI.error(`Failed to start daemon server: ${message}`) + process.exit(1) + } const serverHost = server.hostname ?? opts.hostname const daemonHost = serverHost === "0.0.0.0" ? "127.0.0.1" : serverHost const daemonPort = server.port ?? opts.port @@ -827,6 +898,7 @@ export const DaemonCommand = cmd({ await server.stop().catch((stopErr) => { log.debug("failed to stop server after sanity check failure", { error: String(stopErr) }) }) + await Daemon.releaseLock() process.exit(1) } @@ -971,6 +1043,7 @@ export const DaemonCommand = cmd({ } await Daemon.removePidFile() + await Daemon.releaseLock() await server.stop() } @@ -1109,10 +1182,12 @@ export const DaemonStopCommand = cmd({ console.log("Daemon did not stop gracefully, sending SIGKILL") process.kill(state.pid, "SIGKILL") await Daemon.removePidFile() + await Daemon.releaseLock() } catch (e) { if ((e as NodeJS.ErrnoException).code === "ESRCH") { console.log("Daemon process not found, cleaning up PID file") await Daemon.removePidFile() + await Daemon.releaseLock() } else { throw e } diff --git a/packages/agent-core/src/cli/cmd/tui/app.tsx b/packages/agent-core/src/cli/cmd/tui/app.tsx index 14ea5977858..4cfef2e09c5 100644 --- a/packages/agent-core/src/cli/cmd/tui/app.tsx +++ b/packages/agent-core/src/cli/cmd/tui/app.tsx @@ -419,20 +419,29 @@ function App() { local.agent.move(1) }, }, - { - title: "Agent cycle reverse", - value: "agent.cycle.reverse", - keybind: "agent_cycle_reverse", - category: "Agent", - onSelect: () => { - local.agent.move(-1) - }, - }, - { - title: local.mode.isHold() ? "Switch to Release mode" : "Switch to Hold mode", - value: "mode.toggle", - keybind: "mode_toggle", - category: "Mode", + { + title: "Agent cycle reverse", + value: "agent.cycle.reverse", + keybind: "agent_cycle_reverse", + category: "Agent", + onSelect: () => { + local.agent.move(-1) + }, + }, + { + title: "Variant cycle", + value: "variant.cycle", + keybind: "variant_cycle", + category: "Agent", + onSelect: () => { + local.model.variant.cycle() + }, + }, + { + title: local.mode.isHold() ? "Switch to Release mode" : "Switch to Hold mode", + value: "mode.toggle", + keybind: "mode_toggle", + category: "Mode", onSelect: () => { local.mode.toggle() }, diff --git a/packages/agent-core/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/agent-core/src/cli/cmd/tui/component/dialog-provider.tsx index 4e1171a4201..dcfc277f998 100644 --- a/packages/agent-core/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/agent-core/src/cli/cmd/tui/component/dialog-provider.tsx @@ -22,6 +22,33 @@ const PROVIDER_PRIORITY: Record = { google: 4, } +function extractErrorMessage(error: unknown): string | null { + if (!error) return null + if (typeof error === "string") return error + if (error instanceof Error) return error.message || null + if (typeof error !== "object") return null + + const err = error as Record + if (typeof err.message === "string") return err.message + + const errors = + (Array.isArray(err.errors) ? err.errors : undefined) ?? + (typeof err.data === "object" && err.data && Array.isArray((err.data as Record).errors) + ? ((err.data as Record).errors as unknown[]) + : undefined) + + if (errors) { + for (const item of errors) { + if (typeof item === "string") return item + if (item && typeof item === "object" && typeof (item as Record).message === "string") { + return String((item as Record).message) + } + } + } + + return null +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() @@ -221,34 +248,45 @@ function ApiMethod(props: ApiMethodProps) { const sdk = useSDK() const sync = useSync() const { theme } = useTheme() + const toast = useToast() + const [error, setError] = createSignal(null) return ( - - OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key. - - - Go to https://opencode.ai/zen to get a key - - - ) : undefined - } + description={() => ( + + {props.providerID === "opencode" ? ( + + + OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key. + + + Go to https://opencode.ai/zen to get a key + + + ) : null} + {error() ? {error()} : null} + + )} onConfirm={async (value) => { if (!value) return - await sdk.client.auth.set({ + const result = await sdk.client.auth.set({ providerID: props.providerID, auth: { type: "api", key: value, }, }) + if (result.error) { + setError(extractErrorMessage(result.error) ?? "Failed to validate API key") + return + } await sdk.client.instance.dispose() await sync.bootstrap() + const suffix = value.slice(-4) + toast.show({ message: `Provider connected and validated (key ••••${suffix})`, variant: "success" }) dialog.replace(() => ) }} /> diff --git a/packages/agent-core/src/cli/cmd/tui/component/tips.tsx b/packages/agent-core/src/cli/cmd/tui/component/tips.tsx index 74602897e9a..85ca8aab560 100644 --- a/packages/agent-core/src/cli/cmd/tui/component/tips.tsx +++ b/packages/agent-core/src/cli/cmd/tui/component/tips.tsx @@ -1,5 +1,8 @@ import { For } from "solid-js" -import { useTheme } from "@tui/context/theme" +import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" + +const themeCount = Object.keys(DEFAULT_THEMES).length +const themeTip = `Use {highlight}:theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between ${themeCount} built-in themes.` type TipPart = { text: string; highlight: boolean } @@ -55,7 +58,7 @@ const TIPS = [ "Press {highlight}Ctrl+X E{/highlight} or {highlight}:editor{/highlight} to compose messages in your external editor.", "Run {highlight}:init{/highlight} to auto-generate project rules based on your codebase structure.", "Run {highlight}:models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.", - "Use {highlight}:theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.", + themeTip, "Press {highlight}Ctrl+X N{/highlight} or {highlight}:new{/highlight} to start a fresh conversation session.", "Use {highlight}:sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.", "Run {highlight}:compact{/highlight} to summarize long sessions when approaching context limits.", diff --git a/packages/agent-core/src/cli/cmd/tui/context/local.tsx b/packages/agent-core/src/cli/cmd/tui/context/local.tsx index 2ad275fafcc..3567381e040 100644 --- a/packages/agent-core/src/cli/cmd/tui/context/local.tsx +++ b/packages/agent-core/src/cli/cmd/tui/context/local.tsx @@ -389,6 +389,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("variant", key, value) save() }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + const current = this.current() + if (!current) { + this.set(variants[0]) + return + } + const index = variants.indexOf(current) + if (index === -1 || index === variants.length - 1) { + this.set(undefined) + return + } + this.set(variants[index + 1]) + }, }, } }) diff --git a/packages/agent-core/src/cli/cmd/tui/util/clipboard.ts b/packages/agent-core/src/cli/cmd/tui/util/clipboard.ts index 3be356d03cc..760efe8556a 100644 --- a/packages/agent-core/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/agent-core/src/cli/cmd/tui/util/clipboard.ts @@ -5,12 +5,75 @@ import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +// 4MB threshold - leave 1MB margin for the 5MB API limit +const IMAGE_SIZE_THRESHOLD = 4 * 1024 * 1024 + export namespace Clipboard { export interface Content { data: string mime: string } + /** + * Compress an image buffer using ImageMagick if it exceeds the size threshold. + * Returns the original buffer if compression fails or isn't needed. + */ + async function compressImageIfNeeded(buffer: Buffer, mime: string): Promise<{ data: Buffer; mime: string }> { + if (buffer.length <= IMAGE_SIZE_THRESHOLD) { + return { data: buffer, mime } + } + + // Check if ImageMagick is available + const magick = Bun.which("magick") || Bun.which("convert") + if (!magick) { + console.warn(`[clipboard] Image is ${(buffer.length / 1024 / 1024).toFixed(1)}MB but ImageMagick not available for compression`) + return { data: buffer, mime } + } + + const tmpInput = path.join(tmpdir(), `agent-core-img-in-${Date.now()}.png`) + const tmpOutput = path.join(tmpdir(), `agent-core-img-out-${Date.now()}.jpg`) + + try { + // Write original image to temp file + await Bun.write(tmpInput, buffer) + + // Progressive compression: try different quality levels until under threshold + // Start with high quality JPEG, reduce if needed + const qualities = [85, 70, 50, 30] + const resizeSteps = ["100%", "75%", "50%", "25%"] + + for (const resize of resizeSteps) { + for (const quality of qualities) { + const cmd = magick.endsWith("convert") + ? `convert "${tmpInput}" -resize ${resize} -quality ${quality} -strip "${tmpOutput}"` + : `magick "${tmpInput}" -resize ${resize} -quality ${quality} -strip "${tmpOutput}"` + + await $`sh -c ${cmd}`.nothrow().quiet() + + const outputFile = Bun.file(tmpOutput) + if (await outputFile.exists()) { + const compressed = Buffer.from(await outputFile.arrayBuffer()) + if (compressed.length <= IMAGE_SIZE_THRESHOLD && compressed.length > 0) { + const ratio = ((1 - compressed.length / buffer.length) * 100).toFixed(0) + console.log(`[clipboard] Compressed image: ${(buffer.length / 1024 / 1024).toFixed(1)}MB → ${(compressed.length / 1024 / 1024).toFixed(1)}MB (${ratio}% reduction, quality=${quality}, resize=${resize})`) + return { data: compressed, mime: "image/jpeg" } + } + } + } + } + + // If all compression attempts failed, return original with warning + console.warn(`[clipboard] Could not compress image below ${IMAGE_SIZE_THRESHOLD / 1024 / 1024}MB threshold`) + return { data: buffer, mime } + } catch (err) { + console.warn(`[clipboard] Image compression failed:`, err) + return { data: buffer, mime } + } finally { + // Clean up temp files + await $`rm -f "${tmpInput}" "${tmpOutput}"`.nothrow().quiet() + } + } + export async function read(): Promise { const os = platform() @@ -21,8 +84,9 @@ export namespace Clipboard { .nothrow() .quiet() const file = Bun.file(tmpfile) - const buffer = await file.arrayBuffer() - return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" } + const buffer = Buffer.from(await file.arrayBuffer()) + const compressed = await compressImageIfNeeded(buffer, "image/png") + return { data: compressed.data.toString("base64"), mime: compressed.mime } } catch { } finally { await $`rm -f "${tmpfile}"`.nothrow().quiet() @@ -36,7 +100,8 @@ export namespace Clipboard { if (base64) { const imageBuffer = Buffer.from(base64.trim(), "base64") if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } + const compressed = await compressImageIfNeeded(imageBuffer, "image/png") + return { data: compressed.data.toString("base64"), mime: compressed.mime } } } } @@ -44,11 +109,15 @@ export namespace Clipboard { if (os === "linux") { const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer() if (wayland && wayland.byteLength > 0) { - return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" } + const buffer = Buffer.from(wayland) + const compressed = await compressImageIfNeeded(buffer, "image/png") + return { data: compressed.data.toString("base64"), mime: compressed.mime } } const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer() if (x11 && x11.byteLength > 0) { - return { data: Buffer.from(x11).toString("base64"), mime: "image/png" } + const buffer = Buffer.from(x11) + const compressed = await compressImageIfNeeded(buffer, "image/png") + return { data: compressed.data.toString("base64"), mime: compressed.mime } } } diff --git a/packages/agent-core/src/config/config.ts b/packages/agent-core/src/config/config.ts index fe79f6e23f9..f8cb87e4a0f 100644 --- a/packages/agent-core/src/config/config.ts +++ b/packages/agent-core/src/config/config.ts @@ -440,9 +440,7 @@ export namespace Config { .int() .positive() .optional() - .describe( - "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", - ), + .describe("Timeout in ms for MCP server requests."), }) .strict() .meta({ @@ -481,9 +479,7 @@ export namespace Config { .int() .positive() .optional() - .describe( - "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", - ), + .describe("Timeout in ms for MCP server requests."), }) .strict() .meta({ diff --git a/packages/agent-core/src/flag/flag.ts b/packages/agent-core/src/flag/flag.ts index 4cdb549096a..c7d1e76d99a 100644 --- a/packages/agent-core/src/flag/flag.ts +++ b/packages/agent-core/src/flag/flag.ts @@ -34,6 +34,9 @@ export namespace Flag { truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA") export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH = number("OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH") export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") + export const OPENCODE_EXPERIMENTAL_LLM_STREAM_START_TIMEOUT_MS = number( + "OPENCODE_EXPERIMENTAL_LLM_STREAM_START_TIMEOUT_MS", + ) export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX") export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT") export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY") diff --git a/packages/agent-core/src/mcp/index.ts b/packages/agent-core/src/mcp/index.ts index 8c1bbfe8471..db0569ecc6d 100644 --- a/packages/agent-core/src/mcp/index.ts +++ b/packages/agent-core/src/mcp/index.ts @@ -131,7 +131,7 @@ export namespace MCP { } // Convert MCP tool definition to AI SDK Tool type - async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise { + async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout: number): Promise { const inputSchema = mcpTool.inputSchema // Spread first, then override type to ensure it's always "object" @@ -141,7 +141,6 @@ export namespace MCP { properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"], additionalProperties: false, } - const config = await Config.get() return dynamicTool({ description: mcpTool.description ?? "", @@ -155,7 +154,7 @@ export namespace MCP { CallToolResultSchema, { resetTimeoutOnProgress: true, - timeout: config.experimental?.mcp_timeout, + timeout, }, ) }, @@ -728,6 +727,9 @@ export namespace MCP { export async function tools() { const result: Record = {} const s = await state() + const cfg = await Config.get() + const config = cfg.mcp ?? {} + const defaultTimeout = cfg.experimental?.mcp_timeout ?? DEFAULT_TIMEOUT const clientsSnapshot = await clients() for (const [clientName, client] of Object.entries(clientsSnapshot)) { @@ -765,10 +767,16 @@ export namespace MCP { if (!toolsResult) { continue } + const mcpConfig = config[clientName] + const timeout = (mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig.timeout : undefined) ?? defaultTimeout for (const mcpTool of toolsResult.tools) { const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_") - result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, s.clients[clientName] ?? client) + result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool( + mcpTool, + s.clients[clientName] ?? client, + timeout, + ) } } return result @@ -836,7 +844,13 @@ export namespace MCP { throw new Failed({ name: serverName }) } - const config = await Config.get() + const cfg = await Config.get() + const config = cfg.mcp ?? {} + const mcpConfig = config[serverName] + const timeout = + (mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig.timeout : undefined) ?? + cfg.experimental?.mcp_timeout ?? + DEFAULT_TIMEOUT try { return await client.callTool( { @@ -846,7 +860,7 @@ export namespace MCP { CallToolResultSchema, { resetTimeoutOnProgress: true, - timeout: config.experimental?.mcp_timeout ?? DEFAULT_TIMEOUT, + timeout, }, ) } catch (error) { diff --git a/packages/agent-core/src/provider/auth.ts b/packages/agent-core/src/provider/auth.ts index e6681ff0891..6aa64301308 100644 --- a/packages/agent-core/src/provider/auth.ts +++ b/packages/agent-core/src/provider/auth.ts @@ -6,6 +6,7 @@ import { fn } from "@/util/fn" import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "@/auth" +import { Provider } from "@/provider/provider" export namespace ProviderAuth { const state = Instance.state(async () => { @@ -91,27 +92,29 @@ export namespace ProviderAuth { result = await match.callback() } - if (result?.type === "success") { - if ("key" in result) { - await Auth.set(input.providerID, { - type: "api", - key: result.key, - }) - } - if ("refresh" in result) { - const info: Auth.Info = { - type: "oauth", - access: result.access, - refresh: result.refresh, - expires: result.expires, + if (result?.type === "success") { + if ("key" in result) { + await Auth.set(input.providerID, { + type: "api", + key: result.key, + }) + await Provider.reload() } - if (result.accountId) { - info.accountId = result.accountId + if ("refresh" in result) { + const info: Auth.Info = { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + } + if (result.accountId) { + info.accountId = result.accountId + } + await Auth.set(input.providerID, info) + await Provider.reload() } - await Auth.set(input.providerID, info) + return } - return - } throw new OauthCallbackFailed({}) }, @@ -127,6 +130,14 @@ export namespace ProviderAuth { type: "api", key: input.key, }) + await Provider.reload() + try { + await Provider.validateAuth(input.providerID) + } catch (error) { + await Auth.remove(input.providerID) + await Provider.reload() + throw error + } }, ) diff --git a/packages/agent-core/src/provider/provider.ts b/packages/agent-core/src/provider/provider.ts index 743702dd6c7..c90014a47e5 100644 --- a/packages/agent-core/src/provider/provider.ts +++ b/packages/agent-core/src/provider/provider.ts @@ -2,7 +2,7 @@ import z from "zod" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" -import { NoSuchModelError, type LanguageModel } from "ai" +import { NoSuchModelError, generateText, type LanguageModel } from "ai" // Use any for provider factories - there are multiple @ai-sdk/provider versions // (2.0.1 and 3.0.3) which causes type incompatibilities between providers @@ -19,6 +19,7 @@ import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" import { Env } from "../env" import { Instance } from "../project/instance" +import { State } from "../project/state" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { THINKING_BUDGETS } from "./constants" @@ -822,6 +823,10 @@ export namespace Provider { for (const [providerID, provider] of Object.entries(await Auth.all())) { if (disabled.has(providerID)) continue if (provider.type === "api") { + const envKeys = database[providerID]?.env ?? [] + for (const envKey of envKeys) { + if (!Env.get(envKey)) Env.set(envKey, provider.key) + } mergeProvider(providerID, { source: "api", key: provider.key, @@ -1244,6 +1249,10 @@ export namespace Provider { return state().then((state) => state.providers) } + export async function reload() { + await State.dispose(Instance.directory) + } + async function getSDK(model: Model) { try { using _ = log.time("getSDK", { @@ -1429,6 +1438,16 @@ export namespace Provider { if (model.includes(item)) return getModel(providerID, model) } } + + const models = Object.values(provider.models) + const candidates = models.some((m) => m.status !== "deprecated") ? models.filter((m) => m.status !== "deprecated") : models + const [fallback] = sortBy( + candidates, + [(m) => (m.id.includes("latest") ? 1 : 0), "desc"], + [(m) => m.release_date, "desc"], + [(m) => m.id, "desc"], + ) + if (fallback) return getModel(providerID, fallback.id) } // Check if opencode provider is available before using it @@ -1489,4 +1508,41 @@ export namespace Provider { providerID: z.string(), }), ) + + export async function validateAuth(providerID: string) { + const provider = await getProvider(providerID) + if (!provider) { + throw new Error(`Provider not found: ${providerID}`) + } + + let model = await getSmallModel(providerID) + if (!model || model.providerID !== providerID) { + const models = Object.values(provider.models) + const candidates = models.some((m) => m.status !== "deprecated") ? models.filter((m) => m.status !== "deprecated") : models + const [fallback] = sortBy( + candidates, + [(m) => (m.id.includes("latest") ? 1 : 0), "desc"], + [(m) => m.release_date, "desc"], + [(m) => m.id, "desc"], + ) + model = fallback ? await getModel(providerID, fallback.id) : undefined + } + if (!model) { + throw new Error(`No model available for provider ${providerID}`) + } + + const language = await getLanguage(model) + const options = ProviderTransform.options(model, "auth-validate", provider.options) + + await generateText({ + model: language, + prompt: "ping", + temperature: 0, + maxOutputTokens: 1, + maxRetries: 0, + abortSignal: AbortSignal.timeout(8000), + providerOptions: ProviderTransform.providerOptions(model, options), + headers: model.headers, + }) + } } diff --git a/packages/agent-core/src/provider/transform.ts b/packages/agent-core/src/provider/transform.ts index 0a0fc8b4d1d..69ec6829f16 100644 --- a/packages/agent-core/src/provider/transform.ts +++ b/packages/agent-core/src/provider/transform.ts @@ -648,6 +648,9 @@ export namespace ProviderTransform { "frequencyPenalty", // -2.0 to 2.0 "presencePenalty", // -2.0 to 2.0 "stop", // Stop sequences + + // Codex API (ChatGPT Pro/Plus OAuth) + "instructions", // System instructions for Codex models ]), // ═══════════════════════════════════════════════════════════════════════ diff --git a/packages/agent-core/src/server/route/auth.ts b/packages/agent-core/src/server/route/auth.ts index 621d77d1c33..e979d3ad845 100644 --- a/packages/agent-core/src/server/route/auth.ts +++ b/packages/agent-core/src/server/route/auth.ts @@ -2,6 +2,7 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import { z } from "zod" import { Auth } from "../../auth" +import { Provider } from "../../provider/provider" import { errors } from "../error" export const AuthRoute = new Hono() @@ -31,18 +32,42 @@ export const AuthRoute = new Hono() ), validator( "json", - z.object({ - api_key: z.string().optional(), - }), + z + .union([ + // Modern client payload + Auth.Info, + // Backwards-compatible payload + z.object({ api_key: z.string() }), + ]) + .optional(), ), async (c) => { const providerID = c.req.valid("param").providerID const body = c.req.valid("json") - if (body.api_key) { - await Auth.set(providerID, { - type: "api", - key: body.api_key, - }) + + if (body) { + const auth = + "type" in body + ? body + : "api_key" in body + ? ({ + type: "api", + key: body.api_key, + } satisfies Auth.Info) + : undefined + + if (auth) { + await Auth.set(providerID, auth) + } + } + await Provider.reload() + try { + await Provider.validateAuth(providerID) + } catch (error) { + await Auth.remove(providerID) + await Provider.reload() + const message = error instanceof Error ? error.message : String(error) + return c.json({ data: null, errors: [{ message, daemonPid: process.pid }], success: false }, 400) } return c.json(true) }, @@ -74,6 +99,7 @@ export const AuthRoute = new Hono() async (c) => { const providerID = c.req.valid("param").providerID await Auth.remove(providerID) + await Provider.reload() return c.json(true) }, ) diff --git a/packages/agent-core/src/server/route/gateway.ts b/packages/agent-core/src/server/route/gateway.ts new file mode 100644 index 00000000000..d43d3262190 --- /dev/null +++ b/packages/agent-core/src/server/route/gateway.ts @@ -0,0 +1,349 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { z } from "zod" +import { Log } from "../../util/log" + +const log = Log.create({ service: "server:gateway" }) + +const GatewayResponseSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), + data: z.unknown().optional(), +}) + +type GatewayResponse = z.infer + +const WhatsAppSendInput = z.object({ + chatId: z.string().optional(), + to: z.string().optional(), + message: z.string(), +}) + +const TelegramSendInput = z.object({ + chatId: z.union([z.string(), z.number()]).optional(), + to: z.union([z.string(), z.number()]).optional(), + message: z.string(), + persona: z.enum(["zee", "stanley", "johny"]).optional(), +}) + +type GatewayRequestFrame = { + type: "req" + id: string + method: string + params?: unknown +} + +type GatewayResponseFrame = { + type: "res" + id: string + ok: boolean + payload?: unknown + error?: { + code: string + message: string + details?: unknown + } +} + +const PROTOCOL_VERSION = 2 +const DEFAULT_GATEWAY_PORT = 18789 +const DEFAULT_GATEWAY_SEND_TIMEOUT_MS = 20_000 + +function resolveGatewayWsUrl(): string { + const urlOverride = process.env.ZEE_GATEWAY_URL?.trim() + if (urlOverride) return urlOverride + + const portRaw = Number.parseInt(process.env.ZEE_GATEWAY_PORT ?? "", 10) + const port = Number.isFinite(portRaw) ? portRaw : DEFAULT_GATEWAY_PORT + return `ws://127.0.0.1:${port}` +} + +function normalizeWhatsAppRecipient(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) throw new Error("chatId is required") + + const withoutPrefix = trimmed.replace(/^whatsapp:/i, "").trim() + const dmMatch = /^(\+?\d+)(?::\d+)?@c\.us$/i.exec(withoutPrefix) + if (dmMatch?.[1]) return dmMatch[1] + + const waMatch = /^(\+?\d+)(?::\d+)?@s\.whatsapp\.net$/i.exec(withoutPrefix) + if (waMatch?.[1]) return waMatch[1] + + return withoutPrefix +} + +function parseGatewayResponseFrame(raw: unknown): GatewayResponseFrame | null { + if (!raw || typeof raw !== "object") return null + const frame = raw as Record + if (frame.type !== "res") return null + if (typeof frame.id !== "string") return null + if (typeof frame.ok !== "boolean") return null + + const error = + frame.error && typeof frame.error === "object" + ? (frame.error as Record) + : undefined + + return { + type: "res", + id: frame.id, + ok: frame.ok, + payload: frame.payload, + error: + error && typeof error.code === "string" && typeof error.message === "string" + ? { + code: error.code, + message: error.message, + details: error.details, + } + : undefined, + } +} + +async function callGateway( + method: string, + params?: unknown, + options: { timeoutMs?: number } = {}, +): Promise { + const url = resolveGatewayWsUrl() + const timeoutMs = options.timeoutMs ?? 10_000 + + const token = process.env.ZEE_GATEWAY_TOKEN?.trim() || undefined + const password = process.env.ZEE_GATEWAY_PASSWORD?.trim() || undefined + const auth = token || password ? { token, password } : undefined + + const connectParams = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + name: "agent-core", + version: process.env.AGENT_CORE_VERSION?.trim() || "dev", + platform: process.platform, + mode: "daemon", + }, + caps: [], + ...(auth ? { auth } : {}), + } + + const connectId = crypto.randomUUID() + const requestId = crypto.randomUUID() + + return await new Promise((resolve, reject) => { + let settled = false + let stage: "connect" | "request" = "connect" + + const ws = new WebSocket(url) + + const timer = setTimeout(() => { + if (settled) return + settled = true + try { + ws.close() + } catch { + // ignore + } + reject(new Error(`Gateway timeout after ${timeoutMs}ms (${url})`)) + }, timeoutMs) + + const stop = (err?: Error, value?: T) => { + if (settled) return + settled = true + clearTimeout(timer) + try { + ws.close() + } catch { + // ignore + } + if (err) reject(err) + else resolve(value as T) + } + + ws.addEventListener("open", () => { + const frame: GatewayRequestFrame = { + type: "req", + id: connectId, + method: "connect", + params: connectParams, + } + ws.send(JSON.stringify(frame)) + }) + + ws.addEventListener("message", (event) => { + const data = (event as MessageEvent).data + const rawText = + typeof data === "string" + ? data + : data instanceof ArrayBuffer + ? Buffer.from(data).toString("utf8") + : String(data) + + let parsed: unknown + try { + parsed = JSON.parse(rawText) + } catch { + return + } + + const frame = parseGatewayResponseFrame(parsed) + if (!frame) return + + if (stage === "connect" && frame.id === connectId) { + if (!frame.ok) { + stop(new Error(frame.error?.message || "Gateway connect failed")) + return + } + stage = "request" + const req: GatewayRequestFrame = { + type: "req", + id: requestId, + method, + params, + } + ws.send(JSON.stringify(req)) + return + } + + if (stage === "request" && frame.id === requestId) { + if (!frame.ok) { + stop(new Error(frame.error?.message || "Gateway request failed")) + return + } + stop(undefined, frame.payload as T) + } + }) + + ws.addEventListener("close", (event) => { + if (settled) return + const ev = event as CloseEvent + const reason = typeof ev.reason === "string" && ev.reason ? `: ${ev.reason}` : "" + stop(new Error(`Gateway closed (${ev.code})${reason}`)) + }) + + ws.addEventListener("error", () => { + if (settled) return + stop(new Error(`Failed to connect to gateway (${url})`)) + }) + }) +} + +async function sendViaGateway(input: { + provider: "whatsapp" | "telegram" + to: string + message: string + accountId?: string +}): Promise { + return await callGateway("send", { + to: input.to, + message: input.message, + provider: input.provider, + ...(input.accountId ? { accountId: input.accountId } : {}), + idempotencyKey: crypto.randomUUID(), + }, { timeoutMs: DEFAULT_GATEWAY_SEND_TIMEOUT_MS }) +} + +export const GatewayRoute = new Hono() + .post( + "/whatsapp/send", + describeRoute({ + summary: "Send WhatsApp message (via Zee gateway)", + description: "Send a WhatsApp message via the local Zee gateway (WebSocket RPC).", + operationId: "gateway.whatsapp.send", + responses: { + 200: { + description: "Send result", + content: { + "application/json": { + schema: resolver(GatewayResponseSchema), + }, + }, + }, + }, + }), + async (c) => { + let body: unknown + try { + body = await c.req.json() + } catch { + const payload: GatewayResponse = { success: false, error: "Invalid JSON body" } + return c.json(payload) + } + + const parsed = WhatsAppSendInput.safeParse(body) + if (!parsed.success) { + const payload: GatewayResponse = { success: false, error: "Invalid request body" } + return c.json(payload) + } + + const toRaw = parsed.data.chatId ?? parsed.data.to + if (!toRaw) { + const payload: GatewayResponse = { success: false, error: 'Missing "chatId" (or "to")' } + return c.json(payload) + } + + try { + const to = normalizeWhatsAppRecipient(toRaw) + const data = await sendViaGateway({ provider: "whatsapp", to, message: parsed.data.message }) + return c.json({ success: true, data } satisfies GatewayResponse) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.warn("whatsapp send failed", { error: message }) + return c.json({ success: false, error: message } satisfies GatewayResponse) + } + }, + ) + .post( + "/telegram/send", + describeRoute({ + summary: "Send Telegram message (via Zee gateway)", + description: "Send a Telegram message via the local Zee gateway (WebSocket RPC).", + operationId: "gateway.telegram.send", + responses: { + 200: { + description: "Send result", + content: { + "application/json": { + schema: resolver(GatewayResponseSchema), + }, + }, + }, + }, + }), + async (c) => { + let body: unknown + try { + body = await c.req.json() + } catch { + const payload: GatewayResponse = { success: false, error: "Invalid JSON body" } + return c.json(payload) + } + + const parsed = TelegramSendInput.safeParse(body) + if (!parsed.success) { + const payload: GatewayResponse = { success: false, error: "Invalid request body" } + return c.json(payload) + } + + const toRaw = parsed.data.chatId ?? parsed.data.to + if (toRaw === undefined || toRaw === null || String(toRaw).trim() === "") { + const payload: GatewayResponse = { success: false, error: 'Missing "chatId" (or "to")' } + return c.json(payload) + } + + const accountId = parsed.data.persona ?? "stanley" + + try { + const to = String(toRaw) + const data = await sendViaGateway({ + provider: "telegram", + to, + message: parsed.data.message, + accountId, + }) + return c.json({ success: true, data } satisfies GatewayResponse) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log.warn("telegram send failed", { error: message }) + return c.json({ success: false, error: message } satisfies GatewayResponse) + } + }, + ) diff --git a/packages/agent-core/src/server/server.ts b/packages/agent-core/src/server/server.ts index 61dd03df2d7..c1638f5a3d6 100644 --- a/packages/agent-core/src/server/server.ts +++ b/packages/agent-core/src/server/server.ts @@ -38,6 +38,7 @@ import { ToolRoute } from "./route/tool" import { ProcessRoute } from "./route/process" import { MemoryRoute } from "./route/memory" import { UsageRoute } from "../usage/route" +import { GatewayRoute } from "./route/gateway" // Default API port for the daemon const DEFAULT_API_PORT = 3210 @@ -156,6 +157,7 @@ export namespace Server { .route("/", ProcessRoute) .route("/", MemoryRoute) .route("/usage", UsageRoute) + .route("/gateway", GatewayRoute) // API Documentation .get( diff --git a/packages/agent-core/src/session/message-v2.ts b/packages/agent-core/src/session/message-v2.ts index 2f5126fec59..c75b432e103 100644 --- a/packages/agent-core/src/session/message-v2.ts +++ b/packages/agent-core/src/session/message-v2.ts @@ -314,6 +314,7 @@ export namespace MessageV2 { }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + options: z.record(z.string(), z.any()).optional(), variant: z.string().optional(), }).meta({ ref: "UserMessage", diff --git a/packages/agent-core/src/session/processor.ts b/packages/agent-core/src/session/processor.ts index 0c42913cb3c..c0d3e77db36 100644 --- a/packages/agent-core/src/session/processor.ts +++ b/packages/agent-core/src/session/processor.ts @@ -17,10 +17,14 @@ import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" import { addWideEventFields, finishWideEvent, runWithWideEventContext } from "@/util/wide-events" +import { Flag } from "@/flag/flag" +import { withTimeout } from "@/util/timeout" import * as UsageTracker from "@/usage/tracker" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 + const DEFAULT_LLM_STREAM_START_TIMEOUT_MS = 30_000 + const LLM_STREAM_START_TIMEOUT_BUFFER_MS = 250 const log = Log.create({ service: "session.processor" }) export type Info = Awaited> @@ -78,11 +82,48 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = await Fallback.stream(streamInput) + const streamStartTimeoutMs = + Flag.OPENCODE_EXPERIMENTAL_LLM_STREAM_START_TIMEOUT_MS ?? DEFAULT_LLM_STREAM_START_TIMEOUT_MS + const streamStartController = new AbortController() + const streamStartTimer = setTimeout(() => { + streamStartController.abort( + new DOMException(`LLM stream did not start within ${streamStartTimeoutMs}ms`, "AbortError"), + ) + }, streamStartTimeoutMs) + const streamAbort = AbortSignal.any([input.abort, streamStartController.signal]) + let removeAbortListener: (() => void) | undefined + const abortPromise = new Promise((_, reject) => { + const onAbort = () => { + const reason = streamAbort.reason ?? new DOMException("Aborted", "AbortError") + reject(reason) + } - for await (const value of stream.fullStream) { - input.abort.throwIfAborted() - switch (value.type) { + if (streamAbort.aborted) { + onAbort() + return + } + + streamAbort.addEventListener("abort", onAbort, { once: true }) + removeAbortListener = () => streamAbort.removeEventListener("abort", onAbort) + }) + let streamStartTimerCleared = false + try { + const stream = await withTimeout( + Fallback.stream({ ...streamInput, abort: streamAbort }), + streamStartTimeoutMs + LLM_STREAM_START_TIMEOUT_BUFFER_MS, + ) + + const iterator = stream.fullStream[Symbol.asyncIterator]() + while (true) { + const result = await Promise.race([iterator.next(), abortPromise]) + if (result.done) break + const value = result.value + if (!streamStartTimerCleared) { + streamStartTimerCleared = true + clearTimeout(streamStartTimer) + } + streamAbort.throwIfAborted() + switch (value.type) { case "start": SessionStatus.set(input.sessionID, { type: "busy" }) break @@ -396,17 +437,24 @@ export namespace SessionProcessor { break default: - log.info("unhandled", { - ...value, - }) - continue - } - if (needsCompaction) break - } - } catch (e: any) { - log.error("process", { - error: e, - stack: JSON.stringify(e.stack), + log.info("unhandled", { + ...value, + }) + continue + } + if (needsCompaction) { + await iterator.return?.() + break + } + } + } finally { + clearTimeout(streamStartTimer) + removeAbortListener?.() + } + } catch (e: any) { + log.error("process", { + error: e, + stack: JSON.stringify(e.stack), }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) const retry = SessionRetry.retryable(error) diff --git a/packages/agent-core/src/session/prompt.ts b/packages/agent-core/src/session/prompt.ts index a26222f46ce..0fa82b424bf 100644 --- a/packages/agent-core/src/session/prompt.ts +++ b/packages/agent-core/src/session/prompt.ts @@ -19,7 +19,7 @@ import { Plugin } from "../plugin" // NOTE: PROMPT_PLAN and BUILD_SWITCH removed - replaced by hold/release mode in TUI import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { clone } from "remeda" +import { clone, mergeDeep } from "remeda" import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" import { LSP } from "../lsp" @@ -99,6 +99,7 @@ export namespace SessionPrompt { "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", ), system: z.string().optional(), + options: z.record(z.string(), z.any()).optional(), variant: z.string().optional(), parts: z.array( z.discriminatedUnion("type", [ @@ -508,7 +509,10 @@ export namespace SessionPrompt { } // normal processing - const agent = await Agent.get(lastUser.agent) + const baseAgent = await Agent.get(lastUser.agent) + const agent = lastUser.options + ? { ...baseAgent, options: mergeDeep(baseAgent.options, lastUser.options) } + : baseAgent const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -833,6 +837,7 @@ export namespace SessionPrompt { agent: agent.name, model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), system: input.system, + options: input.options, variant: input.variant, } diff --git a/packages/agent-core/src/tool/grep.ts b/packages/agent-core/src/tool/grep.ts index ad62621e072..097dedf4aaf 100644 --- a/packages/agent-core/src/tool/grep.ts +++ b/packages/agent-core/src/tool/grep.ts @@ -37,7 +37,15 @@ export const GrepTool = Tool.define("grep", { await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) const rgPath = await Ripgrep.filepath() - const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern] + const args = [ + "-nH", + "--hidden", + "--follow", + "--no-messages", + "--field-match-separator=|", + "--regexp", + params.pattern, + ] if (params.include) { args.push("--glob", params.include) } @@ -52,7 +60,10 @@ export const GrepTool = Tool.define("grep", { const errorOutput = await new Response(proc.stderr).text() const exitCode = await proc.exited - if (exitCode === 1) { + // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) + // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. + // Only fail if exit code is 2 AND no output was produced + if (exitCode === 1 || (exitCode === 2 && !output.trim())) { return { title: params.pattern, metadata: { matches: 0, truncated: false }, @@ -60,10 +71,12 @@ export const GrepTool = Tool.define("grep", { } } - if (exitCode !== 0) { + if (exitCode !== 0 && exitCode !== 2) { throw new Error(`ripgrep failed: ${errorOutput}`) } + const hasErrors = exitCode === 2 + // Handle both Unix (\n) and Windows (\r\n) line endings const lines = output.trim().split(/\r?\n/) const matches = [] @@ -124,6 +137,11 @@ export const GrepTool = Tool.define("grep", { outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") } + if (hasErrors) { + outputLines.push("") + outputLines.push("(Some paths were inaccessible and skipped)") + } + return { title: params.pattern, metadata: { diff --git a/packages/agent-core/src/tool/task.ts b/packages/agent-core/src/tool/task.ts index 170d4448088..96e3d36b831 100644 --- a/packages/agent-core/src/tool/task.ts +++ b/packages/agent-core/src/tool/task.ts @@ -150,6 +150,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { providerID: model.providerID, }, agent: agent.name, + options: agent.options, tools: { todowrite: false, todoread: false, diff --git a/packages/agent-core/test/server/auth-route.test.ts b/packages/agent-core/test/server/auth-route.test.ts new file mode 100644 index 00000000000..942a4dbb30a --- /dev/null +++ b/packages/agent-core/test/server/auth-route.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test, afterAll } from "bun:test" +import { Auth } from "../../src/auth" +import { Provider } from "../../src/provider/provider" + +const originalReload = Provider.reload +const originalValidateAuth = Provider.validateAuth +Provider.reload = async () => {} +Provider.validateAuth = async () => {} +afterAll(() => { + Provider.reload = originalReload + Provider.validateAuth = originalValidateAuth +}) + +const { AuthRoute } = await import("../../src/server/route/auth") + +describe("auth.set endpoint", () => { + test("accepts Auth.Info payload and updates credentials at runtime", async () => { + const response = await AuthRoute.request("/cerebras", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "api", + key: "test-key", + }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + + const stored = await Auth.get("cerebras") + expect(stored?.type).toBe("api") + expect(stored && "key" in stored ? stored.key : undefined).toBe("test-key") + }) + + test("accepts legacy api_key payload", async () => { + const response = await AuthRoute.request("/cerebras", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_key: "legacy-key", + }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + + const stored = await Auth.get("cerebras") + expect(stored?.type).toBe("api") + expect(stored && "key" in stored ? stored.key : undefined).toBe("legacy-key") + }) +}) diff --git a/packages/agent-core/test/server/gateway-route.test.ts b/packages/agent-core/test/server/gateway-route.test.ts new file mode 100644 index 00000000000..2aa227cc55e --- /dev/null +++ b/packages/agent-core/test/server/gateway-route.test.ts @@ -0,0 +1,107 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import { Log } from "../../src/util/log" +import { Server } from "../../src/server/server" + +Log.init({ print: false }) + +describe("gateway routes", () => { + const originalEnv = { + ZEE_GATEWAY_URL: process.env.ZEE_GATEWAY_URL, + ZEE_GATEWAY_PORT: process.env.ZEE_GATEWAY_PORT, + } + + let gatewayServer: ReturnType | null = null + let lastSendParams: Record | null = null + + beforeAll(() => { + gatewayServer = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req, { data: {} })) return + return new Response("Not Found", { status: 404 }) + }, + websocket: { + message(ws, message) { + const raw = typeof message === "string" ? message : message.toString() + const frame = JSON.parse(raw) as { + type?: string + id?: string + method?: string + params?: Record + } + + if (frame.type !== "req" || typeof frame.id !== "string" || typeof frame.method !== "string") return + + if (frame.method === "connect") { + ws.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { type: "hello-ok", protocol: 2 } })) + return + } + + if (frame.method === "send") { + lastSendParams = frame.params ?? null + ws.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { ok: true } })) + return + } + + ws.send(JSON.stringify({ type: "res", id: frame.id, ok: false, error: { code: "unknown", message: "unknown method" } })) + }, + }, + }) + + process.env.ZEE_GATEWAY_URL = `ws://127.0.0.1:${gatewayServer.port}` + delete process.env.ZEE_GATEWAY_PORT + }) + + afterAll(() => { + if (gatewayServer) gatewayServer.stop() + gatewayServer = null + lastSendParams = null + + if (originalEnv.ZEE_GATEWAY_URL === undefined) delete process.env.ZEE_GATEWAY_URL + else process.env.ZEE_GATEWAY_URL = originalEnv.ZEE_GATEWAY_URL + + if (originalEnv.ZEE_GATEWAY_PORT === undefined) delete process.env.ZEE_GATEWAY_PORT + else process.env.ZEE_GATEWAY_PORT = originalEnv.ZEE_GATEWAY_PORT + }) + + beforeEach(() => { + lastSendParams = null + }) + + test("POST /gateway/whatsapp/send uses Zee gateway RPC", async () => { + const app = Server.App() + const response = await app.request("/gateway/whatsapp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId: "15551234567@c.us", message: "Hello" }), + }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + + expect(lastSendParams).not.toBeNull() + expect(lastSendParams!.provider).toBe("whatsapp") + expect(lastSendParams!.message).toBe("Hello") + expect(lastSendParams!.to).toBe("15551234567") + }) + + test("POST /gateway/telegram/send uses Zee gateway RPC", async () => { + const app = Server.App() + const response = await app.request("/gateway/telegram/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId: 123456789, message: "Hi", persona: "johny" }), + }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + + expect(lastSendParams).not.toBeNull() + expect(lastSendParams!.provider).toBe("telegram") + expect(lastSendParams!.accountId).toBe("johny") + expect(lastSendParams!.message).toBe("Hi") + expect(lastSendParams!.to).toBe("123456789") + }) +}) diff --git a/packages/agent-core/test/session/processor-timeout.test.ts b/packages/agent-core/test/session/processor-timeout.test.ts new file mode 100644 index 00000000000..1dabc235a46 --- /dev/null +++ b/packages/agent-core/test/session/processor-timeout.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, mock } from "bun:test" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { Identifier } from "../../src/id/id" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { Flag } from "../../src/flag/flag" + +mock.module("../../src/provider/fallback", () => ({ + Fallback: { + async stream(input: { abort: AbortSignal }) { + async function* fullStream() { + // Simulate a provider stream that never yields and ignores abort. + // This is the failure mode where `for await (...)` would hang forever. + await new Promise(() => {}) + } + return { fullStream: fullStream() } + }, + }, +})) + +describe("SessionProcessor", () => { + test("errors if LLM stream never starts", async () => { + const previousTimeout = Flag.OPENCODE_EXPERIMENTAL_LLM_STREAM_START_TIMEOUT_MS + ;(Flag as any).OPENCODE_EXPERIMENTAL_LLM_STREAM_START_TIMEOUT_MS = 10 + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const user: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "zee", + model: { providerID: "mock", modelID: "mock-model" }, + } + await Session.updateMessage(user) + + const assistant: MessageV2.Assistant = { + id: Identifier.ascending("message"), + parentID: user.id, + sessionID: session.id, + role: "assistant", + mode: "zee", + agent: "zee", + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "mock-model", + providerID: "mock", + time: { created: Date.now() }, + } + await Session.updateMessage(assistant) + + const { SessionProcessor } = await import("../../src/session/processor") + const controller = new AbortController() + const processor = SessionProcessor.create({ + assistantMessage: assistant, + sessionID: session.id, + model: { providerID: "mock", id: "mock-model", name: "mock-model" } as any, + abort: controller.signal, + }) + + const result = await processor.process({ + user, + sessionID: session.id, + model: { providerID: "mock", id: "mock-model", name: "mock-model" } as any, + agent: { name: "zee" } as any, + system: [], + messages: [], + tools: {}, + abort: controller.signal, + }) + + expect(result).toBe("stop") + + const stored = await MessageV2.get({ sessionID: session.id, messageID: assistant.id }) + expect(stored.info.role).toBe("assistant") + if (stored.info.role !== "assistant") throw new Error("Expected assistant message") + expect(stored.info.time.completed).toBeDefined() + expect(stored.info.error).toBeDefined() + }, + }) + } finally { + ;(Flag as any).OPENCODE_EXPERIMENTAL_LLM_STREAM_START_TIMEOUT_MS = previousTimeout + } + }) +}) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 789a5d3b748..d993ee3c464 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -351,8 +351,13 @@ export function DialogConnectProvider(props: { provider: string }) { method: store.methodIndex, }) if (result.error) { - // TODO: show error dialog.close() + showToast({ + variant: "error", + icon: "circle-x", + title: "Connection failed", + description: "Failed to connect provider. Please try again.", + }) return } await complete() diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 7f88b74c883..37891a9aafe 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -172,9 +172,9 @@ function DialogCommand(props: { options: CommandOption[] }) { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) - const dialog = useDialog() const options = createMemo(() => { const seen = new Set() @@ -209,7 +209,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { diff --git a/scripts/reload.sh b/scripts/reload.sh index 645c6c5e8a3..0426d3cbf5b 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -338,11 +338,25 @@ if [[ -f "$BINARY_SRC" ]]; then warn "Could not update bun global install (may need manual: cp $BINARY_SRC $BUN_GLOBAL_BIN)" fi - # Also sync .agent-core configs to bun global install + # Sync .agent-core configs to bun global install + # Use SOURCE config (not dist) because dist strips MCP command arrays for distribution BUN_GLOBAL_CONFIG="$HOME/.bun/install/global/node_modules/agent-core-linux-x64/.agent-core" - DIST_CONFIG="$PKG_DIR/dist/agent-core-linux-x64/.agent-core" - if [[ -d "$DIST_CONFIG" ]] && [[ -d "$BUN_GLOBAL_CONFIG" ]]; then - cp -r "$DIST_CONFIG"/* "$BUN_GLOBAL_CONFIG"/ 2>/dev/null && ok "Synced .agent-core configs" || warn "Could not sync configs" + SOURCE_CONFIG="$REPO_ROOT/.agent-core" + if [[ -d "$SOURCE_CONFIG" ]] && [[ -d "$BUN_GLOBAL_CONFIG" ]]; then + # Copy config file (preserves full MCP commands for local dev) + cp "$SOURCE_CONFIG/agent-core.jsonc" "$BUN_GLOBAL_CONFIG/" 2>/dev/null && ok "Synced agent-core.jsonc (from source)" + # Copy tools if they exist + if [[ -d "$SOURCE_CONFIG/tool" ]]; then + mkdir -p "$BUN_GLOBAL_CONFIG/tool" + cp -r "$SOURCE_CONFIG/tool"/* "$BUN_GLOBAL_CONFIG/tool/" 2>/dev/null && ok "Synced tools" + fi + # Copy agents if they exist + if [[ -d "$SOURCE_CONFIG/agent" ]]; then + mkdir -p "$BUN_GLOBAL_CONFIG/agent" + cp -r "$SOURCE_CONFIG/agent"/* "$BUN_GLOBAL_CONFIG/agent/" 2>/dev/null && ok "Synced agents" + fi + else + warn "Could not sync configs (source: $SOURCE_CONFIG, dest: $BUN_GLOBAL_CONFIG)" fi fi else diff --git a/src/domain/zee/tools.ts b/src/domain/zee/tools.ts index 376b9fccd47..2e98985435d 100644 --- a/src/domain/zee/tools.ts +++ b/src/domain/zee/tools.ts @@ -270,19 +270,19 @@ export const messagingTool: ToolDefinition = { description: `Send messages via WhatsApp or Telegram gateways. Channels: -- **whatsapp**: Zee's WhatsApp gateway (requires active daemon with --whatsapp) -- **telegram**: Stanley/Johny Telegram bots (requires active daemon with --telegram-*) +- **whatsapp**: Zee's WhatsApp gateway (requires `agent-core daemon` with gateway enabled) +- **telegram**: Telegram bots (requires `agent-core daemon` with gateway enabled) WhatsApp: -- \`to\`: Chat ID (from incoming message context, e.g., "1234567890@c.us") +- \`to\`: E164 phone (e.g., "+1555...") or chat JID (e.g., "1234567890@c.us" or "...@g.us") - Only Zee can send via WhatsApp Telegram: -- \`to\`: Numeric chat ID (from incoming message context) -- \`persona\`: Which bot to use - "stanley" (default) or "johny" +- \`to\`: Chat ID (numeric) or @username +- \`persona\`: Which bot/account to use - "stanley" (default) or "johny" Examples: -- WhatsApp: { channel: "whatsapp", to: "1234567890@c.us", message: "Hello!" } +- WhatsApp: { channel: "whatsapp", to: "+15551234567", message: "Hello!" } - Telegram via Stanley: { channel: "telegram", to: "123456789", message: "Market update!", persona: "stanley" }`, parameters: MessagingParams, execute: async (args, ctx): Promise => { @@ -290,9 +290,11 @@ Examples: ctx.metadata({ title: `Sending via ${channel}` }); - // Get daemon port from environment or default - const daemonPort = process.env.AGENT_CORE_DAEMON_PORT || "3456"; - const baseUrl = `http://127.0.0.1:${daemonPort}`; + const rawBaseUrl = + process.env.AGENT_CORE_URL || + process.env.AGENT_CORE_DAEMON_URL || + `http://127.0.0.1:${process.env.AGENT_CORE_PORT || process.env.AGENT_CORE_DAEMON_PORT || "3210"}`; + const baseUrl = rawBaseUrl.replace(/\/$/, ""); try { if (channel === "whatsapp") { @@ -326,9 +328,9 @@ Examples: output: `Failed to send WhatsApp message: ${result.error || "Unknown error"} Troubleshooting: -- Ensure daemon is running with --whatsapp flag -- Check WhatsApp connection status -- Verify chatId format (e.g., "1234567890@c.us")`, +- Ensure `agent-core daemon` is running +- Check `agent-core debug status` shows Gateway: Active +- Verify recipient format (E164 like "+1555..." or JID like "1234567890@c.us")`, }; } @@ -384,8 +386,8 @@ Chat ID must be a numeric value (e.g., 123456789).`, output: `Failed to send Telegram message via ${selectedPersona}: ${result.error || "Unknown error"} Troubleshooting: -- Ensure daemon is running with --telegram-${selectedPersona}-token flag -- Check bot connection status +- Ensure `agent-core daemon` is running +- Check `agent-core debug status` shows Gateway: Active - Verify chatId is numeric`, }; } @@ -1090,9 +1092,11 @@ Examples: ctx.metadata({ title: `WhatsApp: ${remove ? "Remove" : "Add"} reaction` }); - // Get daemon port from environment or default - const daemonPort = process.env.AGENT_CORE_DAEMON_PORT || "3456"; - const baseUrl = `http://127.0.0.1:${daemonPort}`; + const rawBaseUrl = + process.env.AGENT_CORE_URL || + process.env.AGENT_CORE_DAEMON_URL || + `http://127.0.0.1:${process.env.AGENT_CORE_PORT || process.env.AGENT_CORE_DAEMON_PORT || "3210"}`; + const baseUrl = rawBaseUrl.replace(/\/$/, ""); try { // Note: This endpoint needs to be added to the server