diff --git a/.goreleaser.yml b/.goreleaser.yml index b8b0152c..faa9297e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -47,7 +47,7 @@ brews: directory: Formula homepage: "https://github.com/multica-ai/multica" description: "Multica CLI — local agent runtime and management tool for the Multica platform" - license: "MIT" + license: "Apache-2.0" install: | bin.install "multica" test: | diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md new file mode 100644 index 00000000..f2a0df8f --- /dev/null +++ b/CLI_AND_DAEMON.md @@ -0,0 +1,328 @@ +# CLI and Agent Daemon Guide + +The `multica` CLI connects your local machine to Multica. It handles authentication, workspace management, issue tracking, and runs the agent daemon that executes AI tasks locally. + +## Installation + +### Homebrew (macOS/Linux) + +```bash +brew tap multica-ai/tap +brew install multica-cli +``` + +### Build from Source + +```bash +git clone https://github.com/multica-ai/multica.git +cd multica +make build +cp server/bin/multica /usr/local/bin/multica +``` + +### Update + +```bash +multica update +``` + +This auto-detects your installation method (Homebrew or manual) and upgrades accordingly. + +## Quick Start + +```bash +# 1. Authenticate (opens browser for login) +multica login + +# 2. Start the agent daemon +multica daemon start + +# 3. Done — agents in your watched workspaces can now execute tasks on your machine +``` + +`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list. + +## Authentication + +### Browser Login + +```bash +multica login +``` + +Opens your browser for OAuth authentication, creates a 90-day personal access token, and auto-configures your workspaces. + +### Token Login + +```bash +multica login --token +``` + +Authenticate by pasting a personal access token directly. Useful for headless environments. + +### Check Status + +```bash +multica auth status +``` + +Shows your current server, user, and token validity. + +### Logout + +```bash +multica auth logout +``` + +Removes the stored authentication token. + +## Agent Daemon + +The daemon is the local agent runtime. It detects available AI CLIs on your machine, registers them with the Multica server, and executes tasks when agents are assigned work. + +### Start + +```bash +multica daemon start +``` + +By default, the daemon runs in the background and logs to `~/.multica/daemon.log`. + +To run in the foreground (useful for debugging): + +```bash +multica daemon start --foreground +``` + +### Stop + +```bash +multica daemon stop +``` + +### Status + +```bash +multica daemon status +multica daemon status --output json +``` + +Shows PID, uptime, detected agents, and watched workspaces. + +### Logs + +```bash +multica daemon logs # Last 50 lines +multica daemon logs -f # Follow (tail -f) +multica daemon logs -n 100 # Last 100 lines +``` + +### Supported Agents + +The daemon auto-detects these AI CLIs on your PATH: + +| CLI | Command | Description | +|-----|---------|-------------| +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent | +| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent | + +You need at least one installed. The daemon registers each detected CLI as an available runtime. + +### How It Works + +1. On start, the daemon detects installed agent CLIs and registers a runtime for each agent in each watched workspace +2. It polls the server at a configurable interval (default: 3s) for claimed tasks +3. When a task arrives, it creates an isolated workspace directory, spawns the agent CLI, and streams results back +4. Heartbeats are sent periodically (default: 15s) so the server knows the daemon is alive +5. On shutdown, all runtimes are deregistered + +### Configuration + +Daemon behavior is configured via flags or environment variables: + +| Setting | Flag | Env Variable | Default | +|---------|------|--------------|---------| +| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | +| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | +| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` | +| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | +| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname | +| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname | +| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` | +| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` | + +Agent-specific overrides: + +| Variable | Description | +|----------|-------------| +| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary | +| `MULTICA_CLAUDE_MODEL` | Override the Claude model used | +| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary | +| `MULTICA_CODEX_MODEL` | Override the Codex model used | + +### Self-Hosted Server + +When connecting to a self-hosted Multica instance, point the CLI to your server before logging in: + +```bash +export MULTICA_APP_URL=https://app.example.com +export MULTICA_SERVER_URL=wss://api.example.com/ws + +multica login +multica daemon start +``` + +Or set them persistently: + +```bash +multica config set app_url https://app.example.com +multica config set server_url wss://api.example.com/ws +``` + +### Profiles + +Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server. + +```bash +# Start a daemon for the staging server +multica --profile staging login +multica --profile staging daemon start + +# Default profile runs separately +multica daemon start +``` + +Each profile gets its own config directory (`~/.multica/profiles//`), daemon state, health port, and workspace root. + +## Workspaces + +### List Workspaces + +```bash +multica workspace list +``` + +Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces. + +### Watch / Unwatch + +```bash +multica workspace watch +multica workspace unwatch +``` + +### Get Details + +```bash +multica workspace get +multica workspace get --output json +``` + +### List Members + +```bash +multica workspace members +``` + +## Issues + +### List Issues + +```bash +multica issue list +multica issue list --status in_progress +multica issue list --priority urgent --assignee "Agent Name" +multica issue list --limit 20 --output json +``` + +Available filters: `--status`, `--priority`, `--assignee`, `--limit`. + +### Get Issue + +```bash +multica issue get +multica issue get --output json +``` + +### Create Issue + +```bash +multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda" +``` + +Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`. + +### Update Issue + +```bash +multica issue update --title "New title" --priority urgent +``` + +### Assign Issue + +```bash +multica issue assign --to "Lambda" +multica issue assign --unassign +``` + +### Change Status + +```bash +multica issue status in_progress +``` + +Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. + +### Comments + +```bash +# List comments +multica issue comment list + +# Add a comment +multica issue comment add --content "Looks good, merging now" + +# Reply to a specific comment +multica issue comment add --parent --content "Thanks!" + +# Delete a comment +multica issue comment delete +``` + +## Configuration + +### View Config + +```bash +multica config show +``` + +Shows config file path, server URL, app URL, and default workspace. + +### Set Values + +```bash +multica config set server_url wss://api.example.com/ws +multica config set app_url https://app.example.com +multica config set workspace_id +``` + +## Other Commands + +```bash +multica version # Show CLI version and commit hash +multica update # Update to latest version +multica agent list # List agents in the current workspace +``` + +## Output Formats + +Most commands support `--output` with two formats: + +- `table` — human-readable table (default for list commands) +- `json` — structured JSON (useful for scripting and automation) + +```bash +multica issue list --output json +multica daemon status --output json +``` diff --git a/LOCAL_DEVELOPMENT.md b/CONTRIBUTING.md similarity index 98% rename from LOCAL_DEVELOPMENT.md rename to CONTRIBUTING.md index 50b9f80e..d0b8006f 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Local Development Guide +# Contributing Guide -This guide documents the intended local development workflow for Multica. +This guide documents the local development workflow for contributors working on the Multica codebase. It covers: diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..23456c21 --- /dev/null +++ b/LICENSE @@ -0,0 +1,199 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please also get an + "Implied Patent License" from your patent counsel. + + Copyright 2025 Multica + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index db3e2660..30843832 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Multica lets you manage tasks and collaborate with AI agents the same way you wo ### Use Multica Cloud -The fastest way to get started: [app.multica.ai](https://app.multica.ai) +The fastest way to get started: [multica.ai](https://multica.ai) ### Self-Host @@ -42,43 +42,19 @@ make start The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon. -### Install - ```bash +# Install brew tap multica-ai/tap brew install multica-cli -``` -Or build from source: - -```bash -make build -cp server/bin/multica /usr/local/bin/multica -``` - -### Connect Your Agent Runtime - -```bash -# 1. Authenticate +# Authenticate and start multica login - -# 2. Watch your workspace -multica workspace watch - -# 3. Start the local agent daemon multica daemon start ``` The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back. -### Other Commands - -```bash -multica workspace list # List workspaces (watched ones marked with *) -multica agent list # List agents in the current workspace -multica daemon status # Show daemon status -multica version # Show CLI version -``` +See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage. ## Architecture @@ -101,7 +77,7 @@ multica version # Show CLI version ## Development -For contributors working on the Multica codebase, see the [Local Development Guide](LOCAL_DEVELOPMENT.md). +For contributors working on the Multica codebase, see the [Contributing Guide](CONTRIBUTING.md). ### Prerequisites @@ -119,7 +95,7 @@ make setup make start ``` -See [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md) for the full development workflow, worktree support, testing, and troubleshooting. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting. ## License diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index a6e8c092..0ab927a6 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -74,6 +74,7 @@ import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useRuntimeStore } from "@/features/runtimes"; import { useIssueStore } from "@/features/issues"; +import { ActorAvatar } from "@/components/common/actor-avatar"; // --------------------------------------------------------------------------- @@ -97,14 +98,6 @@ const taskStatusConfig: Record w[0]) - .join("") - .toUpperCase() - .slice(0, 2); -} function generateId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; @@ -341,9 +334,7 @@ function AgentListItem({ isSelected ? "bg-accent" : "hover:bg-accent/50" }`} > -
- {getInitials(agent.name)} -
+
@@ -1322,9 +1313,7 @@ function AgentDetail({
{/* Header */}
-
- {getInitials(agent.name)} -
+

{agent.name}

diff --git a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx index fd101e08..e49d5403 100644 --- a/apps/web/app/(dashboard)/settings/_components/members-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/members-tab.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import type { MemberWithUser, MemberRole } from "@/shared/types"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -70,14 +71,7 @@ function MemberRow({ return (
-
- {member.name - .split(" ") - .map((w) => w[0]) - .join("") - .toUpperCase() - .slice(0, 2)} -
+
{member.name}
{member.email}
diff --git a/apps/web/components/common/actor-avatar.tsx b/apps/web/components/common/actor-avatar.tsx index a50d71cc..5a94e7a1 100644 --- a/apps/web/components/common/actor-avatar.tsx +++ b/apps/web/components/common/actor-avatar.tsx @@ -45,6 +45,7 @@ function ActorAvatar({ return (
-
- -
+

{agent.name}

{agent.description && ( diff --git a/apps/web/components/common/title-editor.tsx b/apps/web/components/common/title-editor.tsx index 14837b27..05b99ca6 100644 --- a/apps/web/components/common/title-editor.tsx +++ b/apps/web/components/common/title-editor.tsx @@ -117,11 +117,13 @@ const TitleEditor = forwardRef( }, }); - // Auto-focus after mount + // Auto-focus after mount — delay to wait for Dialog open animation useEffect(() => { if (autoFocus && editor) { - // Move cursor to end - editor.commands.focus("end"); + const timer = setTimeout(() => { + editor.commands.focus("end"); + }, 50); + return () => clearTimeout(timer); } }, [autoFocus, editor]); diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 4c9e0b15..6917fc0f 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -1,10 +1,10 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle } from "lucide-react"; +import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; import { api } from "@/shared/api"; import { useWSEvent } from "@/features/realtime"; -import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload } from "@/shared/types/events"; +import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events"; import type { AgentTask } from "@/shared/types/agent"; import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -106,6 +106,7 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { const [items, setItems] = useState([]); const [elapsed, setElapsed] = useState(""); const [autoScroll, setAutoScroll] = useState(true); + const [cancelling, setCancelling] = useState(false); const scrollRef = useRef(null); const seenSeqs = useRef(new Set()); @@ -165,6 +166,7 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { setActiveTask(null); setItems([]); seenSeqs.current.clear(); + setCancelling(false); }, [issueId]), ); @@ -176,6 +178,19 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { setActiveTask(null); setItems([]); seenSeqs.current.clear(); + setCancelling(false); + }, [issueId]), + ); + + useWSEvent( + "task:cancelled", + useCallback((payload: unknown) => { + const p = payload as TaskCancelledPayload; + if (p.issue_id !== issueId) return; + setActiveTask(null); + setItems([]); + seenSeqs.current.clear(); + setCancelling(false); }, [issueId]), ); @@ -215,6 +230,16 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { setAutoScroll(scrollHeight - scrollTop - clientHeight < 40); }, []); + const handleCancel = useCallback(async () => { + if (!activeTask || cancelling) return; + setCancelling(true); + try { + await api.cancelTask(issueId, activeTask.id); + } catch { + setCancelling(false); + } + }, [activeTask, issueId, cancelling]); + if (!activeTask) return null; const toolCount = items.filter((i) => i.type === "tool_use").length; @@ -236,6 +261,19 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { {toolCount} tool {toolCount === 1 ? "call" : "calls"} )} +
{/* Timeline content */} @@ -302,7 +340,17 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) { }, [issueId]), ); - const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed"); + // Refresh when a task is cancelled + useWSEvent( + "task:cancelled", + useCallback((payload: unknown) => { + const p = payload as TaskCancelledPayload; + if (p.issue_id !== issueId) return; + api.listTasksByIssue(issueId).then(setTasks).catch(() => {}); + }, [issueId]), + ); + + const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled"); if (completedTasks.length === 0) return null; return ( diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 3b5ea855..15b0f946 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -53,7 +53,7 @@ import { import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; -import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; +import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { ActorAvatar } from "@/components/common/actor-avatar"; import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; @@ -180,7 +180,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const currentIndex = allIssues.findIndex((i) => i.id === id); const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; - const { getActorName, getActorInitials } = useActorName(); + const { getActorName } = useActorName(); const { uploadWithToast } = useFileUpload(); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: layoutId, @@ -598,9 +598,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {subscribers.length > 0 ? ( {subscribers.slice(0, 4).map((sub) => ( - - {getActorInitials(sub.user_type, sub.user_id)} - + ))} {subscribers.length > 4 && ( +{subscribers.length - 4} diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index ddefa1ba..67ac07c3 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; -import { Bot, CalendarDays, Check, ChevronRight, Maximize2, Minimize2, UserMinus, X as XIcon } from "lucide-react"; +import { CalendarDays, Check, ChevronRight, Maximize2, Minimize2, UserMinus, X as XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@/shared/types"; @@ -35,6 +35,7 @@ import { useIssueDraftStore } from "@/features/issues/stores/draft-store"; import { api } from "@/shared/api"; import { useFileUpload } from "@/shared/hooks/use-file-upload"; import { FileUploadButton } from "@/components/common/file-upload-button"; +import { ActorAvatar } from "@/components/common/actor-avatar"; // --------------------------------------------------------------------------- // Pill trigger — shared rounded-full button style for toolbar @@ -69,7 +70,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? const workspaceName = useWorkspaceStore((s) => s.workspace?.name); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); - const { getActorName, getActorInitials } = useActorName(); + const { getActorName } = useActorName(); const draft = useIssueDraftStore((s) => s.draft); const setDraft = useIssueDraftStore((s) => s.setDraft); @@ -291,14 +292,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? {assigneeType && assigneeId ? ( <> -
- {assigneeType === "agent" ? : getActorInitials(assigneeType, assigneeId)} -
+ {assigneeLabel} ) : ( @@ -345,9 +339,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? }} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" > -
- {getActorInitials("member", m.user_id)} -
+ {m.name} ))} @@ -368,9 +360,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? }} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors" > -
- -
+ {a.name} ))} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 8c778806..fb309433 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -360,6 +360,12 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/task-runs`); } + async cancelTask(issueId: string, taskId: string): Promise { + return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, { + method: "POST", + }); + } + // Inbox async listInbox(): Promise { return this.fetch("/api/inbox"); diff --git a/apps/web/shared/types/events.ts b/apps/web/shared/types/events.ts index 304a28ee..8b3a5fc6 100644 --- a/apps/web/shared/types/events.ts +++ b/apps/web/shared/types/events.ts @@ -21,6 +21,7 @@ export type WSEventType = | "task:completed" | "task:failed" | "task:message" + | "task:cancelled" | "inbox:new" | "inbox:read" | "inbox:archived" @@ -179,6 +180,13 @@ export interface TaskFailedPayload { status: string; } +export interface TaskCancelledPayload { + task_id: string; + agent_id: string; + issue_id: string; + status: string; +} + export interface ReactionAddedPayload { reaction: Reaction; issue_id: string; diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 2daa4648..d5391544 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -64,12 +64,12 @@ func resolveServerURL(cmd *cobra.Command) string { profile := resolveProfile(cmd) cfg, err := cli.LoadCLIConfigForProfile(profile) if err != nil { - return "http://localhost:8080" + return "https://api.multica.ai" } if cfg.ServerURL != "" { return normalizeAPIBaseURL(cfg.ServerURL) } - return "http://localhost:8080" + return "https://api.multica.ai" } func normalizeAPIBaseURL(raw string) string { diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index e20c9af8..0c502cdd 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -72,7 +72,7 @@ func resolveAppURL(cmd *cobra.Command) string { if err == nil && cfg.AppURL != "" { return strings.TrimRight(cfg.AppURL, "/") } - return "http://localhost:3000" + return "https://multica.ai" } func openBrowser(url string) error { diff --git a/server/cmd/multica/cmd_auth_test.go b/server/cmd/multica/cmd_auth_test.go index de881b44..492a4c28 100644 --- a/server/cmd/multica/cmd_auth_test.go +++ b/server/cmd/multica/cmd_auth_test.go @@ -35,13 +35,13 @@ func TestResolveAppURL(t *testing.T) { } }) - t.Run("defaults to localhost 3000", func(t *testing.T) { + t.Run("defaults to production", func(t *testing.T) { t.Setenv("MULTICA_APP_URL", "") t.Setenv("FRONTEND_ORIGIN", "") t.Setenv("HOME", t.TempDir()) // avoid reading real config - if got := resolveAppURL(cmd); got != "http://localhost:3000" { - t.Fatalf("resolveAppURL() = %q, want %q", got, "http://localhost:3000") + if got := resolveAppURL(cmd); got != "https://multica.ai" { + t.Fatalf("resolveAppURL() = %q, want %q", got, "https://multica.ai") } }) } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 400d3c40..a792b381 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -169,6 +169,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/subscribe", h.SubscribeToIssue) r.Post("/unsubscribe", h.UnsubscribeFromIssue) r.Get("/active-task", h.GetActiveTaskForIssue) + r.Post("/tasks/{taskId}/cancel", h.CancelTask) r.Get("/task-runs", h.ListTasksByIssue) r.Post("/reactions", h.AddIssueReaction) r.Delete("/reactions", h.RemoveIssueReaction) diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 13f0c5da..6ab5ac93 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -682,7 +682,41 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { _ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2) - result, err := d.runTask(ctx, task, provider, taskLog) + // Create a cancellable context so we can interrupt the running agent + // when the server-side task status changes to 'cancelled'. + runCtx, runCancel := context.WithCancel(ctx) + defer runCancel() + + // Poll for cancellation every 5 seconds while the task is running. + cancelledByPoll := make(chan struct{}) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-runCtx.Done(): + return + case <-ticker.C: + if status, err := d.client.GetTaskStatus(ctx, task.ID); err == nil && status == "cancelled" { + taskLog.Info("task cancelled by server, interrupting agent") + runCancel() + close(cancelledByPoll) + return + } + } + } + }() + + result, err := d.runTask(runCtx, task, provider, taskLog) + + // Check if we were cancelled by the polling goroutine. + select { + case <-cancelledByPoll: + taskLog.Info("task cancelled during execution, discarding result") + return + default: + } + if err != nil { taskLog.Error("task failed", "error", err) if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil { diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d08f080d..d0051766 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -525,6 +525,21 @@ func (h *Handler) GetActiveTaskForIssue(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(tasks[0])}) } +// CancelTask cancels a running or queued task by ID. +func (h *Handler) CancelTask(w http.ResponseWriter, r *http.Request) { + taskID := chi.URLParam(r, "taskId") + + task, err := h.TaskService.CancelTask(r.Context(), parseUUID(taskID)) + if err != nil { + slog.Warn("cancel task failed", "task_id", taskID, "error", err) + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + slog.Info("task cancelled by user", "task_id", taskID, "issue_id", uuidToString(task.IssueID)) + writeJSON(w, http.StatusOK, taskToResponse(*task)) +} + // ListTasksByIssue returns all tasks (any status) for an issue — used for execution history. func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) { issueID := chi.URLParam(r, "id") diff --git a/server/internal/service/task.go b/server/internal/service/task.go index b09d56d3..f46d0e14 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -104,6 +104,25 @@ func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UU return s.Queries.CancelAgentTasksByIssue(ctx, issueID) } +// CancelTask cancels a single task by ID. It broadcasts a task:cancelled event +// so frontends can update immediately. +func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) { + task, err := s.Queries.CancelAgentTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("cancel task: %w", err) + } + + slog.Info("task cancelled", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID)) + + // Reconcile agent status + s.ReconcileAgentStatus(ctx, task.AgentID) + + // Broadcast cancellation as a task:failed event so frontends clear the live card + s.broadcastTaskEvent(ctx, protocol.EventTaskCancelled, task) + + return &task, nil +} + // ClaimTask atomically claims the next queued task for an agent, // respecting max_concurrent_tasks. func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.AgentTaskQueue, error) { diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index a951d44e..befca00e 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -11,6 +11,37 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const cancelAgentTask = `-- name: CancelAgentTask :one +UPDATE agent_task_queue +SET status = 'cancelled', completed_at = now() +WHERE id = $1 AND status IN ('queued', 'dispatched', 'running') +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id +` + +func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQueue, error) { + row := q.db.QueryRow(ctx, cancelAgentTask, id) + var i AgentTaskQueue + err := row.Scan( + &i.ID, + &i.AgentID, + &i.IssueID, + &i.Status, + &i.Priority, + &i.DispatchedAt, + &i.StartedAt, + &i.CompletedAt, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.Context, + &i.RuntimeID, + &i.SessionID, + &i.WorkDir, + &i.TriggerCommentID, + ) + return i, err +} + const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :exec UPDATE agent_task_queue SET status = 'cancelled' diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 2b581204..4511200b 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -107,6 +107,12 @@ WHERE (status = 'dispatched' AND dispatched_at < now() - make_interval(secs => @ OR (status = 'running' AND started_at < now() - make_interval(secs => @running_timeout_secs::double precision)) RETURNING id, agent_id, issue_id; +-- name: CancelAgentTask :one +UPDATE agent_task_queue +SET status = 'cancelled', completed_at = now() +WHERE id = $1 AND status IN ('queued', 'dispatched', 'running') +RETURNING *; + -- name: CountRunningTasks :one SELECT count(*) FROM agent_task_queue WHERE agent_id = $1 AND status IN ('dispatched', 'running'); diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 6d9ec027..7a8636ec 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -27,6 +27,7 @@ const ( EventTaskCompleted = "task:completed" EventTaskFailed = "task:failed" EventTaskMessage = "task:message" + EventTaskCancelled = "task:cancelled" // Inbox events EventInboxNew = "inbox:new"