From e8f71137a0247480d6c022645b70e5aa62fcdec5 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 4 Oct 2026 20:51:48 +0200 Subject: [PATCH] Agents ask before they act, tickets tell the whole story, and the agents read as a team Questions and approvals - An agent can ask the human (ask_human, with suggested answers) or ask for an OK before a step that sends, pays or deletes something nobody asked for (request_approval). The run stands still instead of pretending to be done; the human is notified and answers with one click in the chat, the inbox, the task, the phone, or by replying in Slack, Telegram or Teams (only the person marked as the owner can answer there). The run continues where it stopped; stopping it withdraws the question. - Migration 50: questions table, messaging_users.is_owner. Tickets - Priority, due date and labels on every ticket; urgent ones start first and the agent is told the deadline. Filters by them on the board. - A timeline per ticket: every delivery with its result, feedback, notes agents leave (task_note), blocks, pull requests, questions and answers. - Blocked says why (needs input, failed, stopped, cut off by a restart) and offers the matching way on; a ticket whose agent set a follow-up waits instead of landing in review; what each ticket cost and how long the agent worked. Approve / Request changes / Reopen in one click. - Agents: task_get, task_message, task_note; richer task_create/update. - Migration 51: ticket columns, task_events. Team - Every agent has a role and a lead (the built-in agent leads by default); agents are told who does what and who they report to. Reporting lines grant nothing; reach and permissions are unchanged. - Org chart on the Agents page, "Needs you" filter, truthful status (Working in 2 chats, Queued, Needs your answer, Last run failed with a link to the run and Dismiss), "On its plate" on the agent page. - Handoffs both ways: the handoff card opens the teammate's chat, the handed-over chat links back. Messages from automations, the board or other agents are labelled, never shown as the human's. - Duplicate an agent; no new chats with a switched-off agent (and a Switch on bar in its chats); the app learns active runs on connect. - Migration 53: agents.role / reports_to / failed_run_id, messages.source, and a backfill that also runs after a restore. --- README.md | 8 +- apps/desktop/src/App.tsx | 28 +- .../src/components/agents/agent-actions.tsx | 206 ++++-- .../src/components/agents/agent-card.tsx | 25 +- .../src/components/agents/agent-form.tsx | 235 ++++++- .../components/agents/detail/chats-tab.tsx | 10 +- .../components/agents/detail/dreams-panel.tsx | 2 +- .../components/agents/detail/overview-tab.tsx | 109 +-- .../src/components/agents/detail/plate.tsx | 234 +++++++ .../src/components/agents/org-chart.tsx | 170 +++++ .../src/components/chat/agent-picker.tsx | 6 +- apps/desktop/src/components/chat/composer.tsx | 13 +- .../components/chat/conversation-header.tsx | 43 +- .../src/components/chat/message-blocks.tsx | 125 +++- apps/desktop/src/components/chat/messages.tsx | 96 ++- apps/desktop/src/components/chat/pause.tsx | 57 +- .../src/components/chat/question-card.tsx | 339 ++++++++++ .../src/components/chat/queue-tray.tsx | 55 +- apps/desktop/src/components/chat/thread.tsx | 12 +- apps/desktop/src/components/chat/tool-meta.ts | 53 +- .../src/components/chat/voice-mode.tsx | 33 +- .../components/inbox/notification-list.tsx | 3 +- .../src/components/inbox/question-item.tsx | 72 ++ .../src/components/layout/app-shell.tsx | 6 +- .../src/components/layout/recent-chats.tsx | 31 +- .../components/messaging/connection-sheet.tsx | 27 +- .../src/components/runs/run-detail-sheet.tsx | 107 ++- apps/desktop/src/components/runs/run-row.tsx | 2 +- .../src/components/runs/run-status.tsx | 7 +- .../src/components/tasks/task-board.tsx | 78 ++- .../src/components/tasks/task-card.tsx | 86 ++- .../src/components/tasks/task-dialog.tsx | 34 +- .../src/components/tasks/task-fields.tsx | 134 +++- .../src/components/tasks/task-meta.tsx | 128 +++- .../src/components/tasks/task-sheet.tsx | 429 +++++++++++- .../src/components/tasks/task-timeline.tsx | 189 ++++++ apps/desktop/src/lib/api.ts | 39 +- apps/desktop/src/lib/hooks.ts | 10 + apps/desktop/src/lib/queryKeys.ts | 5 + apps/desktop/src/lib/realtime.ts | 39 +- .../desktop/src/pages/agents/agent-detail.tsx | 71 +- apps/desktop/src/pages/agents/agent-new.tsx | 2 + apps/desktop/src/pages/agents/agents-page.tsx | 86 ++- .../src/pages/chat/chat-conversation.tsx | 96 ++- apps/desktop/src/pages/chat/chat-home.tsx | 6 +- apps/desktop/src/pages/inbox/inbox-page.tsx | 61 +- apps/desktop/src/pages/tasks/tasks-page.tsx | 271 +++++++- apps/desktop/src/stores/live.ts | 33 +- apps/desktop/src/stores/ui.ts | 6 + apps/mobile/src/app/(tabs)/(home)/index.tsx | 42 +- apps/mobile/src/app/(tabs)/_layout.tsx | 2 +- apps/mobile/src/app/(tabs)/agents/index.tsx | 16 +- apps/mobile/src/app/agent/[id].tsx | 17 +- apps/mobile/src/app/chat/[id].tsx | 21 +- apps/mobile/src/app/task/[id].tsx | 44 +- apps/mobile/src/components/message.tsx | 76 ++- apps/mobile/src/components/rows.tsx | 21 +- apps/mobile/src/components/task-row.tsx | 21 +- apps/mobile/src/lib/api.ts | 8 +- apps/mobile/src/lib/format.ts | 9 + apps/mobile/src/lib/query.ts | 1 + apps/mobile/src/lib/realtime.ts | 9 + docs/ARCHITECTURE.md | 143 +++- packages/core/src/agents/claudeMd.ts | 1 + packages/core/src/agents/service.ts | 204 +++++- packages/core/src/agents/templates.ts | 9 + packages/core/src/automations/conditions.ts | 2 +- packages/core/src/automations/events.ts | 10 +- packages/core/src/backup/backup.ts | 11 + packages/core/src/db/migrations.ts | 140 ++++ packages/core/src/mcp/http.ts | 3 +- packages/core/src/mcp/tools.ts | 277 +++++++- packages/core/src/messaging/bridge.ts | 148 +++- packages/core/src/messaging/service.ts | 38 +- packages/core/src/mobile/scope.ts | 5 + packages/core/src/runner/prompt.ts | 175 ++++- packages/core/src/runner/runner.ts | 292 +++++++- packages/core/src/runner/stream.ts | 10 + packages/core/src/scheduler/scheduler.ts | 16 +- packages/core/src/server/app.ts | 2 + packages/core/src/server/routes/agents.ts | 17 +- packages/core/src/server/routes/chat.ts | 12 +- packages/core/src/server/routes/messaging.ts | 14 +- packages/core/src/server/routes/questions.ts | 53 ++ packages/core/src/server/routes/system.ts | 4 +- packages/core/src/server/routes/tasks.ts | 22 +- packages/core/src/server/ws.ts | 7 +- packages/core/src/services/conversations.ts | 70 +- packages/core/src/services/followups.ts | 12 +- packages/core/src/services/messageQueue.ts | 6 + packages/core/src/services/pauses.ts | 42 +- packages/core/src/services/questions.ts | 634 ++++++++++++++++++ packages/core/src/tasks/attachments.ts | 14 - packages/core/src/tasks/service.ts | 602 +++++++++++++++-- packages/core/test/composio.test.ts | 3 + packages/core/test/conversations.test.ts | 3 +- packages/core/test/credentials.test.ts | 3 + packages/core/test/fixtures/fake-claude.ts | 130 +++- packages/core/test/mcp-servers.test.ts | 3 + packages/core/test/mcp.test.ts | 6 +- packages/core/test/messaging.test.ts | 47 ++ packages/core/test/questions.test.ts | 421 ++++++++++++ packages/core/test/tasks.test.ts | 17 +- packages/core/test/team.test.ts | 417 ++++++++++++ packages/core/test/tickets.test.ts | 294 ++++++++ packages/core/test/totp.test.ts | 3 + packages/shared/src/api.ts | 32 + packages/shared/src/events.ts | 13 +- packages/shared/src/index.ts | 1 + packages/shared/src/messaging.ts | 12 + packages/shared/src/models.ts | 127 +++- packages/shared/src/tasks.ts | 232 ++++++- packages/shared/src/team.ts | 193 ++++++ 113 files changed, 8371 insertions(+), 785 deletions(-) create mode 100644 apps/desktop/src/components/agents/detail/plate.tsx create mode 100644 apps/desktop/src/components/agents/org-chart.tsx create mode 100644 apps/desktop/src/components/chat/question-card.tsx create mode 100644 apps/desktop/src/components/inbox/question-item.tsx create mode 100644 apps/desktop/src/components/tasks/task-timeline.tsx create mode 100644 packages/core/src/server/routes/questions.ts create mode 100644 packages/core/src/services/questions.ts create mode 100644 packages/core/test/questions.test.ts create mode 100644 packages/core/test/team.test.ts create mode 100644 packages/core/test/tickets.test.ts create mode 100644 packages/shared/src/team.ts diff --git a/README.md b/README.md index b8a386c0..54d282e7 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,16 @@ needs to be useful: |---|---| | 💬 **Chat first** | Start with one conversation with *Godmode*, your main AI coworker. It can answer, act, and create other agents for you. | | 🤖 **Persistent agents** | Bots with their own instructions, conversations, memory (`MEMORY.md`), routines and history — each in its own git repo. | -| 🫧 **Characters & personality** | Every agent is a little creature with a face: pick its body, eyes, mouth, hat, glasses or bow tie and colour (or hit *Surprise me*), give it a name and a personality — Buddy, Sunny, Calm, Witty, Straight shooter, Curious, Butler, Hype or your own words — and it greets you and reports back in that voice. Its face shows what it's doing: it thinks, looks busy while it works, waves when it needs you, hops when the job is done and naps while paused. Godmode itself is the mint mochi with the lightning bolt. | +| 🫧 **Characters & personality** | Every agent is a little creature with a face: pick its body, eyes, mouth, hat, glasses or bow tie and colour (or hit *Surprise me*), give it a name and a personality — Buddy, Sunny, Calm, Witty, Straight shooter, Curious, Butler, Hype or your own words — and it greets you and reports back in that voice. Its face shows what it's doing: it thinks, looks busy while it works, waves when it needs you, hops when the job is done and naps while switched off. Godmode itself is the mint mochi with the lightning bolt. | | 🌙 **Dreaming** | While you're away, agents review their recent conversations and rewrite their memory: they pick up what they learned (even if nobody said "remember this"), merge duplicates, fix contradictions and update dates ("is going to Singapore in July" → "went to Singapore in July"). Memory loads into every new chat; every dream can be reviewed as a diff and undone. | | 📁 **Working folders** | Optionally point a chat or an agent at any folder on your machine — it works on the files there and keeps its memory in its own repo. | -| 📋 **Task board** | A Trello-style Kanban board per workspace: create tickets, assign them to agents, drag them across *Backlog → Todo → In progress → In review → Done*. A ticket that lands in *Todo* starts its agent. Every ticket works in its own git worktree of the workspace's repository (a cloned URL or a local folder), on its own branch, so agents running side by side never get in each other's way. When the agent on a **Coding** ticket is done, Godmode pushes its branch and opens a pull request — merged pull requests close their ticket. Reply to a ticket to ask for changes; the pull request updates. Archive finished tickets (one by one, or a whole *Done* column) to keep the board focused — they keep everything and come back in one click. | +| 📋 **Task board** | A Trello-style Kanban board per workspace: create tickets, assign them to agents, drag them across *Backlog → Todo → In progress → In review → Done*. A ticket that lands in *Todo* starts its agent. Every ticket works in its own git worktree of the workspace's repository (a cloned URL or a local folder), on its own branch, so agents running side by side never get in each other's way. When the agent on a **Coding** ticket is done, Godmode pushes its branch and opens a pull request — merged pull requests close their ticket. Reply to a ticket to ask for changes; the pull request updates. Archive finished tickets (one by one, or a whole *Done* column) to keep the board focused — they keep everything and come back in one click. Tickets carry a **priority**, a **due date** and **labels** (filter by them; urgent ones start first and the agent is told the deadline). Every ticket keeps a **timeline** — each delivery with its full result, your feedback, the agent's notes, blocks and pull requests — and shows what it **cost** and how long the agent worked. Review in one click (**Approve** / **Request changes** / **Reopen**); *Blocked* says why — the agent needs something, the run failed, a restart cut it off — and offers the matching way on. A ticket whose agent set itself a follow-up shows *Waiting — continues tomorrow at 10:00* instead of landing in review. | | ⚡ **Automations** | Work starts when it happens: on a schedule (“weekdays at 08:00”), when something happens in a connected app (a new email, a Slack message, a calendar event, a Notion update), when a condition you describe comes true (“competitor pricing changes”), or when a webhook is called. Describe it in one sentence and Godmode sets it up. | | ⏰ **Follow-ups** | When a task needs waiting — a reply to an email, a delivery, a build, office hours — the agent sets itself a time and picks the chat up again on its own, with all the context, like a coworker who says “I'll check back tomorrow at 10”. You see when it comes back and can continue now, move it or cancel it. | -| 🤝 **Delegation** | Agents hand tasks to peer agents or spawn short-lived subagents. The Godmode agent can list, check, create and configure all agents. | +| 🤝 **Delegation** | Agents hand tasks to peer agents or spawn short-lived subagents. The Godmode agent can list, check, create and configure all agents. Follow a handoff both ways: open the teammate's chat from the handoff card, and jump back to the chat that asked from there. | +| 🏢 **Team & org chart** | Give every agent a role (*Bookkeeper*, *Research analyst*) and a lead; agents are told who does what and who they report to, so work goes to the right one. The **org chart** shows the whole team with what each is doing right now. Status tells the truth — *Working in 2 chats*, *Queued*, *Needs your answer*, *Last run failed* (one click opens the run) — and each agent's page shows **what's on its plate**: live work, waiting chats, board tickets, follow-ups and its next automations. Duplicate an agent in one click; messages from automations, the board or other agents are labelled as such, never shown as yours. | | 📨 **Message queue** | Keep writing while an agent works: your messages wait in a queue above the message box, and the agent picks them up at its next step and works them into what it is doing — a correction lands right away, an extra request comes after the current step. Reword or remove a queued message until it is picked up, or hit *Send now* to interrupt. | +| 🙋 **Questions & approvals** | When a decision is yours — or before a step that sends, pays or deletes something you didn't ask for — the agent asks: a question with suggested answers, or *Approve / Decline* for exactly that step. The work stands still (it doesn't pretend to be done), you're notified, and you answer with one click in the chat, the inbox, the task, from your phone or by replying in Slack, Telegram or Teams; the agent continues right where it stopped. Stop the run and the question is withdrawn. | | ⏸️ **Pause & continue** | Pause an agent or a single chat and continue later: it lets the step it is in finish, stands still, and picks the work up exactly where it stopped — same run, same message, same Claude session. When Claude's usage limit is reached mid-task, the work waits instead of failing and continues by itself once the limit has reset. | | ⌨️ **Slash commands** | Type `/` for every Claude Code command — `/compact`, `/model`, `/effort`, `/clear`… — with argument hints and tab completion. | | 🌐 **Real browser** | browser-use drives a managed Chromium over CDP. Watch it live right next to the chat and *take control* for CAPTCHAs. | diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0f864a90..5ff10a11 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -37,6 +37,7 @@ const SettingsPage = lazy(() => import("@/pages/settings/settings-page")); export function App() { const qc = useQueryClient(); + const navigate = useNavigate(); const [coreReady, setCoreReady] = useState(false); const [coreError, setCoreError] = useState(null); @@ -73,6 +74,31 @@ export function App() { useEffect(() => { if (!authed) return; return onServerEvent((event) => { + // A question that was answered or withdrawn (here, in another window, on the phone) takes its toast along. + if (event.type === "question.updated" && event.question.status !== "open") { + const q = event.question; + toast.dismiss(`question:${q.taskId ? `/tasks?task=${q.taskId}` : `/chat/${q.conversationId}`}`); + return; + } + if (event.type === "notification" && event.notification.kind === "question") { + // An agent waits for the human: a toast that leads to the question, unless it is on screen already. + const n = event.notification; + const here = n.link && `${location.pathname}${location.search}` === n.link && document.hasFocus(); + if (here) { + void api.notifications.read([n.id]).catch(() => undefined); + return; + } + toast.warning(n.title, { + id: n.link ? `question:${n.link}` : undefined, + description: n.body || undefined, + duration: 20_000, + action: n.link ? { label: "Answer", onClick: () => navigate(n.link!) } : undefined, + }); + const desktopOn = qc.getQueryData<{ settings?: { general?: { desktopNotifications?: boolean } } }>(qk.bootstrap)?.settings?.general + ?.desktopNotifications; + if (desktopOn !== false && !document.hasFocus()) void notifyDesktop(n.title, n.body); + return; + } if (event.type === "notification") { const n = event.notification; const fn = n.kind === "error" ? toast.error : n.kind === "warning" || n.kind === "missing_login" ? toast.warning : n.kind === "success" ? toast.success : toast; @@ -82,7 +108,7 @@ export function App() { if (desktopOn !== false && !document.hasFocus()) void notifyDesktop(n.title, n.body); } }); - }, [authed, qc]); + }, [authed, qc, navigate]); if (coreError) return ; if (!coreReady || auth.isLoading) return ; diff --git a/apps/desktop/src/components/agents/agent-actions.tsx b/apps/desktop/src/components/agents/agent-actions.tsx index 306ca3cd..d5dad0f7 100644 --- a/apps/desktop/src/components/agents/agent-actions.tsx +++ b/apps/desktop/src/components/agents/agent-actions.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router"; +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Play } from "lucide-react"; -import type { Agent, CharacterMood } from "@godmode/shared"; +import type { Agent, AgentPresence, CharacterMood } from "@godmode/shared"; +import { agentPresence, presenceLabel } from "@godmode/shared"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; import { useMissingLogins, useScopeWorkspace } from "@/lib/hooks"; @@ -36,36 +37,88 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; +import { isGrantCancelled, withGrant } from "@/components/vault/grant"; -/** The live run of an agent, if it is working right now. */ +/** The agent's live runs: the working ones first, then the queued ones, oldest first. */ +export function useAgentLiveRuns(agentId: string | undefined): LiveRun[] { + const ids = useLive((s) => + agentId + ? Object.values(s.runs) + .filter((r) => r.agentId === agentId) + .sort((a, b) => Number(a.status !== "running") - Number(b.status !== "running") || a.startedAt - b.startedAt) + .map((r) => r.runId) + .join(",") + : "", + ); + const runs = useLive((s) => s.runs); + return useMemo(() => (ids ? ids.split(",").flatMap((id) => (runs[id] ? [runs[id]] : [])) : []), [ids, runs]); +} + +/** The agent's first run that is working right now (a queued one isn't). */ export function useAgentLiveRun(agentId: string | undefined): LiveRun | null { - return useLive((s) => (agentId ? (Object.values(s.runs).find((r) => r.agentId === agentId) ?? null) : null)); + return useLive((s) => (agentId ? (Object.values(s.runs).find((r) => r.agentId === agentId && r.status === "running") ?? null) : null)); +} + +/** What the agent is doing right now, the same everywhere it shows (card, chart, header, picker). */ +export function useAgentPresence(agent: Agent): AgentPresence { + const runs = useAgentLiveRuns(agent.id); + const { data: missing = [] } = useMissingLogins("open"); + return agentPresence(agent, { + running: runs.filter((r) => r.status === "running").length, + queued: runs.filter((r) => r.status === "queued").length, + needsLogin: missing.some((m) => m.agentId === agent.id), + }); } -/** The agent's character mood outside a chat: busy while it runs, waving while a login it needs is missing. */ +/** The agent's character mood outside a chat: busy while it works, waving while it needs the human. */ export function useAgentMood(agent: Agent): CharacterMood { const live = useAgentLiveRun(agent.id); - const { data: missing = [] } = useMissingLogins("open"); + const presence = useAgentPresence(agent); if (live) return liveMood(live).mood; - if (!agent.enabled) return "sleeping"; - if (missing.some((m) => m.agentId === agent.id)) return "attention"; - if (agent.status === "error") return "error"; - return "idle"; + switch (presence.state) { + case "off": + return "sleeping"; + case "waiting": + return "attention"; + case "failed": + return "error"; + default: + return "idle"; + } } -/** Start a fresh conversation with an agent and open it. */ +type ChatTarget = Pick; + +/** Start a fresh conversation with an agent and open it. A switched-off agent gets a "Switch on and chat" offer instead. */ export function useStartAgentChat() { const navigate = useNavigate(); const qc = useQueryClient(); const workspace = useScopeWorkspace(); - return useMutation({ - mutationFn: (agentId: string) => api.conversations.create({ agentId, workspaceId: workspace?.id }), + const mutation = useMutation({ + mutationFn: async ({ agent, switchOn }: { agent: ChatTarget; switchOn?: boolean }) => { + if (switchOn) { + const updated = await api.agents.update(agent.id, { enabled: true }); + qc.setQueryData(qk.agent(updated.id), updated); + qc.invalidateQueries({ queryKey: qk.agents }); + } + return api.conversations.create({ agentId: agent.id, workspaceId: workspace?.id }); + }, onSuccess: (conversation) => { qc.invalidateQueries({ queryKey: qk.conversationsAll }); navigate(`/chat/${conversation.id}`); }, onError: (err) => toast.error("Couldn't start a chat", { description: errorMessage(err) }), }); + return { + ...mutation, + mutate: (agent: ChatTarget) => { + if (agent.enabled) return mutation.mutate({ agent }); + toast(`${agent.name} is switched off`, { + description: "It doesn't answer until you switch it on.", + action: { label: "Switch on and chat", onClick: () => mutation.mutate({ agent, switchOn: true }) }, + }); + }, + }; } export function useToggleAgent() { @@ -75,14 +128,32 @@ export function useToggleAgent() { onSuccess: (agent) => { qc.setQueryData(qk.agent(agent.id), agent); qc.invalidateQueries({ queryKey: qk.agents }); - toast.success(agent.enabled ? `${agent.name} enabled` : `${agent.name} disabled`, { - description: agent.enabled ? undefined : "Its automations won't run until you enable it again.", + toast.success(agent.enabled ? `${agent.name} is switched on` : `${agent.name} is switched off`, { + description: agent.enabled ? undefined : "Its automations won't run until you switch it on again.", }); }, onError: (err) => toast.error("Couldn't update agent", { description: errorMessage(err) }), }); } +/** Copy an agent's setup (the passphrase first when it may read secrets) and open the copy's settings. */ +export function useDuplicateAgent() { + const navigate = useNavigate(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (agent: Agent) => + agent.permissions.secretAccess === "reveal" ? withGrant((grant) => api.agents.duplicate(agent.id, grant), `Copy ${agent.name}, which may read secrets`) : api.agents.duplicate(agent.id), + onSuccess: (copy, source) => { + qc.invalidateQueries({ queryKey: qk.agents }); + toast.success(`${copy.name} is ready`, { description: `Same setup as ${source.name}, with a fresh memory. Give it its own name and role.` }); + navigate(`/agents/${copy.id}/settings`); + }, + onError: (err, source) => { + if (!isGrantCancelled(err)) toast.error(`Couldn't duplicate ${source.name}`, { description: errorMessage(err) }); + }, + }); +} + /** Pause everything an agent is working on, and continue it where it stopped. */ export function useAgentPause() { const qc = useQueryClient(); @@ -269,56 +340,65 @@ export function DeleteAgentDialog({ ); } -/** Status dot + label, live-aware ("Browsing github.com…" while running). */ -export function AgentStatus({ agent, className, showActivity = true }: { agent: Agent; className?: string; showActivity?: boolean }) { +/** + * Status dot + label, live-aware ("Browsing github.com…" while one run works). With `interactive`, "Last run failed" + * opens that run and a single working run links to its chat. + */ +export function AgentStatus({ + agent, + className, + showActivity = true, + interactive = false, +}: { + agent: Agent; + className?: string; + showActivity?: boolean; + interactive?: boolean; +}) { const live = useAgentLiveRun(agent.id); - const { data: missing = [] } = useMissingLogins("open"); - const running = !!live || agent.status === "running"; - const needsLogin = missing.some((m) => m.agentId === agent.id); - const paused = agent.pausedRuns ?? 0; - const state = !agent.enabled - ? "disabled" - : running - ? "running" - : paused - ? "paused" - : needsLogin - ? "attention" - : agent.status === "error" - ? "error" - : "idle"; - const label = - state === "running" - ? showActivity && live?.activity - ? live.activity - : "Working…" - : state === "disabled" - ? "Disabled" - : state === "paused" - ? paused > 1 - ? `Paused · ${paused} chats` - : "Paused" - : state === "attention" - ? "Needs a login" - : state === "error" - ? "Last run failed" - : "Idle"; + const presence = useAgentPresence(agent); + const { state } = presence; + const label = state === "working" && presence.running === 1 && showActivity && live?.activity ? live.activity : presenceLabel(presence); + const text = ( + + {label} + + ); + const dot = + state === "working" ? ( + + ) : ( + + ); + const linkClass = cn("relative z-10 inline-flex min-w-0 items-center gap-1.5 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none", className); + if (interactive && state === "failed" && agent.failedRunId) { + return ( + e.stopPropagation()}> + {dot} + {text} + + ); + } + if (interactive && state === "working" && presence.running === 1 && live) { + return ( + e.stopPropagation()}> + {dot} + {text} + + ); + } return ( - {state === "running" ? ( - - ) : ( - - )} - {label} + {dot} + {text} ); } diff --git a/apps/desktop/src/components/agents/agent-card.tsx b/apps/desktop/src/components/agents/agent-card.tsx index 0e2b3c82..e094c733 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 { Cpu, Ellipsis, History, MessageSquare, Pause, Pencil, Play, Power, PowerOff, StepForward, Trash2, Workflow } from "lucide-react"; +import { Copy, Cpu, Ellipsis, 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"; @@ -15,7 +15,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { AgentStatus, useAgentLiveRun, useAgentMood, useAgentPause, useStartAgentChat, useToggleAgent } from "./agent-actions"; +import { AgentStatus, useAgentLiveRun, useAgentMood, useAgentPause, useDuplicateAgent, useStartAgentChat, useToggleAgent } from "./agent-actions"; export function AgentCard({ agent, @@ -35,9 +35,10 @@ export function AgentCard({ const mood = useAgentMood(agent); const chat = useStartAgentChat(); const toggle = useToggleAgent(); + const duplicate = useDuplicateAgent(); const { pause, resume } = useAgentPause(); const paused = (agent.pausedRuns ?? 0) > 0; - const chatting = chat.isPending && chat.variables === agent.id; + const chatting = chat.isPending && chat.variables?.agent.id === agent.id; return (
Built-in )}
- + {agent.role && ( +

+ {agent.role} +

+ )} + {running && } @@ -72,7 +78,7 @@ export function AgentCard({ - chat.mutate(agent.id)}> + chat.mutate(agent)}> New chat onRunTask(agent)} disabled={!agent.enabled}> @@ -94,9 +100,14 @@ export function AgentCard({ navigate(`/agents/${agent.id}/settings`)}> Edit + {!agent.isDefault && ( + duplicate.mutate(agent)} disabled={duplicate.isPending}> + Duplicate + + )} toggle.mutate({ id: agent.id, enabled: !agent.enabled })}> - {agent.enabled ? : } {agent.enabled ? "Disable" : "Enable"} + {agent.enabled ? : } {agent.enabled ? "Switch off" : "Switch on"} {!agent.isDefault && ( onDelete(agent)}> @@ -126,7 +137,7 @@ export function AgentCard({
- {paused ? ( diff --git a/apps/desktop/src/components/agents/agent-form.tsx b/apps/desktop/src/components/agents/agent-form.tsx index f104edce..a71091a0 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -24,7 +24,20 @@ import { Wrench, } from "lucide-react"; import type { Agent, AgentCharacter, AgentInput, Effort, SecretAccessMode, SubagentDefinition } from "@godmode/shared"; -import { DEFAULT_MODEL, EFFORT_LABELS, EFFORT_OPTIONS, characterGreeting, defaultCharacter, effortForModel, findModel } from "@godmode/shared"; +import { + DEFAULT_MODEL, + EFFORT_LABELS, + EFFORT_OPTIONS, + MAX_AGENT_ROLE_LENGTH, + characterGreeting, + defaultCharacter, + effortForModel, + findModel, + leadProblem, + normalizeRole, + reportsOf, + withinReach, +} from "@godmode/shared"; import { api } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; import { useAllAgents, useBootstrap, useModelCatalog, useSshServers, useVmChoices, useWorkspaces } from "@/lib/hooks"; @@ -67,6 +80,10 @@ export interface AgentFormValues { personality: string; description: string; instructions: string; + /** Job title on the team. */ + role: string; + /** Its lead; null = the built-in agent. */ + reportsTo: string | null; workspaceId: string | null; model: string; effort: Effort | null; @@ -104,6 +121,8 @@ export function agentToValues( personality: source?.personality ?? "", description: source?.description ?? "", instructions: source?.instructions ?? "", + role: source?.role ?? "", + reportsTo: source?.reportsTo ?? null, workspaceId: source?.workspaceId !== undefined ? source.workspaceId : (defaults.workspaceId ?? null), model: source?.model ?? "", effort: source?.effort ?? null, @@ -137,6 +156,8 @@ export function valuesToInput(v: AgentFormValues): AgentInput { personality: v.personality.trim(), description: v.description.trim(), instructions: v.instructions, + role: normalizeRole(v.role), + reportsTo: v.reportsTo, model: v.model, effort: v.effort, permissions: { @@ -187,6 +208,7 @@ function validate(v: AgentFormValues): Record { const SECTIONS = [ { id: "identity", label: "Identity" }, + { id: "team", label: "Team" }, { id: "personality", label: "Personality" }, { id: "instructions", label: "Instructions" }, { id: "brain", label: "Model" }, @@ -336,6 +358,20 @@ export function AgentForm({ /> {err("name") &&

{err("name")}

}
+
+ + set("role", e.target.value)} + placeholder={isDefault ? "e.g. Chief of staff" : "e.g. Bookkeeper"} + maxLength={MAX_AGENT_ROLE_LENGTH} + aria-describedby="agent-role-hint" + /> +

+ Its job title — shown under its name and told to its teammates. +

+
+ + set("reportsTo", v)} + /> + +