diff --git a/apps/dashboard/src/api/client.ts b/apps/dashboard/src/api/client.ts
index 90f42db..dbb88f6 100644
--- a/apps/dashboard/src/api/client.ts
+++ b/apps/dashboard/src/api/client.ts
@@ -75,6 +75,43 @@ export async function getDaemons(): Promise<{ daemons: unknown[] }> {
return result.value as { daemons: unknown[] };
}
+export async function getDaemonDetail(role: string): Promise<{
+ role: string;
+ description: string;
+ status: 'active' | 'paused' | 'stopped';
+ trigger: Record & { type: string };
+ stats: Record;
+}> {
+ const result = await getClient().daemons.get(role);
+ if (result.isErr()) throw result.error;
+ return result.value as ReturnType extends Promise ? T : never;
+}
+
+export async function createDaemon(request: Record): Promise {
+ const result = await getClient().daemons.create(
+ request as Parameters[0],
+ );
+ if (result.isErr()) throw result.error;
+ return result.value;
+}
+
+export async function updateDaemon(
+ role: string,
+ request: Record,
+): Promise {
+ const result = await getClient().daemons.update(
+ role,
+ request as Parameters[1],
+ );
+ if (result.isErr()) throw result.error;
+ return result.value;
+}
+
+export async function deleteDaemon(role: string): Promise {
+ const result = await getClient().daemons.delete(role);
+ if (result.isErr()) throw result.error;
+}
+
// --- Snapshot Configs ---
function apiKeyHeaders(): Record {
diff --git a/apps/dashboard/src/components/DaemonForm.tsx b/apps/dashboard/src/components/DaemonForm.tsx
new file mode 100644
index 0000000..c85f878
--- /dev/null
+++ b/apps/dashboard/src/components/DaemonForm.tsx
@@ -0,0 +1,693 @@
+import { useEffect, useState } from 'react';
+import { toast } from 'sonner';
+import { X } from 'lucide-react';
+
+import { Button } from './ui/button.js';
+import { Input } from './ui/input.js';
+import { Label } from './ui/label.js';
+import { Textarea } from './ui/textarea.js';
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from './ui/sheet.js';
+import { Badge } from './ui/badge.js';
+import { createDaemon, updateDaemon, getDaemonDetail, getSnapshotConfigs } from '../api/client.js';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type TriggerType = 'github' | 'webhook' | 'schedule' | 'watch';
+type DaemonStatus = 'active' | 'paused' | 'stopped';
+
+interface DaemonFormData {
+ role: string;
+ description: string;
+ status: DaemonStatus;
+ triggerType: TriggerType;
+ // GitHub trigger
+ githubRepos: string[];
+ githubEvents: string[];
+ githubCommand: string;
+ // Webhook trigger
+ webhookEvents: string[];
+ webhookSecret: string;
+ webhookSignatureScheme: 'hmac-sha256' | 'slack-v0' | 'none';
+ webhookSignatureHeader: string;
+ // Schedule trigger
+ cron: string;
+ // Watch trigger
+ watchCondition: string;
+ watchIntervalMs: number;
+ // Execution
+ snapshot: string;
+ script: string;
+ timeoutMs: number;
+ vcpus: number;
+ memoryMB: number;
+ // Governance
+ maxActionsPerHour: number;
+ auditLog: boolean;
+}
+
+const DEFAULT_FORM: DaemonFormData = {
+ role: '',
+ description: '',
+ status: 'active',
+ triggerType: 'github',
+ githubRepos: [],
+ githubEvents: ['issue_comment'],
+ githubCommand: '',
+ webhookEvents: [],
+ webhookSecret: '',
+ webhookSignatureScheme: 'hmac-sha256',
+ webhookSignatureHeader: 'x-hub-signature-256',
+ cron: '',
+ watchCondition: '',
+ watchIntervalMs: 60000,
+ snapshot: '',
+ script: '',
+ timeoutMs: 300000,
+ vcpus: 2,
+ memoryMB: 4096,
+ maxActionsPerHour: 20,
+ auditLog: true,
+};
+
+interface DaemonFormProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** When set, form is in edit mode */
+ editRole?: string | undefined;
+ onSaved: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// GitHub event options
+// ---------------------------------------------------------------------------
+
+const GITHUB_EVENT_OPTIONS = [
+ { value: 'pull_request.opened', label: 'PR opened' },
+ { value: 'pull_request.synchronize', label: 'PR synchronized' },
+ { value: 'pull_request.closed', label: 'PR closed' },
+ { value: 'issue_comment', label: 'Issue comment' },
+ { value: 'push', label: 'Push' },
+ { value: 'issues.opened', label: 'Issue opened' },
+];
+
+// ---------------------------------------------------------------------------
+// Tag Input
+// ---------------------------------------------------------------------------
+
+function TagInput({
+ tags,
+ onChange,
+ placeholder,
+}: {
+ tags: string[];
+ onChange: (tags: string[]) => void;
+ placeholder?: string;
+}) {
+ const [input, setInput] = useState('');
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if ((e.key === 'Enter' || e.key === ',') && input.trim()) {
+ e.preventDefault();
+ const value = input.trim().replace(/,$/, '');
+ if (value && !tags.includes(value)) {
+ onChange([...tags, value]);
+ }
+ setInput('');
+ }
+ if (e.key === 'Backspace' && !input && tags.length > 0) {
+ onChange(tags.slice(0, -1));
+ }
+ }
+
+ function removeTag(tag: string) {
+ onChange(tags.filter((t) => t !== tag));
+ }
+
+ return (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+
+ ))}
+ setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={tags.length === 0 ? placeholder : ''}
+ className="flex-1 min-w-24 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 outline-none"
+ />
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// DaemonForm
+// ---------------------------------------------------------------------------
+
+export function DaemonForm({ open, onOpenChange, editRole, onSaved }: DaemonFormProps) {
+ const [form, setForm] = useState(DEFAULT_FORM);
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [snapshots, setSnapshots] = useState([]);
+
+ const isEdit = Boolean(editRole);
+
+ // Fetch snapshots for dropdown
+ useEffect(() => {
+ if (!open) return;
+ getSnapshotConfigs()
+ .then((configs) => setSnapshots(configs.map((c) => c.id)))
+ .catch(() => {
+ // Silently fail - user can type manually
+ });
+ }, [open]);
+
+ // Load daemon data when editing
+ useEffect(() => {
+ if (!open || !editRole) {
+ if (!editRole) setForm(DEFAULT_FORM);
+ return;
+ }
+ setLoading(true);
+ getDaemonDetail(editRole)
+ .then((daemon) => {
+ // eslint-disable-next-line typescript/no-explicit-any
+ const trigger = daemon.trigger as Record;
+ const triggerType = trigger.type as TriggerType;
+ const partial: Partial = {
+ role: daemon.role as string,
+ description: (daemon.description as string) ?? '',
+ status: daemon.status as DaemonStatus,
+ triggerType,
+ };
+
+ if (triggerType === 'github') {
+ partial.githubRepos = (trigger.repos as string[]) ?? [];
+ partial.githubEvents = (trigger.events as string[]) ?? ['issue_comment'];
+ partial.githubCommand = (trigger.command as string) ?? '';
+ } else if (triggerType === 'webhook') {
+ partial.webhookEvents = (trigger.events as string[]) ?? [];
+ partial.webhookSecret = (trigger.secret as string) ?? '';
+ partial.webhookSignatureScheme =
+ (trigger.signatureScheme as 'hmac-sha256' | 'slack-v0' | 'none') ?? 'hmac-sha256';
+ partial.webhookSignatureHeader =
+ (trigger.signatureHeader as string) ?? 'x-hub-signature-256';
+ } else if (triggerType === 'schedule') {
+ partial.cron = trigger.cron as string;
+ } else if (triggerType === 'watch') {
+ partial.watchCondition = trigger.condition as string;
+ partial.watchIntervalMs = (trigger.intervalMs as number) ?? 60000;
+ }
+
+ setForm({ ...DEFAULT_FORM, ...partial });
+ })
+ .catch((err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err);
+ toast.error(`Failed to load daemon: ${message}`);
+ })
+ .finally(() => setLoading(false));
+ }, [open, editRole]);
+
+ function update(key: K, value: DaemonFormData[K]) {
+ setForm((prev) => ({ ...prev, [key]: value }));
+ }
+
+ function buildTrigger() {
+ switch (form.triggerType) {
+ case 'github':
+ return {
+ type: 'github' as const,
+ repos: form.githubRepos,
+ events: form.githubEvents,
+ ...(form.githubCommand ? { command: form.githubCommand } : {}),
+ };
+ case 'webhook':
+ return {
+ type: 'webhook' as const,
+ events: form.webhookEvents,
+ signatureScheme: form.webhookSignatureScheme,
+ ...(form.webhookSecret ? { secret: form.webhookSecret } : {}),
+ ...(form.webhookSignatureHeader ? { signatureHeader: form.webhookSignatureHeader } : {}),
+ };
+ case 'schedule':
+ return {
+ type: 'schedule' as const,
+ cron: form.cron,
+ };
+ case 'watch':
+ return {
+ type: 'watch' as const,
+ condition: form.watchCondition,
+ intervalMs: form.watchIntervalMs,
+ };
+ }
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSaving(true);
+
+ try {
+ const trigger = buildTrigger();
+
+ if (isEdit && editRole) {
+ await updateDaemon(editRole, {
+ description: form.description,
+ trigger,
+ ...(form.script
+ ? { workload: { type: 'script' as const, script: form.script, env: {} } }
+ : {}),
+ resources: { vcpus: form.vcpus, memoryMB: form.memoryMB },
+ governance: {
+ maxActionsPerHour: form.maxActionsPerHour,
+ requiresApproval: [],
+ auditLog: form.auditLog,
+ },
+ });
+ toast.success(`Daemon "${editRole}" updated`);
+ } else {
+ await createDaemon({
+ role: form.role,
+ description: form.description,
+ snapshot: form.snapshot,
+ trigger,
+ workload: { type: 'script' as const, script: form.script, env: {} },
+ resources: { vcpus: form.vcpus, memoryMB: form.memoryMB },
+ governance: {
+ maxActionsPerHour: form.maxActionsPerHour,
+ requiresApproval: [],
+ auditLog: form.auditLog,
+ },
+ });
+ toast.success(`Daemon "${form.role}" created`);
+ }
+
+ onSaved();
+ onOpenChange(false);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ toast.error(message);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const isValid =
+ form.role.length > 0 &&
+ form.snapshot.length > 0 &&
+ form.script.length > 0 &&
+ (form.triggerType !== 'github' || form.githubRepos.length > 0) &&
+ (form.triggerType !== 'schedule' || form.cron.length > 0) &&
+ (form.triggerType !== 'watch' || form.watchCondition.length > 0);
+
+ const triggerTypes: { value: TriggerType; label: string }[] = [
+ { value: 'github', label: 'GitHub' },
+ { value: 'webhook', label: 'Webhook' },
+ { value: 'schedule', label: 'Schedule' },
+ { value: 'watch', label: 'Watch' },
+ ];
+
+ return (
+
+
+
+ {isEdit ? 'Edit Daemon' : 'Create Daemon'}
+
+ {isEdit
+ ? 'Update daemon configuration'
+ : 'Configure a persistent agent role that responds to triggers'}
+
+
+
+ {loading ? (
+ Loading...
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/pages/Daemons.tsx b/apps/dashboard/src/pages/Daemons.tsx
index 9e4abff..9780414 100644
--- a/apps/dashboard/src/pages/Daemons.tsx
+++ b/apps/dashboard/src/pages/Daemons.tsx
@@ -1,11 +1,25 @@
+import { useState } from 'react';
import { Link } from '@tanstack/react-router';
+import { toast } from 'sonner';
+import { Pencil, Trash2 } from 'lucide-react';
-import { getDaemons } from '../api/client.js';
+import { deleteDaemon, getDaemons } from '../api/client.js';
+import { DaemonForm } from '../components/DaemonForm.js';
import { RelativeTime } from '../components/RelativeTime.js';
import { StatusBadge } from '../components/StatusBadge.js';
import { Alert, AlertDescription } from '../components/ui/alert.js';
import { Badge } from '../components/ui/badge.js';
+import { Button } from '../components/ui/button.js';
import { Card, CardContent } from '../components/ui/card.js';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '../components/ui/dialog.js';
import { Skeleton } from '../components/ui/skeleton.js';
import { usePolling } from '../hooks/usePolling.js';
@@ -31,11 +45,13 @@ function TriggerBadge({ trigger }: { trigger: Daemon['trigger'] }) {
webhook: 'bg-blue-400/10 text-blue-400 border-blue-400/20',
schedule: 'bg-purple-400/10 text-purple-400 border-purple-400/20',
watch: 'bg-amber-400/10 text-amber-400 border-amber-400/20',
+ github: 'bg-zinc-400/10 text-zinc-300 border-zinc-400/20',
};
const labels: Record = {
webhook: trigger.events?.join(', ') ?? 'webhook',
schedule: trigger.cron ?? 'schedule',
watch: 'watch',
+ github: 'github',
};
return (
@@ -50,12 +66,52 @@ function TriggerBadge({ trigger }: { trigger: Daemon['trigger'] }) {
export function Daemons() {
const daemons = usePolling(getDaemons, 5000);
+ const [formOpen, setFormOpen] = useState(false);
+ const [editRole, setEditRole] = useState(undefined);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+
+ function handleCreate() {
+ setEditRole(undefined);
+ setFormOpen(true);
+ }
+
+ function handleEdit(role: string) {
+ setEditRole(role);
+ setFormOpen(true);
+ }
+
+ async function handleDelete() {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ await deleteDaemon(deleteTarget);
+ toast.success(`Daemon "${deleteTarget}" deleted`);
+ setDeleteTarget(null);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ toast.error(message);
+ } finally {
+ setDeleting(false);
+ }
+ }
return (
Daemons
-
Persistent agent roles that respond to triggers
+
+
+ Persistent agent roles that respond to triggers
+
+
+
{daemons.loading ? (
@@ -78,7 +134,25 @@ export function Daemons() {
{d.role}
-
+
+
+
+
+
{d.description && {d.description}
}
{d.network?.expose && d.network.expose.length > 0 && (
@@ -125,11 +199,46 @@ export function Daemons() {
Browse templates
{' '}
- or create a daemon with POST /v1/daemons
+ or{' '}
+
)}
+
+ {/* Create/Edit form */}
+ {
+ // Polling will pick up changes
+ }}
+ />
+
+ {/* Delete confirmation */}
+
);
}