Compare commits

...

7 Commits

Author SHA1 Message Date
DaniAkash
354b074780 chore(merge): sync feat/program-scheduler with dev 2026-04-20 22:24:39 +05:30
DaniAkash
678d9368d6 fix: update program scheduling copy 2026-04-15 15:06:41 +05:30
DaniAkash
865ef21b5b feat: add program run result dialog 2026-04-15 15:05:27 +05:30
DaniAkash
4cba9e2020 feat: add browseros program scheduler 2026-04-15 13:08:44 +05:30
DaniAkash
d343fd1735 fix: remove openclaw role bootstrap assertion 2026-04-15 12:20:44 +05:30
DaniAkash
63fed8e79b feat: add agent program management ui 2026-04-15 12:19:54 +05:30
Dani Akash
1fdad55b4a feat: add agent program management backend 2026-04-14 20:15:21 +05:30
16 changed files with 3352 additions and 18 deletions

View File

@@ -1,3 +1,7 @@
import type {
BrowserOSCustomRoleInput,
BrowserOSRoleBoundary,
} from '@browseros/shared/types/role-aware-agents'
import {
AlertCircle,
ChevronDown,
@@ -33,19 +37,57 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import { AgentTerminal } from './AgentTerminal'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import { AgentProgramsPage } from './programs/AgentProgramsPage'
import {
type AgentEntry,
type OpenClawStatus,
type RoleTemplateSummary,
useOpenClawAgents,
useOpenClawMutations,
useOpenClawRoles,
useOpenClawStatus,
usePodmanOverrides,
} from './useOpenClaw'
const CUSTOM_ROLE_VALUE = '__custom__'
const PLAIN_AGENT_VALUE = '__plain__'
type AgentCreationMode = 'builtin' | 'custom' | 'plain'
function createDefaultCustomRoleBoundaries(): BrowserOSRoleBoundary[] {
return [
{
key: 'draft-external-comms',
label: 'Draft external communications',
description: 'May prepare outbound messages for review.',
defaultMode: 'allow',
},
{
key: 'send-external-comms',
label: 'Send external communications',
description: 'Should require approval before sending messages.',
defaultMode: 'ask',
},
{
key: 'calendar-mutations',
label: 'Modify calendar events',
description: 'Should ask before moving or creating calendar events.',
defaultMode: 'ask',
},
]
}
function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
const CONTROL_PLANE_COPY: Record<
OpenClawStatus['controlPlaneStatus'],
{
@@ -375,6 +417,7 @@ export const AgentsPage: FC = () => {
loading: agentsLoading,
error: agentsError,
} = useOpenClawAgents(agentsQueryEnabled)
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
const {
setupOpenClaw,
createAgent,
@@ -392,15 +435,43 @@ export const AgentsPage: FC = () => {
const [setupOpen, setSetupOpen] = useState(false)
const [setupProviderId, setSetupProviderId] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedRoleValue, setSelectedRoleValue] = useState<
| RoleTemplateSummary['id']
| typeof CUSTOM_ROLE_VALUE
| typeof PLAIN_AGENT_VALUE
>('chief-of-staff')
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [customRole, setCustomRole] = useState<BrowserOSCustomRoleInput>({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [programAgent, setProgramAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
const compatibleProviders = getOpenClawSupportedProviders(providers)
const creationMode: AgentCreationMode =
selectedRoleValue === CUSTOM_ROLE_VALUE
? 'custom'
: selectedRoleValue === PLAIN_AGENT_VALUE
? 'plain'
: 'builtin'
const isCustomRole = creationMode === 'custom'
const isPlainAgent = creationMode === 'plain'
const selectedRole =
creationMode === 'builtin'
? (roles.find((role) => role.id === selectedRoleValue) ??
roles[0] ??
null)
: null
useEffect(() => {
if (compatibleProviders.length === 0) return
@@ -419,13 +490,48 @@ export const AgentsPage: FC = () => {
defaultProviderId,
])
useEffect(() => {
if (!createOpen || roles.length === 0) return
const defaultRole = roles.find((role) => role.id === 'chief-of-staff')
const nextRole = defaultRole ?? roles[0]
setSelectedRoleValue((current) => {
if (current === CUSTOM_ROLE_VALUE || current === PLAIN_AGENT_VALUE)
return current
const hasCurrent = roles.some((role) => role.id === current)
return hasCurrent ? current : nextRole.id
})
setNewName((current) => current || nextRole.defaultAgentName)
}, [createOpen, roles])
useEffect(() => {
if (!createOpen) return
setNewName((current) => current || 'agent')
}, [createOpen])
if (isCustomRole) {
setNewName(
(current) =>
current || customRole.name.trim().toLowerCase().replace(/\s+/g, '-'),
)
return
}
if (isPlainAgent) {
setNewName((current) => current || 'agent')
return
}
if (selectedRole) {
setNewName((current) => current || selectedRole.defaultAgentName)
}
}, [createOpen, isCustomRole, isPlainAgent, customRole.name, selectedRole])
const inlineError =
error ?? statusError?.message ?? agentsError?.message ?? null
error ??
statusError?.message ??
agentsError?.message ??
rolesError?.message ??
null
const gatewayUiState = useMemo(() => {
if (!status) {
@@ -491,10 +597,34 @@ export const AgentsPage: FC = () => {
(item) => item.id === createProviderId,
)
const normalizedName = newName.trim().toLowerCase().replace(/\s+/g, '-')
const customRolePayload = isCustomRole
? {
...customRole,
name: customRole.name.trim(),
shortDescription: customRole.shortDescription.trim(),
longDescription: customRole.longDescription.trim(),
}
: undefined
if (
isCustomRole &&
(!customRolePayload?.name ||
!customRolePayload.shortDescription ||
!customRolePayload.longDescription)
) {
setError(
'Custom roles require a role name, short description, and long description.',
)
return
}
if (creationMode === 'builtin' && !selectedRole) return
await runWithErrorHandling(async () => {
await createAgent({
name: normalizedName,
roleId: creationMode === 'builtin' ? selectedRole?.id : undefined,
customRole: isCustomRole ? customRolePayload : undefined,
providerType: provider?.type,
providerName: provider?.name,
baseUrl: provider?.baseUrl,
@@ -503,6 +633,13 @@ export const AgentsPage: FC = () => {
})
setCreateOpen(false)
setNewName('')
setCustomRole({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
})
}
@@ -550,6 +687,15 @@ export const AgentsPage: FC = () => {
)
}
if (programAgent) {
return (
<AgentProgramsPage
agent={programAgent}
onBack={() => setProgramAgent(null)}
/>
)
}
if (statusLoading && !status) {
return (
<div className="flex items-center justify-center py-20">
@@ -793,13 +939,32 @@ export const AgentsPage: FC = () => {
<CardTitle className="text-base">
{agent.name}
</CardTitle>
{agent.role && (
<Badge variant="secondary">
{agent.role.roleName}
</Badge>
)}
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
{agent.role && (
<p className="text-muted-foreground text-xs">
{agent.role.shortDescription}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setProgramAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
>
<Wrench className="mr-1 size-4" />
Programs
</Button>
<Button
variant="ghost"
size="sm"
@@ -865,6 +1030,246 @@ export const AgentsPage: FC = () => {
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-role">
Agent Role
</label>
<Select
value={selectedRoleValue}
onValueChange={(value) => {
if (value === CUSTOM_ROLE_VALUE) {
setSelectedRoleValue(CUSTOM_ROLE_VALUE)
setNewName(
customRole.name
.trim()
.toLowerCase()
.replace(/\s+/g, '-') || 'custom-agent',
)
return
}
if (value === PLAIN_AGENT_VALUE) {
setSelectedRoleValue(PLAIN_AGENT_VALUE)
setNewName('agent')
return
}
const role = roles.find((item) => item.id === value)
if (!role) return
setSelectedRoleValue(role.id)
setNewName(role.defaultAgentName)
}}
disabled={rolesLoading}
>
<SelectTrigger id="agent-role">
<SelectValue
placeholder={
rolesLoading ? 'Loading roles...' : 'Select a role'
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
<SelectItem value={PLAIN_AGENT_VALUE}>Plain Agent</SelectItem>
<SelectItem value={CUSTOM_ROLE_VALUE}>Custom Role</SelectItem>
</SelectContent>
</Select>
{selectedRole && !isCustomRole && (
<Card>
<CardContent className="space-y-3 py-4">
<div>
<div className="font-medium text-sm">
{selectedRole.name}
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.shortDescription}
</p>
</div>
<div>
<div className="font-medium text-xs">
Recommended Apps
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.recommendedApps.join(', ')}
</p>
</div>
<div>
<div className="font-medium text-xs">
Default Boundaries
</div>
<ul className="space-y-1 text-muted-foreground text-xs">
{selectedRole.boundaries.map((boundary) => (
<li key={boundary.key}>
{boundary.label}: {boundary.defaultMode}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
)}
{isPlainAgent && (
<Card>
<CardContent className="space-y-2 py-4">
<div className="font-medium text-sm">Plain Agent</div>
<p className="text-muted-foreground text-xs">
No role bootstrap or defaults. Intended for temporary
development and testing only.
</p>
</CardContent>
</Card>
)}
</div>
{isCustomRole && (
<Card>
<CardContent className="space-y-4 py-4">
<div className="space-y-2">
<label
htmlFor="custom-role-name"
className="font-medium text-sm"
>
Custom Role Name
</label>
<Input
id="custom-role-name"
value={customRole.name}
onChange={(event) => {
const name = event.target.value
setCustomRole((current) => ({ ...current, name }))
setNewName(
name.trim().toLowerCase().replace(/\s+/g, '-') ||
'custom-agent',
)
}}
placeholder="Board Prep Operator"
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-short-description"
className="font-medium text-sm"
>
Short Description
</label>
<Input
id="custom-role-short-description"
value={customRole.shortDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
shortDescription: event.target.value,
}))
}
placeholder="Prepares executive briefs and weekly follow-ups."
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-long-description"
className="font-medium text-sm"
>
Long Description
</label>
<Textarea
id="custom-role-long-description"
value={customRole.longDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
longDescription: event.target.value,
}))
}
placeholder="Describe the role, purpose, and what kinds of outcomes this agent should produce."
rows={4}
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-apps"
className="font-medium text-sm"
>
Recommended Apps
</label>
<Input
id="custom-role-apps"
value={customRole.recommendedApps.join(', ')}
onChange={(event) =>
setCustomRole((current) => ({
...current,
recommendedApps: parseCommaSeparatedList(
event.target.value,
),
}))
}
placeholder="gmail, slack, notion"
/>
<p className="text-muted-foreground text-xs">
Comma-separated. Used as role guidance only in this
milestone.
</p>
</div>
<div className="space-y-3">
<div>
<div className="font-medium text-sm">
Boundary Defaults
</div>
<p className="text-muted-foreground text-xs">
Set the starting behavior for common high-impact
actions.
</p>
</div>
{customRole.boundaries.map((boundary) => (
<div
key={boundary.key}
className="grid gap-2 rounded-lg border p-3"
>
<div>
<div className="font-medium text-sm">
{boundary.label}
</div>
<p className="text-muted-foreground text-xs">
{boundary.description}
</p>
</div>
<Select
value={boundary.defaultMode}
onValueChange={(value) =>
setCustomRole((current) => ({
...current,
boundaries: current.boundaries.map((item) =>
item.key === boundary.key
? {
...item,
defaultMode:
value as BrowserOSRoleBoundary['defaultMode'],
}
: item,
),
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="block">Block</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div>
<label
htmlFor="agent-name"
@@ -898,8 +1303,10 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
rolesLoading ||
!gatewayUiState.canManageAgents ||
compatibleProviders.length === 0
compatibleProviders.length === 0 ||
(creationMode === 'builtin' && !selectedRole)
}
className="w-full"
>

View File

@@ -0,0 +1,359 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import {
ArrowLeft,
CalendarClock,
Loader2,
Play,
Plus,
Trash2,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import type { AgentEntry } from '../useOpenClaw'
import {
useOpenClawMutations,
useOpenClawProgramRuns,
useOpenClawPrograms,
} from '../useOpenClaw'
import { ProgramFormDialog } from './ProgramFormDialog'
import { ProgramRunHistory } from './ProgramRunHistory'
import { ProgramRunResultDialog } from './ProgramRunResultDialog'
interface AgentProgramsPageProps {
agent: AgentEntry
onBack: () => void
}
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'Manual only'
case 'daily':
return `Daily at ${program.schedule.time}`
case 'hourly':
return `Every ${program.schedule.interval} hour(s)`
case 'minutes':
return `Every ${program.schedule.interval} minute(s)`
}
}
export function AgentProgramsPage({ agent, onBack }: AgentProgramsPageProps) {
const {
programs,
loading: programsLoading,
error: programsError,
} = useOpenClawPrograms(agent.agentId)
const {
runs,
loading: runsLoading,
error: runsError,
} = useOpenClawProgramRuns(agent.agentId)
const {
createProgram,
updateProgram,
deleteProgram,
runProgram,
creatingProgram,
updatingProgram,
deletingProgram,
runningProgram,
} = useOpenClawMutations()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingProgram, setEditingProgram] =
useState<BrowserOSAgentProgram | null>(null)
const [viewingRunId, setViewingRunId] = useState<string | null>(null)
const programNames = useMemo(
() =>
Object.fromEntries(programs.map((program) => [program.id, program.name])),
[programs],
)
const viewingRun: BrowserOSProgramRun | null = viewingRunId
? (runs.find((run) => run.id === viewingRunId) ?? null)
: null
const saving = creatingProgram || updatingProgram
const handleCreate = async (
input: CreateAgentProgramInput | UpdateAgentProgramInput,
) => {
try {
if (editingProgram) {
await updateProgram({
agentId: agent.agentId,
programId: editingProgram.id,
input: input as UpdateAgentProgramInput,
})
toast.success('Program updated')
} else {
await createProgram({
agentId: agent.agentId,
input: input as CreateAgentProgramInput,
})
toast.success('Program created')
}
setDialogOpen(false)
setEditingProgram(null)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to save program',
)
}
}
const handleToggle = async (
program: BrowserOSAgentProgram,
enabled: boolean,
) => {
try {
await updateProgram({
agentId: agent.agentId,
programId: program.id,
input: { enabled },
})
toast.success(enabled ? 'Program enabled' : 'Program disabled')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update program',
)
}
}
const handleDelete = async (program: BrowserOSAgentProgram) => {
try {
await deleteProgram({
agentId: agent.agentId,
programId: program.id,
})
toast.success(`Deleted "${program.name}"`)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to delete program',
)
}
}
const handleRunNow = async (program: BrowserOSAgentProgram) => {
try {
const result = await runProgram({
agentId: agent.agentId,
programId: program.id,
})
if (result.run.status === 'failed') {
toast.error(
result.run.error ?? `Program run failed for "${program.name}"`,
)
return
}
toast.success(`Completed "${program.name}"`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Program run failed')
}
}
const inlineError = programsError?.message ?? runsError?.message ?? null
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">{agent.name} Programs</h1>
<p className="text-muted-foreground text-sm">
Define and manually test reusable responsibilities for this agent.
</p>
</div>
</div>
{inlineError && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive text-sm">
{inlineError}
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">Programs</CardTitle>
<p className="text-muted-foreground text-sm">
Define reusable responsibilities for this agent. Programs can run
on schedule or be triggered manually for validation.
</p>
</div>
<Button
onClick={() => {
setEditingProgram(null)
setDialogOpen(true)
}}
>
<Plus className="mr-2 size-4" />
New Program
</Button>
</CardHeader>
<CardContent>
{programsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : programs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No programs yet. Create your first program to define a recurring
responsibility for this agent.
</div>
) : (
<div className="space-y-4">
{programs.map((program) => (
<div key={program.id} className="rounded-lg border p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{program.name}</div>
<Badge
variant={program.enabled ? 'default' : 'outline'}
>
{program.enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Badge variant="secondary">
{describeSchedule(program)}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{program.description}
</p>
<p className="line-clamp-4 text-sm">{program.prompt}</p>
<div className="text-muted-foreground text-xs">
Last run:{' '}
{program.lastRunAt
? new Date(program.lastRunAt).toLocaleString()
: 'Never'}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border px-3 py-2">
<span className="text-sm">Enabled</span>
<Switch
checked={program.enabled}
onCheckedChange={(checked) =>
void handleToggle(program, checked)
}
disabled={updatingProgram}
/>
</div>
<Button
variant="outline"
onClick={() => void handleRunNow(program)}
disabled={runningProgram}
>
<Play className="mr-2 size-4" />
Run Now
</Button>
<Button
variant="outline"
onClick={() => {
setEditingProgram(program)
setDialogOpen(true)
}}
>
<CalendarClock className="mr-2 size-4" />
Edit
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleDelete(program)}
disabled={deletingProgram}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
{program.standingOrders.length > 0 && (
<>
<Separator className="my-4" />
<div className="space-y-2">
<div className="font-medium text-sm">
Standing Orders
</div>
<div className="space-y-2">
{program.standingOrders.map((order) => (
<div
key={order.id}
className="rounded-md bg-muted/40 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium">
{order.title}
</span>
<Badge
variant={
order.enabled ? 'secondary' : 'outline'
}
>
{order.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-xs">
{order.instruction}
</p>
</div>
))}
</div>
</div>
</>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
<ProgramRunHistory
runs={runs}
loading={runsLoading}
programNames={programNames}
onViewRun={(run) => setViewingRunId(run.id)}
/>
<ProgramFormDialog
open={dialogOpen}
program={editingProgram}
saving={saving}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) {
setEditingProgram(null)
}
}}
onSave={handleCreate}
/>
<ProgramRunResultDialog
run={viewingRun}
programName={
viewingRun
? (programNames[viewingRun.programId] ?? 'Unknown Program')
: undefined
}
onOpenChange={(open) => !open && setViewingRunId(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,448 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
type ProgramScheduleType = BrowserOSProgramSchedule['type']
interface ProgramDraft {
name: string
description: string
prompt: string
enabled: boolean
scheduleType: ProgramScheduleType
scheduleTime: string
scheduleInterval: number
standingOrders: BrowserOSStandingOrder[]
}
interface ProgramFormDialogProps {
open: boolean
program: BrowserOSAgentProgram | null
saving: boolean
onOpenChange: (open: boolean) => void
onSave: (
input: CreateAgentProgramInput | UpdateAgentProgramInput,
) => Promise<void>
}
function createEmptyStandingOrder(): BrowserOSStandingOrder {
return {
id: crypto.randomUUID(),
title: '',
instruction: '',
enabled: true,
}
}
function toDraft(program: BrowserOSAgentProgram | null): ProgramDraft {
if (!program) {
return {
name: '',
description: '',
prompt: '',
enabled: true,
scheduleType: 'manual',
scheduleTime: '09:00',
scheduleInterval: 1,
standingOrders: [],
}
}
return {
name: program.name,
description: program.description,
prompt: program.prompt,
enabled: program.enabled,
scheduleType: program.schedule.type,
scheduleTime:
program.schedule.type === 'daily' ? program.schedule.time : '09:00',
scheduleInterval:
program.schedule.type === 'hourly' || program.schedule.type === 'minutes'
? program.schedule.interval
: 1,
standingOrders: program.standingOrders,
}
}
function toSchedule(draft: ProgramDraft): BrowserOSProgramSchedule {
switch (draft.scheduleType) {
case 'daily':
return {
type: 'daily',
time: draft.scheduleTime,
}
case 'hourly':
return {
type: 'hourly',
interval: draft.scheduleInterval,
}
case 'minutes':
return {
type: 'minutes',
interval: draft.scheduleInterval,
}
case 'manual':
default:
return { type: 'manual' }
}
}
export function ProgramFormDialog({
open,
program,
saving,
onOpenChange,
onSave,
}: ProgramFormDialogProps) {
const [draft, setDraft] = useState<ProgramDraft>(() => toDraft(program))
useEffect(() => {
if (!open) return
setDraft(toDraft(program))
}, [open, program])
const isEditing = !!program
const canSave = useMemo(() => {
return (
draft.name.trim() !== '' &&
draft.description.trim() !== '' &&
draft.prompt.trim() !== ''
)
}, [draft])
const handleSave = async () => {
const payload = {
name: draft.name.trim(),
description: draft.description.trim(),
prompt: draft.prompt.trim(),
schedule: toSchedule(draft),
enabled: draft.enabled,
standingOrders: draft.standingOrders
.filter(
(order) =>
order.title.trim() !== '' || order.instruction.trim() !== '',
)
.map((order) => ({
...order,
title: order.title.trim(),
instruction: order.instruction.trim(),
})),
}
await onSave(payload)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Program' : 'Create Program'}
</DialogTitle>
<DialogDescription>
Define a reusable responsibility for this agent. Automatic schedule
execution lands in the next milestone, but you can save and run it
manually now.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-name">
Program Name
</label>
<Input
id="program-name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="Morning Brief"
/>
</div>
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="program-description"
>
Description
</label>
<Input
id="program-description"
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Prepare the executive morning brief."
/>
</div>
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-prompt">
Prompt
</label>
<Textarea
id="program-prompt"
rows={6}
value={draft.prompt}
onChange={(event) =>
setDraft((current) => ({
...current,
prompt: event.target.value,
}))
}
placeholder="Review email, Slack, calendar, Linear, and Notion for urgent updates..."
/>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-schedule">
Schedule
</label>
<Select
value={draft.scheduleType}
onValueChange={(value) =>
setDraft((current) => ({
...current,
scheduleType: value as ProgramScheduleType,
}))
}
>
<SelectTrigger id="program-schedule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual only</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="minutes">Every N minutes</SelectItem>
</SelectContent>
</Select>
</div>
{draft.scheduleType === 'daily' && (
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-time">
Time
</label>
<Input
id="program-time"
type="time"
value={draft.scheduleTime}
onChange={(event) =>
setDraft((current) => ({
...current,
scheduleTime: event.target.value,
}))
}
/>
</div>
)}
{(draft.scheduleType === 'hourly' ||
draft.scheduleType === 'minutes') && (
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="program-interval"
>
Interval
</label>
<Input
id="program-interval"
type="number"
min={1}
value={draft.scheduleInterval}
onChange={(event) =>
setDraft((current) => ({
...current,
scheduleInterval: Math.max(
1,
Number(event.target.value) || 1,
),
}))
}
/>
</div>
)}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<div className="font-medium text-sm">Enabled</div>
<p className="text-muted-foreground text-xs">
Save this program as active for future scheduling.
</p>
</div>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Standing Orders</div>
<p className="text-muted-foreground text-xs">
Persistent instructions that should always guide this program.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setDraft((current) => ({
...current,
standingOrders: [
...current.standingOrders,
createEmptyStandingOrder(),
],
}))
}
>
<Plus className="mr-2 size-4" />
Add Order
</Button>
</div>
{draft.standingOrders.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No standing orders yet.
</div>
) : (
<div className="space-y-3">
{draft.standingOrders.map((order) => (
<div
key={order.id}
className="space-y-3 rounded-lg border p-3"
>
<div className="flex items-center justify-between gap-2">
<Input
value={order.title}
onChange={(event) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map(
(item) =>
item.id === order.id
? { ...item, title: event.target.value }
: item,
),
}))
}
placeholder="Keep it concise"
/>
<Button
variant="ghost"
size="icon"
onClick={() =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.filter(
(item) => item.id !== order.id,
),
}))
}
>
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
rows={3}
value={order.instruction}
onChange={(event) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map((item) =>
item.id === order.id
? { ...item, instruction: event.target.value }
: item,
),
}))
}
placeholder="Keep the output concise and action-oriented."
/>
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2">
<span className="text-sm">Enabled</span>
<Switch
checked={order.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map(
(item) =>
item.id === order.id
? { ...item, enabled: checked }
: item,
),
}))
}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
>
{saving
? 'Saving...'
: isEditing
? 'Save Changes'
: 'Create Program'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,121 @@
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
import { AlertCircle, CheckCircle2, Clock3, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
interface ProgramRunHistoryProps {
runs: BrowserOSProgramRun[]
loading: boolean
programNames: Record<string, string>
onViewRun: (run: BrowserOSProgramRun) => void
}
function formatDateTime(value?: string): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
function RunStatusBadge({ status }: { status: BrowserOSProgramRun['status'] }) {
switch (status) {
case 'running':
return <Badge variant="secondary">Running</Badge>
case 'completed':
return <Badge variant="default">Completed</Badge>
case 'failed':
return <Badge variant="destructive">Failed</Badge>
case 'cancelled':
return <Badge variant="outline">Cancelled</Badge>
case 'pending':
default:
return <Badge variant="outline">Pending</Badge>
}
}
export function ProgramRunHistory({
runs,
loading,
programNames,
onViewRun,
}: ProgramRunHistoryProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Runs</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : runs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No runs yet. Run a program manually to validate it.
</div>
) : (
<ScrollArea className="h-[340px] pr-3">
<div className="space-y-3">
{runs.map((run) => (
<div key={run.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="font-medium text-sm">
{programNames[run.programId] ?? 'Unknown Program'}
</div>
<div className="text-muted-foreground text-xs">
Trigger: {run.trigger}
</div>
</div>
<RunStatusBadge status={run.status} />
</div>
<div className="mt-3 space-y-1 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<Clock3 className="size-3.5" />
Started: {formatDateTime(run.startedAt)}
</div>
<div className="flex items-center gap-2">
{run.status === 'failed' ? (
<AlertCircle className="size-3.5 text-destructive" />
) : (
<CheckCircle2 className="size-3.5 text-muted-foreground" />
)}
Completed: {formatDateTime(run.completedAt)}
</div>
</div>
{run.summary && <p className="mt-3 text-sm">{run.summary}</p>}
{!run.summary && run.finalResult && (
<p className="mt-3 line-clamp-4 text-sm">
{run.finalResult}
</p>
)}
{run.error && (
<p className="mt-3 text-destructive text-sm">{run.error}</p>
)}
{(run.finalResult || run.error) && (
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onViewRun(run)}
>
View Results
</Button>
</div>
)}
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,124 @@
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import {
AlertCircle,
Check,
CheckCircle2,
Copy,
Loader2,
XCircle,
} from 'lucide-react'
import { type FC, useState } from 'react'
import { MessageResponse } from '@/components/ai-elements/message'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
dayjs.extend(duration)
interface ProgramRunResultDialogProps {
run: BrowserOSProgramRun | null
programName?: string
onOpenChange: (open: boolean) => void
}
const formatDateTime = (dateStr: string) =>
dayjs(dateStr).format('MMM D, YYYY, h:mm A')
function formatDuration(startedAt: string, completedAt?: string): string {
if (!completedAt) return 'Still running'
const diff = dayjs(completedAt).diff(dayjs(startedAt))
const d = dayjs.duration(diff)
const mins = Math.floor(d.asMinutes())
const secs = d.seconds()
if (mins === 0) return `${secs} seconds`
return `${mins}m ${secs}s`
}
export const ProgramRunResultDialog: FC<ProgramRunResultDialogProps> = ({
run,
programName,
onOpenChange,
}) => {
const [copied, setCopied] = useState(false)
const content = run?.finalResult ?? run?.error ?? ''
const handleCopy = async () => {
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!run) return null
return (
<Dialog open={!!run} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{run.status === 'completed' ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : run.status === 'failed' ? (
<XCircle className="h-5 w-5 text-destructive" />
) : (
<Loader2 className="h-5 w-5 animate-spin text-accent-orange" />
)}
{programName || 'Program Run'}
</DialogTitle>
<div className="text-muted-foreground text-sm">
{formatDateTime(run.startedAt)} {' '}
{formatDuration(run.startedAt, run.completedAt)}
</div>
</DialogHeader>
<ScrollArea className="max-h-[70vh]">
{run.status === 'failed' && run.error ? (
<div className="flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<span className="font-medium text-sm">Program failed</span>
</div>
<p className="text-destructive text-sm">{run.error}</p>
</div>
) : run.finalResult ? (
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4 [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
<MessageResponse>{run.finalResult}</MessageResponse>
</div>
) : (
<div className="rounded-lg border border-border bg-muted/50 p-4 text-muted-foreground text-sm">
No result available
</div>
)}
</ScrollArea>
<DialogFooter>
{content && (
<Button variant="outline" onClick={handleCopy}>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
)}
<Button onClick={() => onOpenChange(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,3 +1,13 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -7,6 +17,27 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
@@ -16,6 +47,10 @@ export interface OpenClawStatus {
port: number | null
agentCount: number
error: string | null
scheduler?: {
running: boolean
activeProgramCount: number
}
controlPlaneStatus:
| 'disconnected'
| 'connecting'
@@ -36,6 +71,8 @@ export interface OpenClawStatus {
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
@@ -51,6 +88,9 @@ export interface OpenClawSetupInput {
modelId?: string
}
export interface AgentProgramEntry extends BrowserOSAgentProgram {}
export interface AgentProgramRunEntry extends BrowserOSProgramRun {}
export function getModelDisplayName(model: unknown): string | undefined {
if (typeof model === 'string') return model.split('/').pop()
return undefined
@@ -59,6 +99,9 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
programs: 'openclaw-programs',
programRuns: 'openclaw-program-runs',
podmanOverrides: 'openclaw-podman-overrides',
} as const
@@ -95,6 +138,38 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function fetchOpenClawPrograms(
baseUrl: string,
agentId: string,
): Promise<AgentProgramEntry[]> {
const data = await clawFetch<{ programs: AgentProgramEntry[] }>(
baseUrl,
`/agents/${agentId}/programs`,
)
return data.programs ?? []
}
async function fetchOpenClawProgramRuns(
baseUrl: string,
agentId: string,
): Promise<AgentProgramRunEntry[]> {
const data = await clawFetch<{ runs: AgentProgramRunEntry[] }>(
baseUrl,
`/agents/${agentId}/program-runs`,
)
return data.runs ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
@@ -104,6 +179,27 @@ async function invalidateOpenClawQueries(
])
}
async function invalidateAgentProgramQueries(
queryClient: ReturnType<typeof useQueryClient>,
baseUrl: string,
agentId: string,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.programs, baseUrl, agentId],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.programRuns, baseUrl, agentId],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.agents, baseUrl],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl],
}),
])
}
export function useOpenClawStatus(pollMs = 5000) {
const {
baseUrl,
@@ -147,6 +243,71 @@ export function useOpenClawAgents(enabled = true) {
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawPrograms(agentId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentProgramEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.programs, baseUrl, agentId],
queryFn: () => fetchOpenClawPrograms(baseUrl as string, agentId as string),
enabled: !!baseUrl && !urlLoading && !!agentId && enabled,
})
return {
programs: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawProgramRuns(agentId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentProgramRunEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.programRuns, baseUrl, agentId],
queryFn: () =>
fetchOpenClawProgramRuns(baseUrl as string, agentId as string),
enabled: !!baseUrl && !urlLoading && !!agentId && enabled,
})
return {
runs: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
@@ -159,6 +320,8 @@ export function useOpenClawMutations() {
}
const onSuccess = () => invalidateOpenClawQueries(queryClient)
const invalidateProgramsFor = (agentId: string) =>
invalidateAgentProgramQueries(queryClient, ensureBaseUrl(), agentId)
const setupMutation = useMutation({
mutationFn: async (input: OpenClawSetupInput) =>
@@ -224,6 +387,88 @@ export function useOpenClawMutations() {
onSuccess,
})
const createProgramMutation = useMutation({
mutationFn: async ({
agentId,
input,
}: {
agentId: string
input: CreateAgentProgramInput
}) =>
clawFetch<{ program: AgentProgramEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const updateProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
input,
}: {
agentId: string
programId: string
input: UpdateAgentProgramInput
}) =>
clawFetch<{ program: AgentProgramEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const deleteProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
}: {
agentId: string
programId: string
}) =>
clawFetch<{ success: boolean }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}`,
{
method: 'DELETE',
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const runProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
}: {
agentId: string
programId: string
}) =>
clawFetch<{ run: AgentProgramRunEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}/run`,
{
method: 'POST',
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
return {
setupOpenClaw: setupMutation.mutateAsync,
createAgent: createMutation.mutateAsync,
@@ -232,6 +477,10 @@ export function useOpenClawMutations() {
stopOpenClaw: stopMutation.mutateAsync,
restartOpenClaw: restartMutation.mutateAsync,
reconnectOpenClaw: reconnectMutation.mutateAsync,
createProgram: createProgramMutation.mutateAsync,
updateProgram: updateProgramMutation.mutateAsync,
deleteProgram: deleteProgramMutation.mutateAsync,
runProgram: runProgramMutation.mutateAsync,
actionInProgress:
setupMutation.isPending ||
createMutation.isPending ||
@@ -239,11 +488,19 @@ export function useOpenClawMutations() {
startMutation.isPending ||
stopMutation.isPending ||
restartMutation.isPending ||
reconnectMutation.isPending,
reconnectMutation.isPending ||
createProgramMutation.isPending ||
updateProgramMutation.isPending ||
deleteProgramMutation.isPending ||
runProgramMutation.isPending,
settingUp: setupMutation.isPending,
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
creatingProgram: createProgramMutation.isPending,
updatingProgram: updateProgramMutation.isPending,
deletingProgram: deleteProgramMutation.isPending,
runningProgram: runProgramMutation.isPending,
}
}

View File

@@ -10,8 +10,14 @@
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
import path from 'node:path'
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { getOpenClawDir } from '../../lib/browseros-dir'
import { logger } from '../../lib/logger'
import { getMonitoringService } from '../../monitoring/service'
import type { MonitoringChatTurn } from '../../monitoring/types'
@@ -22,13 +28,32 @@ import {
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
import {
getOpenClawService,
type OpenClawAgentEntry,
} from '../services/openclaw/openclaw-service'
import { OpenClawProgramMaterializer } from '../services/openclaw/program-materializer'
import { OpenClawProgramStorage } from '../services/openclaw/program-storage'
import {
validateCreateProgramInput,
validateUpdateProgramInput,
} from '../services/openclaw/program-validation'
function getCreateAgentValidationError(body: { name?: string }): string | null {
if (!body.name?.trim()) {
return 'Name is required'
}
return null
function isValidBoundaryMode(
value: unknown,
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
return value === 'allow' || value === 'ask' || value === 'block'
}
function isValidCustomRoleBoundary(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const boundary = value as Record<string, unknown>
return (
typeof boundary.key === 'string' &&
typeof boundary.label === 'string' &&
typeof boundary.description === 'string' &&
isValidBoundaryMode(boundary.defaultMode)
)
}
function getPodmanOverrideValidationError(body: {
@@ -52,6 +77,19 @@ function getPodmanOverrideValidationError(body: {
return null
}
const openclawProgramStorage = new OpenClawProgramStorage(getOpenClawDir())
const openclawProgramMaterializer = new OpenClawProgramMaterializer(
getOpenClawDir(),
openclawProgramStorage,
)
async function findOpenClawAgent(
agentId: string,
): Promise<OpenClawAgentEntry | null> {
const agents = await getOpenClawService().listAgents()
return agents.find((agent) => agent.agentId === agentId) ?? null
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
@@ -168,23 +206,240 @@ export function createOpenClawRoutes() {
}
})
.get('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const programs = await openclawProgramStorage.listPrograms(agent.name)
return c.json({ programs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateCreateProgramInput(await c.req.json())
const program = await openclawProgramStorage.createProgram(agent, input)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ program }, 201)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.patch('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateUpdateProgramInput(await c.req.json())
const program = await openclawProgramStorage.updateProgram(
agent.name,
c.req.param('programId'),
input,
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ program })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid') ||
message.includes('At least one')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.delete('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const deleted = await openclawProgramStorage.deleteProgram(
agent.name,
c.req.param('programId'),
)
if (!deleted) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ success: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/program-runs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const runs = await openclawProgramStorage.listRuns(agent.name)
return c.json({ runs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs/:programId/run', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const program = await openclawProgramStorage.getProgram(
agent.name,
c.req.param('programId'),
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
const run = await getOpenClawService().runProgramOnce(
agent.agentId,
program,
)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ run })
} catch (err) {
if (err instanceof OpenClawAgentNotFoundError) {
return c.json({ error: err.message }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
id: role.id,
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps: role.recommendedApps,
boundaries: role.boundaries,
defaultAgentName: role.defaultAgentName,
})),
})
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
const validationError = getCreateAgentValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (body.roleId && body.customRole) {
return c.json(
{ error: 'Provide either roleId or customRole, not both' },
400,
)
}
if (
body.customRole &&
(!body.customRole.name?.trim() ||
!body.customRole.shortDescription?.trim() ||
!body.customRole.longDescription?.trim())
) {
return c.json(
{
error:
'Custom roles require name, shortDescription, and longDescription',
},
400,
)
}
if (
body.customRole &&
(!Array.isArray(body.customRole.recommendedApps) ||
!Array.isArray(body.customRole.boundaries))
) {
return c.json(
{
error: 'Custom roles require recommendedApps and boundaries arrays',
},
400,
)
}
if (
body.customRole &&
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
) {
return c.json(
{
error: 'Custom role recommendedApps must be an array of strings',
},
400,
)
}
if (
body.customRole &&
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
) {
return c.json(
{
error:
'Custom role boundaries must include key, label, description, and a valid defaultMode',
},
400,
)
}
try {
const agent = await getOpenClawService().createAgent({
name: body.name.trim(),
name,
roleId: body.roleId,
customRole: body.customRole,
providerType: body.providerType,
providerName: body.providerName,
baseUrl: body.baseUrl,

View File

@@ -15,6 +15,15 @@ import {
OPENCLAW_GATEWAY_PORT,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import type {
BrowserOSAgentRoleId,
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
} from '@browseros/shared/types/role-programs'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import type { MonitoringChatTurn } from '../../../monitoring/types'
@@ -48,6 +57,17 @@ import {
import type { OpenClawStreamEvent } from './openclaw-types'
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
import { OpenClawProgramMaterializer } from './program-materializer'
import {
OpenClawProgramScheduler,
type OpenClawSchedulerSnapshot,
} from './program-scheduler'
import { OpenClawProgramStorage } from './program-storage'
import {
buildRoleBootstrapFiles,
resolveRoleTemplate,
toRoleSummary,
} from './role-bootstrap'
const READY_TIMEOUT_MS = 30_000
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
@@ -87,9 +107,12 @@ export interface OpenClawStatusResponse {
controlPlaneStatus: OpenClawControlPlaneStatus
lastGatewayError: string | null
lastRecoveryReason: OpenClawGatewayRecoveryReason | null
scheduler: OpenClawSchedulerSnapshot
}
export type OpenClawAgentEntry = OpenClawAgentRecord
export interface OpenClawAgentEntry extends OpenClawAgentRecord {
role?: BrowserOSAgentRoleSummary
}
export interface SetupInput {
providerType?: string
@@ -130,6 +153,9 @@ export class OpenClawService {
private lastGatewayError: string | null = null
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
private stopLogTail: (() => void) | null = null
private programStorage: OpenClawProgramStorage
private programMaterializer: OpenClawProgramMaterializer
private scheduler: OpenClawProgramScheduler
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
@@ -141,6 +167,17 @@ export class OpenClawService {
this.port,
async () => this.token,
)
this.programStorage = new OpenClawProgramStorage(this.openclawDir)
this.programMaterializer = new OpenClawProgramMaterializer(
this.openclawDir,
this.programStorage,
)
this.scheduler = new OpenClawProgramScheduler(
this.programStorage,
this.programMaterializer,
(agentId, program, trigger) => this.runProgram(agentId, program, trigger),
async () => this.listAgents(),
)
this.browserosServerPort =
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
@@ -252,6 +289,7 @@ export class OpenClawService {
)
}
await this.startScheduler()
this.lastError = null
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.port}`)
logger.info('OpenClaw setup complete', { port: this.port })
@@ -284,12 +322,14 @@ export class OpenClawService {
this.controlPlaneStatus = 'connecting'
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
await this.startScheduler()
this.lastError = null
logger.info('OpenClaw gateway started', { port: this.port })
}
async stop(): Promise<void> {
logger.info('Stopping OpenClaw service', { port: this.port })
await this.scheduler.stop()
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
await this.runtime.stopGateway()
@@ -309,6 +349,7 @@ export class OpenClawService {
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
logProgress('Restarting OpenClaw gateway...')
await this.scheduler.stop()
await this.runtime.restartGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
@@ -324,6 +365,7 @@ export class OpenClawService {
logProgress('Probing OpenClaw control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
await this.startScheduler()
this.lastError = null
logProgress('Gateway restarted successfully')
logger.info('OpenClaw gateway restarted', { port: this.port })
@@ -352,6 +394,7 @@ export class OpenClawService {
}
async shutdown(): Promise<void> {
await this.scheduler.stop()
this.controlPlaneStatus = 'disconnected'
this.stopGatewayLogTail()
try {
@@ -378,6 +421,7 @@ export class OpenClawService {
controlPlaneStatus: 'disconnected',
lastGatewayError: null,
lastRecoveryReason: null,
scheduler: this.scheduler.getSnapshot(),
}
}
@@ -394,6 +438,7 @@ export class OpenClawService {
controlPlaneStatus: 'disconnected',
lastGatewayError: this.lastGatewayError,
lastRecoveryReason: this.lastRecoveryReason,
scheduler: this.scheduler.getSnapshot(),
}
}
@@ -424,6 +469,7 @@ export class OpenClawService {
controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected',
lastGatewayError: this.lastGatewayError,
lastRecoveryReason: this.lastRecoveryReason,
scheduler: this.scheduler.getSnapshot(),
}
}
@@ -431,6 +477,8 @@ export class OpenClawService {
async createAgent(input: {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
@@ -444,6 +492,8 @@ export class OpenClawService {
logger.debug('Creating OpenClaw agent', {
name,
roleId: input.roleId,
roleSource: input.customRole ? 'custom' : input.roleId ? 'builtin' : null,
providerType: input.providerType,
providerName: input.providerName,
hasBaseUrl: !!input.baseUrl,
@@ -482,11 +532,28 @@ export class OpenClawService {
throw error
}
const roleTemplate = input.roleId
? resolveRoleTemplate(input.roleId)
: input.customRole
if (roleTemplate) {
await this.writeRoleBootstrapFiles(name, roleTemplate)
}
logger.info('Agent created via CLI', {
agentId: agent.agentId,
roleId: input.roleId,
roleSource: roleTemplate
? 'id' in roleTemplate
? 'builtin'
: 'custom'
: null,
providerType: input.providerType,
})
return agent
return {
...agent,
role: roleTemplate ? toRoleSummary(roleTemplate) : undefined,
}
}
async removeAgent(agentId: string): Promise<void> {
@@ -511,7 +578,15 @@ export class OpenClawService {
async listAgents(): Promise<OpenClawAgentEntry[]> {
await this.assertGatewayReady()
logger.debug('Listing OpenClaw agents')
return this.runControlPlaneCall(() => this.cliClient.listAgents())
const agents = await this.runControlPlaneCall(() =>
this.cliClient.listAgents(),
)
return Promise.all(
agents.map(async (agent) => ({
...agent,
role: await this.readRoleSummary(agent.name),
})),
)
}
// ── Chat Stream (HTTP) ───────────────────────────────────────────────
@@ -539,6 +614,109 @@ export class OpenClawService {
)
}
async runProgram(
agentId: string,
program: BrowserOSAgentProgram,
trigger: 'manual' | 'schedule' | 'retry',
): Promise<BrowserOSProgramRun> {
const agent = await this.findAgentById(agentId)
if (!agent) {
throw new OpenClawAgentNotFoundError(agentId)
}
const runId = crypto.randomUUID()
const sessionKey = crypto.randomUUID()
const startedAt = new Date().toISOString()
await this.programStorage.appendRun(agent.name, {
id: runId,
programId: program.id,
agentId,
startedAt,
status: 'running',
trigger,
sessionKey,
})
try {
const stream = await this.chatStream(
agentId,
sessionKey,
program.prompt,
[],
)
const result = await this.collectProgramRunResult(stream)
const completedAt = new Date().toISOString()
const summary =
result.finalResult
.split('\n')
.find((line) => line.trim())
?.trim() ?? `Completed ${program.name}`
const updatedRun = await this.programStorage.updateRun(
agent.name,
runId,
{
completedAt,
status: 'completed',
finalResult: result.finalResult,
summary: summary.slice(0, 280),
},
)
await this.programStorage.updateProgram(
agent.name,
program.id,
{ lastRunAt: completedAt },
{ touchUpdatedAt: false },
)
if (!updatedRun) {
throw new Error('Program run record disappeared during completion')
}
await this.refreshScheduledProgramsForAgent(agent.name)
return updatedRun
} catch (error) {
const completedAt = new Date().toISOString()
const message = error instanceof Error ? error.message : String(error)
const updatedRun = await this.programStorage.updateRun(
agent.name,
runId,
{
completedAt,
status: 'failed',
error: message,
},
)
if (updatedRun) {
await this.refreshScheduledProgramsForAgent(agent.name)
return updatedRun
}
throw error
}
}
async runProgramOnce(
agentId: string,
program: BrowserOSAgentProgram,
): Promise<BrowserOSProgramRun> {
return this.runProgram(agentId, program, 'manual')
}
async refreshScheduledProgramsForAgent(agentName: string): Promise<void> {
try {
await this.scheduler.refreshAgent(agentName)
} catch (error) {
logger.warn('Failed to refresh scheduled programs for agent', {
agentName,
error: error instanceof Error ? error.message : String(error),
})
}
}
// ── Podman Overrides ─────────────────────────────────────────────────
async applyPodmanOverrides(input: {
@@ -647,6 +825,7 @@ export class OpenClawService {
}
await this.runControlPlaneCall(() => this.cliClient.probe())
await this.startScheduler()
logger.info('OpenClaw gateway auto-started')
} catch (err) {
logger.warn('OpenClaw auto-start failed', {
@@ -1080,6 +1259,95 @@ export class OpenClawService {
onLog?.(msg)
}
}
private async findAgentById(
agentId: string,
): Promise<OpenClawAgentEntry | null> {
const agents = await this.listAgents()
return agents.find((agent) => agent.agentId === agentId) ?? null
}
private async collectProgramRunResult(
stream: ReadableStream<OpenClawStreamEvent>,
): Promise<{ finalResult: string }> {
const reader = stream.getReader()
let text = ''
let finalText: string | null = null
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
switch (value.type) {
case 'text-delta':
text += (value.data.text as string) ?? ''
break
case 'done':
finalText = ((value.data.text as string) ?? '').trim() || null
break
case 'error': {
const message =
(value.data.message as string) ??
(value.data.error as string) ??
'Program run failed'
throw new Error(message)
}
}
}
} finally {
await reader.cancel()
}
return { finalResult: finalText ?? text.trim() }
}
private async startScheduler(): Promise<void> {
try {
await this.scheduler.start()
} catch (error) {
logger.warn('Failed to start OpenClaw program scheduler', {
error: error instanceof Error ? error.message : String(error),
})
}
}
private async writeRoleBootstrapFiles(
agentName: string,
roleTemplate:
| BrowserOSCustomRoleInput
| ReturnType<typeof resolveRoleTemplate>,
): Promise<void> {
const workspaceDir = this.getHostWorkspaceDir(agentName)
await mkdir(workspaceDir, { recursive: true })
const files = buildRoleBootstrapFiles({
role: roleTemplate,
agentName,
})
await Promise.all(
Object.entries(files).map(([fileName, content]) =>
writeFile(`${workspaceDir}/${fileName}`, content as string, 'utf-8'),
),
)
}
private async readRoleSummary(
agentName: string,
): Promise<BrowserOSAgentRoleSummary | undefined> {
const metadataPath = `${this.getHostWorkspaceDir(agentName)}/.browseros-role.json`
try {
const content = await readFile(metadataPath, 'utf-8')
const parsed = JSON.parse(content) as BrowserOSAgentRoleSummary
if (!parsed || typeof parsed !== 'object') return undefined
if (typeof parsed.roleName !== 'string') return undefined
if (typeof parsed.shortDescription !== 'string') return undefined
return parsed
} catch {
return undefined
}
}
}
let service: OpenClawService | null = null

View File

@@ -0,0 +1,119 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { BrowserOSAgentProgram } from '@browseros/shared/types/role-programs'
import type { OpenClawProgramStorage } from './program-storage'
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'manual only'
case 'daily': {
const weekdaySummary = program.schedule.daysOfWeek?.length
? ` on ${program.schedule.daysOfWeek.join(', ')}`
: ''
return `daily at ${program.schedule.time}${weekdaySummary}`
}
case 'hourly':
return `every ${program.schedule.interval} hour(s)`
case 'minutes':
return `every ${program.schedule.interval} minute(s)`
}
}
function buildProgramsMd(programs: BrowserOSAgentProgram[]): string {
const sections =
programs.length === 0
? ['No BrowserOS-managed programs configured yet.']
: programs.map(
(program) => `## ${program.name}
- Status: ${program.enabled ? 'enabled' : 'disabled'}
- Schedule: ${describeSchedule(program)}
- Next run: ${program.nextRunAt ?? 'not scheduled'}
- Goal: ${program.description}
- Prompt: ${program.prompt}
`,
)
return `# BrowserOS Programs
This file is generated by BrowserOS. Edit program settings in BrowserOS, not here.
${sections.join('\n')}
`
}
function buildStandingOrdersMd(programs: BrowserOSAgentProgram[]): string {
const sections = programs.flatMap((program) => {
if (program.standingOrders.length === 0) return []
const lines = program.standingOrders
.map(
(order) =>
`- ${order.title} (${order.enabled ? 'enabled' : 'disabled'}): ${order.instruction}`,
)
.join('\n')
return [`## ${program.name}\n${lines}`]
})
return `# Standing Orders
This file is generated by BrowserOS. Edit standing orders in BrowserOS, not here.
${sections.length > 0 ? sections.join('\n\n') : 'No standing orders configured yet.'}
`
}
function buildProgramsMetadata(
agentName: string,
programs: BrowserOSAgentProgram[],
): string {
return `${JSON.stringify(
{
version: 1,
agentName,
programs: programs.map((program) => ({
id: program.id,
name: program.name,
enabled: program.enabled,
schedule: program.schedule,
updatedAt: program.updatedAt,
lastRunAt: program.lastRunAt,
nextRunAt: program.nextRunAt,
})),
},
null,
2,
)}\n`
}
export class OpenClawProgramMaterializer {
constructor(
private openclawDir: string,
private storage: OpenClawProgramStorage,
) {}
private getHostWorkspaceDir(agentName: string): string {
return join(
this.openclawDir,
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
}
async syncAgentPrograms(agentName: string): Promise<void> {
const programs = await this.storage.listPrograms(agentName)
const workspaceDir = this.getHostWorkspaceDir(agentName)
await mkdir(workspaceDir, { recursive: true })
await Promise.all([
writeFile(join(workspaceDir, 'PROGRAMS.md'), buildProgramsMd(programs)),
writeFile(
join(workspaceDir, 'STANDING-ORDERS.md'),
buildStandingOrdersMd(programs),
),
writeFile(
join(workspaceDir, '.browseros-programs.json'),
buildProgramsMetadata(agentName, programs),
),
])
}
}

View File

@@ -0,0 +1,118 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramSchedule,
} from '@browseros/shared/types/role-programs'
const MINUTE_MS = 60_000
const HOUR_MS = 60 * MINUTE_MS
function toValidDate(value?: string): Date | null {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function getIntervalMs(
schedule: Extract<BrowserOSProgramSchedule, { type: 'minutes' | 'hourly' }>,
): number {
return schedule.type === 'minutes'
? schedule.interval * MINUTE_MS
: schedule.interval * HOUR_MS
}
function getAnchorDate(program: BrowserOSAgentProgram, now: Date): Date {
return (
toValidDate(program.lastRunAt) ??
toValidDate(program.updatedAt) ??
toValidDate(program.createdAt) ??
now
)
}
function getNextIntervalRunAt(
program: BrowserOSAgentProgram,
now: Date,
): Date | null {
const schedule = program.schedule
if (schedule.type !== 'minutes' && schedule.type !== 'hourly') return null
const intervalMs = getIntervalMs(schedule)
if (intervalMs <= 0) return null
const anchor = getAnchorDate(program, now)
let nextRunAt = new Date(anchor.getTime() + intervalMs)
while (nextRunAt.getTime() <= now.getTime()) {
nextRunAt = new Date(nextRunAt.getTime() + intervalMs)
}
return nextRunAt
}
function getNextDailyRunAt(
program: BrowserOSAgentProgram,
now: Date,
): Date | null {
if (program.schedule.type !== 'daily') return null
const [hoursString, minutesString] = program.schedule.time.split(':')
const hours = Number.parseInt(hoursString ?? '', 10)
const minutes = Number.parseInt(minutesString ?? '', 10)
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
hours < 0 ||
hours > 23 ||
minutes < 0 ||
minutes > 59
) {
return null
}
const allowedDays = program.schedule.daysOfWeek?.length
? new Set(program.schedule.daysOfWeek)
: null
for (let offset = 0; offset < 8; offset += 1) {
const candidate = new Date(now)
candidate.setDate(now.getDate() + offset)
candidate.setHours(hours, minutes, 0, 0)
if (
allowedDays &&
!allowedDays.has(candidate.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6)
) {
continue
}
if (candidate.getTime() > now.getTime()) {
return candidate
}
}
return null
}
export function isSchedulableProgram(program: BrowserOSAgentProgram): boolean {
return program.schedule.type !== 'manual'
}
export function getNextProgramRunAt(
program: BrowserOSAgentProgram,
now = new Date(),
): Date | null {
if (!program.enabled || !isSchedulableProgram(program)) {
return null
}
switch (program.schedule.type) {
case 'minutes':
case 'hourly':
return getNextIntervalRunAt(program, now)
case 'daily':
return getNextDailyRunAt(program, now)
case 'manual':
return null
}
}

View File

@@ -0,0 +1,212 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
} from '@browseros/shared/types/role-programs'
import { logger } from '../../../lib/logger'
import type { OpenClawProgramMaterializer } from './program-materializer'
import { getNextProgramRunAt, isSchedulableProgram } from './program-schedule'
import type { OpenClawProgramStorage } from './program-storage'
interface ScheduledProgramHandle {
programId: string
agentId: string
agentName: string
timeout: ReturnType<typeof setTimeout>
nextRunAt: string
}
export interface OpenClawSchedulerSnapshot {
running: boolean
activeProgramCount: number
}
export class OpenClawProgramScheduler {
private handles = new Map<string, ScheduledProgramHandle>()
private running = false
constructor(
private programStorage: OpenClawProgramStorage,
private programMaterializer: OpenClawProgramMaterializer,
private runProgram: (
agentId: string,
program: BrowserOSAgentProgram,
trigger: 'manual' | 'schedule' | 'retry',
) => Promise<BrowserOSProgramRun>,
private listAgents: () => Promise<Array<{ agentId: string; name: string }>>,
) {}
getSnapshot(): OpenClawSchedulerSnapshot {
return {
running: this.running,
activeProgramCount: this.handles.size,
}
}
async start(): Promise<void> {
this.running = true
await this.rehydrate()
}
async stop(): Promise<void> {
this.running = false
this.clearAllHandles()
}
async rehydrate(): Promise<void> {
this.clearAllHandles()
if (!this.running) return
const agents = await this.listAgents()
for (const agent of agents) {
await this.refreshAgent(agent.name)
}
}
async refreshAgent(agentName: string): Promise<void> {
this.clearAgentHandles(agentName)
const programs = await this.programStorage.listPrograms(agentName)
let materialized = false
for (const program of programs) {
const changed = await this.syncProgram(program)
materialized = materialized || changed
}
if (materialized) {
await this.programMaterializer.syncAgentPrograms(agentName)
}
}
async refreshProgram(agentName: string, programId: string): Promise<void> {
this.clearProgramHandle(programId)
const program = await this.programStorage.getProgram(agentName, programId)
const changed = program ? await this.syncProgram(program) : false
if (changed) {
await this.programMaterializer.syncAgentPrograms(agentName)
}
}
async removeProgram(programId: string): Promise<void> {
this.clearProgramHandle(programId)
}
private clearAllHandles(): void {
for (const handle of this.handles.values()) {
clearTimeout(handle.timeout)
}
this.handles.clear()
}
private clearAgentHandles(agentName: string): void {
for (const [programId, handle] of this.handles.entries()) {
if (handle.agentName !== agentName) continue
clearTimeout(handle.timeout)
this.handles.delete(programId)
}
}
private clearProgramHandle(programId: string): void {
const handle = this.handles.get(programId)
if (!handle) return
clearTimeout(handle.timeout)
this.handles.delete(programId)
}
private async syncProgram(program: BrowserOSAgentProgram): Promise<boolean> {
if (!this.running) {
return false
}
if (!program.enabled || !isSchedulableProgram(program)) {
if (!program.nextRunAt) return false
await this.programStorage.updateProgram(
program.agentName,
program.id,
{ nextRunAt: undefined },
{ touchUpdatedAt: false },
)
return true
}
const nextRunAt = getNextProgramRunAt(program)
const nextRunAtIso = nextRunAt?.toISOString()
let changed = false
if (program.nextRunAt !== nextRunAtIso) {
await this.programStorage.updateProgram(
program.agentName,
program.id,
{ nextRunAt: nextRunAtIso },
{ touchUpdatedAt: false },
)
changed = true
}
if (!nextRunAt) {
return changed
}
const delayMs = Math.max(1000, nextRunAt.getTime() - Date.now())
const timeout = setTimeout(() => {
void this.executeScheduledProgram({
agentId: program.agentId,
agentName: program.agentName,
programId: program.id,
})
}, delayMs)
this.handles.set(program.id, {
programId: program.id,
agentId: program.agentId,
agentName: program.agentName,
timeout,
nextRunAt: nextRunAtIso ?? program.nextRunAt ?? new Date().toISOString(),
})
return changed
}
private async executeScheduledProgram(input: {
agentId: string
agentName: string
programId: string
}): Promise<void> {
this.handles.delete(input.programId)
if (!this.running) return
const program = await this.programStorage.getProgram(
input.agentName,
input.programId,
)
if (!program || !program.enabled || !isSchedulableProgram(program)) {
await this.refreshProgram(input.agentName, input.programId)
return
}
try {
await this.runProgram(input.agentId, program, 'schedule')
logger.info('Scheduled program run completed', {
agentId: input.agentId,
agentName: input.agentName,
programId: input.programId,
})
} catch (error) {
logger.error('Scheduled program run failed', {
agentId: input.agentId,
agentName: input.agentName,
programId: input.programId,
error: error instanceof Error ? error.message : String(error),
})
} finally {
if (this.running) {
await this.refreshProgram(input.agentName, input.programId)
}
}
}
}

View File

@@ -0,0 +1,188 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
interface ProgramStorageAgent {
agentId: string
name: string
role?: {
roleId?: string
}
}
interface UpdateProgramOptions {
touchUpdatedAt?: boolean
}
const MAX_PROGRAM_RUNS_PER_AGENT = 100
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const content = await readFile(filePath, 'utf-8')
return JSON.parse(content) as T
} catch {
return fallback
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
function sortPrograms(programs: BrowserOSAgentProgram[]) {
return [...programs].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
)
}
function normalizeRuns(runs: BrowserOSProgramRun[]) {
return [...runs]
.sort((left, right) => right.startedAt.localeCompare(left.startedAt))
.slice(0, MAX_PROGRAM_RUNS_PER_AGENT)
}
export class OpenClawProgramStorage {
constructor(private openclawDir: string) {}
private getProgramsFile(agentName: string): string {
return join(this.openclawDir, 'programs', `${agentName}.json`)
}
private getProgramRunsFile(agentName: string): string {
return join(this.openclawDir, 'program-runs', `${agentName}.json`)
}
async listPrograms(agentName: string): Promise<BrowserOSAgentProgram[]> {
const programs = await readJsonFile<BrowserOSAgentProgram[]>(
this.getProgramsFile(agentName),
[],
)
return sortPrograms(programs)
}
async getProgram(
agentName: string,
programId: string,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
return programs.find((program) => program.id === programId) ?? null
}
async createProgram(
agent: ProgramStorageAgent,
input: CreateAgentProgramInput,
): Promise<BrowserOSAgentProgram> {
const programs = await this.listPrograms(agent.name)
const now = new Date().toISOString()
const program: BrowserOSAgentProgram = {
id: crypto.randomUUID(),
agentId: agent.agentId,
agentName: agent.name,
roleId: agent.role?.roleId,
name: input.name,
description: input.description,
prompt: input.prompt,
schedule: input.schedule,
enabled: input.enabled ?? true,
standingOrders: input.standingOrders ?? [],
createdAt: now,
updatedAt: now,
}
await writeJsonFile(this.getProgramsFile(agent.name), [
...programs,
program,
])
return program
}
async updateProgram(
agentName: string,
programId: string,
input: UpdateAgentProgramInput,
options?: UpdateProgramOptions,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
const current = programs.find((program) => program.id === programId)
if (!current) return null
const nextProgram: BrowserOSAgentProgram = {
...current,
...input,
updatedAt:
options?.touchUpdatedAt === false
? current.updatedAt
: new Date().toISOString(),
}
await writeJsonFile(
this.getProgramsFile(agentName),
programs.map((program) =>
program.id === programId ? nextProgram : program,
),
)
return nextProgram
}
async deleteProgram(agentName: string, programId: string): Promise<boolean> {
const programs = await this.listPrograms(agentName)
const remaining = programs.filter((program) => program.id !== programId)
if (remaining.length === programs.length) return false
await writeJsonFile(this.getProgramsFile(agentName), remaining)
return true
}
async listRuns(agentName: string): Promise<BrowserOSProgramRun[]> {
const runs = await readJsonFile<BrowserOSProgramRun[]>(
this.getProgramRunsFile(agentName),
[],
)
return normalizeRuns(runs)
}
async writeRuns(
agentName: string,
runs: BrowserOSProgramRun[],
): Promise<void> {
await writeJsonFile(this.getProgramRunsFile(agentName), normalizeRuns(runs))
}
async appendRun(
agentName: string,
run: BrowserOSProgramRun,
): Promise<BrowserOSProgramRun> {
const runs = await this.listRuns(agentName)
await this.writeRuns(agentName, [run, ...runs])
return run
}
async updateRun(
agentName: string,
runId: string,
input: Partial<BrowserOSProgramRun>,
): Promise<BrowserOSProgramRun | null> {
const runs = await this.listRuns(agentName)
const current = runs.find((run) => run.id === runId)
if (!current) return null
const nextRun: BrowserOSProgramRun = {
...current,
...input,
}
await this.writeRuns(
agentName,
runs.map((run) => (run.id === runId ? nextRun : run)),
)
return nextRun
}
}

View File

@@ -0,0 +1,179 @@
import type {
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function assertNonEmptyString(
value: unknown,
field: string,
): asserts value is string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} is required`)
}
}
function validateStandingOrder(value: unknown): BrowserOSStandingOrder {
if (!isRecord(value)) {
throw new Error('Standing orders must be objects')
}
assertNonEmptyString(value.title, 'Standing order title')
assertNonEmptyString(value.instruction, 'Standing order instruction')
if (typeof value.enabled !== 'boolean') {
throw new Error('Standing order enabled must be a boolean')
}
return {
id:
typeof value.id === 'string' && value.id.trim() !== ''
? value.id
: crypto.randomUUID(),
title: value.title.trim(),
instruction: value.instruction.trim(),
enabled: value.enabled,
}
}
function validateStandingOrders(
value: unknown,
): BrowserOSStandingOrder[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value)) {
throw new Error('standingOrders must be an array')
}
return value.map(validateStandingOrder)
}
function isValidTime(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
}
function validateDaysOfWeek(value: unknown): Array<0 | 1 | 2 | 3 | 4 | 5 | 6> {
if (!Array.isArray(value)) {
throw new Error('schedule.daysOfWeek must be an array')
}
return value.map((day) => {
if (
typeof day !== 'number' ||
!Number.isInteger(day) ||
day < 0 ||
day > 6
) {
throw new Error('schedule.daysOfWeek must contain values from 0 to 6')
}
return day as 0 | 1 | 2 | 3 | 4 | 5 | 6
})
}
function validateSchedule(value: unknown): BrowserOSProgramSchedule {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new Error('schedule is required')
}
switch (value.type) {
case 'manual':
return { type: 'manual' }
case 'daily': {
assertNonEmptyString(value.time, 'schedule.time')
if (!isValidTime(value.time)) {
throw new Error('schedule.time must be in HH:MM format')
}
return {
type: 'daily',
time: value.time,
daysOfWeek:
value.daysOfWeek === undefined
? undefined
: validateDaysOfWeek(value.daysOfWeek),
}
}
case 'hourly':
case 'minutes': {
if (
typeof value.interval !== 'number' ||
!Number.isInteger(value.interval) ||
value.interval < 1
) {
throw new Error('schedule.interval must be an integer >= 1')
}
return {
type: value.type,
interval: value.interval,
}
}
default:
throw new Error('schedule.type is invalid')
}
}
export function validateCreateProgramInput(
value: unknown,
): CreateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
assertNonEmptyString(value.name, 'name')
assertNonEmptyString(value.description, 'description')
assertNonEmptyString(value.prompt, 'prompt')
return {
name: value.name.trim(),
description: value.description.trim(),
prompt: value.prompt.trim(),
schedule: validateSchedule(value.schedule),
enabled: value.enabled === undefined ? true : !!value.enabled,
standingOrders: validateStandingOrders(value.standingOrders) ?? [],
}
}
export function validateUpdateProgramInput(
value: unknown,
): UpdateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
const output: UpdateAgentProgramInput = {}
if (value.name !== undefined) {
assertNonEmptyString(value.name, 'name')
output.name = value.name.trim()
}
if (value.description !== undefined) {
assertNonEmptyString(value.description, 'description')
output.description = value.description.trim()
}
if (value.prompt !== undefined) {
assertNonEmptyString(value.prompt, 'prompt')
output.prompt = value.prompt.trim()
}
if (value.enabled !== undefined) {
if (typeof value.enabled !== 'boolean') {
throw new Error('enabled must be a boolean')
}
output.enabled = value.enabled
}
if (value.schedule !== undefined) {
output.schedule = validateSchedule(value.schedule)
}
if (value.standingOrders !== undefined) {
output.standingOrders = validateStandingOrders(value.standingOrders)
}
if (Object.keys(output).length === 0) {
throw new Error('At least one program field must be provided')
}
return output
}

View File

@@ -0,0 +1,200 @@
import {
type BROWSEROS_ROLE_TEMPLATES,
getBrowserOSRoleTemplate,
} from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
BrowserOSRoleTemplate,
} from '@browseros/shared/types/role-aware-agents'
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
interface BootstrapRenderableRole {
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
boundaries: BrowserOSRoleTemplate['boundaries']
bootstrap: BrowserOSRoleTemplate['bootstrap']
}
export interface RoleBootstrapFiles {
'AGENTS.md': string
'SOUL.md': string
'TOOLS.md': string
'.browseros-role.json': string
}
export function resolveRoleTemplate(
roleId: BrowserOSAgentRoleId,
): RoleTemplate {
const role = getBrowserOSRoleTemplate(roleId)
if (!role) {
throw new Error(`Unknown BrowserOS role: ${roleId}`)
}
return role
}
export function buildRoleBootstrapFiles(input: {
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
agentName: string
}): RoleBootstrapFiles {
const normalizedRole = normalizeRoleForBootstrap(input.role)
const roleId = 'id' in input.role ? input.role.id : undefined
return {
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
'SOUL.md': normalizedRole.bootstrap.soulMd,
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
'.browseros-role.json': `${JSON.stringify(
{
version: 1,
roleSource: roleId ? 'builtin' : 'custom',
roleId,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
createdBy: 'browseros',
agentName: input.agentName,
},
null,
2,
)}\n`,
}
}
export function toRoleSummary(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BrowserOSAgentRoleSummary {
const normalizedRole = normalizeRoleForBootstrap(role)
return {
roleSource: 'id' in role ? 'builtin' : 'custom',
roleId: 'id' in role ? role.id : undefined,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
}
}
export function normalizeCustomRole(
role: BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
const recommendedApps = Array.isArray(role.recommendedApps)
? role.recommendedApps.filter(
(app): app is string => typeof app === 'string',
)
: []
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
return {
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps,
boundaries,
bootstrap: {
agentsMd:
role.bootstrap?.agentsMd?.trim() ||
buildAgentsMd({
name: role.name,
longDescription: role.longDescription,
boundaries,
}),
soulMd:
role.bootstrap?.soulMd?.trim() ||
buildSoulMd({
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
}),
toolsMd:
role.bootstrap?.toolsMd?.trim() ||
buildToolsMd({
boundaries,
recommendedApps,
}),
},
}
}
function normalizeRoleForBootstrap(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
return 'id' in role ? role : normalizeCustomRole(role)
}
function buildAgentsMd(input: {
name: string
longDescription: string
boundaries: BrowserOSRoleTemplate['boundaries']
}): string {
const boundaryLines = input.boundaries
.map(
(boundary) =>
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
)
.join('\n')
return `# ${input.name}
You are the ${input.name} specialist for this workspace.
## Core Purpose
${input.longDescription}
## Operating Rules
${boundaryLines}
## Default Output Style
- concise
- action-oriented
- explicit about blockers and approvals
`
}
function buildSoulMd(input: {
name: string
shortDescription: string
longDescription: string
}): string {
return `# Operating Style
You act like a trusted ${input.name}.
## Working Posture
- calm
- structured
- direct
- explicit about tradeoffs
## Role Framing
${input.shortDescription}
${input.longDescription}
`
}
function buildToolsMd(input: {
boundaries: BrowserOSRoleTemplate['boundaries']
recommendedApps: string[]
}): string {
const boundaryLines = input.boundaries
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
.join('\n')
const appsLine =
input.recommendedApps.length > 0
? input.recommendedApps.join(', ')
: 'No specific apps configured yet.'
return `# Tooling Guidelines
- Use BrowserOS MCP for browser and connected SaaS tasks.
- Prefer read, summarize, and draft flows.
- Keep outputs in the workspace when possible so work remains inspectable.
## Recommended Apps
${appsLine}
## Boundary Defaults
${boundaryLines}
`
}

View File

@@ -57,6 +57,10 @@
"types": "./src/types/role-aware-agents.ts",
"default": "./src/types/role-aware-agents.ts"
},
"./types/role-programs": {
"types": "./src/types/role-programs.ts",
"default": "./src/types/role-programs.ts"
},
"./schemas/llm": {
"types": "./src/schemas/llm.ts",
"default": "./src/schemas/llm.ts"

View File

@@ -0,0 +1,75 @@
export type BrowserOSProgramSchedule =
| {
type: 'daily'
time: string
daysOfWeek?: Array<0 | 1 | 2 | 3 | 4 | 5 | 6>
}
| {
type: 'hourly'
interval: number
}
| {
type: 'minutes'
interval: number
}
| {
type: 'manual'
}
export interface BrowserOSStandingOrder {
id: string
title: string
instruction: string
enabled: boolean
}
export interface BrowserOSAgentProgram {
id: string
agentId: string
agentName: string
roleId?: string
name: string
description: string
prompt: string
schedule: BrowserOSProgramSchedule
enabled: boolean
standingOrders: BrowserOSStandingOrder[]
createdAt: string
updatedAt: string
lastRunAt?: string
nextRunAt?: string
}
export interface BrowserOSProgramRun {
id: string
programId: string
agentId: string
startedAt: string
completedAt?: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
trigger: 'manual' | 'schedule' | 'retry'
summary?: string
finalResult?: string
error?: string
sessionKey?: string
}
export interface CreateAgentProgramInput {
name: string
description: string
prompt: string
schedule: BrowserOSProgramSchedule
enabled?: boolean
standingOrders?: BrowserOSStandingOrder[]
}
export interface UpdateAgentProgramInput {
name?: string
description?: string
prompt?: string
schedule?: BrowserOSProgramSchedule
enabled?: boolean
standingOrders?: BrowserOSStandingOrder[]
lastRunAt?: string
nextRunAt?: string
}