feat(cli): Comprehensive CLI Enhancement Suite - 20+ new commands (#2074)

Integrated into release/v3.8.0
This commit is contained in:
Paijo
2026-05-10 10:58:13 +07:00
committed by GitHub
parent bc941d3dd9
commit 9d663db3f0
20 changed files with 4881 additions and 1 deletions

2822
bin/cli-commands.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,67 @@ if (args.includes("--help") || args.includes("-h")) {
omniroute --no-open Don't open browser automatically
omniroute --mcp Start MCP server (stdio transport for IDEs)
omniroute reset-encrypted-columns Reset encrypted credentials (recovery)
\x1b[1mServer Management:\x1b[0m
omniroute serve Start the OmniRoute server
omniroute stop Stop the running server
omniroute restart Restart the server
omniroute dashboard Open dashboard in browser
omniroute open Alias for dashboard (same as dashboard)
\x1b[1mCLI Integration Suite:\x1b[0m
omniroute setup Interactive wizard to configure CLI tools
omniroute doctor Run health diagnostics
omniroute status Show comprehensive status
omniroute logs Stream request logs (--json, --search, --follow)
omniroute config show Display current configuration
\x1b[1mProvider & Keys:\x1b[0m
omniroute provider list List available providers
omniroute provider add Add OmniRoute as provider
omniroute keys add Add API key for provider
omniroute keys list List configured API keys
omniroute keys remove Remove API key
\x1b[1mModels & Combos:\x1b[0m
omniroute models List available models (--json, --search)
omniroute models <prov> Filter models by provider
omniroute combo list List routing combos
omniroute combo switch Switch active combo
omniroute combo create Create new combo
omniroute combo delete Delete a combo
\x1b[1mBackup & Restore:\x1b[0m
omniroute backup Create backup of config & DB
omniroute restore Restore from backup (list or specify timestamp)
\x1b[1mMonitoring:\x1b[0m
omniroute health Detailed health (breakers, cache, memory)
omniroute quota Show provider quota usage
omniroute cache Show cache status
omniroute cache clear Clear semantic/signature cache
\x1b[1mProtocols:\x1b[0m
omniroute mcp status MCP server status
omniroute mcp restart Restart MCP server
omniroute a2a status A2A server status
omniroute a2a card Show A2A agent card
\x1b[1mTunnels & Network:\x1b[0m
omniroute tunnel list List active tunnels
omniroute tunnel create Create tunnel (cloudflare/tailscale/ngrok)
omniroute tunnel stop Stop a tunnel
\x1b[1mEnvironment:\x1b[0m
omniroute env show Show environment variables
omniroute env get <key> Get specific env var
omniroute env set <k> <v> Set env var (temporary)
\x1b[1mTools & Utils:\x1b[0m
omniroute test Test provider connectivity
omniroute update Check for updates
omniroute completion Generate shell completion
omniroute --help Show this help
omniroute --version Show version
@@ -160,6 +221,24 @@ if (args.includes("--version") || args.includes("-v")) {
process.exit(0);
}
// ── CLI Integration Suite subcommands ───────────────────────────────────────
const subcommands = [
"setup", "doctor", "status", "logs", "provider", "config", "test", "update",
"serve", "stop", "restart",
"keys", "models", "combo",
"completion", "dashboard",
"backup", "restore", "quota", "health",
"cache", "mcp", "a2a", "tunnel",
"env", "open"
];
const subcommand = args[0];
if (subcommands.includes(subcommand)) {
const { runSubcommand } = await import("./cli-commands.mjs");
await runSubcommand(subcommand, args.slice(1));
process.exit(0);
}
// ── reset-encrypted-columns subcommand ──────────────────────────────────────
// Recovery tool for users who lost STORAGE_ENCRYPTION_KEY after upgrade (#1622)
if (args.includes("reset-encrypted-columns")) {

View File

@@ -0,0 +1,478 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Badge } from "@/shared/components";
import { useTranslations } from "next-intl";
interface CloudAgentTask {
id: string;
provider: string;
status: "pending" | "running" | "waiting_approval" | "completed" | "failed" | "cancelled";
description: string;
createdAt: string;
updatedAt: string;
result?: string;
error?: string;
plan?: string;
messages: Array<{ role: string; content: string; timestamp: string }>;
}
const CLOUD_AGENTS = [
{
id: "jules",
name: "Jules",
provider: "Google",
description: "Google's autonomous coding agent",
icon: "🟡",
color: "bg-yellow-500/10 text-yellow-600",
},
{
id: "devin",
name: "Devin",
provider: "Cognition",
description: "Cognition's AI software engineer",
icon: "🔵",
color: "bg-blue-500/10 text-blue-600",
},
{
id: "codex-cloud",
name: "Codex Cloud",
provider: "OpenAI",
description: "OpenAI's cloud-based coding agent",
icon: "⚡",
color: "bg-emerald-500/10 text-emerald-600",
},
];
export default function CloudAgentsPage() {
const [tasks, setTasks] = useState<CloudAgentTask[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTask, setSelectedTask] = useState<CloudAgentTask | null>(null);
const [newTask, setNewTask] = useState({
provider: "jules",
description: "",
});
const [messageInput, setMessageInput] = useState("");
const t = useTranslations("cloudAgents");
const fetchTasks = useCallback(async () => {
try {
const res = await fetch("/api/v1/agents/tasks");
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch (err) {
console.error("Failed to fetch tasks:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleCreateTask = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
const res = await fetch("/api/v1/agents/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: newTask.provider,
description: newTask.description,
}),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => [data.task, ...prev]);
setNewTask({ provider: "jules", description: "" });
}
} catch (err) {
console.error("Failed to create task:", err);
} finally {
setCreating(false);
}
};
const handleSendMessage = async () => {
if (!selectedTask || !messageInput.trim()) return;
try {
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "message",
message: messageInput,
}),
});
if (res.ok) {
const data = await res.json();
setSelectedTask(data.task);
setTasks((prev) => prev.map((task) => (task.id === selectedTask.id ? data.task : task)));
setMessageInput("");
}
} catch (err) {
console.error("Failed to send message:", err);
}
};
const handleApprovePlan = async () => {
if (!selectedTask) return;
try {
const res = await fetch(`/api/v1/agents/tasks/${selectedTask.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "approve" }),
});
if (res.ok) {
const data = await res.json();
setSelectedTask(data.task);
setTasks((prev) => prev.map((t) => (t.id === selectedTask.id ? data.task : t)));
}
} catch (err) {
console.error("Failed to approve plan:", err);
}
};
const handleCancelTask = async (taskId: string) => {
try {
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "cancel" }),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
if (selectedTask?.id === taskId) {
setSelectedTask(data.task);
}
}
} catch (err) {
console.error("Failed to cancel task:", err);
}
};
const handleDeleteTask = async (taskId: string) => {
try {
const res = await fetch(`/api/v1/agents/tasks/${taskId}`, {
method: "DELETE",
});
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
if (selectedTask?.id === taskId) {
setSelectedTask(null);
}
}
} catch (err) {
console.error("Failed to delete task:", err);
}
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: "bg-zinc-500/10 text-zinc-500", label: t("statusPending") },
running: { color: "bg-blue-500/10 text-blue-500", label: t("statusRunning") },
waiting_approval: {
color: "bg-amber-500/10 text-amber-600",
label: t("statusWaitingApproval"),
},
completed: { color: "bg-emerald-500/10 text-emerald-600", label: t("statusCompleted") },
failed: { color: "bg-red-500/10 text-red-500", label: t("statusFailed") },
cancelled: { color: "bg-zinc-500/10 text-zinc-400", label: t("statusCancelled") },
};
const s = statusMap[status] || statusMap.pending;
return (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${s.color}`}
>
{status === "running" && <span className="animate-pulse"></span>}
{s.label}
</span>
);
};
const getAgentInfo = (providerId: string) => {
return CLOUD_AGENTS.find((a) => a.id === providerId) || CLOUD_AGENTS[0];
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">{t("loading")}</p>
</div>
);
}
return (
<div className="flex flex-col gap-6 p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-text-muted mt-1">{t("description")}</p>
</div>
</div>
<Card className="border-purple-500/20 bg-purple-500/5">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-sm font-semibold text-text-main">{t("aboutTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("aboutDescription")}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{CLOUD_AGENTS.map((agent) => (
<div
key={agent.id}
className="rounded-lg border border-purple-500/15 bg-purple-500/5 p-3"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{agent.icon}</span>
<p className="text-sm font-medium text-text-main">{agent.name}</p>
</div>
<p className="text-xs text-text-muted">{agent.description}</p>
<p className="text-[10px] text-purple-500 mt-1">{agent.provider}</p>
</div>
))}
</div>
<div className="rounded-lg border border-purple-500/15 bg-surface/40 p-3 text-sm text-text-muted">
<span className="font-medium text-text-main">{t("howItWorksTitle")}</span>
<span className="ml-1">{t("howItWorksDesc")}</span>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[20px]">add_task</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("newTaskTitle")}</h3>
<p className="text-sm text-text-muted">{t("newTaskDescription")}</p>
</div>
</div>
<form onSubmit={handleCreateTask} className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">
{t("selectAgent")}
</label>
<select
value={newTask.provider}
onChange={(e) => setNewTask({ ...newTask, provider: e.target.value })}
className="w-full rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{CLOUD_AGENTS.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.provider})
</option>
))}
</select>
</div>
</div>
<div>
<Input
label={t("taskDescription")}
placeholder={t("taskDescriptionPlaceholder")}
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
required
/>
</div>
<div className="flex justify-end">
<Button type="submit" variant="primary" loading={creating}>
<span className="material-symbols-outlined text-[16px] mr-1">rocket_launch</span>
{t("startTask")}
</Button>
</div>
</form>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{t("tasks")}</h2>
{tasks.length === 0 ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[40px] mb-2">assignment</span>
<p>{t("noTasks")}</p>
</div>
) : (
tasks.map((task) => {
const agent = getAgentInfo(task.provider);
return (
<Card
key={task.id}
className={`cursor-pointer transition-all hover:border-primary/30 ${
selectedTask?.id === task.id ? "border-primary ring-1 ring-primary/20" : ""
}`}
onClick={() => setSelectedTask(task)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-lg">{agent.icon}</span>
<div>
<p className="text-sm font-medium text-text-main line-clamp-1">
{task.description || t("untitledTask")}
</p>
<p className="text-xs text-text-muted">
{agent.name} {new Date(task.createdAt).toLocaleString()}
</p>
</div>
</div>
{getStatusBadge(task.status)}
</div>
</Card>
);
})
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{t("taskDetail")}</h2>
{selectedTask ? (
<Card className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getAgentInfo(selectedTask.provider).icon}</span>
<div>
<p className="font-medium">{getAgentInfo(selectedTask.provider).name}</p>
<p className="text-xs text-text-muted">
{t("created")}: {new Date(selectedTask.createdAt).toLocaleString()}
</p>
</div>
</div>
{getStatusBadge(selectedTask.status)}
</div>
{selectedTask.status === "waiting_approval" && selectedTask.plan && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-amber-600">
description
</span>
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t("planReady")}
</span>
</div>
<pre className="text-xs text-text-muted whitespace-pre-wrap bg-black/5 dark:bg-white/5 rounded p-2 max-h-32 overflow-auto">
{selectedTask.plan}
</pre>
<div className="flex gap-2 mt-2">
<Button variant="primary" size="sm" onClick={handleApprovePlan}>
<span className="material-symbols-outlined text-[14px] mr-1">check</span>
{t("approvePlan")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleCancelTask(selectedTask.id)}
>
{t("rejectPlan")}
</Button>
</div>
</div>
)}
{selectedTask.messages.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{t("conversation")}</p>
<div className="flex flex-col gap-2 max-h-64 overflow-auto">
{selectedTask.messages.map((msg, i) => (
<div
key={i}
className={`p-2 rounded-lg text-xs ${
msg.role === "assistant"
? "bg-purple-500/10 text-text-main"
: "bg-surface/40 text-text-main"
}`}
>
<span className="font-medium capitalize">{msg.role}: </span>
{msg.content}
</div>
))}
</div>
</div>
)}
{selectedTask.result && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-emerald-600">
check_circle
</span>
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
{t("result")}
</span>
</div>
<pre className="text-xs text-text-muted whitespace-pre-wrap">
{selectedTask.result}
</pre>
</div>
)}
{selectedTask.error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[16px] text-red-500">
error
</span>
<span className="text-sm font-medium text-red-600">{t("error")}</span>
</div>
<p className="text-xs text-text-muted">{selectedTask.error}</p>
</div>
)}
{selectedTask.status === "running" && (
<div className="flex gap-2">
<Input
placeholder={t("sendMessagePlaceholder")}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSendMessage()}
className="flex-1"
/>
<Button variant="primary" onClick={handleSendMessage}>
<span className="material-symbols-outlined text-[16px]">send</span>
</Button>
</div>
)}
<div className="flex justify-between pt-3 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={() => handleCancelTask(selectedTask.id)}
disabled={["completed", "failed", "cancelled"].includes(selectedTask.status)}
>
<span className="material-symbols-outlined text-[14px] mr-1">cancel</span>
{t("cancel")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTask(selectedTask.id)}
className="text-red-500 hover:text-red-400"
>
<span className="material-symbols-outlined text-[14px] mr-1">delete</span>
{t("delete")}
</Button>
</div>
</Card>
) : (
<div className="text-center py-8 text-text-muted border border-dashed border-border/50 rounded-lg">
<span className="material-symbols-outlined text-[40px] mb-2">touch_app</span>
<p>{t("selectTaskPrompt")}</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
IMAGE_ONLY_PROVIDER_IDS,
VIDEO_PROVIDER_IDS,
isClaudeCodeCompatibleProvider,
CLOUD_AGENT_PROVIDERS,
} from "@/shared/constants/providers";
import { useRouter } from "next/navigation";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
@@ -488,6 +489,13 @@ export default function ProvidersPage() {
searchQuery
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
cloudAgentProviderEntriesAll,
showConfiguredOnly,
searchQuery
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
const upstreamProxyEntries = filterConfiguredProviderEntries(
upstreamProxyEntriesAll,
@@ -970,6 +978,51 @@ export default function ProvidersPage() {
</div>
)}
{/* Cloud Agent Providers */}
{cloudAgentProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("cloudAgentProviders")}{" "}
<span
className="size-2.5 rounded-full bg-violet-500"
title={t("cloudAgentProviders")}
/>
<ProviderCountBadge {...countConfigured(cloudAgentProviderEntriesAll)} />
</h2>
<button
onClick={() => handleBatchTest("cloud-agent")}
disabled={!!testingMode}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
testingMode === "cloud-agent"
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("testAll")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "cloud-agent" ? "sync" : "play_arrow"}
</span>
{testingMode === "cloud-agent" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{cloudAgentProviderEntries.map(
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
<ProviderCard
key={providerId}
providerId={providerId}
provider={provider}
stats={stats}
authType={displayAuthType}
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
/>
)
)}
</div>
</div>
)}
{/* Local / Self-Hosted Providers */}
{localProviderEntries.length > 0 && (
<div className="flex flex-col gap-4">

View File

@@ -22,6 +22,7 @@ export default function ProxyTab() {
} catch {}
};
useEffect(() => {
mountedRef.current = true;
async function init() {

View File

@@ -0,0 +1,186 @@
import { NextRequest, NextResponse } from "next/server";
import { extractApiKey } from "@/sse/services/auth";
import { getAgent } from "@/lib/cloudAgent/registry";
import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db";
import { z } from "zod";
import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCorsHeaders() });
}
const ApproveSchema = z.object({
action: z.literal("approve"),
});
const MessageSchema = z.object({
action: z.literal("message"),
message: z.string().min(1),
});
const CancelSchema = z.object({
action: z.literal("cancel"),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCorsHeaders() }
);
}
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
const agent = getAgent(task.provider_id);
if (agent && task.external_id) {
try {
const statusResult = await agent.getStatus(task.external_id, { apiKey });
updateCloudAgentTask(id, {
status: statusResult.status,
result: statusResult.result ? JSON.stringify(statusResult.result) : null,
activities: JSON.stringify(statusResult.activities),
error: statusResult.error || null,
completed_at:
statusResult.status === "completed" || statusResult.status === "failed"
? new Date().toISOString()
: null,
});
} catch (err) {
console.error("Failed to sync task status:", err);
}
}
const updatedTask = getCloudAgentTaskById(id);
return NextResponse.json(
{
data: {
id: updatedTask!.id,
providerId: updatedTask!.provider_id,
externalId: updatedTask!.external_id,
status: updatedTask!.status,
prompt: updatedTask!.prompt,
source: JSON.parse(updatedTask!.source),
options: JSON.parse(updatedTask!.options),
result: updatedTask!.result ? JSON.parse(updatedTask!.result) : null,
activities: JSON.parse(updatedTask!.activities),
error: updatedTask!.error,
createdAt: updatedTask!.created_at,
updatedAt: updatedTask!.updated_at,
completedAt: updatedTask!.completed_at,
},
},
{ headers: getCorsHeaders() }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const body = await request.json();
const task = getCloudAgentTaskById(id);
if (!task) {
return NextResponse.json(
{ error: "Task not found" },
{ status: 404, headers: getCorsHeaders() }
);
}
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
let validated;
if (body.action === "approve") {
validated = ApproveSchema.parse(body);
} else if (body.action === "message") {
validated = MessageSchema.parse(body);
} else if (body.action === "cancel") {
validated = CancelSchema.parse(body);
} else {
return NextResponse.json(
{ error: "Invalid action" },
{ status: 400, headers: getCorsHeaders() }
);
}
const agent = getAgent(task.provider_id);
if (!agent) {
return NextResponse.json(
{ error: "Agent not found" },
{ status: 500, headers: getCorsHeaders() }
);
}
if (validated.action === "approve") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to approve" },
{ status: 400, headers: getCorsHeaders() }
);
}
await agent.approvePlan(task.external_id, { apiKey });
updateCloudAgentTask(id, { status: "running" });
} else if (validated.action === "message") {
if (!task.external_id) {
return NextResponse.json(
{ error: "No external task to message" },
{ status: 400, headers: getCorsHeaders() }
);
}
const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey });
const activities = JSON.parse(task.activities);
activities.push(activity);
updateCloudAgentTask(id, { activities: JSON.stringify(activities) });
} else if (validated.action === "cancel") {
updateCloudAgentTask(id, { status: "cancelled" });
}
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400, headers: getCorsHeaders() }
);
}
logger.error({ err: error }, "Failed to process task action");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { extractApiKey } from "@/sse/services/auth";
import { getAgent } from "@/lib/cloudAgent/registry";
import {
insertCloudAgentTask,
getCloudAgentTaskById,
getAllCloudAgentTasks,
getCloudAgentTasksByProvider,
getCloudAgentTasksByStatus,
updateCloudAgentTask,
deleteCloudAgentTask,
} from "@/lib/cloudAgent/db";
import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types";
import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers";
import { z } from "zod";
import pino from "pino";
const logger = pino({ name: "cloud-agents-api" });
function getCorsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS() {
return new NextResponse(null, { headers: getCorsHeaders() });
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const providerId = searchParams.get("provider");
const status = searchParams.get("status");
const limit = parseInt(searchParams.get("limit") || "50", 10);
let tasks;
if (providerId) {
tasks = getCloudAgentTasksByProvider(providerId, limit);
} else if (status) {
tasks = getCloudAgentTasksByStatus(status, limit);
} else {
tasks = getAllCloudAgentTasks(limit);
}
return NextResponse.json(
{
data: tasks.map((t) => ({
id: t.id,
providerId: t.provider_id,
externalId: t.external_id,
status: t.status,
prompt: t.prompt,
source: JSON.parse(t.source),
options: JSON.parse(t.options),
result: t.result ? JSON.parse(t.result) : null,
activities: JSON.parse(t.activities),
error: t.error,
createdAt: t.created_at,
updatedAt: t.updated_at,
completedAt: t.completed_at,
})),
},
{ headers: getCorsHeaders() }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = CreateCloudAgentTaskSchema.parse(body);
const apiKey = extractApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "API key required" },
{ status: 401, headers: getCorsHeaders() }
);
}
const agent = getAgent(validated.providerId);
if (!agent) {
return NextResponse.json(
{ error: `Unknown provider: ${validated.providerId}` },
{ status: 400, headers: getCorsHeaders() }
);
}
const task = await agent.createTask(
{
prompt: validated.prompt,
source: validated.source,
options: validated.options || {},
},
{ apiKey }
);
insertCloudAgentTask({
id: task.id,
provider_id: task.providerId,
external_id: task.externalId || null,
status: task.status,
prompt: task.prompt,
source: JSON.stringify(task.source),
options: JSON.stringify(task.options),
result: null,
activities: JSON.stringify(task.activities),
error: null,
created_at: task.createdAt,
updated_at: task.updatedAt,
completed_at: null,
});
return NextResponse.json(
{
data: {
id: task.id,
providerId: task.providerId,
externalId: task.externalId,
status: task.status,
prompt: task.prompt,
source: task.source,
options: task.options,
createdAt: task.createdAt,
},
},
{ status: 201, headers: getCorsHeaders() }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400, headers: getCorsHeaders() }
);
}
logger.error({ err: error }, "Failed to create cloud agent task");
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get("id");
if (!taskId) {
return NextResponse.json(
{ error: "Task ID required" },
{ status: 400, headers: getCorsHeaders() }
);
}
deleteCloudAgentTask(taskId);
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: getCorsHeaders() }
);
}
}

