diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5a7a7bd9..52562af3 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -24,6 +24,9 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; +import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; +import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -384,5 +387,30 @@ export function setupIpcHandlers() { 'composio:execute-action': async (_event, args) => { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, + // Agent schedule handlers + 'agent-schedule:getConfig': async () => { + const repo = container.resolve('agentScheduleRepo'); + await repo.ensureConfig(); + return repo.getConfig(); + }, + 'agent-schedule:getState': async () => { + const repo = container.resolve('agentScheduleStateRepo'); + await repo.ensureState(); + return repo.getState(); + }, + 'agent-schedule:updateAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + await repo.upsert(args.agentName, args.entry); + // Trigger the runner to pick up the change immediately + triggerAgentScheduleRun(); + return { success: true }; + }, + 'agent-schedule:deleteAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + const stateRepo = container.resolve('agentScheduleStateRepo'); + await repo.delete(args.agentName); + await stateRepo.deleteAgentState(args.agentName); + return { success: true }; + }, }); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1a679ca0..d03748d5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -51,6 +51,9 @@ import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' +import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' type DirEntry = z.infer type RunEventType = z.infer @@ -499,6 +502,22 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // Background tasks state + type BackgroundTaskItem = { + name: string + description?: string + schedule: z.infer["agents"][string]["schedule"] + enabled: boolean + startingMessage?: string + status?: z.infer["agents"][string]["status"] + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + } + const [backgroundTasks, setBackgroundTasks] = useState([]) + const [selectedBackgroundTask, setSelectedBackgroundTask] = useState(null) + // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) useEffect(() => { runIdRef.current = runId @@ -663,6 +682,63 @@ function App() { loadRuns() }, [loadRuns]) + // Load background tasks + const loadBackgroundTasks = useCallback(async () => { + try { + const [configResult, stateResult] = await Promise.all([ + window.ipc.invoke('agent-schedule:getConfig', null), + window.ipc.invoke('agent-schedule:getState', null), + ]) + + const tasks: BackgroundTaskItem[] = Object.entries(configResult.agents).map(([name, entry]) => { + const state = stateResult.agents[name] + return { + name, + description: entry.description, + schedule: entry.schedule, + enabled: entry.enabled ?? true, + startingMessage: entry.startingMessage, + status: state?.status, + nextRunAt: state?.nextRunAt, + lastRunAt: state?.lastRunAt, + lastError: state?.lastError, + runCount: state?.runCount ?? 0, + } + }) + + setBackgroundTasks(tasks) + } catch (err) { + console.error('Failed to load background tasks:', err) + } + }, []) + + // Load background tasks on mount + useEffect(() => { + loadBackgroundTasks() + }, [loadBackgroundTasks]) + + // Handle toggling background task enabled state + const handleToggleBackgroundTask = useCallback(async (taskName: string, enabled: boolean) => { + const task = backgroundTasks.find(t => t.name === taskName) + if (!task) return + + try { + await window.ipc.invoke('agent-schedule:updateAgent', { + agentName: taskName, + entry: { + schedule: task.schedule, + enabled, + startingMessage: task.startingMessage, + description: task.description, + }, + }) + // Reload to get updated state + await loadBackgroundTasks() + } catch (err) { + console.error('Failed to update background task:', err) + } + }, [backgroundTasks, loadBackgroundTasks]) + // Load a specific run and populate conversation const loadRun = useCallback(async (id: string) => { try { @@ -1169,6 +1245,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setSelectedBackgroundTask(null) }, []) const handleChatInputSubmit = (text: string) => { @@ -1193,6 +1270,8 @@ function App() { // Clear forward history when navigating to a new file setFileHistoryForward([]) setSelectedPath(path) + // Clear background task selection when navigating to a file + setSelectedBackgroundTask(null) }, [selectedPath]) const navigateBack = useCallback(() => { @@ -1686,7 +1765,16 @@ function App() { const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" - const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') + const headerTitle = selectedPath + ? selectedPath + : isGraphOpen + ? 'Graph View' + : selectedBackgroundTask + ? `Background Task: ${selectedBackgroundTask}` + : 'Chat' + const selectedTask = selectedBackgroundTask + ? backgroundTasks.find(t => t.name === selectedBackgroundTask) + : null return ( @@ -1716,8 +1804,18 @@ function App() { currentRunId={runId} tasksActions={{ onNewChat: handleNewChat, - onSelectRun: loadRun, + onSelectRun: (runIdToLoad) => { + setSelectedBackgroundTask(null) + loadRun(runIdToLoad) + }, + onSelectBackgroundTask: (taskName) => { + setSelectedBackgroundTask(taskName) + setSelectedPath(null) + setIsGraphOpen(false) + }, }} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> {/* Header with sidebar triggers */} @@ -1819,6 +1917,21 @@ function App() { ) + ) : selectedTask ? ( +
+ handleToggleBackgroundTask(selectedTask.name, enabled)} + /> +
) : (
diff --git a/apps/x/apps/renderer/src/components/background-task-detail.tsx b/apps/x/apps/renderer/src/components/background-task-detail.tsx new file mode 100644 index 00000000..78d69f2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/background-task-detail.tsx @@ -0,0 +1,175 @@ +import { Bot, Calendar, Clock, AlertCircle, CheckCircle } from "lucide-react" +import { Switch } from "@/components/ui/switch" + +interface BackgroundTaskSchedule { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string +} + +interface BackgroundTaskDetailProps { + name: string + description?: string + schedule: BackgroundTaskSchedule + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + onToggleEnabled: (enabled: boolean) => void +} + +function formatScheduleDescription(schedule: BackgroundTaskSchedule): string { + switch (schedule.type) { + case "cron": + return `Runs on cron schedule: ${schedule.expression}` + case "window": + return `Runs once between ${schedule.startTime} and ${schedule.endTime} based on: ${schedule.cron}` + case "once": + return `Runs once at ${schedule.runAt}` + default: + return "Unknown schedule type" + } +} + +function formatDateTime(isoString: string | null | undefined): string { + if (!isoString) return "Never" + try { + const date = new Date(isoString) + return date.toLocaleString() + } catch { + return isoString + } +} + +export function BackgroundTaskDetail({ + name, + description, + schedule, + enabled, + status, + nextRunAt, + lastRunAt, + lastError, + runCount = 0, + onToggleEnabled, +}: BackgroundTaskDetailProps) { + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{name}

+

Background Agent

+
+
+
+ + {/* Content */} +
+ {/* Description */} + {description && ( +
+

Description

+

{description}

+
+ )} + + {/* Schedule */} +
+

Schedule

+
+
+ + {schedule.type} Schedule +
+

+ {formatScheduleDescription(schedule)} +

+
+
+ + {/* Enabled Toggle - hide for completed one-time schedules */} + {status === "triggered" ? ( +
+

Status

+
+
+ +

Completed

+
+

+ This one-time agent has finished running and will not run again. +

+
+
+ ) : ( +
+

Status

+
+
+

{enabled ? "Enabled" : "Disabled"}

+

+ {enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"} +

+
+ +
+
+ )} + + {/* Run Statistics */} +
+

Run History

+
+
+

{runCount}

+

Total Runs

+
+
+

{formatDateTime(lastRunAt)}

+

Last Run

+
+
+
+ + {/* Next Run */} + {nextRunAt && schedule.type !== "once" && ( +
+

Next Scheduled Run

+
+
+ + {formatDateTime(nextRunAt)} +
+
+
+ )} + + {/* Last Error */} + {lastError && ( +
+

Last Error

+
+
+ +

{lastError}

+
+
+
+ )} +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index cee59809..68819635 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { useState } from "react" import { + Bot, ChevronRight, ChevronsDownUp, ChevronsUpDown, @@ -78,9 +79,27 @@ type RunListItem = { agentId: string } +type BackgroundTaskItem = { + name: string + description?: string + schedule: { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string + } + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null +} + type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void + onSelectBackgroundTask?: (taskName: string) => void } type SidebarContentPanelProps = { @@ -93,6 +112,8 @@ type SidebarContentPanelProps = { runs?: RunListItem[] currentRunId?: string | null tasksActions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null } & React.ComponentProps const sectionTitles = { @@ -110,6 +131,8 @@ export function SidebarContentPanel({ runs = [], currentRunId, tasksActions, + backgroundTasks = [], + selectedBackgroundTask, ...props }: SidebarContentPanelProps) { const { activeSection } = useSidebarSection() @@ -137,6 +160,8 @@ export function SidebarContentPanel({ runs={runs} currentRunId={currentRunId} actions={tasksActions} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> )} @@ -653,15 +678,40 @@ function Tree({ ) } +// Get status indicator color +function getStatusColor(status?: string, enabled?: boolean): string { + // Disabled agents always show gray + if (enabled === false) { + return "bg-gray-400" + } + switch (status) { + case "running": + return "bg-blue-500" + case "finished": + return "bg-green-500" + case "failed": + return "bg-red-500" + case "triggered": + return "bg-gray-400" + case "scheduled": + default: + return "bg-yellow-500" + } +} + // Tasks Section function TasksSection({ runs, currentRunId, actions, + backgroundTasks = [], + selectedBackgroundTask, }: { runs: RunListItem[] currentRunId?: string | null actions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null }) { return ( @@ -677,6 +727,35 @@ function TasksSection({
+ {/* Background Tasks Section */} + {backgroundTasks.length > 0 && ( + <> +
+ Background Tasks +
+ + {backgroundTasks.map((task) => ( + + actions?.onSelectBackgroundTask?.(task.name)} + className="gap-2" + > +
+ + +
+ + {task.name} + +
+
+ ))} +
+ + )} {runs.length > 0 && ( <>
diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts index ae89cd8b..7ac1b89e 100644 --- a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts @@ -146,15 +146,19 @@ You can add a ` + "`description`" + ` field to describe what the agent does. Thi } ` + "```" + ` -### Schedule State +### Schedule State (Read-Only) -The runner tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: +**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. + +The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: - ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) - ` + "`lastRunAt`" + `: When the agent last ran - ` + "`nextRunAt`" + `: When the agent will run next - ` + "`lastError`" + `: Error message if the last run failed - ` + "`runCount`" + `: Total number of runs +When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `. + ## Agent File Format Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. @@ -544,7 +548,7 @@ Use the search tool to find information on the web. 5. When creating multi-agent workflows, create an orchestrator agent 6. Add other agents as tools with ` + "`type: agent`" + ` for chaining 7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations -8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` +8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) 9. Confirm work done and outline next steps once changes are complete `; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 2835a90b..767de9a0 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -3,6 +3,8 @@ import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, Work import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; import { LlmModelConfig } from './models.js'; +import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; +import { AgentScheduleState } from './agent-schedule-state.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -353,6 +355,32 @@ const ipcSchemas = { }), res: z.null(), }, + // Agent schedule channels + 'agent-schedule:getConfig': { + req: z.null(), + res: AgentScheduleConfig, + }, + 'agent-schedule:getState': { + req: z.null(), + res: AgentScheduleState, + }, + 'agent-schedule:updateAgent': { + req: z.object({ + agentName: z.string(), + entry: AgentScheduleEntry, + }), + res: z.object({ + success: z.literal(true), + }), + }, + 'agent-schedule:deleteAgent': { + req: z.object({ + agentName: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, } as const; // ============================================================================