import { Ban, Flame, Play, Power } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { toast } from 'sonner'
import type { AgentListResponse } from '@/types/agent'
import type { Skill } from '@/types'
import type { PaletteItem, CommandProvider, QueryContext } from './types'
interface CronJob {
id: string
name: string
}
/**
* Control provider — verb `control` (prefix `!`).
*
* Direct REST actions (Tier 1, design §4). Conventions (§5 "inline action
* args"): destructive targets (agent kill, cron trigger) need no action token —
* the entity alone implies the verb's default; toggle-able targets (skill,
* token-maxing) require an explicit `enable|disable|start|stop` action.
*
* Note: `@maxing` has no colon, so the lexer yields `{ name: 'maxing' }` with no
* `type` — match it by name too.
*/
export function useControlProvider(): CommandProvider {
const { t } = useTranslation()
const qc = useQueryClient()
const agentsQ = useQuery({
queryKey: ['agents', 'running', 'palette'],
queryFn: () =>
api.get<AgentListResponse>(
'/api/agents?status=running&per_page=100&sort_by=created_at&sort_dir=desc',
),
refetchInterval: 10_000,
})
const cronQ = useQuery({
queryKey: ['cron-jobs'],
queryFn: async (): Promise<CronJob[]> => {
const res = await api.get<{ jobs: CronJob[] }>('/api/cron-jobs')
return Array.isArray(res?.jobs) ? res.jobs : []
},
refetchInterval: 30_000,
})
const skillsQ = useQuery({
queryKey: ['skills'],
queryFn: () => api.get<{ skills: Skill[] }>('/api/skills'),
staleTime: 60_000,
})
const killMutation = useMutation({
mutationFn: (id: string) => api.post(`/api/agents/${id}/kill`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['agents'] })
toast.success(t('commandPalette.killedAgent'))
},
onError: () => toast.error(t('commandPalette.controlFailed')),
})
const triggerMutation = useMutation({
mutationFn: (id: string) => api.post(`/api/cron-jobs/${id}/trigger`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cron-jobs'] })
toast.success(t('commandPalette.triggeredCron'))
},
onError: () => toast.error(t('commandPalette.controlFailed')),
})
const skillToggle = useMutation({
mutationFn: ({ name, enable }: { name: string; enable: boolean }) =>
api.post(`/api/skills/${encodeURIComponent(name)}/${enable ? 'enable' : 'disable'}`),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ['skills'] })
toast.success(t(v.enable ? 'commandPalette.skillEnabled' : 'commandPalette.skillDisabled'))
},
onError: () => toast.error(t('commandPalette.controlFailed')),
})
const maxingMutation = useMutation({
mutationFn: (start: boolean) =>
api.post(`/api/token-maxing/${start ? 'start' : 'stop'}`),
onSuccess: (_d, start) =>
toast.success(t(start ? 'commandPalette.maxingStarted' : 'commandPalette.maxingStopped')),
onError: () => toast.error(t('commandPalette.controlFailed')),
})
const agents = agentsQ.data?.items ?? []
const crons = cronQ.data ?? []
const skills = skillsQ.data?.skills ?? []
return useMemo(
() => ({
id: 'control',
verbs: ['control'],
resolve(ctx: QueryContext): PaletteItem[] {
if (ctx.verb !== 'control') return []
const ent = ctx.entity
if (!ent) return []
const name = ent.name.toLowerCase()
const action = ctx.action
// @maxing start|stop — matched by type OR bare name (no colon typed).
if (ent.type === 'maxing' || name === 'maxing') {
if (action !== 'start' && action !== 'stop') {
return [
{
id: 'control-maxing-hint',
verb: 'control',
icon: <Flame className="h-4 w-4" />,
title: t('commandPalette.maxingHint'),
score: 100,
onSelect: () => {},
},
]
}
return [
{
id: `control-maxing-${action}`,
verb: 'control',
icon: <Flame className="h-4 w-4" />,
title: t(action === 'start' ? 'commandPalette.maxingStart' : 'commandPalette.maxingStop'),
score: 100,
onSelect: () => maxingMutation.mutate(action === 'start'),
},
]
}
// @agent → kill (entity implies default; no action token)
if (ent.type === 'agent' || !ent.type) {
const agent = agents.find(
(a) =>
a.id === ent.name ||
a.name.toLowerCase().includes(name) ||
a.id.toLowerCase() === name,
)
if (agent) {
return [
{
id: `control-agent-${agent.id}`,
verb: 'control',
icon: <Ban className="h-4 w-4" />,
title: t('commandPalette.killAgent', { name: agent.name }),
subtitle: agent.id,
score: 100,
onSelect: () => killMutation.mutate(agent.id),
},
]
}
if (ent.type === 'agent') return []
}
// @cron → trigger
if (ent.type === 'cron' || !ent.type) {
const cron = crons.find(
(c) =>
c.id === ent.name ||
c.name.toLowerCase().includes(name) ||
c.id.toLowerCase() === name,
)
if (cron) {
return [
{
id: `control-cron-${cron.id}`,
verb: 'control',
icon: <Play className="h-4 w-4" />,
title: t('commandPalette.triggerCron', { name: cron.name }),
score: 100,
onSelect: () => triggerMutation.mutate(cron.id),
},
]
}
if (ent.type === 'cron') return []
}
// @skill enable|disable (requires action token)
if (ent.type === 'skill' || (!ent.type && action)) {
const skill = skills.find(
(s) => s.name.toLowerCase() === name || s.name.toLowerCase().includes(name),
)
if (skill) {
if (action !== 'enable' && action !== 'disable') {
return [
{
id: `control-skill-hint-${skill.name}`,
verb: 'control',
icon: <Power className="h-4 w-4" />,
title: t('commandPalette.skillToggleHint', { name: skill.name }),
score: 100,
onSelect: () => {},
},
]
}
return [
{
id: `control-skill-${skill.name}-${action}`,
verb: 'control',
icon: <Power className="h-4 w-4" />,
title: t(
action === 'enable'
? 'commandPalette.enableSkill'
: 'commandPalette.disableSkill',
{ name: skill.name },
),
score: 100,
onSelect: () =>
skillToggle.mutate({ name: skill.name, enable: action === 'enable' }),
},
]
}
}
return []
},
}),
[t, agents, crons, skills, killMutation, triggerMutation, skillToggle, maxingMutation],
)
}