View File

@@ -158,6 +158,7 @@
"noLockouts": "No Lockouts",
"webSearchDesc": "Web Search Desc",
"audioProvidersHeading": "Audio Providers Heading",
"cloudAgentProviders": "Cloud Agent Providers",
"minutesAgo": "Minutes Ago",
"a": "A",
"liveAutoRefreshing": "Live Auto Refreshing",
@@ -668,6 +669,7 @@
"playground": "Playground",
"searchTools": "Search Tools",
"agents": "Agents",
"cloudAgents": "Cloud Agents",
"memory": "Memory",
"skills": "Skills",
"docs": "Docs",
@@ -4851,6 +4853,42 @@
"settingsRoutingLink": "Settings/Routing",
"openSettings": "Settings"
},
"cloudAgents": {
"title": "Cloud Agents",
"description": "Manage autonomous coding agents (Jules, Devin, Codex Cloud)",
"loading": "Loading tasks...",
"aboutTitle": "About Cloud Agents",
"aboutDescription": "Cloud agents are remote AI coding assistants that can execute tasks autonomously. They work differently from local CLI agents - you interact with them through OmniRoute's API.",
"howItWorksTitle": "How it works:",
"howItWorksDesc": "Create a task → Agent analyzes and proposes a plan → You approve → Agent executes → Results returned",
"newTaskTitle": "Create New Task",
"newTaskDescription": "Start a new task with a cloud agent",
"selectAgent": "Select Agent",
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the agent to do...",
"startTask": "Start Task",
"tasks": "Tasks",
"taskDetail": "Task Detail",
"noTasks": "No tasks yet. Create one to get started.",
"untitledTask": "Untitled Task",
"created": "Created",
"conversation": "Conversation",
"result": "Result",
"error": "Error",
"planReady": "Plan Ready for Approval",
"approvePlan": "Approve Plan",
"rejectPlan": "Reject & Cancel",
"sendMessagePlaceholder": "Send a message to the agent...",
"cancel": "Cancel",
"delete": "Delete",
"selectTaskPrompt": "Select a task to view details",
"statusPending": "Pending",
"statusRunning": "Running",
"statusWaitingApproval": "Waiting Approval",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"statusCancelled": "Cancelled"
},
"templateNames": {
"simple-chat": "Simple Chat",
"streaming": "Streaming",

View File

@@ -0,0 +1,148 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class CodexCloudAgent extends CloudAgentBase {
readonly providerId = "codex-cloud";
readonly baseUrl = "https://api.openai.com/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repository_context: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
if (params.options.environment) {
body.environment = {
setup: params.options.environment,
};
}
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "pending");
const activities: CloudAgentActivity[] = [];
if (data.subagents) {
for (const subagent of data.subagents) {
activities.push({
id: this.generateActivityId(),
type: "command",
content: `Subagent: ${subagent.name} - ${subagent.status}`,
timestamp: new Date().toISOString(),
});
}
}
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && (data.result || data.pr_url)) {
result = {
prUrl: data.pr_url || data.result?.pr_url,
commitMessage: data.result?.commit_message,
summary: data.result?.summary,
duration: data.elapsed_time,
};
}
return {
status,
externalId,
result,
activities,
error: data.error || data.error_message,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Codex Cloud does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/codex/cloud/tasks/${externalId}/followup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Codex Cloud send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}

