diff --git a/apps/desktop/src/components/agents/agent-card.tsx b/apps/desktop/src/components/agents/agent-card.tsx index 46405f5..28d9de4 100644 --- a/apps/desktop/src/components/agents/agent-card.tsx +++ b/apps/desktop/src/components/agents/agent-card.tsx @@ -1,6 +1,6 @@ import { Link, useNavigate } from "react-router"; import { formatDistanceToNowStrict } from "date-fns"; -import { Copy, Cpu, Ellipsis, History, MessageSquare, Pause, Pencil, Play, Power, PowerOff, StepForward, Trash2, Workflow } from "lucide-react"; +import { Check, Copy, Cpu, Ellipsis, FolderInput, History, MessageSquare, Pause, Pencil, Play, Power, PowerOff, StepForward, Trash2, Workflow } from "lucide-react"; import type { Agent } from "@godmode/shared"; import { AgentAvatar, ScopeBadge } from "@/components/common"; import { WorkingTicks } from "@/components/aicss/Motion"; @@ -12,8 +12,14 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useAllAgents, useWorkspaces } from "@/lib/hooks"; +import { GroupIcon } from "./agent-groups"; +import { planMove, useMoveAgent } from "./agent-dnd"; import { cn } from "@/lib/utils"; import { AgentStatus, useAgentLiveRun, useAgentMood, useAgentPause, useDuplicateAgent, useStartAgentChat, useToggleAgent } from "./agent-actions"; @@ -175,6 +181,7 @@ export function AgentMenu({ Duplicate )} + {!agent.isDefault && } toggle.mutate({ id: agent.id, enabled: !agent.enabled })}> {agent.enabled ? : } {agent.enabled ? "Switch off" : "Switch on"} @@ -188,3 +195,33 @@ export function AgentMenu({ ); } + +/** The menu's way to move an agent to another workspace, with its team (the same as dragging it there). */ +function MoveToMenu({ agent }: { agent: Agent }) { + const { data: workspaces = [] } = useWorkspaces(); + const { data: all = [] } = useAllAgents(); + const move = useMoveAgent(); + const to = (workspaceId: string | null) => { + const plan = planMove(agent, { kind: "workspace", workspaceId }, all.length ? all : [agent], workspaces); + if (plan?.ok) move.mutate({ agent, plan }); + }; + return ( + + + Move to + + + {[null, ...workspaces].map((w) => { + const here = agent.workspaceId === (w?.id ?? null); + return ( + to(w?.id ?? null)}> + + {w?.name ?? "Global"} + {here && } + + ); + })} + + + ); +} diff --git a/apps/desktop/src/components/agents/agent-dnd.tsx b/apps/desktop/src/components/agents/agent-dnd.tsx new file mode 100644 index 0000000..0042b1c --- /dev/null +++ b/apps/desktop/src/components/agents/agent-dnd.tsx @@ -0,0 +1,374 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react"; +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + useDraggable, + useDroppable, + useSensor, + useSensors, + type CollisionDetection, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, + type Modifier, +} from "@dnd-kit/core"; +import { useMutation, useQueryClient, type QueryKey } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "motion/react"; +import { toast } from "sonner"; +import { Ban, CornerDownRight, FolderInput } from "lucide-react"; +import type { Agent, AgentMoveInput, AgentPlacement, Workspace } from "@godmode/shared"; +import { AgentAvatar } from "@/components/common"; +import { api, errorMessage } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { GroupIcon, WorkspaceSection } from "./agent-groups"; + +/** Where an agent can be dropped: on an agent (it becomes the lead), a workspace, or the built-in agent (no lead of its own). */ +export type AgentDropTarget = { kind: "agent"; agent: Agent } | { kind: "workspace"; workspaceId: string | null } | { kind: "hub"; agent: Agent }; + +export type MovePlan = + | { ok: true; input: AgentMoveInput; workspaceId: string | null; reportsTo: string | null | undefined; label: string; done: string; team: Agent[] } + | { ok: false; reason: string }; + +/** Everyone under `agent`, top-down. */ +export function teamOf(agent: Agent, all: Agent[]): Agent[] { + const out: Agent[] = []; + const seen = new Set([agent.id]); + for (let i = -1; i < out.length; i++) { + const lead = i < 0 ? agent : out[i]!; + for (const a of all) { + if (a.reportsTo !== lead.id || seen.has(a.id)) continue; + seen.add(a.id); + out.push(a); + } + } + return out; +} + +const wsName = (id: string | null, workspaces: Workspace[]) => (id ? (workspaces.find((w) => w.id === id)?.name ?? "the workspace") : "Global"); + +/** What dropping `agent` on `target` would do; null when it changes nothing. */ +export function planMove(agent: Agent, target: AgentDropTarget, all: Agent[], workspaces: Workspace[]): MovePlan | null { + if (agent.isDefault) return null; + const team = teamOf(agent, all); + const along = (ws: string | null) => (ws !== agent.workspaceId ? team : []); + if (target.kind === "hub" || (target.kind === "agent" && target.agent.isDefault)) { + if (!agent.reportsTo) return null; + return { ok: true, input: { reportsTo: null }, workspaceId: agent.workspaceId, reportsTo: null, label: `Report to ${target.agent.name}`, done: `${agent.name} now reports to ${target.agent.name}`, team: [] }; + } + if (target.kind === "workspace") { + if (target.workspaceId === agent.workspaceId) return null; + const ws = target.workspaceId; + return { ok: true, input: { workspaceId: ws }, workspaceId: ws, reportsTo: undefined, label: `Move to ${wsName(ws, workspaces)}`, done: `Moved ${agent.name} to ${wsName(ws, workspaces)}`, team: along(ws) }; + } + const lead = target.agent; + if (lead.id === agent.id) return null; + if (team.some((a) => a.id === lead.id)) return { ok: false, reason: `${lead.name} reports to ${agent.name}` }; + const ws = lead.workspaceId ?? agent.workspaceId; + if (agent.reportsTo === lead.id && ws === agent.workspaceId) return null; + return { + ok: true, + input: ws !== agent.workspaceId ? { workspaceId: ws, reportsTo: lead.id } : { reportsTo: lead.id }, + workspaceId: ws, + reportsTo: lead.id, + label: `Report to ${lead.name}${ws !== agent.workspaceId ? ` in ${wsName(ws, workspaces)}` : ""}`, + done: `${agent.name} now reports to ${lead.name}${ws !== agent.workspaceId ? ` in ${wsName(ws, workspaces)}` : ""}`, + team: along(ws), + }; +} + +interface DndState { + dragged: Agent | null; + plan: (target: AgentDropTarget) => MovePlan | null; +} + +const Ctx = createContext({ dragged: null, plan: () => null }); + +/** The smallest target under the pointer: a row inside a panel wins over the panel. */ +const innermost: CollisionDetection = (args) => { + const area = (id: string | number) => { + const r = args.droppableRects.get(id); + return r ? r.width * r.height : Infinity; + }; + return pointerWithin(args).sort((a, b) => area(a.id) - area(b.id)); +}; + +/** The ghost sits beside the pointer, so the row under it stays readable. */ +const besideCursor: Modifier = ({ transform, activatorEvent, draggingNodeRect }) => { + if (!draggingNodeRect || !activatorEvent || !("clientX" in activatorEvent)) return transform; + const e = activatorEvent as PointerEvent; + return { ...transform, x: transform.x + e.clientX - draggingNodeRect.left + 14, y: transform.y + e.clientY - draggingNodeRect.top + 10 }; +}; + +/** A click right after a drop would open the agent it started on. */ +function swallowNextClick() { + const stop = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }; + window.addEventListener("click", stop, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", stop, { capture: true }), 0); +} + +function inScope(scope: unknown, workspaceId: string | null) { + return scope === "all" || (scope === "global" ? workspaceId === null : scope === workspaceId); +} + +/** Move an agent (and its team) with an optimistic update and an "Undo" in the toast. */ +export function useMoveAgent() { + const qc = useQueryClient(); + const patchLists = (fn: (list: Agent[], scope: unknown) => Agent[]) => { + const snapshot = qc.getQueriesData({ queryKey: ["agents", "list"] }); + for (const [key, data] of snapshot) if (Array.isArray(data)) qc.setQueryData(key, fn(data, key[2])); + return snapshot; + }; + const restore = (snapshot: [QueryKey, Agent[] | undefined][]) => { + for (const [key, data] of snapshot) qc.setQueryData(key, data); + }; + + const undo = useMutation({ + // Leads first, so each report finds its lead where it was. + mutationFn: async (previous: AgentPlacement[]) => { + for (const p of previous) await api.agents.update(p.id, { workspaceId: p.workspaceId, reportsTo: p.reportsTo }); + }, + onSuccess: () => toast.success("Moved back"), + onError: (err) => toast.error("Couldn't move it back", { description: errorMessage(err) }), + onSettled: () => qc.invalidateQueries({ queryKey: qk.agents }), + }); + + return useMutation({ + mutationFn: ({ agent, plan }: { agent: Agent; plan: Extract }) => api.agents.move(agent.id, plan.input), + onMutate: ({ agent, plan }) => { + const moving = new Set(plan.team.map((a) => a.id)); + return patchLists((list, scope) => + list + .map((a) => + a.id === agent.id + ? { ...a, workspaceId: plan.workspaceId, ...(plan.reportsTo !== undefined ? { reportsTo: plan.reportsTo } : {}) } + : moving.has(a.id) + ? { ...a, workspaceId: plan.workspaceId } + : a, + ) + .filter((a) => inScope(scope, a.workspaceId) || a.isDefault), + ); + }, + onSuccess: (res, { plan }) => { + const team = res.moved.length - 1; + toast.success(plan.done, { + description: team > 0 ? `Its team of ${team} moved along.` : undefined, + action: { label: "Undo", onClick: () => undo.mutate(res.previous) }, + }); + }, + onError: (err, _vars, snapshot) => { + if (snapshot) restore(snapshot); + toast.error("Couldn't move the agent", { description: errorMessage(err) }); + }, + onSettled: () => qc.invalidateQueries({ queryKey: qk.agents }), + }); +} + +/** Drag agents onto each other, onto workspaces or onto the dock that opens while dragging; every move can be undone. */ +export function AgentDnd({ all, workspaces, children }: { all: Agent[]; workspaces: Workspace[]; children: ReactNode }) { + const [dragged, setDragged] = useState(null); + const [over, setOver] = useState(null); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const plan = useCallback((target: AgentDropTarget) => (dragged ? planMove(dragged, target, all, workspaces) : null), [dragged, all, workspaces]); + const move = useMoveAgent(); + const box = useRef(null); + const [dockAt, setDockAt] = useState<{ left: number; width: number } | null>(null); + // Scrolling while the pointer rests on the dock would slide the page under it. + const [inDock, setInDock] = useState(false); + + const end = () => { + setDragged(null); + setOver(null); + setInDock(false); + document.body.style.removeProperty("user-select"); + }; + const onDragStart = (e: DragStartEvent) => { + const agent = e.active.data.current?.agent as Agent | undefined; + if (!agent) return; + setDragged(agent); + const r = box.current?.getBoundingClientRect(); + setDockAt(r ? { left: r.left, width: r.width } : null); + document.body.style.userSelect = "none"; + }; + const onDragOver = (e: DragOverEvent) => setOver((e.over?.data.current?.target as AgentDropTarget | undefined) ?? null); + const onDragEnd = (e: DragEndEvent) => { + const target = e.over?.data.current?.target as AgentDropTarget | undefined; + const agent = dragged; + end(); + swallowNextClick(); + if (!agent || !target) return; + const p = planMove(agent, target, all, workspaces); + if (p?.ok) move.mutate({ agent, plan: p }); + else if (p) toast.error(`Can't move ${agent.name} there`, { description: `${p.reason}.` }); + }; + + const current = dragged && over ? planMove(dragged, over, all, workspaces) : null; + const state = useMemo(() => ({ dragged, plan }), [dragged, plan]); + return ( + + +
{children}
+ + {dragged && } +
+
+ ); +} + +/** The agent under the pointer while it is dragged, saying what letting go would do. */ +function Ghost({ agent, plan }: { agent: Agent; plan: MovePlan | null }) { + return ( +
+ +
+

{agent.name}

+

+ {plan?.ok ? ( + <> + + + {plan.label} + {plan.team.length > 0 && · with {plan.team.length} report{plan.team.length === 1 ? "" : "s"}} + + + ) : plan ? ( + <> + + {plan.reason} + + ) : ( + "Drop on an agent or a workspace" + )} +

+
+
+ ); +} + +/** Every workspace as a drop target while an agent is dragged, also the ones not on screen. */ +function Dock({ + dragged, + workspaces, + at, + onInside, +}: { + dragged: Agent | null; + workspaces: Workspace[]; + /** Centred over the page's content, not the window. */ + at: { left: number; width: number } | null; + onInside: (inside: boolean) => void; +}) { + return ( + + {dragged && !dragged.isDefault && ( + +
onInside(true)} + onPointerLeave={() => onInside(false)} + className="pointer-events-auto flex max-w-full flex-wrap items-center justify-center gap-1 rounded-2xl border bg-card/90 p-1.5 shadow-float backdrop-blur-xl" role="group" aria-label="Move to workspace"> + + Move to + + + {workspaces.map((w) => ( + + ))} +
+
+ )} +
+ ); +} + +function DockTarget({ workspace, here }: { workspace: Workspace | null; here: boolean }) { + const drop = useAgentDrop({ kind: "workspace", workspaceId: workspace?.id ?? null }, `dock:${workspace?.id ?? "global"}`); + return ( +
+ + {workspace?.name ?? "Global"} + {here && here} +
+ ); +} + +/** Makes an agent draggable; the built-in agent stays put. */ +export function useAgentDrag(agent: Agent, id = `agent:${agent.id}`) { + const { setNodeRef, listeners, isDragging } = useDraggable({ id, data: { agent }, disabled: agent.isDefault }); + return { + ref: setNodeRef, + isDragging, + props: agent.isDefault + ? {} + : { + ...listeners, + // Links and images inside would start the webview's own drag. + onDragStart: (e: React.DragEvent) => e.preventDefault(), + }, + }; +} + +/** + * A drop target. `state`: "none" when nothing is dragged or dropping here changes nothing, "valid" or "invalid" while + * the dragged agent is over it, "available" while it could go here. + */ +export function useAgentDrop(target: AgentDropTarget | null, id: string) { + const { dragged, plan } = useContext(Ctx); + const p = dragged && target ? plan(target) : null; + const { setNodeRef, isOver } = useDroppable({ id, data: { target }, disabled: !p }); + const state: "none" | "available" | "valid" | "invalid" = !p ? "none" : isOver ? (p.ok ? "valid" : "invalid") : p.ok ? "available" : "none"; + return { ref: setNodeRef, state, dragging: !!dragged }; +} + +export function useDraggedAgent() { + return useContext(Ctx).dragged; +} + +/** A workspace (or team) section that takes dropped agents. */ +export function DroppableSection({ target, dropId, ...props }: ComponentProps & { target: AgentDropTarget | null; dropId: string }) { + const drop = useAgentDrop(target, dropId); + return ; +} + +/** Wraps a card or row so it can be picked up. */ +export function DraggableAgent({ agent, className, children }: { agent: Agent; className?: string; children: ReactNode }) { + const drag = useAgentDrag(agent); + return ( +
+ {children} +
+ ); +} diff --git a/apps/desktop/src/components/agents/agent-groups.tsx b/apps/desktop/src/components/agents/agent-groups.tsx index 403a824..6b42ec3 100644 --- a/apps/desktop/src/components/agents/agent-groups.tsx +++ b/apps/desktop/src/components/agents/agent-groups.tsx @@ -59,6 +59,8 @@ export function WorkspaceSection({ forceOpen, aside, className, + dropRef, + dropState = "none", children, }: { group: AgentGroup; @@ -75,6 +77,9 @@ export function WorkspaceSection({ /** Extra line under the heading, e.g. who the group reports to. */ aside?: ReactNode; className?: string; + /** Takes dragged agents (agent-dnd). */ + dropRef?: (el: HTMLElement | null) => void; + dropState?: "none" | "available" | "valid" | "invalid"; children: ReactNode; }) { const folded = useUi((s) => s.collapsedAgentGroups.includes(group.key)); @@ -87,14 +92,22 @@ export function WorkspaceSection({ const faces = group.agents.slice(0, 5); return (
-
-

+
+

+ {dropState === "valid" && ( + + Drop here + + )} {collapsed && ( @@ -156,18 +174,21 @@ export function WorkspaceSection({ function GroupStats({ working, needsYou }: { working: number; needsYou: number }) { if (!working && !needsYou) return null; + // In a narrow heading the name keeps the room: just dot and number, the words on hover. return ( {working > 0 && ( - + - {working} working + {working} + working )} {needsYou > 0 && ( - + - {needsYou} need{needsYou === 1 ? "s" : ""} you + {needsYou} + need{needsYou === 1 ? "s" : ""} you )} diff --git a/apps/desktop/src/components/agents/agent-list.tsx b/apps/desktop/src/components/agents/agent-list.tsx index 5a57c9d..3633eb8 100644 --- a/apps/desktop/src/components/agents/agent-list.tsx +++ b/apps/desktop/src/components/agents/agent-list.tsx @@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { cn } from "@/lib/utils"; import { AgentStatus, useAgentLiveRun, useAgentMood, useStartAgentChat } from "./agent-actions"; import { AgentMenu } from "./agent-card"; +import { useAgentDrag } from "./agent-dnd"; const COLUMNS = "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,11rem)_6.5rem_auto] @5xl:grid-cols-[minmax(0,1fr)_minmax(0,12rem)_minmax(0,11rem)_3.5rem_6.5rem_auto]"; @@ -55,8 +56,18 @@ function AgentRow({ const chat = useStartAgentChat(); const chatting = chat.isPending && chat.variables?.agent.id === agent.id; const lead = leadOf(agent, all); + const drag = useAgentDrag(agent); return ( -
  • +
  • diff --git a/apps/desktop/src/components/agents/org-chart.tsx b/apps/desktop/src/components/agents/org-chart.tsx index 3cb7af0..248687c 100644 --- a/apps/desktop/src/components/agents/org-chart.tsx +++ b/apps/desktop/src/components/agents/org-chart.tsx @@ -1,13 +1,14 @@ import { useLayoutEffect, useRef, useState } from "react"; import { Link } from "react-router"; -import { Plus, Users } from "lucide-react"; +import { Ban, CornerDownRight, Plus, Users } from "lucide-react"; import type { Agent, TeamNode } from "@godmode/shared"; import { leadOf, presenceLabel, teamTree } from "@godmode/shared"; import { AgentAvatar } from "@/components/common"; import { useWorkspaces } from "@/lib/hooks"; import { cn } from "@/lib/utils"; import { AgentStatus, useAgentMood, useAgentPresence } from "./agent-actions"; -import { WorkspaceSection, type AgentGroup } from "./agent-groups"; +import { type AgentGroup } from "./agent-groups"; +import { DroppableSection, useAgentDrag, useAgentDrop, type AgentDropTarget } from "./agent-dnd"; interface Panel { group: AgentGroup; @@ -82,8 +83,17 @@ export function OrgChart({
    {col.map((p) => { const s = stats(p.group.agents); + const target: AgentDropTarget | null = byWorkspace + ? { kind: "workspace", workspaceId: p.group.key === "global" ? null : p.group.key } + : p.lead + ? { kind: "agent", agent: p.lead } + : p.group.key === "org:direct" && hub + ? { kind: "hub", agent: hub.agent } + : null; return ( - ))} - + ); })}
    @@ -119,7 +129,6 @@ export function OrgChart({ )} -

    Change who an agent reports to in its settings, under Team.

    ); } @@ -167,13 +176,17 @@ function Hub({ agent, dim, reports }: { agent: Agent; dim: boolean; reports: Age const workspaces = new Set(reports.flatMap((a) => (a.workspaceId ? [a.workspaceId] : []))).size; const where = [global && "Global", workspaces > 0 && `${workspaces} workspace${workspaces === 1 ? "" : "s"}`].filter(Boolean).join(" and "); const label = `${agent.name}, ${agent.role || "no role yet"}, ${presenceLabel(presence)}, reports to you`; + const drop = useAgentDrop({ kind: "hub", agent }, "hub"); return (
    @@ -232,13 +245,23 @@ function Row({ node, all, dim }: { node: TeamNode; all: Agent[]; dim: boo const lead = leadOf(agent, all); const label = `${agent.name}, ${agent.role || "no role yet"}, ${presenceLabel(presence)}, reports to ${lead?.name ?? "Godmode"}`; const team = node.reports.length; + const drag = useAgentDrag(agent, `row:${agent.id}`); + const drop = useAgentDrop({ kind: "agent", agent }, `lead:${agent.id}`); return (
    { + drag.ref(el); + drop.ref(el); + }} + {...drag.props} className={cn( - "group relative flex items-start gap-2.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-foreground/[0.035]", + "group relative flex items-start gap-2.5 rounded-lg px-1.5 py-1.5 transition-[background-color,box-shadow] hover:bg-foreground/[0.035]", presence.state === "working" && "bg-brand/[0.05]", !agent.enabled && "[&>:not([data-line])]:opacity-60", dim && "[&>:not([data-line])]:opacity-35", + drag.isDragging && "[&>:not([data-line])]:opacity-30", + drop.state === "valid" && "bg-brand/[0.08] ring-2 ring-brand/50 hover:bg-brand/[0.08]", + drop.state === "invalid" && "bg-destructive/[0.05] ring-2 ring-destructive/40", "[&>*]:transition-opacity", )} > @@ -274,7 +297,17 @@ function Row({ node, all, dim }: { node: TeamNode; all: Agent[]; dim: boo )}
    {/* Idle is the norm: just the dot, so whoever is working or needs you stands out. */} - {presence.state === "idle" ? ( + {drop.state === "valid" || drop.state === "invalid" ? ( + + {drop.state === "valid" ? : } + {drop.state === "valid" ? "Report here" : "Not here"} + + ) : presence.state === "idle" ? ( Idle diff --git a/apps/desktop/src/components/agents/team-templates.tsx b/apps/desktop/src/components/agents/team-templates.tsx index 5c28136..1dd4af7 100644 --- a/apps/desktop/src/components/agents/team-templates.tsx +++ b/apps/desktop/src/components/agents/team-templates.tsx @@ -1,157 +1,346 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { toast } from "sonner"; -import { ArrowRight, CalendarClock } from "lucide-react"; -import type { AgentTemplate, TeamTemplate } from "@godmode/shared"; +import { Archive, ArrowRight, Briefcase, Building2, CalendarClock, Code, FlaskConical, Handshake, Megaphone, Rocket, ShoppingBag, UserRound, Users, type LucideIcon } from "lucide-react"; +import type { AgentTemplate, OrgTemplateNode, TeamTemplate } from "@godmode/shared"; import { AgentAvatar } from "@/components/common"; import { localTimezone } from "@/components/agents/cron"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { api, errorMessage } from "@/lib/api"; -import { useAgentTemplates, useScopeWorkspace, useTeamTemplates } from "@/lib/hooks"; +import { useAgentTemplates, useScopeWorkspace, useTeamTemplates, useWorkspaces } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { useUi } from "@/stores/ui"; +import { cn } from "@/lib/utils"; + +const ICONS: Record = { + rocket: Rocket, + briefcase: Briefcase, + "shopping-bag": ShoppingBag, + building: Building2, + "user-round": UserRound, + archive: Archive, + megaphone: Megaphone, + handshake: Handshake, + code: Code, + flask: FlaskConical, +}; const face = (t: Pick) => ({ id: t.id, avatar: t.avatar, color: t.color, character: t.character }); +interface Seat { + path: string; + depth: number; + template: AgentTemplate; + reports: Seat[]; +} + +function seatsOf(node: OrgTemplateNode, byId: Map, path = "0", depth = 0): Seat | null { + const template = byId.get(node.template); + if (!template) return null; + const reports = (node.reports ?? []).map((r, i) => seatsOf(r, byId, `${path}.${i}`, depth + 1)).filter((s): s is Seat => !!s); + return { path, depth, template, reports }; +} + +const flat = (s: Seat): Seat[] => [s, ...s.reports.flatMap(flat)]; + +export function StructureIcon({ icon, className }: { icon: string; className?: string }) { + const Icon = ICONS[icon] ?? Users; + return ( + + + + ); +} + /** - * Whole teams to start with — a lead and its reports, wired up in the org chart — next to the single agents. One click - * (and a look at who leads whom) instead of hiring one agent at a time. + * Org charts to start with: whole companies (a CEO, its executives and their teams) and single teams, wired up in the + * org chart in one go instead of hiring one agent at a time. */ -export function TeamTemplates({ compact }: { compact?: boolean }) { +export function TeamTemplates({ kinds = ["company", "team"] }: { kinds?: TeamTemplate["kind"][] }) { const teams = useTeamTemplates(); const { data: agents = [] } = useAgentTemplates(); + const [kind, setKind] = useState(kinds[0]!); const [picked, setPicked] = useState(null); - const byId = new Map(agents.map((a) => [a.id, a])); + const byId = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); if (teams.isError) return null; + const shown = (teams.data ?? []).filter((t) => t.kind === kind); return ( <> -
    + {kinds.length > 1 && ( +
    + {kinds.map((k) => ( + + ))} +
    + )} +
    {teams.isLoading && - Array.from({ length: 4 }, (_, i) => ( -
    + Array.from({ length: 3 }, (_, i) => ( +
    - + +
    ))} - {(teams.data ?? []).map((team, i) => ( - setPicked(team)} - initial={{ opacity: 0, y: 8 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: Math.min(i, 8) * 0.04 }} - className="group flex min-h-36 flex-col rounded-xl border bg-card p-4 text-left shadow-card transition hover:border-foreground/20 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none" - > - - {team.icon} {team.name} - - - {team.description} - - - {team.members.map((m) => { - const t = byId.get(m); - return t ? : null; - })} - {team.members.length + 1} agents - - - ))} + + {shown.map((team, i) => { + const root = seatsOf(team.root, byId); + return root ? setPicked(team)} /> : null; + })} +
    - byId.get(m)).filter((t): t is AgentTemplate => !!t) : []} onClose={() => setPicked(null)} /> + setPicked(null)} /> ); } -function InstallTeamDialog({ team, members, onClose }: { team: TeamTemplate | null; members: AgentTemplate[]; onClose: () => void }) { +function StructureCard({ team, root, index, onPick }: { team: TeamTemplate; root: Seat; index: number; onPick: () => void }) { + const total = flat(root).length; + return ( + + + + + + {team.name} + {total} agents + + {team.description} + + + + + {root.template.name} with {root.reports.map((r) => r.template.name).slice(0, 3).join(", ")} + {root.reports.length > 3 && ` +${root.reports.length - 3}`} + + + + ); +} + +/** The chart in miniature: the top, a line down, its reports in a row with the size of their own teams. */ +function MiniChart({ root }: { root: Seat }) { + return ( + + + + + {root.reports.length > 1 && } + {root.reports.map((r) => ( + + + + +{flat(r).length - 1} + + ))} + + + ); +} + +const NEW = "__new"; +const GLOBAL = "__global"; + +function InstallDialog({ team, root, onClose }: { team: TeamTemplate | null; root: Seat | null; onClose: () => void }) { + return ( + !o && onClose()}> + {team && root && } + + ); +} + +function InstallForm({ team, root, onClose }: { team: TeamTemplate; root: Seat; onClose: () => void }) { const qc = useQueryClient(); const navigate = useNavigate(); - const workspace = useScopeWorkspace(); + const scoped = useScopeWorkspace(); + const scope = useUi((s) => s.workspace); + const setWorkspace = useUi((s) => s.setWorkspace); const setAgentsView = useUi((s) => s.setAgentsView); + const { data: workspaces = [] } = useWorkspaces(); + const [where, setWhere] = useState(team.kind === "company" ? NEW : (scoped?.id ?? GLOBAL)); + const [wsName, setWsName] = useState(team.name); + const [skip, setSkip] = useState>(new Set()); const [automations, setAutomations] = useState(true); - const scheduled = members.filter((m) => m.routine); + const seats = flat(root); + const kept = seats.filter((s) => !skip.has(s.path)); + const scheduled = kept.filter((s) => s.template.routine); + const names = new Map(seats.map((s) => [s.path, s.template.name])); + const parentOf = (path: string) => path.split(".").slice(0, -1).join("."); + const leadName = (path: string) => { + let at = parentOf(path); + while (at && skip.has(at)) at = parentOf(at); + return names.get(at) ?? root.template.name; + }; + const install = useMutation({ // Schedules in the human's own time zone (the core may run elsewhere: Cloud, a server). - mutationFn: () => api.agents.installTeam(team!.id, { workspaceId: workspace?.id ?? null, automations, timezone: localTimezone() }), + mutationFn: () => + api.agents.installTeam(team.id, { + ...(where === NEW ? { newWorkspace: { name: wsName.trim(), color: root.template.color } } : { workspaceId: where === GLOBAL ? null : where }), + automations: automations && scheduled.length > 0, + timezone: localTimezone(), + skip: [...skip], + }), onSuccess: (res) => { qc.invalidateQueries({ queryKey: qk.agents }); qc.invalidateQueries({ queryKey: qk.routines }); qc.invalidateQueries({ queryKey: qk.bootstrap }); - toast.success(`Your ${team!.name} team is ready`, { - description: `${res.lead.name} leads ${res.members.map((m) => m.name).join(", ")}.${res.automations ? ` ${res.automations} automation${res.automations === 1 ? "" : "s"} set up.` : ""}`, + if (res.workspace) qc.invalidateQueries({ queryKey: qk.workspaces }); + const place = res.workspace ? ` in ${res.workspace.name}` : ""; + toast.success(`${team.name} is ready`, { + description: `${res.lead.name} leads ${res.members.length} agent${res.members.length === 1 ? "" : "s"}${place}.${res.automations ? ` ${res.automations} automation${res.automations === 1 ? "" : "s"} set up.` : ""}`, }); onClose(); setAgentsView("chart"); + // Show where they went, unless everything is on screen anyway. + if (scope !== "all") setWorkspace(res.workspace?.id ?? (where === GLOBAL ? "global" : where)); navigate("/agents"); }, - onError: (err) => toast.error("Couldn't start the team", { description: errorMessage(err) }), + onError: (err) => toast.error(`Couldn't set up ${team.name}`, { description: errorMessage(err) }), }); + + const toggle = (path: string) => + setSkip((s) => { + const next = new Set(s); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + + const canInstall = kept.length > 1 && (where !== NEW || wsName.trim().length > 0) && !install.isPending; return ( - !o && !install.isPending && onClose()}> - - {team && ( - <> - - - {team.icon} Start the {team.name} team - - {team.description} - -
    -
    - -
    -

    - {team.lead.name} · {team.lead.role}, leads the team -

    -

    {team.lead.description}

    -
    -
    -
      - {members.map((m) => ( -
    • - - - {m.name} · {m.role} - - {m.routine && } -
    • + <> + + + {team.name} + {team.kind === "company" ? "Company" : "Team"} + + {team.description} + + +
      +
        + +
      +
      + +
      +
      + +
      + + {where === NEW && setWsName(e.target.value)} aria-label="Name of the new workspace" placeholder="Workspace name" className="min-w-40 flex-1" maxLength={80} />} +
      +
      + {scheduled.length > 0 && ( + )} - -
    +
    + + +

    Leads can hand work to their reports. Every agent is yours to adjust.

    + + +
    + + ); +} + +function SeatRow({ seat, skip, onToggle, leadName, last }: { seat: Seat; skip: Set; onToggle: (path: string) => void; leadName: (path: string) => string; last?: boolean }) { + const off = skip.has(seat.path); + const top = seat.depth === 0; + const id = `seat-${seat.path}`; + return ( +
  • + {!top && ( + <> + + {!last && } + + )} +
    + {top ? ( + + ) : ( + onToggle(seat.path)} aria-label={`Hire ${seat.template.name}`} /> + )} + + + {top && Reports to Godmode} + {seat.template.routine && !off && } +
    + {seat.reports.length > 0 && ( +
      + {seat.reports.map((r, i) => ( + + ))} +
    + )} +
  • ); } diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 4c46b65..b02dd83 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -126,8 +126,11 @@ import type { SshGeneratedKey, SshLocalKey, SshServer, + TeamInstallInput, TeamInstallResult, TeamTemplate, + AgentMoveInput, + AgentMoveResult, SshServerInput, SshServerPatch, SshTestInput, @@ -468,8 +471,9 @@ export const api = { templates: () => get("/api/agent-templates"), /** Whole teams to start with: a lead and its reports. */ teams: () => get("/api/team-templates"), - installTeam: (id: string, input: { workspaceId?: string | null; automations?: boolean; timezone?: string }) => - post(`/api/team-templates/${id}/install`, input), + installTeam: (id: string, input: TeamInstallInput) => post(`/api/team-templates/${id}/install`, input), + /** Another workspace and/or another lead; its team moves along unless `withTeam` is false. */ + move: (id: string, input: AgentMoveInput) => post(`/api/agents/${id}/move`, input), /** Start a fresh task conversation for the agent */ run: (id: string, prompt?: string, workspaceId?: string) => post(`/api/agents/${id}/run`, { prompt, workspaceId }), files: (id: string, path = "") => get(`/api/agents/${id}/files`, { path }), diff --git a/apps/desktop/src/pages/agents/agent-new.tsx b/apps/desktop/src/pages/agents/agent-new.tsx index b08a066..7243e6c 100644 --- a/apps/desktop/src/pages/agents/agent-new.tsx +++ b/apps/desktop/src/pages/agents/agent-new.tsx @@ -1,10 +1,11 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { motion } from "motion/react"; import { toast } from "sonner"; import { ArrowLeft, ArrowRight, Bot, CalendarClock, Plus, Sparkles, Wand2, X } from "lucide-react"; -import type { Agent, AgentInput, AgentTemplate } from "@godmode/shared"; +import type { Agent, AgentInput, AgentTemplate, AgentTemplateCategory } from "@godmode/shared"; +import { AGENT_TEMPLATE_CATEGORIES, AGENT_TEMPLATE_CATEGORY_LABELS } from "@godmode/shared"; import { api, errorMessage, isLicenseRequired } from "@/lib/api"; import { isGrantCancelled, withGrant } from "@/components/vault/grant"; import { qk } from "@/lib/queryKeys"; @@ -40,7 +41,7 @@ export default function AgentNewPage() { return templateId ? ( setParams({})} /> ) : ( - setParams({ template: id })} /> + setParams({ template: id })} focusStructures={params.get("start") === "structures"} /> ); } @@ -48,7 +49,7 @@ export default function AgentNewPage() { /* Step 1: describe it, or pick a template */ /* ------------------------------------------------------------------ */ -function ChooseStep({ onPick }: { onPick: (templateId: string) => void }) { +function ChooseStep({ onPick, focusStructures }: { onPick: (templateId: string) => void; focusStructures: boolean }) { const navigate = useNavigate(); const qc = useQueryClient(); const { data: boot } = useBootstrap(); @@ -77,6 +78,12 @@ function ChooseStep({ onPick }: { onPick: (templateId: string) => void }) { onError: (err) => !isLicenseRequired(err) && toast.error("Couldn't reach Godmode", { description: errorMessage(err) }), }); const canDescribe = description.trim().length > 3 && !describe.isPending; + const [category, setCategory] = useState("all"); + const roles = (templates.data ?? []).filter((t) => category === "all" || t.category === category); + const structuresRef = useRef(null); + useEffect(() => { + if (focusStructures) structuresRef.current?.scrollIntoView({ block: "start" }); + }, [focusStructures]); return ( <> @@ -183,19 +190,45 @@ function ChooseStep({ onPick }: { onPick: (templateId: string) => void }) {

    -
    +
    -

    Or start a whole team

    -

    A lead and its reports, wired up in the org chart: the lead hands out the work and reviews it.

    +

    + Or start a whole company +

    +

    An org chart hired in one go: every agent has its job, every lead hands out the work and reviews it.

    -
    -
    +
    +
    -

    Or pick a starting point

    -

    Templates come with instructions and, where it makes sense, a schedule.

    +

    + Or hire one role +

    +

    Each comes with a job description, instructions and, where it makes sense, a schedule.

    +
    +
    + {(["all", ...AGENT_TEMPLATE_CATEGORIES] as const).map((c) => { + const count = c === "all" ? (templates.data?.length ?? 0) : (templates.data ?? []).filter((t) => t.category === c).length; + if (c !== "all" && !count) return null; + return ( + + ); + })}
    @@ -225,7 +258,7 @@ function ChooseStep({ onPick }: { onPick: (templateId: string) => void }) {
    ))} - {(templates.data ?? []).map((t, i) => ( + {roles.map((t, i) => ( onPick(t.id)} /> ))}
    @@ -300,7 +333,12 @@ function TemplateCard({ template, index, onPick }: { template: AgentTemplate; in > {template.name} - {template.role && {template.role}} + {template.role && ( + + {template.role} + {template.leads && Leads a team} + + )} {template.description} {template.routine && ( diff --git a/apps/desktop/src/pages/agents/agents-page.tsx b/apps/desktop/src/pages/agents/agents-page.tsx index 67ab860..3cf0507 100644 --- a/apps/desktop/src/pages/agents/agents-page.tsx +++ b/apps/desktop/src/pages/agents/agents-page.tsx @@ -19,7 +19,8 @@ import { AgentCard } from "@/components/agents/agent-card"; import { DeleteAgentDialog, RunTaskDialog } from "@/components/agents/agent-actions"; import { OrgChart } from "@/components/agents/org-chart"; import { AgentList } from "@/components/agents/agent-list"; -import { groupByWorkspace, WorkspaceSection } from "@/components/agents/agent-groups"; +import { groupByWorkspace } from "@/components/agents/agent-groups"; +import { AgentDnd, DraggableAgent, DroppableSection } from "@/components/agents/agent-dnd"; import { TeamTemplates } from "@/components/agents/team-templates"; type Filter = "all" | "running" | "needs" | "scheduled" | "disabled"; @@ -261,7 +262,9 @@ export default function AgentsPage() { } /> - ) : view === "chart" && chartAgents.length > 0 ? ( + ) : ( + + {view === "chart" && chartAgents.length > 0 ? ( <> {matches && matches.size === 0 && (

    @@ -296,9 +299,9 @@ export default function AgentsPage() { {groups.map((g) => { const st = stats(g.agents); return ( - + - + ); })} @@ -310,22 +313,34 @@ export default function AgentsPage() { {groups.map((g) => { const st = stats(g.agents); return ( - + - + ); })} ) : ( + )} + {agents.some((a) => !a.isDefault) && ( +

    + {view === "chart" + ? "Drag an agent onto another to change who it reports to, or onto a workspace to move it with its team." + : "Drag an agent onto a workspace to move it there with its team."}{" "} + + Add a team or a whole company + +

    + )} +
    )} {/* Just the built-in agent so far: offer a whole team to start with. */} {!agentsQ.isLoading && allAgents.length > 0 && allAgents.every((a) => a.isDefault) && (

    - Start a team + Start a company

    -

    A lead and its reports in one go — the lead hands out the work, reviews it and tells you what needs you.

    +

    A whole org chart in one go — every agent has its job, and every lead hands out the work, reviews it and tells you what needs you.

    )} @@ -337,6 +352,8 @@ export default function AgentsPage() { ); } +const wsTarget = (key: string) => ({ kind: "workspace" as const, workspaceId: key === "global" ? null : key }); + function CardGrid({ agents, routineCounts, @@ -355,7 +372,9 @@ function CardGrid({ {agents.map((agent, i) => ( - + + + ))} {!grouped && ( diff --git a/packages/core/src/agents/service.ts b/packages/core/src/agents/service.ts index 87690fa..30d9632 100644 --- a/packages/core/src/agents/service.ts +++ b/packages/core/src/agents/service.ts @@ -12,7 +12,10 @@ import type { AgentHeartbeat, AgentHeartbeatInput, AgentInput, + AgentMoveInput, + AgentMoveResult, AgentPermissions, + AgentPlacement, AgentStatus, Effort, GitCommit, @@ -924,6 +927,45 @@ export function repairReportingLines(): number { return changed; } +/** + * Move an agent to another workspace and/or under another lead (drag and drop in the agents view). Moving workspaces + * takes its team along by default: everyone under it follows, top-down, and keeps their lead. Returns where everyone + * was, in the order to put them back. + */ +export async function moveAgent(id: string, input: AgentMoveInput, actor = "user"): Promise { + const agent = getAgent(id); + if (agent.isDefault) throw badRequest("Godmode leads the team and reports to you"); + const workspaceId = input.workspaceId === undefined ? agent.workspaceId : input.workspaceId; + if (workspaceId) assertWorkspace(workspaceId); + const agents = listAgents(); + const team: Agent[] = []; + if (workspaceId !== agent.workspaceId) { + const seen = new Set([agent.id]); + const queue = [agent]; + while (queue.length) { + const lead = queue.shift()!; + for (const r of agents) { + if (r.reportsTo !== lead.id || seen.has(r.id)) continue; + seen.add(r.id); + // Without its team, only the reports that lose it (another workspace) are touched — by updateAgent. + if (input.withTeam !== false) { + team.push(r); + queue.push(r); + } else if (workspaceId && r.workspaceId !== workspaceId) team.push(r); + } + } + } + const followers = input.withTeam !== false ? team : []; + const placement = (a: Agent): AgentPlacement => ({ id: a.id, workspaceId: a.workspaceId, reportsTo: a.reportsTo }); + const previous = [agent, ...team].map(placement); + + const moved = [await updateAgent(agent.id, { workspaceId, ...(input.reportsTo !== undefined ? { reportsTo: input.reportsTo } : {}) }, actor)]; + for (const a of followers) moved.push(await updateAgent(a.id, { workspaceId, reportsTo: a.reportsTo }, actor)); + audit(actor, "agent.move", agent.id, { workspaceId, reportsTo: moved[0]!.reportsTo, team: followers.map((a) => a.id) }); + return { moved, previous }; +} + + /** * A copy of the agent's setup under " copy" (then "copy 2", …): look, personality, role, lead, instructions, * model, permissions, tools and folders. Not its memory, chats or automations — it starts fresh. diff --git a/packages/core/src/agents/teams.ts b/packages/core/src/agents/teams.ts index 4e6c306..4f04a50 100644 --- a/packages/core/src/agents/teams.ts +++ b/packages/core/src/agents/teams.ts @@ -1,111 +1,141 @@ /** - * Ready-made teams (GET /api/team-templates): a lead and its reports, built from the agent templates, installed in one - * go with their reporting lines — the way a small company starts a department instead of hiring one person at a time. + * Ready-made structures (GET /api/team-templates), built from the agent templates and installed in one go with their + * reporting lines: a team (a lead and its reports) or a whole company (a CEO, its executives and their teams) — the + * way a company starts a department instead of hiring one person at a time. */ -import type { Agent, AgentTemplate, TeamInstallResult, TeamTemplate } from "@godmode/shared"; +import type { Agent, AgentTemplate, OrgTemplateNode, TeamInstallInput, TeamInstallResult, TeamTemplate, Workspace } from "@godmode/shared"; import { audit } from "../services/audit"; -import { createRoutine } from "../services/routines"; +import { createRoutine, deleteRoutine } from "../services/routines"; +import { createWorkspace, deleteWorkspace } from "../services/workspaces"; import { badRequest, notFound } from "../util"; import { all } from "../db"; import { logger } from "../log"; import { createAgent, deleteAgent } from "./service"; -import { deleteRoutine } from "../services/routines"; import { AGENT_TEMPLATES } from "./templates"; const log = logger("teams"); -/** How a lead works: hand out, review, report — never do the reports' work itself. */ -function leadInstructions(area: string, goal: string): string { - return `Goal: ${goal} - -You lead the ${area} team. Your reports are listed in your team section; each has its own job. - -When work comes in: -1. Decide what it needs and who on your team does each part best. Do small, quick things yourself; hand everything that is one of your reports' jobs to them. -2. On a board ticket: split it with task_split — one part per report, each self-contained (what to do, what to deliver back). Your ticket waits until the parts are delivered, then you continue with their results. -3. In a chat: hand the work over with agent_delegate, then put together what comes back. -4. Review what your reports deliver before you pass it on: check it against what was asked, send a part back with task_message when it isn't good enough. -5. Report to the human in short: what was done, what needs their decision, what's next. - -Keep a short list in MEMORY.md of what each report does well and the human's preferences for your area. -Never: make commitments, payments or public posts for the human without their OK — ask with request_approval.`; -} +const seat = (template: string, ...reports: OrgTemplateNode[]): OrgTemplateNode => (reports.length ? { template, reports } : { template }); export const TEAM_TEMPLATES: TeamTemplate[] = [ + { + id: "startup", + kind: "company", + name: "Software startup", + icon: "rocket", + description: "A CEO with a CTO, a CMO and a head of sales, each with a small team: build it, tell people about it, sell it.", + root: seat( + "ceo", + seat("cto", seat("software-engineer"), seat("code-reviewer"), seat("web-qa-tester")), + seat("cmo", seat("content-writer"), seat("social-media-manager")), + seat("head-of-sales", seat("lead-researcher"), seat("sales-development-rep")), + ), + }, + { + id: "agency", + kind: "company", + name: "Agency", + icon: "briefcase", + description: "New business, delivery and the back office: prospects found and approached, client work built and reviewed, invoices in order.", + root: seat( + "ceo", + seat("head-of-sales", seat("lead-researcher"), seat("sales-development-rep")), + seat("cto", seat("software-engineer"), seat("code-reviewer"), seat("web-qa-tester")), + seat("office-manager", seat("inbox-triage"), seat("invoice-collector")), + ), + }, + { + id: "online-shop", + kind: "company", + name: "Online shop", + icon: "shopping-bag", + description: "Marketing that brings buyers, operations that answer them and watch competitors, finance that keeps the books.", + root: seat( + "ceo", + seat("cmo", seat("social-media-manager"), seat("seo-specialist"), seat("content-writer")), + seat("coo", seat("customer-support"), seat("price-monitor")), + seat("cfo", seat("invoice-collector"), seat("bookkeeping-helper")), + ), + }, + { + id: "saas", + kind: "company", + name: "SaaS company", + icon: "building", + description: "The full C-suite with every department staffed: product, engineering, marketing, sales, operations and finance.", + root: seat( + "ceo", + seat("head-of-product", seat("research-analyst")), + seat("cto", seat("software-engineer"), seat("code-reviewer"), seat("uptime-watcher"), seat("web-qa-tester")), + seat("cmo", seat("content-writer"), seat("seo-specialist"), seat("social-media-manager")), + seat("head-of-sales", seat("lead-researcher"), seat("sales-development-rep")), + seat("coo", seat("customer-support"), seat("recruiter")), + seat("cfo", seat("bookkeeping-helper"), seat("invoice-collector")), + ), + }, + { + id: "solo-founder", + kind: "company", + name: "Founder's office", + icon: "user-round", + description: "For one person running a business: an assistant who keeps your day, inbox, invoices and posts handled.", + root: seat("executive-assistant", seat("inbox-triage"), seat("daily-briefing"), seat("invoice-collector"), seat("social-media-manager"), seat("research-analyst")), + }, { id: "back-office", + kind: "team", name: "Back office", - icon: "🗂️", + icon: "archive", description: "Inbox, invoices and bookkeeping handled every week, with an office manager who keeps it together.", - lead: { - id: "office-manager", - role: "Office manager", - name: "Office Manager", - avatar: "🗂️", - color: "amber", - character: { body: "gumdrop", eyes: "dots", mouth: "smile", top: "none", face: "glasses", neck: "bowtie" }, - personality: "butler", - description: "Runs the back office: inbox, invoices and the books — and tells you only what needs you.", - instructions: leadInstructions("back-office", "keep the admin side of the business running without the human having to think about it."), - }, - members: ["inbox-triage", "invoice-collector", "bookkeeping-helper"], + root: seat("office-manager", seat("inbox-triage"), seat("invoice-collector"), seat("bookkeeping-helper")), }, { id: "marketing", + kind: "team", name: "Marketing", - icon: "📣", - description: "Posts planned and drafted, the market watched and topics researched — led by a marketing lead.", - lead: { - id: "marketing-lead", - role: "Marketing lead", - name: "Marketing Lead", - avatar: "📣", - color: "rose", - character: { body: "drop", eyes: "happy", mouth: "grin", top: "party", face: "none", neck: "scarf" }, - personality: "hype", - description: "Plans what to say and where, hands the work to the team and keeps the brand consistent.", - instructions: leadInstructions("marketing", "grow the business's reach with consistent, on-brand content informed by what the market does."), - }, - members: ["social-media-manager", "price-monitor", "research-analyst"], + icon: "megaphone", + description: "Content written, posts planned, search and the market watched — led by a CMO.", + root: seat("cmo", seat("content-writer"), seat("social-media-manager"), seat("seo-specialist"), seat("price-monitor")), }, { id: "sales", + kind: "team", name: "Sales", - icon: "🤝", - description: "Prospects found, qualified and researched, so the human only talks to the right people.", - lead: { - id: "sales-lead", - role: "Sales lead", - name: "Sales Lead", - avatar: "🤝", - color: "emerald", - character: { body: "pebble", eyes: "wink", mouth: "grin", top: "none", face: "shades", neck: "bowtie" }, - personality: "sunny", - description: "Keeps the pipeline full: decides whom to look for, reviews the leads and prepares the human's calls.", - instructions: leadInstructions("sales", "keep a pipeline of well-qualified prospects and prepare the human for every conversation."), - }, - members: ["lead-researcher", "research-analyst"], + icon: "handshake", + description: "Prospects found, qualified and approached, so the human only talks to the right people.", + root: seat("head-of-sales", seat("lead-researcher"), seat("sales-development-rep"), seat("research-analyst")), + }, + { + id: "engineering", + kind: "team", + name: "Engineering", + icon: "code", + description: "Tickets implemented, every pull request reviewed and the sites watched — led by a CTO.", + root: seat("cto", seat("software-engineer"), seat("code-reviewer"), seat("uptime-watcher")), }, { id: "product-qa", + kind: "team", name: "Product & QA", - icon: "🧪", + icon: "flask", description: "The website and app tested regularly, bugs written up clearly, questions about the product researched.", - lead: { - id: "product-lead", - role: "Product lead", - name: "Product Lead", - avatar: "🧪", - color: "violet", - character: { body: "ghost", eyes: "lines", mouth: "flat", top: "antenna", face: "glasses", neck: "none" }, - personality: "straight", - description: "Decides what gets tested and researched, reviews the findings and turns them into clear next steps.", - instructions: leadInstructions("product", "keep the product working and well understood: tested regularly, problems written up clearly, questions answered with evidence."), - }, - members: ["web-qa-tester", "research-analyst"], + root: seat("head-of-product", seat("web-qa-tester"), seat("research-analyst")), }, ]; +const templateById = new Map(AGENT_TEMPLATES.map((t) => [t.id, t])); + +/** Every seat with its path ("0" = the top, "0.2" = its third report), top-down. */ +export function seatsOf(root: OrgTemplateNode): { path: string; node: OrgTemplateNode; parent: string | null }[] { + const out: { path: string; node: OrgTemplateNode; parent: string | null }[] = []; + const walk = (node: OrgTemplateNode, path: string, parent: string | null) => { + out.push({ path, node, parent }); + node.reports?.forEach((r, i) => walk(r, `${path}.${i}`, path)); + }; + walk(root, "0", null); + return out; +} + /** A name nobody on the team has yet: "Research Analyst", then "Research Analyst (Sales)", then "… 2". */ function freeName(name: string, team: string, taken: Set): string { const candidates = [name, `${name} (${team})`, ...Array.from({ length: 8 }, (_, i) => `${name} (${team} ${i + 2})`)]; @@ -132,27 +162,49 @@ function agentInput(t: AgentTemplate, name: string, workspaceId: string | null, } /** - * Create the team: its lead (reporting to the built-in agent), then each member reporting to the lead, and — when asked — - * the members' automations from their templates. + * Create the structure top-down: the top seat (reporting to the built-in agent), then every seat under its lead — or, + * when its lead was left out, under the nearest one kept — and, when asked, the automations from their templates. + * All or nothing: a failure removes what was made, so the next try doesn't duplicate half a company. */ -export async function installTeam( - id: string, - opts: { workspaceId?: string | null; automations?: boolean; timezone?: string } = {}, -): Promise { +export async function installTeam(id: string, opts: TeamInstallInput = {}): Promise { const team = TEAM_TEMPLATES.find((t) => t.id === id); if (!team) throw notFound("Team"); - const members = team.members.map((m) => AGENT_TEMPLATES.find((t) => t.id === m)); - if (members.some((m) => !m)) throw badRequest(`The ${team.name} team template is incomplete`); - const workspaceId = opts.workspaceId ?? null; + const seats = seatsOf(team.root); + if (seats.some((s) => !templateById.has(s.node.template))) throw badRequest(`The ${team.name} template is incomplete`); + const skip = new Set((opts.skip ?? []).filter((p) => p !== "0")); + const kept = seats.filter((s) => !skip.has(s.path)); + if (kept.length < 2) throw badRequest("Keep at least one agent besides the top"); + // Names stay apart from the agents there are (two teams may both bring a Research Analyst). const taken = new Set(all<{ name: string }>("SELECT name FROM agents").map((a) => a.name.toLowerCase())); + let workspace: Workspace | null = null; const agents: Agent[] = []; const routineIds: string[] = []; + const created = new Map(); try { - const lead = await createAgent(agentInput(team.lead, freeName(team.lead.name, team.name, taken), workspaceId, null, true) as Parameters[0]); - agents.push(lead); - for (const t of members as AgentTemplate[]) { - const agent = await createAgent(agentInput(t, freeName(t.name, team.name, taken), workspaceId, lead.id, false) as Parameters[0]); + if (opts.newWorkspace) { + workspace = createWorkspace({ + name: opts.newWorkspace.name, + icon: opts.newWorkspace.icon, + color: opts.newWorkspace.color, + description: team.description, + }); + } + const workspaceId = workspace ? workspace.id : (opts.workspaceId ?? null); + // A seat kept leads when anyone kept ends up under it. + const leadOf = (path: string | null): string | null => { + let at = path; + while (at && skip.has(at)) at = seats.find((s) => s.path === at)!.parent; + return at; + }; + const leads = new Set(kept.flatMap((s) => (s.parent ? [leadOf(s.parent)!] : []))); + for (const s of kept) { + const t = templateById.get(s.node.template)!; + const lead = s.parent ? created.get(leadOf(s.parent)!)! : null; + const agent = await createAgent( + agentInput(t, freeName(t.name, team.name, taken), workspaceId, lead?.id ?? null, leads.has(s.path)) as Parameters[0], + ); + created.set(s.path, agent); agents.push(agent); if (opts.automations && t.routine) { routineIds.push( @@ -160,10 +212,14 @@ export async function installTeam( ); } } - audit("user", "team.install", lead.id, { team: id, members: agents.slice(1).map((a) => a.id), automations: routineIds.length }); - return { lead, members: agents.slice(1), automations: routineIds.length }; + audit("user", "team.install", agents[0]!.id, { + team: id, + members: agents.slice(1).map((a) => a.id), + automations: routineIds.length, + ...(workspace ? { workspace: workspace.id } : {}), + }); + return { lead: agents[0]!, members: agents.slice(1), automations: routineIds.length, workspace }; } catch (err) { - // All or nothing: half a team would be duplicated by the next try. for (const r of routineIds.reverse()) { try { deleteRoutine(r); @@ -172,8 +228,7 @@ export async function installTeam( } } for (const a of agents.reverse()) await deleteAgent(a.id).catch((e) => log.warn(`could not remove agent ${a.id} of a failed team install`, e)); + if (workspace) await deleteWorkspace(workspace.id, true).catch((e) => log.warn(`could not remove workspace ${workspace!.id} of a failed team install`, e)); throw err; } } - - diff --git a/packages/core/src/agents/templates.ts b/packages/core/src/agents/templates.ts index 7dc80bc..7e913a6 100644 --- a/packages/core/src/agents/templates.ts +++ b/packages/core/src/agents/templates.ts @@ -5,10 +5,28 @@ */ import type { AgentTemplate } from "@godmode/shared"; +/** How a lead works: hand out, review, report — never do the reports' work itself. `duties` is what the area needs on top. */ +export function leadInstructions(area: string, goal: string, duties = ""): string { + return `Goal: ${goal} + +You lead ${area}. Your reports are listed in your team section; each has its own job. +${duties ? `\n${duties}\n` : ""} +When work comes in: +1. Decide what it needs and who on your team does each part best. Do small, quick things yourself; hand everything that is one of your reports' jobs to them. +2. On a board ticket: split it with task_split — one part per report, each self-contained (what to do, what to deliver back). Your ticket waits until the parts are delivered, then you continue with their results. +3. In a chat: hand the work over with agent_delegate, then put together what comes back. +4. Review what your reports deliver before you pass it on: check it against what was asked, send a part back with task_message when it isn't good enough. +5. Report to the human in short: what was done, what needs their decision, what's next. + +Keep a short list in MEMORY.md of what each report does well and the human's preferences for your area. +Never: make commitments, payments or public posts for the human without their OK — ask with request_approval.`; +} + export const AGENT_TEMPLATES: AgentTemplate[] = [ { id: "inbox-triage", role: "Inbox manager", + category: "operations", name: "Inbox Triage", avatar: "📥", color: "sky", @@ -38,6 +56,7 @@ Never: send emails, unsubscribe, delete messages or click links in suspicious em { id: "research-analyst", role: "Research analyst", + category: "product", name: "Research Analyst", avatar: "🔎", color: "indigo", @@ -59,6 +78,7 @@ Keep a list of trusted and untrustworthy sources per domain in MEMORY.md.`, { id: "invoice-collector", role: "Invoice collector", + category: "finance", name: "Invoice Collector", avatar: "🧾", color: "emerald", @@ -87,6 +107,7 @@ Never pay invoices, change payment methods, cancel subscriptions or change accou { id: "daily-briefing", role: "Briefing writer", + category: "operations", name: "Daily Briefing", avatar: "☀️", color: "amber", @@ -115,6 +136,7 @@ Learn what the human reads and what they skip; adjust the format in MEMORY.md ac { id: "social-media-manager", role: "Social media manager", + category: "marketing", name: "Social Media Manager", avatar: "📣", color: "fuchsia", @@ -139,6 +161,7 @@ Update MEMORY.md with performance learnings (best times, formats and topics).`, { id: "price-monitor", role: "Market monitor", + category: "marketing", name: "Price & Competitor Monitor", avatar: "📈", color: "orange", @@ -166,6 +189,7 @@ Pages change layout: when extraction breaks, record the new selectors/steps in M { id: "lead-researcher", role: "Lead researcher", + category: "sales", name: "Lead Researcher", avatar: "🎯", color: "rose", @@ -189,6 +213,7 @@ Never send outreach messages or connection requests yourself, never buy data, an { id: "web-qa-tester", role: "QA tester", + category: "engineering", name: "Web App QA Tester", avatar: "🧪", color: "cyan", @@ -217,6 +242,7 @@ Keep flow scripts and known flaky steps up to date in MEMORY.md.`, { id: "bookkeeping-helper", role: "Bookkeeper", + category: "finance", name: "Bookkeeping Helper", avatar: "📒", color: "lime", @@ -237,4 +263,412 @@ Output: a short status — items processed, missing receipts, anomalies, and ope Never make payments or transfers, never change bank or accounting settings, and never file anything with authorities. Flag uncertain tax treatments instead of guessing.`, }, + /* Leadership ------------------------------------------------------- */ + { + id: "ceo", + role: "Chief executive officer", + category: "leadership", + leads: true, + name: "CEO", + avatar: "👑", + color: "amber", + character: { body: "squircle", eyes: "dots", mouth: "smile", top: "crown", face: "glasses", neck: "bowtie" }, + personality: "straight", + description: "Sets the goals, keeps every department pointed at them and tells you weekly what moved and what needs you.", + instructions: leadInstructions( + "the company: your reports are the heads of its departments, and they lead their own teams", + "turn the human's vision into a few clear goals and make sure every department works towards them.", + `Your job as CEO: +- Keep the company's goals, priorities and constraints (budget, brand, what never to do) in MEMORY.md. Ask the human for them on the first run; they decide, you execute. +- Work goes to the department that owns it — never past a department head to their reports. If something spans departments, split it and say who delivers what. +- Every week: ask each department head for a short status (what shipped, numbers, blockers), then send the human one page: progress against the goals, risks, decisions needed, next week's focus. +- Notice what nobody owns and propose who should — or that a new agent is needed.`, + ), + routine: { + name: "Weekly company review", + cron: "0 9 * * 1", + prompt: "Run the weekly company review: collect a status from every department head and send me the one-page summary.", + }, + }, + { + id: "cto", + role: "Chief technology officer", + category: "leadership", + leads: true, + name: "CTO", + avatar: "🛠️", + color: "indigo", + character: { body: "ghost", eyes: "lines", mouth: "flat", top: "headphones", face: "glasses", neck: "none" }, + personality: "straight", + description: "Owns the tech: turns requests into engineering tickets, reviews the code that ships and keeps the systems healthy.", + instructions: leadInstructions( + "engineering", + "ship working software in small, reviewed steps and keep the systems the business runs on healthy.", + `Your job as CTO: +- Record the repositories, stack, environments, deploy process and coding conventions in MEMORY.md (read the repos' own CLAUDE.md/README first). +- Break features and bugs into tickets small enough to finish and review in one go, with acceptance criteria. Engineers implement; the reviewer and QA check before anything is merged. +- Decide technical trade-offs and write the decision and the reason into MEMORY.md. Escalate cost, security and data-loss risks to the human. +- Never deploy to production, rotate secrets or delete data without the human's explicit OK.`, + ), + }, + { + id: "cmo", + role: "Chief marketing officer", + category: "leadership", + leads: true, + name: "CMO", + avatar: "📣", + color: "rose", + character: { body: "drop", eyes: "happy", mouth: "grin", top: "party", face: "none", neck: "scarf" }, + personality: "hype", + description: "Decides what to say, where and to whom, hands the content to the team and reports what actually brought customers.", + instructions: leadInstructions( + "marketing", + "grow the business's reach and turn it into customers with consistent, on-brand content informed by what the market does.", + `Your job as CMO: +- Keep positioning, audience, brand voice, channels and the goals (traffic, signups, leads) in MEMORY.md. +- Plan a monthly content calendar across channels and hand each piece to the right writer or channel manager. +- Every week: collect the numbers (reach, clicks, signups) your team can see, decide what to do more and less of, and tell the human in five lines. +- Nothing goes public without the human's approval of that exact content.`, + ), + routine: { + name: "Weekly marketing plan", + cron: "0 10 * * 1", + prompt: "Review last week's marketing results and plan this week's content with the team.", + }, + }, + { + id: "cfo", + role: "Chief financial officer", + category: "leadership", + leads: true, + name: "CFO", + avatar: "💼", + color: "emerald", + character: { body: "pebble", eyes: "sleepy", mouth: "smile", top: "none", face: "glasses", neck: "bowtie" }, + personality: "butler", + description: "Keeps the money side in order: invoices collected, books reconciled, cash and spend reported every month.", + instructions: leadInstructions( + "finance", + "always know where the money stands: complete books, no surprise costs, and a clear monthly picture for the human.", + `Your job as CFO: +- Record bank and payment accounts, the accounting tool, tax setup, the accountant's deadlines and every recurring cost in MEMORY.md. +- Each month: make sure all invoices are collected and the books are reconciled, then report revenue, costs by category, cash, unusual or growing costs and subscriptions that look unused. +- Flag anything due soon (taxes, invoices, renewals) with the date. +- Never pay, transfer, cancel or sign anything — prepare it and ask with request_approval.`, + ), + routine: { + name: "Monthly finance report", + cron: "0 9 3 * *", + prompt: "Prepare last month's finance report with the team: books reconciled, revenue, costs, cash and what needs my attention.", + }, + }, + { + id: "coo", + role: "Chief operating officer", + category: "leadership", + leads: true, + name: "COO", + avatar: "⚙️", + color: "cyan", + character: { body: "gumdrop", eyes: "dots", mouth: "smile", top: "cap", face: "none", neck: "none" }, + personality: "calm", + description: "Runs day-to-day operations: customers answered, processes documented and nothing falling through the cracks.", + instructions: leadInstructions( + "operations", + "keep the day-to-day running smoothly: customers answered on time, recurring work done, processes written down.", + `Your job as COO: +- Write every recurring process (who, what, when, done means) into workspace/processes/ and keep it current. +- Watch response times and backlogs (support inbox, open tickets, open questions) and rebalance work when something piles up. +- Turn every repeated problem into a fix: a clearer process, a new automation or a note for another department.`, + ), + }, + { + id: "head-of-product", + role: "Head of product", + category: "leadership", + leads: true, + name: "Head of Product", + avatar: "🧭", + color: "violet", + character: { body: "ghost", eyes: "lines", mouth: "flat", top: "antenna", face: "glasses", neck: "none" }, + personality: "curious", + description: "Decides what gets built, tested and researched next and turns findings into clear, prioritised next steps.", + instructions: leadInstructions( + "product", + "keep the product working and well understood: tested regularly, problems written up clearly, questions answered with evidence.", + `Your job as head of product: +- Keep the product's users, main flows, roadmap and known problems in MEMORY.md. +- Turn feedback, test results and research into a short prioritised list (impact vs effort) and keep it in workspace/product/priorities.md. +- Write tickets so an engineer can start without asking: the problem, who has it, what done looks like.`, + ), + }, + { + id: "head-of-sales", + role: "Head of sales", + category: "leadership", + leads: true, + name: "Head of Sales", + avatar: "🤝", + color: "orange", + character: { body: "pebble", eyes: "wink", mouth: "grin", top: "none", face: "shades", neck: "bowtie" }, + personality: "sunny", + description: "Keeps the pipeline full: decides whom to look for, reviews the leads and prepares the human's sales calls.", + instructions: leadInstructions( + "sales", + "keep a pipeline of well-qualified prospects and prepare the human for every conversation.", + `Your job as head of sales: +- Keep the ideal customer profile, offer, pricing and the pipeline stages in MEMORY.md; track the pipeline in workspace/sales/pipeline.csv. +- Every week: decide which segment to work on, have leads researched and outreach drafted, and review both before the human sees them. +- Before a call the human has: a one-page brief (company, person, signals, likely needs, questions to ask).`, + ), + routine: { + name: "Weekly pipeline review", + cron: "0 9 * * 2", + prompt: "Review the sales pipeline, plan this week's prospecting with the team and tell me who to talk to.", + }, + }, + { + id: "office-manager", + role: "Office manager", + category: "operations", + leads: true, + name: "Office Manager", + avatar: "🗂️", + color: "amber", + character: { body: "gumdrop", eyes: "dots", mouth: "smile", top: "none", face: "glasses", neck: "bowtie" }, + personality: "butler", + description: "Runs the back office: inbox, invoices and the books — and tells you only what needs you.", + instructions: leadInstructions("the back office", "keep the admin side of the business running without the human having to think about it."), + }, + + /* Engineering ------------------------------------------------------ */ + { + id: "software-engineer", + role: "Software engineer", + category: "engineering", + name: "Software Engineer", + avatar: "💻", + color: "sky", + character: { body: "squircle", eyes: "wide", mouth: "smile", top: "headphones", face: "glasses", neck: "none" }, + personality: "straight", + description: "Implements features and fixes from tickets in your repositories, with tests, and hands over a pull request.", + instructions: `Goal: turn tickets into small, working, tested changes the human can merge with confidence. + +First run: record in MEMORY.md the repositories you work in, how to install, run, test and lint each one, and the conventions from their CLAUDE.md/README/CONTRIBUTING. + +For every ticket: +1. Read the ticket and the code it touches. If the goal is ambiguous, pick the most sensible reading, state it in your summary and go — ask only when a wrong guess would waste real work. +2. Work on a branch of your own. Keep the change as small as the ticket allows and follow the code around it (naming, structure, comment density). +3. Add or update tests for what you changed. Run the project's tests, type checks and linters until they pass. +4. Commit with clear messages. When the project uses pull requests, end with a summary that can serve as the PR description: what changed, why, how it was tested, screenshots for UI changes. + +Never: push to the main branch directly, force-push, rewrite shared history, commit secrets or .env files, or run migrations and deploys against production.`, + }, + { + id: "code-reviewer", + role: "Code reviewer", + category: "engineering", + name: "Code Reviewer", + avatar: "🔍", + color: "indigo", + character: { body: "kitty", eyes: "lines", mouth: "flat", top: "none", face: "glasses", neck: "none" }, + personality: "witty", + description: "Reviews open pull requests for bugs, security issues and missing tests before they are merged.", + instructions: `Goal: catch real problems in changes before they reach the main branch, without slowing the team down with nitpicks. + +First run: record in MEMORY.md the repositories to watch, how to reach their pull requests (gh CLI, web) and the project's conventions. + +For every pull request: +1. Read the description and the full diff; open the surrounding code where the diff alone doesn't explain it. +2. Look for, in this order: wrong behaviour and edge cases, security problems (injection, secrets, auth), data loss, missing or weak tests, then readability. +3. Check out the branch and run the tests when the change is risky. +4. Write findings with file and line, why it matters, and a concrete fix. Mark each as blocking or optional. + +Output: a verdict (approve / changes needed) with the findings. Post review comments only when the human allowed that in MEMORY.md; otherwise report them. Never merge, close or push to someone else's branch.`, + routine: { + name: "Review open pull requests", + cron: "0 10,15 * * 1-5", + prompt: "Review every open pull request that changed since your last review and report your verdicts.", + }, + }, + { + id: "uptime-watcher", + role: "Site reliability engineer", + category: "engineering", + name: "Uptime Watcher", + avatar: "📡", + color: "lime", + character: { body: "cloud", eyes: "dots", mouth: "o", top: "antenna", face: "none", neck: "none" }, + personality: "calm", + description: "Checks your websites, APIs, certificates and domains every morning and raises the alarm before customers notice.", + instructions: `Goal: notice outages, slow pages, expiring certificates and domains before they hurt the business. + +First run: build the watchlist in MEMORY.md — every website, API health endpoint and domain, what a healthy response looks like, and who to tell. + +Each run: +1. Request every URL: status code, response time, and that the expected content is there. Open the important pages in the browser and look for visible errors. +2. Check TLS certificate and domain expiry dates; anything under 21 days is a warning, under 7 days an alert. +3. Compare with the previous run in workspace/uptime/log.csv (date, target, status, ms, notes) and append this run. + +Output: one line when everything is fine; otherwise a short incident note (what, since when, evidence, likely cause) and a notify_user alert. Never restart servers, change DNS or renew anything yourself.`, + routine: { + name: "Morning health check", + cron: "30 7 * * *", + prompt: "Check every watched site, endpoint, certificate and domain and report anything unhealthy.", + }, + }, + + /* Marketing --------------------------------------------------------- */ + { + id: "content-writer", + role: "Content writer", + category: "marketing", + name: "Content Writer", + avatar: "✍️", + color: "fuchsia", + character: { body: "blob", eyes: "happy", mouth: "smile", top: "beret", face: "none", neck: "scarf" }, + personality: "curious", + description: "Writes blog articles, newsletters and landing page copy in your voice, researched and ready to review.", + instructions: `Goal: produce useful, accurate, on-brand writing the human only needs to approve. + +First run: record in MEMORY.md the audience, brand voice (collect 3 examples the human likes), topics, words to avoid, the publishing tools and the call to action for each kind of piece. + +For every piece: +1. Clarify the reader, the one thing they should take away and the call to action. +2. Research the topic from primary sources; note every fact's source. +3. Write an outline first for anything longer than 600 words, then the draft: a strong first line, short paragraphs, concrete examples, no filler, no clichés. +4. Add a title, a meta description (under 155 characters) and suggested internal links. + +Output: save to workspace/content/drafts/-.md with the sources at the end, and reply with the title, the summary and the path. Never publish anything yourself.`, + }, + { + id: "seo-specialist", + role: "SEO specialist", + category: "marketing", + name: "SEO Specialist", + avatar: "🧲", + color: "orange", + character: { body: "pebble", eyes: "wide", mouth: "grin", top: "cap", face: "glasses", neck: "none" }, + personality: "witty", + description: "Audits your site every week, finds keywords worth winning and turns them into concrete fixes and content briefs.", + instructions: `Goal: grow organic traffic that converts, with fixes and content that matter most first. + +First run: record in MEMORY.md the site, its main pages and offers, the target market and language, competitors, and the tools you can use (Search Console, analytics) through the browser. + +Each run: +1. Technical check: indexability, titles and descriptions, broken links, slow or very large pages, missing structured data on key pages. +2. Look at what the site ranks for and where it is close (positions 5–20); research keywords the target customers search with their intent and difficulty. +3. Check what changed on competitors' sites. + +Output: a prioritised list (impact, effort, the exact page and change) in workspace/seo/.md, plus up to three content briefs (keyword, intent, outline, questions to answer, internal links). Never change the live site yourself.`, + routine: { + name: "Weekly SEO review", + cron: "0 8 * * 3", + prompt: "Run the weekly SEO review and give me the prioritised fixes and content briefs.", + }, + }, + + /* Sales ------------------------------------------------------------ */ + { + id: "sales-development-rep", + role: "Sales development rep", + category: "sales", + name: "Outreach Writer", + avatar: "✉️", + color: "rose", + character: { body: "drop", eyes: "wink", mouth: "smile", top: "none", face: "blush", neck: "bowtie" }, + personality: "sunny", + description: "Drafts personal first messages and follow-ups for your qualified leads, for you to approve and send.", + instructions: `Goal: start conversations with the right prospects through short, personal messages that don't read like templates. + +First run: record in MEMORY.md the offer, the proof points (customers, results), the channels used (email, LinkedIn), the tone, the follow-up cadence and the leads list to work from (usually workspace/leads/leads.csv). + +For each lead: +1. Re-check the lead's signal and the person's role; skip leads already contacted (workspace/sales/outreach.csv). +2. Write a first message of at most 90 words: why them, why now (the signal), one relevant proof point, one easy question. No buzzwords, no fake familiarity. +3. Prepare two follow-ups that add something new instead of "just checking in". + +Output: append the drafts to workspace/sales/outreach.csv (lead, channel, message, follow-ups, status "draft") and list them for approval. Never send messages, connection requests or emails yourself.`, + }, + + /* Operations ------------------------------------------------------- */ + { + id: "customer-support", + role: "Customer support", + category: "operations", + name: "Support Agent", + avatar: "🎧", + color: "sky", + character: { body: "blob", eyes: "happy", mouth: "smile", top: "headphones", face: "blush", neck: "none" }, + personality: "sunny", + description: "Answers customer questions from your support inbox or helpdesk with drafts grounded in your docs and policies.", + instructions: `Goal: every customer gets a correct, friendly answer quickly, and recurring problems are spotted early. + +First run: record in MEMORY.md the support channels (inbox, helpdesk, chat), the knowledge sources (FAQ, docs, policies for refunds, shipping, cancellations), the tone and what must always go to the human. + +Each run: +1. Go through new and open conversations since the last run. +2. For each: understand the actual problem, look up the answer in the knowledge sources and the customer's history, and draft a reply that solves it in as few messages as possible. +3. Tag each conversation (question, bug, billing, refund, complaint, other). Anything about money, legal matters, angry customers or unclear policy goes to the human. + +Output: drafts saved in the support tool (not sent, unless MEMORY.md says the human allowed sending a kind of answer), plus a short list of escalations and patterns seen this run. Never promise refunds, discounts or dates on your own.`, + routine: { + name: "Support queue", + cron: "0 9,12,16 * * 1-5", + prompt: "Work through the support queue since your last run: draft replies, escalate what needs me and report patterns.", + }, + }, + { + id: "executive-assistant", + role: "Executive assistant", + category: "operations", + leads: true, + name: "Executive Assistant", + avatar: "📅", + color: "violet", + character: { body: "squircle", eyes: "happy", mouth: "smile", top: "bow", face: "none", neck: "scarf" }, + personality: "butler", + description: "Keeps your calendar, prepares your meetings, books travel and runs the small errands that eat your day.", + instructions: `Goal: give the human their time back by handling scheduling, preparation and errands end to end. + +First run: record in MEMORY.md the calendars and mail accounts in use, working hours, meeting preferences (lengths, buffers, no-meeting days), travel preferences and the people who matter most. + +Typical tasks: +- Find times and prepare invitations; resolve conflicts and double bookings and propose what to move. +- The evening before each workday: a prep note per meeting (who, why, what they want, open points from earlier conversations). +- Research and prepare bookings (travel, restaurants, appointments) with two or three options and the full price. +- Keep a running list of the human's open promises and follow-ups in workspace/assistant/follow-ups.md. + +When you lead others (inbox, briefings, invoices), hand them their part with agent_delegate and review what comes back. + +Never accept or send invitations, book, pay or reply on the human's behalf without their explicit OK for that exact step.`, + routine: { + name: "Tomorrow's prep", + cron: "0 18 * * 0-4", + prompt: "Prepare tomorrow: check the calendar for conflicts and write a prep note for every meeting.", + }, + }, + { + id: "recruiter", + role: "Recruiter", + category: "operations", + name: "Recruiter", + avatar: "🧑‍💼", + color: "emerald", + character: { body: "kitty", eyes: "dots", mouth: "cat", top: "beret", face: "glasses", neck: "none" }, + personality: "calm", + description: "Writes job posts, screens applicants against your criteria and schedules the interviews worth having.", + instructions: `Goal: fill open roles with the right people while the human only talks to strong candidates. + +First run: record in MEMORY.md the open roles, must-haves and nice-to-haves for each, the salary range if shared, where applications arrive and the interview steps. + +Typical tasks: +- Draft job posts that are specific about the work, the team and the conditions. +- Screen new applications against the criteria: score 1–5 with the evidence from the CV and links, and the open questions for an interview. +- Draft replies: invitations with proposed times, polite rejections, follow-ups. +- Keep workspace/hiring/.csv current (candidate, date, score, stage, next step). + +Judge only job-relevant qualifications; never let age, gender, origin, religion, disability or similar influence a score. Never send messages to candidates or post jobs without the human's OK.`, + }, ]; diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index badc9d4..0ec9bf1 100644 --- a/packages/core/src/cloud/scope.ts +++ b/packages/core/src/cloud/scope.ts @@ -109,6 +109,7 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["GET|DELETE", "/api/agents/:id", A], ["PATCH", "/api/agents/:id", agentPermissions], ["POST", "/api/agents/:id/run", agentPermissions], + ["POST", "/api/agents/:id/move", A], ["POST", "/api/agents/:id/duplicate", A], ["DELETE", "/api/agents/:id/failed-run", A], ["POST", "/api/agents/:id/pause", agentPermissions], diff --git a/packages/core/src/server/routes/agents.ts b/packages/core/src/server/routes/agents.ts index 40d618f..d71602a 100644 --- a/packages/core/src/server/routes/agents.ts +++ b/packages/core/src/server/routes/agents.ts @@ -16,6 +16,7 @@ import { deleteAgent, dismissFailedRun, duplicateAgent, + moveAgent, getAgent, listAgentCommits, listAgentFiles, @@ -188,7 +189,15 @@ export function registerAgentRoutes(app: Hono): void { app.post("/api/team-templates/:id/install", async (c) => { const input = await body( c, - z.object({ workspaceId: z.string().max(100).nullable().optional(), automations: z.boolean().optional(), timezone: z.string().max(100).optional() }), + z.object({ + workspaceId: z.string().max(100).nullable().optional(), + newWorkspace: z + .object({ name: z.string().trim().min(1, "Name the workspace").max(80), icon: z.string().trim().max(16).optional(), color: z.string().trim().max(20).optional() }) + .optional(), + automations: z.boolean().optional(), + timezone: z.string().max(100).optional(), + skip: z.array(z.string().max(40)).max(100).optional(), + }), ); return c.json(await installTeam(c.req.param("id"), input), 201); }); @@ -214,6 +223,15 @@ export function registerAgentRoutes(app: Hono): void { return c.json({ ok: true }); }); + // Drag and drop: another workspace and/or another lead, its team along. + app.post("/api/agents/:id/move", async (c) => { + const input = await body( + c, + z.object({ workspaceId: z.string().max(100).nullable().optional(), reportsTo: z.string().max(100).nullable().optional(), withTeam: z.boolean().optional() }), + ); + return c.json(await moveAgent(c.req.param("id"), input)); + }); + // Same setup, fresh memory, no chats or automations. A copy that reads secrets needs the passphrase like a new one. app.post("/api/agents/:id/duplicate", async (c) => { const source = getAgent(c.req.param("id")); diff --git a/packages/core/test/teams.test.ts b/packages/core/test/teams.test.ts index 06b7aa5..7893594 100644 --- a/packages/core/test/teams.test.ts +++ b/packages/core/test/teams.test.ts @@ -1,12 +1,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import type { TeamInstallResult, TeamTemplate } from "@godmode/shared"; +import type { AgentMoveResult, TeamInstallResult, TeamTemplate, Workspace } from "@godmode/shared"; import { reportsOf } from "@godmode/shared"; import { setupEnv, type TestEnv } from "./fixtures/runner-harness"; import { listAgents } from "../src/agents/service"; import { getAccessToken } from "../src/server/auth"; import { listRoutines } from "../src/services/routines"; import { AGENT_TEMPLATES } from "../src/agents/templates"; -import { TEAM_TEMPLATES } from "../src/agents/teams"; +import { TEAM_TEMPLATES, seatsOf } from "../src/agents/teams"; import { deviceMayCall } from "../src/mobile/scope"; let env: TestEnv; @@ -22,11 +22,21 @@ afterAll(async () => { }); describe("ready-made teams", () => { - test("every team is built from agent templates that exist", () => { + test("every structure is built from agent templates that exist, led by templates made to lead", () => { + expect(new Set(TEAM_TEMPLATES.map((t) => t.id)).size).toBe(TEAM_TEMPLATES.length); for (const team of TEAM_TEMPLATES) { - expect(team.members.length).toBeGreaterThan(1); - for (const m of team.members) expect(AGENT_TEMPLATES.some((t) => t.id === m)).toBe(true); + const seats = seatsOf(team.root); + expect(seats.length).toBeGreaterThan(2); + for (const s of seats) { + const t = AGENT_TEMPLATES.find((a) => a.id === s.node.template); + expect(t).toBeDefined(); + if (s.node.reports?.length) expect(t!.leads).toBe(true); + } + // A company is at least two levels of leads deep. + if (team.kind === "company") expect(seats.some((s) => s.path.split(".").length === 3) || team.id === "solo-founder").toBe(true); } + expect(TEAM_TEMPLATES.filter((t) => t.kind === "company").length).toBeGreaterThanOrEqual(4); + for (const exec of ["ceo", "cto", "cmo", "cfo", "coo"]) expect(AGENT_TEMPLATES.find((t) => t.id === exec)?.category).toBe("leadership"); }); test("installing a team creates its lead and reports, wired up, with their automations when asked", async () => { @@ -52,11 +62,62 @@ describe("ready-made teams", () => { // A second team that brings the same agent gets its own name. const sales = (await (await call("/api/team-templates/sales/install", { method: "POST", body: JSON.stringify({ timezone: "Europe/Berlin" }) })).json()) as TeamInstallResult; const again = (await (await call("/api/team-templates/sales/install", { method: "POST", body: "{}" })).json()) as TeamInstallResult; - expect(again.lead.name).toBe("Sales Lead (Sales)"); + expect(again.lead.name).toBe("Head of Sales (Sales)"); expect(new Set(listAgents({ workspaceId: "all" }).map((a) => a.name)).size).toBe(listAgents({ workspaceId: "all" }).length); expect(sales.members.map((m) => m.name)).toContain("Research Analyst"); expect((await call("/api/team-templates/nope/install", { method: "POST", body: "{}" })).status).toBe(404); expect(deviceMayCall("POST", "/api/team-templates/back-office/install")).toBe(false); }); + + test("a company installs as a tree, into a new workspace, with left-out seats' reports moved up", async () => { + const res = await call("/api/team-templates/startup/install", { + method: "POST", + // Leave out the CMO (0.1): its writer and social media manager report to the CEO instead. + body: JSON.stringify({ newWorkspace: { name: "Acme" }, skip: ["0.1", "0"] }), + }); + expect(res.status).toBe(201); + const company = (await res.json()) as TeamInstallResult; + expect(company.workspace?.name).toBe("Acme"); + expect(company.lead.role).toBe("Chief executive officer"); + expect(company.members.some((m) => m.role === "Chief marketing officer")).toBe(false); + const agents = listAgents({ workspaceId: "all" }); + const mine = agents.filter((a) => a.workspaceId === company.workspace!.id); + expect(mine.length).toBe(1 + company.members.length); + const byRole = (role: string) => mine.find((a) => a.role === role)!; + expect(byRole("Content writer").reportsTo).toBe(company.lead.id); + expect(byRole("Software engineer").reportsTo).toBe(byRole("Chief technology officer").id); + expect(byRole("Chief technology officer").permissions.allowDelegation).toBe(true); + + expect((await call("/api/team-templates/back-office/install", { method: "POST", body: JSON.stringify({ skip: ["0.0", "0.1", "0.2"] }) })).status).toBe(400); + }); + + test("moving an agent to another workspace takes its team along, and the previous placements put it back", async () => { + const ws = (await (await call("/api/workspaces", { method: "POST", body: JSON.stringify({ name: "Elsewhere" }) })).json()) as Workspace; + const team = (await (await call("/api/team-templates/engineering/install", { method: "POST", body: "{}" })).json()) as TeamInstallResult; + const res = await call(`/api/agents/${team.lead.id}/move`, { method: "POST", body: JSON.stringify({ workspaceId: ws.id }) }); + expect(res.status).toBe(200); + const { moved, previous } = (await res.json()) as AgentMoveResult; + expect(moved[0]!.id).toBe(team.lead.id); + expect(moved.map((a) => a.id).sort()).toEqual([team.lead.id, ...team.members.map((m) => m.id)].sort()); + let agents = listAgents({ workspaceId: "all" }); + for (const m of team.members) expect(agents.find((a) => a.id === m.id)).toMatchObject({ workspaceId: ws.id, reportsTo: team.lead.id }); + expect(previous.every((p) => p.workspaceId === null)).toBe(true); + + // Its own report can't become its lead. + const loop = await call(`/api/agents/${team.lead.id}/move`, { method: "POST", body: JSON.stringify({ reportsTo: team.members[0]!.id }) }); + expect(loop.status).toBe(400); + + // Alone: its reports stay and report to the built-in agent. + const alone = (await (await call(`/api/agents/${team.lead.id}/move`, { method: "POST", body: JSON.stringify({ workspaceId: null, withTeam: false }) })).json()) as AgentMoveResult; + expect(alone.moved.map((a) => a.id)).toEqual([team.lead.id]); + agents = listAgents({ workspaceId: "all" }); + // A global lead can still lead them. + for (const m of team.members) expect(agents.find((a) => a.id === m.id)).toMatchObject({ workspaceId: ws.id, reportsTo: team.lead.id }); + + const back = await call(`/api/agents/${team.lead.id}/move`, { method: "POST", body: JSON.stringify({ workspaceId: ws.id, withTeam: false }) }); + expect(((await back.json()) as AgentMoveResult).previous[0]).toMatchObject({ workspaceId: null }); + const missing = await call(`/api/agents/${team.lead.id}/move`, { method: "POST", body: JSON.stringify({ workspaceId: "wsp_nope" }) }); + expect(missing.status).toBe(400); + }); }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 8f0f222..d0ec19a 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -86,6 +86,19 @@ export interface AgentInput { heartbeat?: AgentHeartbeatInput; } +export const AGENT_TEMPLATE_CATEGORIES = ["leadership", "engineering", "product", "marketing", "sales", "operations", "finance"] as const; +export type AgentTemplateCategory = (typeof AGENT_TEMPLATE_CATEGORIES)[number]; + +export const AGENT_TEMPLATE_CATEGORY_LABELS: Record = { + leadership: "Leadership", + engineering: "Engineering", + product: "Product & research", + marketing: "Marketing", + sales: "Sales", + operations: "Operations", + finance: "Finance", +}; + export interface AgentTemplate { id: string; name: string; @@ -97,19 +110,42 @@ export interface AgentTemplate { instructions: string; /** Its job title on the team. */ role: string; + category: AgentTemplateCategory; + /** Made to lead others: hands out work and reviews it. */ + leads?: boolean; routine?: { name: string; cron: string; prompt: string }; } -/** A whole team to start with: a lead and its reports (agent templates), wired up in the org chart. */ +/** One seat of an org chart template: an agent template and who reports to it. */ +export interface OrgTemplateNode { + template: string; + reports?: OrgTemplateNode[]; +} + +/** + * A structure to start with, built from agent templates and wired up in the org chart: a team (a lead and its reports) + * or a whole company (a CEO, its executives and their teams). + */ export interface TeamTemplate { id: string; + kind: "team" | "company"; name: string; + /** lucide icon name, drawn by the app. */ icon: string; description: string; - /** Leads the team: hands out the work, reviews it and reports to the human. */ - lead: AgentTemplate; - /** Its reports, by agent template id (`GET /api/agent-templates`). */ - members: string[]; + /** The top of the chart; reports to the built-in agent. */ + root: OrgTemplateNode; +} + +export interface TeamInstallInput { + /** Where they work: a workspace id, null = global. Ignored with `newWorkspace`. */ + workspaceId?: ID | null; + /** Create a workspace for them first. */ + newWorkspace?: { name: string; icon?: string; color?: string }; + automations?: boolean; + timezone?: string; + /** Seats to leave out, by path ("0", "0.1" = the second report of the first report). Their reports move up. */ + skip?: string[]; } /** What installing a team created. */ @@ -118,6 +154,31 @@ export interface TeamInstallResult { members: Agent[]; /** Automations created from the members' templates (when asked for). */ automations: number; + /** The workspace made for them, when asked for. */ + workspace: Workspace | null; +} + +/** Where an agent sits: its workspace and its lead (null = the built-in agent). */ +export interface AgentPlacement { + id: ID; + workspaceId: ID | null; + reportsTo: ID | null; +} + +export interface AgentMoveInput { + /** Omit to stay in its workspace. */ + workspaceId?: ID | null; + /** Omit to keep its lead where it still can. */ + reportsTo?: ID | null; + /** Its reports, and theirs, move along into the new workspace. Default true. */ + withTeam?: boolean; +} + +export interface AgentMoveResult { + /** Every agent that moved, the dragged one first. */ + moved: Agent[]; + /** Where they were before, in the order to put them back. */ + previous: AgentPlacement[]; } export interface RoutineInput {