>();
-function fetchRegistry(deploymentUrl: string, apiKey: string): Promise {
+function fetchRegistry(
+ deploymentUrl: string,
+ apiKey: string
+): Promise {
const key = deploymentUrl.replace(/\/$/, "");
const hit = cache.get(key);
if (hit) return hit;
@@ -76,9 +79,7 @@ function fetchRegistry(deploymentUrl: string, apiKey: string): Promise;
+ return {
+ cron_id: cron.cron_id,
+ name:
+ typeof meta.name === "string" && meta.name ? meta.name : "Unnamed Task",
+ prompt: typeof meta.prompt === "string" ? meta.prompt : "",
+ schedule: cron.schedule,
+ next_run_date: cron.next_run_date ?? null,
+ created_at: cron.created_at,
+ updated_at: cron.updated_at,
+ };
+}
+
+export async function listScheduledTasks(): Promise {
+ const client = makeClient();
+ if (!client) return [];
+ // The TS SDK's crons.search doesn't support metadata filtering — fetch all
+ // and filter client-side. Only EvoScientist crons carry run_kind in metadata.
+ const crons = await client.crons.search({ limit: 200 });
+ return crons
+ .filter(
+ (c) =>
+ (c.metadata as Record)?.run_kind === SCHEDULED_RUN_KIND
+ )
+ .map(parseCron);
+}
+
+export async function createScheduledTask(params: {
+ name: string;
+ prompt: string;
+ schedule: string;
+}): Promise {
+ const client = makeClient();
+ if (!client) throw new Error("No EvoScientist deployment configured.");
+ const cron = await client.crons.create(SCHEDULER_GRAPH_ID, {
+ input: { messages: [{ role: "user", content: params.prompt }] },
+ schedule: params.schedule,
+ metadata: {
+ run_kind: SCHEDULED_RUN_KIND,
+ name: params.name,
+ prompt: params.prompt,
+ },
+ });
+ return parseCron(cron as unknown as Cron);
+}
+
+export async function deleteScheduledTask(cronId: string): Promise {
+ const client = makeClient();
+ if (!client) throw new Error("No EvoScientist deployment configured.");
+ await client.crons.delete(cronId);
+}
+
+export async function runScheduledTaskNow(prompt: string): Promise {
+ const client = makeClient();
+ if (!client) throw new Error("No EvoScientist deployment configured.");
+ const thread = await client.threads.create({ graphId: SCHEDULER_GRAPH_ID });
+ await client.runs.create(thread.thread_id, SCHEDULER_GRAPH_ID, {
+ input: { messages: [{ role: "user", content: prompt }] },
+ metadata: {
+ run_kind: SCHEDULED_RUN_KIND,
+ name: "manual-run",
+ prompt,
+ },
+ });
+}
+
+export function useScheduledTasks(): {
+ tasks: ScheduledTask[];
+ loading: boolean;
+ error: string | null;
+ refresh: () => void;
+} {
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [rev, setRev] = useState(0);
+
+ const refresh = useCallback(() => setRev((r) => r + 1), []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ listScheduledTasks()
+ .then((result) => {
+ if (!cancelled) {
+ setTasks(result);
+ setError(null);
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Failed to load scheduled tasks."
+ );
+ setTasks([]);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [rev]);
+
+ return { tasks, loading, error, refresh };
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index bb6448f..4f01a8f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -26,6 +26,7 @@ import { ChatProvider } from "@/providers/ChatProvider";
import { ChatInterface } from "@/app/components/ChatInterface";
import { SkillsMarketplace } from "@/app/components/SkillsMarketplace";
import { MemoryPanel } from "@/app/components/MemoryPanel";
+import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import { HealthIndicator } from "@/app/components/HealthIndicator";
import { InspectorPanel } from "@/app/components/InspectorPanel";
@@ -420,6 +421,7 @@ function HomePageInner({
{view === "skills" &&
}
{view === "memory" &&
}
+ {view === "schedule" &&
}
{inspector && isDesktopLayout && (
diff --git a/src/lib/cronUtils.ts b/src/lib/cronUtils.ts
new file mode 100644
index 0000000..efd8196
--- /dev/null
+++ b/src/lib/cronUtils.ts
@@ -0,0 +1,155 @@
+/** Cron expression helpers for the Scheduled Tasks panel. */
+
+export type Frequency = "daily" | "weekly" | "monthly" | "custom";
+
+export interface ScheduleSpec {
+ frequency: Frequency;
+ hour: number;
+ minute: number;
+ /** 0 = Sunday … 6 = Saturday (used when frequency === "weekly") */
+ dayOfWeek: number;
+ /** 1–28 (used when frequency === "monthly") */
+ dayOfMonth: number;
+ /** Raw cron string (used when frequency === "custom") */
+ custom: string;
+}
+
+export const DEFAULT_SCHEDULE_SPEC: ScheduleSpec = {
+ frequency: "daily",
+ hour: 9,
+ minute: 0,
+ dayOfWeek: 1,
+ dayOfMonth: 1,
+ custom: "0 9 * * *",
+};
+
+export const DAY_NAMES = [
+ "Sun",
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+] as const;
+
+function clampNumber(value: number, min: number, max: number): number {
+ if (!Number.isFinite(value)) return min;
+ return Math.min(max, Math.max(min, value));
+}
+
+export function validateCronExpression(cron: string): string | null {
+ const trimmed = cron.trim();
+ if (!trimmed) return "Enter a cron expression.";
+ const parts = trimmed.split(/\s+/);
+ if (parts.length !== 5) {
+ return "Use five cron fields: minute hour day month weekday.";
+ }
+ return null;
+}
+
+export function specToCron(spec: ScheduleSpec): string {
+ const minute = clampNumber(spec.minute, 0, 59);
+ const hour = clampNumber(spec.hour, 0, 23);
+ const dayOfWeek = clampNumber(spec.dayOfWeek, 0, 6);
+ const dayOfMonth = clampNumber(spec.dayOfMonth, 1, 28);
+
+ switch (spec.frequency) {
+ case "daily":
+ return `${minute} ${hour} * * *`;
+ case "weekly":
+ return `${minute} ${hour} * * ${dayOfWeek}`;
+ case "monthly":
+ return `${minute} ${hour} ${dayOfMonth} * *`;
+ case "custom":
+ return spec.custom.trim();
+ }
+ return `${minute} ${hour} * * *`;
+}
+
+/** Parse a cron string back into a ScheduleSpec (best-effort). */
+export function cronToSpec(cron: string): ScheduleSpec {
+ const parts = cron.trim().split(/\s+/);
+ if (parts.length !== 5) {
+ return { ...DEFAULT_SCHEDULE_SPEC, frequency: "custom", custom: cron };
+ }
+ const [min, hr, dom, month, dow] = parts;
+ const minute = parseInt(min, 10);
+ const hour = parseInt(hr, 10);
+
+ if (
+ month === "*" &&
+ dom === "*" &&
+ dow === "*" &&
+ !isNaN(minute) &&
+ !isNaN(hour)
+ ) {
+ return { ...DEFAULT_SCHEDULE_SPEC, frequency: "daily", hour, minute };
+ }
+ if (
+ month === "*" &&
+ dom === "*" &&
+ !isNaN(parseInt(dow, 10)) &&
+ !isNaN(minute) &&
+ !isNaN(hour)
+ ) {
+ return {
+ ...DEFAULT_SCHEDULE_SPEC,
+ frequency: "weekly",
+ hour: clampNumber(hour, 0, 23),
+ minute: clampNumber(minute, 0, 59),
+ dayOfWeek: clampNumber(parseInt(dow, 10), 0, 6),
+ };
+ }
+ if (
+ month === "*" &&
+ dow === "*" &&
+ !isNaN(parseInt(dom, 10)) &&
+ !isNaN(minute) &&
+ !isNaN(hour)
+ ) {
+ return {
+ ...DEFAULT_SCHEDULE_SPEC,
+ frequency: "monthly",
+ hour: clampNumber(hour, 0, 23),
+ minute: clampNumber(minute, 0, 59),
+ dayOfMonth: clampNumber(parseInt(dom, 10), 1, 28),
+ };
+ }
+ return { ...DEFAULT_SCHEDULE_SPEC, frequency: "custom", custom: cron };
+}
+
+/** Human-readable summary of a cron expression. */
+export function cronLabel(cron: string): string {
+ const spec = cronToSpec(cron);
+ const time = `${String(spec.hour).padStart(2, "0")}:${String(
+ spec.minute
+ ).padStart(2, "0")}`;
+ switch (spec.frequency) {
+ case "daily":
+ return `Every day at ${time}`;
+ case "weekly":
+ return `Every ${DAY_NAMES[spec.dayOfWeek]} at ${time}`;
+ case "monthly":
+ return `Monthly on day ${spec.dayOfMonth} at ${time}`;
+ default:
+ return cron;
+ }
+}
+
+/** Format a next_run_date ISO string into a short relative label. */
+export function nextRunLabel(iso: string | null): string {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "—";
+ const now = Date.now();
+ const diff = d.getTime() - now;
+ if (diff < 0) return "Overdue";
+ const mins = Math.round(diff / 60_000);
+ if (mins < 1) return "in <1m";
+ if (mins < 60) return `in ${mins}m`;
+ const hrs = Math.round(diff / 3_600_000);
+ if (hrs < 24) return `in ${hrs}h`;
+ const days = Math.round(diff / 86_400_000);
+ return `in ${days}d`;
+}