View File

@@ -0,0 +1,137 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class DevinAgent extends CloudAgentBase {
readonly providerId = "devin";
readonly baseUrl = "https://api.devin.ai/v1";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
repo_url: params.source.repoUrl,
};
if (params.source.branch) {
body.branch = params.source.branch;
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.id,
status: this.mapStatus(data.status || "created"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.status || "created");
const activities: CloudAgentActivity[] = (data.messages || []).map(
(msg: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: "message" as const,
content: (msg.content as string) || "",
timestamp: (msg.created_at as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.output) {
result = {
prUrl: data.pr_url,
summary: data.output,
duration: data.duration,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(_externalId: string, _credentials: AgentCredentials): Promise<void> {
throw new Error("Devin does not support plan approval - it auto-plans");
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey}`,
},
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Devin send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
_credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
return [];
}
}

View File

@@ -0,0 +1,170 @@
import {
CloudAgentBase,
type AgentCredentials,
type CreateTaskParams,
type GetStatusResult,
} from "../baseAgent.ts";
import type { CloudAgentTask, CloudAgentActivity } from "../types.ts";
import { CLOUD_AGENT_STATUS } from "../types.ts";
export class JulesAgent extends CloudAgentBase {
readonly providerId = "jules";
readonly baseUrl = "https://jules.googleapis.com/v1alpha";
async createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask> {
const taskId = this.generateTaskId();
const body: Record<string, unknown> = {
prompt: params.prompt,
source: {
repository: {
owner: params.source.repoUrl.split("/").filter(Boolean).slice(-2, -1)[0] || "",
name: params.source.repoName,
},
branch: params.source.branch || "main",
},
};
if (params.options.autoCreatePr) {
body.automationMode = "AUTO_CREATE_PR";
}
const response = await fetch(`${this.baseUrl}/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules create task failed: ${response.status} ${error}`);
}
const data = await response.json();
return {
id: taskId,
providerId: this.providerId,
externalId: data.name?.split("/").pop() || taskId,
status: this.mapStatus(data.state || "pending"),
prompt: params.prompt,
source: params.source,
options: params.options,
activities: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async getStatus(externalId: string, _credentials: AgentCredentials): Promise<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
"X-Goog-Api-Key": _credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules get status failed: ${response.status} ${error}`);
}
const data = await response.json();
const status = this.mapStatus(data.state || "pending");
const activities: CloudAgentActivity[] = (data.activities || []).map(
(act: Record<string, unknown>) => ({
id: this.generateActivityId(),
type: act.type as CloudAgentActivity["type"],
content: (act.description as string) || "",
timestamp: (act.timestamp as string) || new Date().toISOString(),
})
);
let result;
if (status === CLOUD_AGENT_STATUS.COMPLETED && data.outputs) {
result = {
prUrl: data.outputs.prUrl,
commitMessage: data.outputs.commitMessage,
summary: data.outputs.summary,
};
}
return {
status,
externalId,
result,
activities,
error: data.error,
};
}
async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:approvePlan`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules approve plan failed: ${response.status} ${error}`);
}
}
async sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}:sendMessage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": credentials.apiKey,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules send message failed: ${response.status} ${error}`);
}
return {
id: this.generateActivityId(),
type: "message",
content: message,
timestamp: new Date().toISOString(),
};
}
async listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]> {
const response = await fetch(`${this.baseUrl}/sources`, {
headers: {
"X-Goog-Api-Key": credentials.apiKey,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Jules list sources failed: ${response.status} ${error}`);
}
const data = await response.json();
return (data.sources || []).map((source: Record<string, unknown>) => ({
name: source.name as string,
url: `https://github.com/${source.repoOwner}/${source.repoName}`,
branch: source.defaultBranch as string | undefined,
}));
}
}

