Background agents (#530)

a common place to track and add background agents
This commit is contained in:
arkml 2026-05-06 11:59:37 +05:30 committed by GitHub
parent 7b119fbfcd
commit e54b5cd27f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 596 additions and 591 deletions

View file

@ -52,6 +52,8 @@ import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
import {
fetchYaml,
listNotesWithTracks,
setNoteTracksActive,
updateTrackBlock,
replaceTrackBlockYaml,
deleteTrackBlock,
@ -135,6 +137,14 @@ function resolveShellPath(filePath: string): string {
return workspace.resolveWorkspacePath(filePath);
}
function toKnowledgeTrackPath(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized.startsWith('knowledge/')) {
throw new Error('Track note path must be within knowledge/')
}
return normalized.slice('knowledge/'.length)
}
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -832,6 +842,19 @@ export function setupIpcHandlers() {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:setNoteActive': async (_event, args) => {
try {
const note = await setNoteTracksActive(toKnowledgeTrackPath(args.path), args.active);
if (!note) return { success: false, error: 'No track blocks found in note' };
return { success: true, note };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
'track:listNotes': async () => {
const notes = await listNotesWithTracks();
return { notes };
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();

View file

@ -16,6 +16,7 @@ import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/ba
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
import { BackgroundAgentsView } from '@/components/background-agents-view';
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
@ -142,6 +143,7 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
const BACKGROUND_AGENTS_TAB_PATH = '__rowboat_background_agents__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
const clampNumber = (value: number, min: number, max: number) =>
@ -271,6 +273,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBackgroundAgentsTabPath = (path: string) => path === BACKGROUND_AGENTS_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
@ -327,6 +330,24 @@ const buildSuggestedTopicExplorePrompt = ({
].join('\n')
}
const buildBackgroundAgentSetupPrompt = () => [
'Help me set up a background agent.',
'In this flow, a background agent is the same thing as a note-based track block. Do not tell me they are separate concepts.',
'Do not propose a separate standalone agent, workflow file, or agent-schedule.json setup unless I explicitly ask for that.',
'Assume the default home for this setup is knowledge/Tasks/. If that folder does not exist, create it later when setting things up.',
'Start with a short, plain-English explanation of what a background agent is.',
'Do not make the explanation too terse.',
'Give 2 or 3 simple examples of the kinds of things a background agent could help keep updated.',
'Do not mention triggers, event-based vs schedule-based behavior, track blocks, skills, note paths, or other internal implementation details unless I ask.',
'In the first reply, tell me that you will create this in my Tasks folder by default.',
'Do not ask me where it should save or update results unless I explicitly say I want it somewhere else.',
'Then ask only what I want it to monitor or update and how often I want it to run.',
'Keep it concise and friendly, but not abrupt.',
'Do not give me a long taxonomy, a big list of options, or a multi-step breakdown unless I ask for more detail.',
'Do not create or modify anything yet.',
'If I confirm later, load the tracks skill, check for a matching note under knowledge/Tasks/ first, and create one there if needed.',
].join('\n')
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
@ -508,6 +529,7 @@ type ViewState =
| { type: 'graph' }
| { type: 'task'; name: string }
| { type: 'suggested-topics' }
| { type: 'background-agents' }
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type !== b.type) return false
@ -521,12 +543,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics>&...
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics|background-agents>&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics
* background-agents: ?type=background-agents
*/
function parseDeepLink(input: string): ViewState | null {
const SCHEME = 'rowboat://'
@ -551,6 +574,8 @@ function parseDeepLink(input: string): ViewState | null {
}
case 'suggested-topics':
return { type: 'suggested-topics' }
case 'background-agents':
return { type: 'background-agents' }
default:
return null
}
@ -656,7 +681,13 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null)
const [isBackgroundAgentsOpen, setIsBackgroundAgentsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{
path: string | null
graph: boolean
suggestedTopics: boolean
backgroundAgents: boolean
} | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
nodes: [],
@ -977,6 +1008,7 @@ function App() {
const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View'
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
if (isBackgroundAgentsTabPath(tab.path)) return 'Background agents'
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
@ -2660,6 +2692,8 @@ function App() {
if (existingTab) {
setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(path)
return
}
@ -2667,6 +2701,8 @@ function App() {
setFileTabs(prev => [...prev, { id, path }])
setActiveFileTabId(id)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(path)
}, [fileTabs, dismissBrowserOverlay])
@ -2685,16 +2721,26 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
return
}
if (isSuggestedTopicsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
return
}
if (isBackgroundAgentsTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
return
}
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(tab.path)
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
@ -2723,6 +2769,7 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
@ -2736,13 +2783,21 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
} else if (isBackgroundAgentsTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
} else {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(newActiveTab.path)
}
}
@ -2773,8 +2828,13 @@ function App() {
dismissBrowserOverlay()
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
backgroundAgents: isBackgroundAgentsOpen,
})
} else {
setExpandedFrom(null)
}
@ -2782,7 +2842,8 @@ function App() {
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
setIsBackgroundAgentsOpen(false)
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
@ -2897,27 +2958,40 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) {
setExpandedFrom({
path: selectedPath,
graph: isGraphOpen,
suggestedTopics: isSuggestedTopicsOpen,
backgroundAgents: isBackgroundAgentsOpen,
})
}
dismissBrowserOverlay()
setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, dismissBrowserOverlay])
setIsBackgroundAgentsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, dismissBrowserOverlay])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
if (expandedFrom.graph) {
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
} else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
} else if (expandedFrom.backgroundAgents) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
} else if (expandedFrom.path) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
@ -2927,11 +3001,12 @@ function App() {
const currentViewState = React.useMemo<ViewState>(() => {
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
if (isBackgroundAgentsOpen) return { type: 'background-agents' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
}, [selectedBackgroundTask, isBackgroundAgentsOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@ -2988,6 +3063,17 @@ function App() {
setActiveFileTabId(id)
}, [fileTabs])
const ensureBackgroundAgentsFileTab = useCallback(() => {
const existing = fileTabs.find((tab) => isBackgroundAgentsTabPath(tab.path))
if (existing) {
setActiveFileTabId(existing.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: BACKGROUND_AGENTS_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) {
case 'file':
@ -2997,6 +3083,7 @@ function App() {
// visible in the middle pane.
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
@ -3011,6 +3098,7 @@ function App() {
setSelectedPath(null)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -3023,6 +3111,7 @@ function App() {
setIsGraphOpen(false)
setIsBrowserOpen(false)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -3035,8 +3124,20 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(true)
setIsBackgroundAgentsOpen(false)
ensureSuggestedTopicsFileTab()
return
case 'background-agents':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(true)
ensureBackgroundAgentsFileTab()
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
@ -3045,6 +3146,7 @@ function App() {
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
setIsSuggestedTopicsOpen(false)
setIsBackgroundAgentsOpen(false)
if (view.runId) {
await loadRun(view.runId)
} else {
@ -3052,7 +3154,7 @@ function App() {
}
return
}
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
}, [ensureBackgroundAgentsFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState
@ -3374,7 +3476,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3452,15 +3554,17 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen)
const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH
: isSuggestedTopicsOpen
? SUGGESTED_TOPICS_TAB_PATH
: isBackgroundAgentsOpen
? BACKGROUND_AGENTS_TAB_PATH
: selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
@ -3515,7 +3619,7 @@ function App() {
}
document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isBackgroundAgentsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
@ -3540,7 +3644,7 @@ function App() {
}),
},
}))
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -3662,14 +3766,14 @@ function App() {
},
openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' })
},
openBases: () => {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -4253,7 +4357,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
@ -4270,7 +4374,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
if (section === 'knowledge' && !selectedPath && !isGraphOpen) {
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen) {
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
}
}}>
@ -4303,7 +4407,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4314,7 +4418,7 @@ function App() {
return
}
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad)
return
@ -4338,14 +4442,14 @@ function App() {
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat()
} else {
@ -4375,6 +4479,8 @@ function App() {
onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
isBackgroundAgentsOpen={isBackgroundAgentsOpen}
onOpenBackgroundAgents={() => void navigateToView({ type: 'background-agents' })}
/>
<SidebarInset
className={cn(
@ -4394,7 +4500,7 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && fileTabs.length >= 1 ? (
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ''}
@ -4402,7 +4508,7 @@ function App() {
getTabId={(t) => t.id}
onSwitchTab={switchFileTab}
onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
/>
) : (
<TabBar
@ -4455,7 +4561,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4470,7 +4576,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBackgroundAgentsOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4485,7 +4591,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBackgroundAgentsOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4518,6 +4624,15 @@ function App() {
}}
/>
</div>
) : isBackgroundAgentsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BackgroundAgentsView
onOpenNote={(path) => navigateToFile(path)}
onAddNewBackgroundAgent={() => {
submitFromPalette(buildBackgroundAgentSetupPrompt(), null)
}}
/>
</div>
) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView

View file

@ -0,0 +1,250 @@
import { useCallback, useEffect, useState } from 'react'
import { Bot, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { stripKnowledgePrefix, wikiLabel } from '@/lib/wiki-links'
import { toast } from '@/lib/toast'
type BackgroundAgentNote = {
path: string
trackCount: number
createdAt: string | null
lastRunAt: string | null
isActive: boolean
}
type BackgroundAgentsViewProps = {
onOpenNote: (path: string) => void
onAddNewBackgroundAgent: () => void
}
function formatDateLabel(iso: string | null): string {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatDateTimeLabel(iso: string | null): string {
if (!iso) return 'Never'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return 'Never'
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function isKnowledgeMarkdownPath(path: string | undefined): boolean {
return typeof path === 'string' && path.startsWith('knowledge/') && path.endsWith('.md')
}
export function BackgroundAgentsView({ onOpenNote, onAddNewBackgroundAgent }: BackgroundAgentsViewProps) {
const [notes, setNotes] = useState<BackgroundAgentNote[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [updatingPaths, setUpdatingPaths] = useState<Set<string>>(new Set())
const loadNotes = useCallback(async () => {
setLoading(true)
try {
const result = await window.ipc.invoke('track:listNotes', null)
setNotes(result.notes)
setError(null)
} catch (err) {
console.error('Failed to load background agent notes:', err)
setError('Could not load background agents.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNotes()
}, [loadNotes])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
const scheduleReload = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
void loadNotes()
}, 200)
}
const cleanupWorkspace = window.ipc.on('workspace:didChange', (event) => {
switch (event.type) {
case 'created':
case 'changed':
case 'deleted':
if (isKnowledgeMarkdownPath(event.path)) scheduleReload()
break
case 'moved':
if (isKnowledgeMarkdownPath(event.from) || isKnowledgeMarkdownPath(event.to)) {
scheduleReload()
}
break
case 'bulkChanged':
if (!event.paths || event.paths.some(isKnowledgeMarkdownPath)) {
scheduleReload()
}
break
}
})
const cleanupTracks = window.ipc.on('tracks:events', () => {
scheduleReload()
})
return () => {
cleanupWorkspace()
cleanupTracks()
if (timeout) clearTimeout(timeout)
}
}, [loadNotes])
const handleToggleState = useCallback(async (note: BackgroundAgentNote, active: boolean) => {
setUpdatingPaths((prev) => new Set(prev).add(note.path))
try {
const result = await window.ipc.invoke('track:setNoteActive', {
path: note.path,
active,
})
if (!result.success || !result.note) {
throw new Error(result.error ?? 'Failed to update background agent state')
}
const updatedNote = result.note
setNotes((prev) => prev.map((entry) => (
entry.path === note.path ? updatedNote : entry
)))
} catch (err) {
console.error('Failed to update background agent note state:', err)
toast(err instanceof Error ? err.message : 'Failed to update background agent state', 'error')
} finally {
setUpdatingPaths((prev) => {
const next = new Set(prev)
next.delete(note.path)
return next
})
}
}, [])
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 border-b border-border px-6 py-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Bot className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">Background agents</h2>
</div>
<Button type="button" size="sm" onClick={onAddNewBackgroundAgent}>
Add new background agent
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Notes that contain track blocks. Toggle a note inactive to pause every background agent in it.
</p>
</div>
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Bot className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : notes.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<div className="rounded-full bg-muted p-3">
<Bot className="size-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No notes with background agents yet.
</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-border/60 bg-muted/30 text-left">
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Note</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Created date</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">Last ran</th>
<th className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">State</th>
</tr>
</thead>
<tbody>
{notes.map((note) => {
const isUpdating = updatingPaths.has(note.path)
return (
<tr key={note.path} className="border-b border-border/50 last:border-b-0 hover:bg-muted/20">
<td className="px-4 py-3 align-top">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onOpenNote(note.path)}
className="truncate text-left text-sm font-medium text-foreground hover:text-primary"
title={note.path}
>
{wikiLabel(note.path)}
</button>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{note.trackCount} {note.trackCount === 1 ? 'agent' : 'agents'}
</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{stripKnowledgePrefix(note.path)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateLabel(note.createdAt)}
</td>
<td className="px-4 py-3 text-sm text-foreground/80">
{formatDateTimeLabel(note.lastRunAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{isUpdating ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<span className="size-4 shrink-0" aria-hidden="true" />
)}
<Switch
checked={note.isActive}
onCheckedChange={(checked) => { void handleToggleState(note, checked) }}
disabled={isUpdating}
/>
<span className="min-w-16 text-xs font-medium text-foreground/80">
{note.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View file

@ -214,6 +214,8 @@ type SidebarContentPanelProps = {
onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void
isBackgroundAgentsOpen?: boolean
onOpenBackgroundAgents?: () => void
} & React.ComponentProps<typeof Sidebar>
const sectionTabs: { id: ActiveSection; label: string }[] = [
@ -491,6 +493,8 @@ export function SidebarContentPanel({
onToggleBrowser,
isSuggestedTopicsOpen = false,
onOpenSuggestedTopics,
isBackgroundAgentsOpen = false,
onOpenBackgroundAgents,
...props
}: SidebarContentPanelProps) {
const { activeSection, setActiveSection } = useSidebarSection()
@ -506,6 +510,7 @@ export function SidebarContentPanel({
const isMeetingQuickActionSelected = isMeetingActionActive
const isBrowserQuickActionSelected = isBrowserOpen && !isSearchOpen && !isMeetingQuickActionSelected
const isSuggestedTopicsQuickActionSelected = isSuggestedTopicsOpen && !isBrowserOpen
const isBackgroundAgentsQuickActionSelected = isBackgroundAgentsOpen && !isBrowserOpen
const handleRowboatLogin = useCallback(async () => {
try {
@ -679,6 +684,21 @@ export function SidebarContentPanel({
<span>Suggested Topics</span>
</button>
)}
{onOpenBackgroundAgents && (
<button
type="button"
onClick={onOpenBackgroundAgents}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isBackgroundAgentsQuickActionSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Bot className="size-4" />
<span>Background agents</span>
</button>
)}
</div>
</SidebarHeader>
<SidebarContent>

View file

@ -1,555 +0,0 @@
export const skill = String.raw`
# Background Agents
Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace.
## Core Concepts
**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent.
- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
- Agents configure a model, tools (in frontmatter), and instructions (in the body)
- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents**
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root
## How multi-agent workflows work
1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + `
2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below)
3. The orchestrator calls other agents as tools when needed
4. Data flows through tool call parameters and responses
## Scheduling Background Agents
Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root.
### Schedule Configuration File
` + "```json" + `
{
"agents": {
"agent_name": {
"schedule": { ... },
"enabled": true
}
}
}
` + "```" + `
### Schedule Types
**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat).
**1. Cron Schedule** - Runs at exact times defined by cron expression
` + "```json" + `
{
"schedule": {
"type": "cron",
"expression": "0 8 * * *"
},
"enabled": true
}
` + "```" + `
Common cron expressions:
- ` + "`*/5 * * * *`" + ` - Every 5 minutes
- ` + "`0 8 * * *`" + ` - Every day at 8am
- ` + "`0 9 * * 1`" + ` - Every Monday at 9am
- ` + "`0 0 1 * *`" + ` - First day of every month at midnight
**2. Window Schedule** - Runs once during a time window
` + "```json" + `
{
"schedule": {
"type": "window",
"cron": "0 0 * * *",
"startTime": "08:00",
"endTime": "10:00"
},
"enabled": true
}
` + "```" + `
The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am").
**3. Once Schedule** - Runs exactly once at a specific time
` + "```json" + `
{
"schedule": {
"type": "once",
"runAt": "2024-02-05T10:30:00"
},
"enabled": true
}
` + "```" + `
Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix).
### Starting Message
You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `.
` + "```json" + `
{
"schedule": { "type": "cron", "expression": "0 8 * * *" },
"enabled": true,
"startingMessage": "Please summarize my emails from the last 24 hours"
}
` + "```" + `
### Description
You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI.
` + "```json" + `
{
"schedule": { "type": "cron", "expression": "0 8 * * *" },
"enabled": true,
"description": "Summarizes emails and calendar events every morning"
}
` + "```" + `
### Complete Schedule Example
` + "```json" + `
{
"agents": {
"daily_digest": {
"schedule": {
"type": "cron",
"expression": "0 8 * * *"
},
"enabled": true,
"description": "Daily email and calendar summary",
"startingMessage": "Summarize my emails and calendar for today"
},
"morning_briefing": {
"schedule": {
"type": "window",
"cron": "0 0 * * *",
"startTime": "07:00",
"endTime": "09:00"
},
"enabled": true,
"description": "Morning news and updates briefing"
},
"one_time_setup": {
"schedule": {
"type": "once",
"runAt": "2024-12-01T12:00:00"
},
"enabled": true,
"description": "One-time data migration task"
}
}
}
` + "```" + `
### Schedule State (Read-Only)
**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root:
- ` + "`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.
### Basic Structure
` + "```markdown" + `
---
model: gpt-5.1
tools:
tool_key:
type: builtin
name: tool_name
---
# Instructions
Your detailed instructions go here in Markdown format.
` + "```" + `
### Frontmatter Fields
- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5')
- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json
- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions
### Instructions (Body)
The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting.
### Naming Rules
- Agent filename determines the agent name (without .md extension)
- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent"
- Use lowercase with underscores for multi-word names
- No spaces or special characters in names
- **The agent name in agent-schedule.json must match the filename** (without .md)
### Agent Format Example
` + "```markdown" + `
---
model: gpt-5.1
tools:
search:
type: mcp
name: firecrawl_search
description: Search the web
mcpServerName: firecrawl
inputSchema:
type: object
properties:
query:
type: string
description: Search query
required:
- query
---
# Web Search Agent
You are a web search agent. When asked a question:
1. Use the search tool to find relevant information
2. Summarize the results clearly
3. Cite your sources
Be concise and accurate.
` + "```" + `
## Tool Types & Schemas
Tools in agents must follow one of three types. Each has specific required fields.
### 1. Builtin Tools
Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.)
**YAML Schema:**
` + "```yaml" + `
tool_key:
type: builtin
name: tool_name
` + "```" + `
**Required fields:**
- ` + "`type`" + `: Must be "builtin"
- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile")
**Example:**
` + "```yaml" + `
bash:
type: builtin
name: executeCommand
` + "```" + `
**Available builtin tools:**
- ` + "`executeCommand`" + ` - Execute shell commands
- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations
- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations
- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management
- ` + "`analyzeAgent`" + ` - Analyze agent structure
- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management
- ` + "`loadSkill`" + ` - Load skill guidance
### 2. MCP Tools
Tools from external MCP servers (APIs, databases, web scraping, etc.)
**YAML Schema:**
` + "```yaml" + `
tool_key:
type: mcp
name: tool_name_from_server
description: What the tool does
mcpServerName: server_name_from_config
inputSchema:
type: object
properties:
param:
type: string
description: Parameter description
required:
- param
` + "```" + `
**Required fields:**
- ` + "`type`" + `: Must be "mcp"
- ` + "`name`" + `: Exact tool name from MCP server
- ` + "`description`" + `: What the tool does (helps agent understand when to use it)
- ` + "`mcpServerName`" + `: Server name from config/mcp.json
- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters
**Example:**
` + "```yaml" + `
search:
type: mcp
name: firecrawl_search
description: Search the web
mcpServerName: firecrawl
inputSchema:
type: object
properties:
query:
type: string
description: Search query
required:
- query
` + "```" + `
**Important:**
- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server
- Copy the schema exactlydon't modify property types or structure
- Only include ` + "`required`" + ` array if parameters are mandatory
### 3. Agent Tools (for chaining agents)
Reference other agents as tools to build multi-agent workflows
**YAML Schema:**
` + "```yaml" + `
tool_key:
type: agent
name: target_agent_name
` + "```" + `
**Required fields:**
- ` + "`type`" + `: Must be "agent"
- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory)
**Example:**
` + "```yaml" + `
summariser:
type: agent
name: summariser_agent
` + "```" + `
**How it works:**
- Use ` + "`type: agent`" + ` to call other agents as tools
- The target agent will be invoked with the parameters you pass
- Results are returned as tool output
- This is how you build multi-agent workflows
- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `)
## Complete Multi-Agent Workflow Example
**Email digest workflow** - This is all done through agents calling other agents:
**1. Task-specific agent** (` + "`agents/email_reader.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
read_file:
type: builtin
name: workspace-readFile
list_dir:
type: builtin
name: workspace-readdir
---
# Email Reader Agent
Read emails from the gmail_sync folder and extract key information.
Look for unread or recent emails and summarize the sender, subject, and key points.
Don't ask for human input.
` + "```" + `
**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
email_reader:
type: agent
name: email_reader
write_file:
type: builtin
name: workspace-writeFile
---
# Daily Summary Agent
1. Use the email_reader tool to get email summaries
2. Create a consolidated daily digest
3. Save the digest to ~/Desktop/daily_digest.md
Don't ask for human input.
` + "```" + `
Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions.
**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
daily_summary:
type: agent
name: daily_summary
search:
type: mcp
name: search
mcpServerName: exa
description: Search the web for news
inputSchema:
type: object
properties:
query:
type: string
description: Search query
---
# Morning Briefing Workflow
Create a morning briefing:
1. Get email digest using daily_summary
2. Search for relevant news using the search tool
3. Compile a comprehensive morning briefing
Execute these steps in sequence. Don't ask for human input.
` + "```" + `
**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `:
` + "```json" + `
{
"agents": {
"morning_briefing": {
"schedule": {
"type": "cron",
"expression": "0 7 * * *"
},
"enabled": true,
"startingMessage": "Create my morning briefing for today"
}
}
}
` + "```" + `
This schedules the morning briefing workflow to run every day at 7am local time.
## Naming and organization rules
- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter
- Agent filename (without .md) becomes the agent name
- When referencing an agent as a tool, use its filename without extension
- When scheduling an agent, use its filename without extension in agent-schedule.json
- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users
## Best practices for background agents
1. **Single responsibility**: Each agent should do one specific thing well
2. **Clear delegation**: Agent instructions should explicitly say when to call other agents
3. **Autonomous operation**: Add "Don't ask for human input" for background agents
4. **Data passing**: Make it clear what data to extract and pass between agents
5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze")
6. **Orchestration**: Create a top-level agent that coordinates the workflow
7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks
8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene
9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations
10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md"
## Validation & Best Practices
### CRITICAL: Schema Compliance
- Agent files MUST be valid Markdown with YAML frontmatter
- Agent filename (without .md) becomes the agent name
- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent")
- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema
- Agent tools MUST reference existing agent files
- Invalid agents will fail to load and prevent workflow execution
### File Creation/Update Process
1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter
2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + `
3. Validate YAML syntax in frontmatter before writingmalformed YAML breaks the agent
4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `)
5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + `
### Common Validation Errors to Avoid
**WRONG - Missing frontmatter delimiters:**
` + "```markdown" + `
model: gpt-5.1
# My Agent
Instructions here
` + "```" + `
**WRONG - Invalid YAML indentation:**
` + "```markdown" + `
---
tools:
bash:
type: builtin
---
` + "```" + `
(bash should be indented under tools)
**WRONG - Invalid tool type:**
` + "```yaml" + `
tools:
tool1:
type: custom
name: something
` + "```" + `
(type must be builtin, mcp, or agent)
**WRONG - Unquoted strings containing colons:**
` + "```yaml" + `
tools:
search:
description: Number of results (default: 8)
` + "```" + `
(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `)
**WRONG - MCP tool missing required fields:**
` + "```yaml" + `
tools:
search:
type: mcp
name: firecrawl_search
` + "```" + `
(Missing: description, mcpServerName, inputSchema)
**CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
---
# Simple Agent
Do simple tasks as instructed.
` + "```" + `
**CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `):
` + "```markdown" + `
---
model: gpt-5.1
tools:
search:
type: mcp
name: firecrawl_search
description: Search the web
mcpServerName: firecrawl
inputSchema:
type: object
properties:
query:
type: string
---
# Search Agent
Use the search tool to find information on the web.
` + "```" + `
## Capabilities checklist
1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing
2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes
3. Validate YAML frontmatter syntax before creating/updating agents
4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update
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 ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
9. Confirm work done and outline next steps once changes are complete
`;
export default skill;

View file

@ -7,7 +7,6 @@ import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import backgroundAgentsSkill from "./background-agents/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import appNavigationSkill from "./app-navigation/skill.js";
@ -65,12 +64,6 @@ const definitions: SkillDefinition[] = [
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "background-agents",
title: "Background Agents",
summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.",
content: backgroundAgentsSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",

View file

@ -349,6 +349,20 @@ In that flow:
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
### Background agent setup flow
Sometimes the user arrives from the Background agents panel and wants help creating a new background agent without naming a note yet.
In this flow, treat "background agent" and "track block" as the same feature. The user-facing term can stay "background agent", but the implementation is a track block inside a note. Do **not** claim these are different systems, and do **not** redirect the user toward standalone agent files or ` + "`" + `agent-schedule.json` + "`" + ` unless they explicitly ask for that architecture.
In that flow:
1. On the first turn, **do not create or modify anything yet**. Briefly explain what you can set up, say you will put it in ` + "`" + `knowledge/Tasks/` + "`" + ` by default, and ask what it should monitor plus how often it should run.
2. **Do not** ask the user where the results should live unless they explicitly said they want a different folder or there is a real ambiguity you cannot resolve.
3. If the user clearly confirms later, treat ` + "`" + `knowledge/Tasks/` + "`" + ` as the default target folder.
4. Before creating a new note there, search ` + "`" + `knowledge/Tasks/` + "`" + ` for an existing matching note and update it if one already exists.
5. If ` + "`" + `knowledge/Tasks/` + "`" + ` does not exist, create it as part of setup instead of bouncing back to ask.
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
## The Exact Text to Insert
Write it verbatim like this (including the blank line between fence and target):

View file

@ -70,6 +70,122 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer<
return blocks.find(b => b.track.trackId === trackId) ?? null;
}
type TrackNoteSummary = {
path: string;
trackCount: number;
createdAt: string | null;
lastRunAt: string | null;
isActive: boolean;
};
async function summarizeTrackNote(
filePath: string,
tracks: z.infer<typeof TrackStateSchema>[],
): Promise<TrackNoteSummary | null> {
if (tracks.length === 0) return null;
const stats = await fs.stat(absPath(filePath));
const createdMs = stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.ctimeMs;
let latestRunAt: string | null = null;
let latestRunMs = -1;
for (const { track } of tracks) {
if (!track.lastRunAt) continue;
const candidateMs = Date.parse(track.lastRunAt);
if (Number.isNaN(candidateMs) || candidateMs <= latestRunMs) continue;
latestRunMs = candidateMs;
latestRunAt = track.lastRunAt;
}
return {
path: `knowledge/${filePath}`,
trackCount: tracks.length,
createdAt: createdMs > 0 ? new Date(createdMs).toISOString() : null,
lastRunAt: latestRunAt,
isActive: tracks.every(({ track }) => track.active !== false),
};
}
export async function listNotesWithTracks(): Promise<TrackNoteSummary[]> {
async function walk(relativeDir = ''): Promise<string[]> {
const dirPath = absPath(relativeDir);
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const childRelPath = relativeDir
? path.posix.join(relativeDir, entry.name)
: entry.name;
if (entry.isDirectory()) {
files.push(...await walk(childRelPath));
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
files.push(childRelPath);
}
}
return files;
} catch {
return [];
}
}
const markdownFiles = await walk();
const notes = await Promise.all(markdownFiles.map(async (relativePath) => {
try {
const tracks = await fetchAll(relativePath);
return await summarizeTrackNote(relativePath, tracks);
} catch {
return null;
}
}));
return notes
.filter((note): note is TrackNoteSummary => note !== null)
.sort((a, b) => {
const aName = path.basename(a.path, '.md').toLowerCase();
const bName = path.basename(b.path, '.md').toLowerCase();
if (aName !== bName) return aName.localeCompare(bName);
return a.path.localeCompare(b.path);
});
}
export async function setNoteTracksActive(filePath: string, active: boolean): Promise<TrackNoteSummary | null> {
return withFileLock(absPath(filePath), async () => {
const blocks = await fetchAll(filePath);
if (blocks.length === 0) return null;
const alreadyMatches = blocks.every(({ track }) => (track.active !== false) === active);
if (alreadyMatches) {
return summarizeTrackNote(filePath, blocks);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const updatedBlocks = blocks
.map((block) => ({
...block,
track: { ...block.track, active },
}))
.sort((a, b) => b.fenceStart - a.fenceStart);
for (const block of updatedBlocks) {
const yaml = stringifyYaml(block.track).trimEnd();
const yamlLines = yaml ? yaml.split('\n') : [];
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
}
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
return summarizeTrackNote(filePath, updatedBlocks);
});
}
/**
* Fetch a track block and return its canonical YAML string (or null if not found).
* Useful for IPC handlers that need to return the fresh YAML without taking a
@ -196,4 +312,4 @@ export async function deleteTrackBlock(filePath: string, trackId: string): Promi
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
});
}
}

View file

@ -662,6 +662,35 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'track:setNoteActive': {
req: z.object({
path: RelPath,
active: z.boolean(),
}),
res: z.object({
success: z.boolean(),
note: z.object({
path: RelPath,
trackCount: z.number().int().positive(),
createdAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
isActive: z.boolean(),
}).optional(),
error: z.string().optional(),
}),
},
'track:listNotes': {
req: z.null(),
res: z.object({
notes: z.array(z.object({
path: RelPath,
trackCount: z.number().int().positive(),
createdAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
isActive: z.boolean(),
})),
}),
},
// Embedded browser (WebContentsView) channels
'browser:setBounds': {
req: z.object({