View File

@@ -0,0 +1,95 @@
import type {
CloudAgentTask,
CloudAgentStatus,
CloudAgentSource,
CloudAgentResult,
CloudAgentActivity,
} from "./types.ts";
export interface AgentCredentials {
apiKey: string;
baseUrl?: string;
}
export interface CreateTaskParams {
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
}
export interface GetStatusResult {
status: CloudAgentStatus;
externalId?: string;
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
}
export abstract class CloudAgentBase {
abstract readonly providerId: string;
abstract readonly baseUrl: string;
abstract createTask(
params: CreateTaskParams,
credentials: AgentCredentials
): Promise<CloudAgentTask>;
abstract getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult>;
abstract approvePlan(externalId: string, credentials: AgentCredentials): Promise<void>;
abstract sendMessage(
externalId: string,
message: string,
credentials: AgentCredentials
): Promise<CloudAgentActivity>;
abstract listSources(
credentials: AgentCredentials
): Promise<{ name: string; url: string; branch?: string }[]>;
protected mapStatus(status: string): CloudAgentStatus {
const statusLower = status.toLowerCase();
if (statusLower.includes("completed") || statusLower.includes("done")) {
return "completed";
}
if (statusLower.includes("failed") || statusLower.includes("error")) {
return "failed";
}
if (statusLower.includes("cancelled") || statusLower.includes("canceled")) {
return "cancelled";
}
if (
statusLower.includes("running") ||
statusLower.includes("active") ||
statusLower.includes("executing")
) {
return "running";
}
if (
statusLower.includes("pending") ||
statusLower.includes("queued") ||
statusLower.includes("waiting")
) {
return "queued";
}
if (statusLower.includes("approval") || statusLower.includes("plan")) {
return "awaiting_approval";
}
return "queued";
}
protected generateTaskId(): string {
return `task_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
protected generateActivityId(): string {
return `act_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
}

145
src/lib/cloudAgent/db.ts Normal file
View File

@@ -0,0 +1,145 @@
import { getDbInstance } from "@/lib/db/core.ts";
export interface CloudAgentTaskRow {
id: string;
provider_id: string;
external_id: string | null;
status: string;
prompt: string;
source: string;
options: string;
result: string | null;
activities: string;
error: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export function createCloudAgentTaskTable(): void {
const db = getDbInstance();
db.exec(`
CREATE TABLE IF NOT EXISTS cloud_agent_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
external_id TEXT,
status TEXT NOT NULL DEFAULT 'queued',
prompt TEXT NOT NULL,
source TEXT NOT NULL,
options TEXT DEFAULT '{}',
result TEXT,
activities TEXT DEFAULT '[]',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_provider
ON cloud_agent_tasks(provider_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_status
ON cloud_agent_tasks(status)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cloud_agent_tasks_created
ON cloud_agent_tasks(created_at DESC)
`);
}
export function insertCloudAgentTask(task: CloudAgentTaskRow): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO cloud_agent_tasks (
id, provider_id, external_id, status, prompt, source,
options, result, activities, error, created_at, updated_at, completed_at
) VALUES (
@id, @provider_id, @external_id, @status, @prompt, @source,
@options, @result, @activities, @error, @created_at, @updated_at, @completed_at
)
`
).run(task);
}
// Whitelist of allowed columns for update operations
const ALLOWED_UPDATE_COLUMNS = new Set([
"status",
"prompt",
"source",
"options",
"result",
"activities",
"error",
"completed_at",
]);
export function updateCloudAgentTask(
id: string,
updates: Partial<Omit<CloudAgentTaskRow, "id">>
): void {
const db = getDbInstance();
// Validate keys against whitelist to prevent SQL injection
const validUpdates: Partial<Omit<CloudAgentTaskRow, "id">> = {};
for (const [key, value] of Object.entries(updates)) {
if (ALLOWED_UPDATE_COLUMNS.has(key)) {
(validUpdates as Record<string, unknown>)[key] = value;
}
}
const fields = Object.keys(validUpdates)
.map((key) => `${key} = @${key}`)
.join(", ");
if (!fields) return; // No valid updates
db.prepare(
`
UPDATE cloud_agent_tasks
SET ${fields}, updated_at = datetime('now')
WHERE id = @id
`
).run({ id, ...validUpdates });
}
export function getCloudAgentTaskById(id: string): CloudAgentTaskRow | null {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE id = ?")
.get(id) as CloudAgentTaskRow | null;
}
export function getCloudAgentTasksByProvider(providerId: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare(
"SELECT * FROM cloud_agent_tasks WHERE provider_id = ? ORDER BY created_at DESC LIMIT ?"
)
.all(providerId, limit) as CloudAgentTaskRow[];
}
export function getCloudAgentTasksByStatus(status: string, limit = 50): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?")
.all(status, limit) as CloudAgentTaskRow[];
}
export function getAllCloudAgentTasks(limit = 100): CloudAgentTaskRow[] {
const db = getDbInstance();
return db
.prepare("SELECT * FROM cloud_agent_tasks ORDER BY created_at DESC LIMIT ?")
.all(limit) as CloudAgentTaskRow[];
}
export function deleteCloudAgentTask(id: string): void {
const db = getDbInstance();
db.prepare("DELETE FROM cloud_agent_tasks WHERE id = ?").run(id);
}

View File

@@ -0,0 +1,8 @@
export * from "./types.ts";
export * from "./baseAgent.ts";
export * from "./registry.ts";
export * from "./db.ts";
import { createCloudAgentTaskTable } from "./db.ts";
createCloudAgentTaskTable();

View File

@@ -0,0 +1,25 @@
import type { CloudAgentBase } from "./baseAgent.ts";
import { JulesAgent } from "./agents/jules.ts";
import { DevinAgent } from "./agents/devin.ts";
import { CodexCloudAgent } from "./agents/codex.ts";
const AGENTS: Record<string, CloudAgentBase> = {
jules: new JulesAgent(),
devin: new DevinAgent(),
"codex-cloud": new CodexCloudAgent(),
};
export function getAgent(providerId: string): CloudAgentBase | null {
return AGENTS[providerId] || null;
}
export function getAvailableAgents(): string[] {
return Object.keys(AGENTS);
}
export function isCloudAgentProvider(providerId: string): boolean {
return providerId in AGENTS;
}
export { JulesAgent, DevinAgent, CodexCloudAgent };
export type { CloudAgentBase } from "./baseAgent.ts";

111
src/lib/cloudAgent/types.ts Normal file
View File

@@ -0,0 +1,111 @@
import { z } from "zod";
export const CLOUD_AGENT_STATUS = {
QUEUED: "queued",
RUNNING: "running",
AWAITING_APPROVAL: "awaiting_approval",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type CloudAgentStatus = (typeof CLOUD_AGENT_STATUS)[keyof typeof CLOUD_AGENT_STATUS];
export const CloudAgentStatusSchema = z.enum([
"queued",
"running",
"awaiting_approval",
"completed",
"failed",
"cancelled",
]);
export interface CloudAgentSource {
repoName: string;
repoUrl: string;
branch?: string;
}
export interface CloudAgentResult {
prUrl?: string;
prNumber?: number;
commitMessage?: string;
diffUrl?: string;
summary?: string;
duration?: number;
cost?: number;
}
export interface CloudAgentActivity {
id: string;
type: "plan" | "command" | "code_change" | "message" | "error" | "completion";
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface CloudAgentTask {
id: string;
providerId: "jules" | "devin" | "codex-cloud";
externalId?: string;
status: CloudAgentStatus;
prompt: string;
source: CloudAgentSource;
options: {
autoCreatePr?: boolean;
planApprovalRequired?: boolean;
environment?: Record<string, string>;
};
result?: CloudAgentResult;
activities: CloudAgentActivity[];
error?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export const CloudAgentSourceSchema = z.object({
repoName: z.string().min(1),
repoUrl: z.string().url(),
branch: z.string().optional(),
});
export const CloudAgentResultSchema = z.object({
prUrl: z.string().url().optional(),
prNumber: z.number().int().positive().optional(),
commitMessage: z.string().optional(),
diffUrl: z.string().url().optional(),
summary: z.string().optional(),
duration: z.number().int().positive().optional(),
cost: z.number().positive().optional(),
});
export const CloudAgentActivitySchema = z.object({
id: z.string(),
type: z.enum(["plan", "command", "code_change", "message", "error", "completion"]),
content: z.string(),
timestamp: z.string().datetime(),
metadata: z.record(z.unknown()).optional(),
});
export const CloudAgentTaskOptionsSchema = z.object({
autoCreatePr: z.boolean().optional(),
planApprovalRequired: z.boolean().optional(),
environment: z.record(z.string()).optional(),
});
export const CreateCloudAgentTaskSchema = z.object({
providerId: z.enum(["jules", "devin", "codex-cloud"]),
prompt: z.string().min(1).max(10000),
source: CloudAgentSourceSchema,
options: CloudAgentTaskOptionsSchema.optional(),
});
export const UpdateCloudAgentTaskSchema = z.object({
id: z.string().min(1),
action: z.enum(["approve", "reject", "cancel", "message"]),
message: z.string().optional(),
});
export type CreateCloudAgentTaskInput = z.infer<typeof CreateCloudAgentTaskSchema>;
export type UpdateCloudAgentTaskInput = z.infer<typeof UpdateCloudAgentTaskSchema>;

View File

@@ -1,6 +1,7 @@
import {
APIKEY_PROVIDERS,
AUDIO_ONLY_PROVIDERS,
CLOUD_AGENT_PROVIDERS,
FREE_PROVIDERS,
LOCAL_PROVIDERS,
OAUTH_PROVIDERS,
@@ -21,7 +22,8 @@ export type StaticProviderCatalogCategory =
| "search"
| "audio"
| "upstream-proxy"
| "apikey";
| "apikey"
| "cloud-agent";
export interface ProviderCatalogMetadata {
id: string;
@@ -133,6 +135,12 @@ export const STATIC_PROVIDER_CATALOG_GROUPS: Record<
displayAuthType: "apikey",
toggleAuthType: "apikey",
},
"cloud-agent": {
category: "cloud-agent",
providers: CLOUD_AGENT_PROVIDERS as ProviderRecord,
displayAuthType: "apikey",
toggleAuthType: "apikey",
},
};
export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCategory[] = [
@@ -143,6 +151,7 @@ export const STATIC_PROVIDER_CATALOG_RESOLUTION_ORDER: StaticProviderCatalogCate
"search",
"audio",
"upstream-proxy",
"cloud-agent",
"apikey",
];

View File

@@ -1880,6 +1880,38 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
}
// Cloud Agent Providers
export const CLOUD_AGENT_PROVIDERS = {
jules: {
id: "jules",
alias: "jl",
name: "Jules (Google)",
icon: "smart_toy",
color: "#4285F4",
website: "https://jules.google",
authHint: "Get your API key from Jules Settings → API. Free during alpha.",
},
devin: {
id: "devin",
alias: "dv",
name: "Devin (Cognition)",
icon: "precision_manufacturing",
color: "#8B5CF6",
website: "https://devin.ai",
authHint: "Get your API token from Devin Settings → API.",
},
"codex-cloud": {
id: "codex-cloud",
alias: "cx-cloud",
name: "Codex Cloud (OpenAI)",
icon: "cloud",
color: "#10A37F",
website: "https://platform.openai.com/docs/codex",
authHint:
"Use your OpenAI API key or ChatGPT subscription. Codex Cloud included with Plus/Pro/Business.",
},
};
// All providers (combined)
export const AI_PROVIDERS = {
...FREE_PROVIDERS,
@@ -1890,6 +1922,7 @@ export const AI_PROVIDERS = {
...SEARCH_PROVIDERS,
...AUDIO_ONLY_PROVIDERS,
...UPSTREAM_PROXY_PROVIDERS,
...CLOUD_AGENT_PROVIDERS,
};
export type AiProviderId = keyof typeof AI_PROVIDERS;
@@ -1968,3 +2001,4 @@ validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");

View File

@@ -14,6 +14,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"limits",
"cli-tools",
"agents",
"cloud-agents",
"memory",
"skills",
"translator",
@@ -69,6 +70,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" },
{ id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" },
{ id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" },
{ id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" },
{ id: "skills", href: "/dashboard/skills", i18nKey: "skills", icon: "auto_fix_high" },
];

View File

@@ -0,0 +1,166 @@
import test from "node:test";
import assert from "node:assert/strict";
const { CLOUD_AGENT_STATUS, CloudAgentStatusSchema, CloudAgentTask } =
await import("../../src/lib/cloudAgent/types.ts");
const { CreateCloudAgentTaskSchema, UpdateCloudAgentTaskSchema } =
await import("../../src/lib/cloudAgent/types.ts");
const { isCloudAgentProvider, getAgent, getAvailableAgents } =
await import("../../src/lib/cloudAgent/registry.ts");
test("CLOUD_AGENT_STATUS has correct values", () => {
assert.strictEqual(CLOUD_AGENT_STATUS.QUEUED, "queued");
assert.strictEqual(CLOUD_AGENT_STATUS.RUNNING, "running");
assert.strictEqual(CLOUD_AGENT_STATUS.AWAITING_APPROVAL, "awaiting_approval");
assert.strictEqual(CLOUD_AGENT_STATUS.COMPLETED, "completed");
assert.strictEqual(CLOUD_AGENT_STATUS.FAILED, "failed");
assert.strictEqual(CLOUD_AGENT_STATUS.CANCELLED, "cancelled");
});
test("CloudAgentStatusSchema validates valid statuses", () => {
const validStatuses = [
"queued",
"running",
"awaiting_approval",
"completed",
"failed",
"cancelled",
];
for (const status of validStatuses) {
const result = CloudAgentStatusSchema.safeParse(status);
assert.strictEqual(result.success, true, `Status ${status} should be valid`);
}
});
test("CloudAgentStatusSchema rejects invalid status", () => {
const result = CloudAgentStatusSchema.safeParse("invalid");
assert.strictEqual(result.success, false);
});
test("CreateCloudAgentTaskSchema validates valid input", () => {
const validInput = {
providerId: "jules",
prompt: "Fix the bug in auth.ts",
source: {
repoName: "my-repo",
repoUrl: "https://github.com/user/my-repo",
branch: "main",
},
options: {
autoCreatePr: true,
planApprovalRequired: true,
},
};
const result = CreateCloudAgentTaskSchema.safeParse(validInput);
assert.strictEqual(result.success, true);
});
test("CreateCloudAgentTaskSchema rejects missing required fields", () => {
const invalidInput = {
providerId: "jules",
};
const result = CreateCloudAgentTaskSchema.safeParse(invalidInput);
assert.strictEqual(result.success, false);
});
test("CreateCloudAgentTaskSchema rejects invalid providerId", () => {
const invalidInput = {
providerId: "invalid-provider",
prompt: "Test",
source: {
repoName: "test",
repoUrl: "https://github.com/test/test",
},
};
const result = CreateCloudAgentTaskSchema.safeParse(invalidInput);
assert.strictEqual(result.success, false);
});
test("CreateCloudAgentTaskSchema rejects invalid repoUrl", () => {
const invalidInput = {
providerId: "jules",
prompt: "Test",
source: {
repoName: "test",
repoUrl: "not-a-url",
},
};
const result = CreateCloudAgentTaskSchema.safeParse(invalidInput);
assert.strictEqual(result.success, false);
});
test("UpdateCloudAgentTaskSchema validates approve action", () => {
const validInput = {
id: "123e4567-e89b-12d3-a456-426614174000",
action: "approve",
};
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
assert.strictEqual(result.success, true);
});
test("UpdateCloudAgentTaskSchema validates cancel action", () => {
const validInput = {
id: "123e4567-e89b-12d3-a456-426614174000",
action: "cancel",
};
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
assert.strictEqual(result.success, true);
});
test("UpdateCloudAgentTaskSchema validates message action with content", () => {
const validInput = {
id: "123e4567-e89b-12d3-a456-426614174000",
action: "message",
message: "Please continue with the next step",
};
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
assert.strictEqual(result.success, true);
});
test("UpdateCloudAgentTaskSchema allows message without content (optional)", () => {
const validInput = {
id: "123e4567-e89b-12d3-a456-426614174000",
action: "message",
};
const result = UpdateCloudAgentTaskSchema.safeParse(validInput);
assert.strictEqual(result.success, true);
});
test("getAvailableAgents returns all registered agents", () => {
const agents = getAvailableAgents();
assert.strictEqual(agents.includes("jules"), true);
assert.strictEqual(agents.includes("devin"), true);
assert.strictEqual(agents.includes("codex-cloud"), true);
assert.strictEqual(agents.length, 3);
});
test("getAgent returns correct agent for valid providerId", () => {
const jules = getAgent("jules");
assert.notStrictEqual(jules, null);
assert.strictEqual(jules?.providerId, "jules");
const devin = getAgent("devin");
assert.notStrictEqual(devin, null);
assert.strictEqual(devin?.providerId, "devin");
const codex = getAgent("codex-cloud");
assert.notStrictEqual(codex, null);
assert.strictEqual(codex?.providerId, "codex-cloud");
});
test("getAgent returns null for invalid providerId", () => {
const result = getAgent("invalid-provider");
assert.strictEqual(result, null);
});
test("isCloudAgentProvider returns true for valid providers", () => {
assert.strictEqual(isCloudAgentProvider("jules"), true);
assert.strictEqual(isCloudAgentProvider("devin"), true);
assert.strictEqual(isCloudAgentProvider("codex-cloud"), true);
});
test("isCloudAgentProvider returns false for invalid providers", () => {
assert.strictEqual(isCloudAgentProvider("openai"), false);
assert.strictEqual(isCloudAgentProvider("anthropic"), false);
assert.strictEqual(isCloudAgentProvider("invalid"), false);
});