[Haik]: notarized app done and working.

This commit is contained in:
haikdc
2026-03-15 05:12:05 -07:00
parent c728b462d6
commit 1b06780079
17 changed files with 768 additions and 142 deletions
+203 -65
View File
@@ -1,104 +1,242 @@
# Open Swarm — Agent Orchestrator
<p align="center">
<img src="assets/icon.png" alt="Open Swarm" width="128" height="128">
</p>
A locally-running React + FastAPI application for managing multiple Claude Code instances in parallel. Designed for power users who run multiple agents simultaneously and need a unified interface to monitor, control, and coordinate them.
<h1 align="center">Open Swarm</h1>
<p align="center">
<strong>An Army of AI Agents at Your Fingertips</strong>
<br>
A locally-running orchestrator for managing multiple Claude Code instances in parallel.
<br>
Launch, monitor, and coordinate entire swarms of coding agents from a single interface.
</p>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/platform-macOS-lightgrey.svg" alt="Platform"></a>
<a href="https://github.com/openswarm-ai/openswarm/stargazers"><img src="https://img.shields.io/github/stars/openswarm-ai/openswarm?style=social" alt="GitHub Stars"></a>
<a href="https://github.com/openswarm-ai/openswarm/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome"></a>
</p>
<br>
<p align="center">
<img src="assets/screenshot.png" alt="Open Swarm Dashboard" width="900">
</p>
<br>
## Why Open Swarm?
Running Claude Code in a terminal works fine for one task. But when you're juggling five agents across different branches, approving tool calls in separate windows, and losing track of who's doing what — it falls apart fast.
- **Parallel agents, one screen** — Launch as many Claude Code instances as you need, arranged on a spatial canvas you can pan and zoom freely
- **Unified approval workflow** — Every tool-use request from every agent surfaces in one place. Approve or deny with a click or a keyboard shortcut.
- **Full conversation control** — Edit prior messages to fork conversations, navigate between branches, resume closed sessions
- **100% local** — Everything runs on your machine. No cloud relay, no telemetry, no third-party backend.
<br>
## Features
- **Multi-agent management** — Launch and monitor multiple Claude Code instances side by side
- **Git worktree isolation** — Each agent works on its own git worktree/branch to avoid conflicts
- **Real-time streaming** — WebSocket-based streaming of agent messages and status updates
- **HITL approvals** — Approve or deny tool usage requests from the dashboard or within each chat
- **Message branching** — Edit prior messages to fork conversations, navigate between branches
- **Prompt template library** — Reusable prompt templates with structured input fields, invoked via `/` commands
- **Skills library** — Manage skills synced to the native `~/.claude/skills/` directory
- **Tools library** — Define custom tool configurations (bash, MCP, Python)
- **Keyboard shortcuts** — Navigate between agents and approve/deny requests without a mouse
- **Diff viewer** — View uncommitted changes in each agent's worktree
**Spatial Dashboard** — Infinite canvas with drag-and-drop agent cards, view cards, and embedded browser cards. Create multiple dashboards for different workspaces.
**Agent Chat** — Full streaming chat interface powered by WebSockets. Real-time token output, cost tracking per session, and persistent history that survives restarts.
**Human-in-the-Loop Approvals**Agents request permission before executing tools. Approve or deny individually, or batch-approve from the dashboard. Configurable per-tool permissions (always allow, ask, deny).
**Message Branching**Edit any prior message to fork the conversation. Navigate freely between branches without losing context.
**Prompt Templates**Build reusable templates with structured input fields. Invoke them inline via `/` slash commands.
**Skills Library** — Manage skills that sync directly to `~/.claude/skills/`. Browse and install from the official Anthropic skills marketplace.
**Tools Library** — Configure MCP tool servers (stdio, HTTP, SSE) with automatic tool discovery. Browse the MCP registry and Google's catalog with GitHub star counts. Includes Google Workspace OAuth integration.
**Agent Modes** — Five built-in modes (Agent, Ask, Plan, View Builder, Skill Builder) plus custom user-defined modes with configurable system prompts and tool restrictions.
**Views & Outputs** — Create interactive HTML/JS/CSS artifacts rendered in iframes. Supports vibe coding (LLM-generates the view), backend Python execution, auto-run with LLM-generated data, and agent-driven data gathering.
**Git Worktree Isolation** — Each agent operates in its own git worktree and branch, preventing conflicts between parallel workstreams.
**Diff Viewer** — Inspect uncommitted changes in any agent's worktree without leaving the app.
**Cost Tracking** — Real-time USD spend tracking per agent session.
**Dark & Light Themes** — Full theme support with Claude-inspired design tokens.
**Keyboard Shortcuts** — Navigate between agents, approve/deny requests, and switch pages without touching a mouse.
<br>
## Quick Start
### Desktop App
Download the latest release for macOS from [GitHub Releases](https://github.com/openswarm-ai/openswarm/releases).
> Windows and Linux builds are planned but not yet available.
### Development Setup
**Prerequisites:** Python 3.11+, Node.js 18+, Git
```bash
git clone https://github.com/openswarm-ai/openswarm.git
cd openswarm
bash run.sh
```
This starts the backend (port 8324), frontend (port 3000), and Electron shell together. Once running, set your Anthropic API key in the in-app Settings page.
To run services individually:
```bash
bash backend/run.sh # API at http://localhost:8324 — docs at /docs
bash frontend/run.sh # App at http://localhost:3000
```
<br>
## Architecture
```
Frontend (React/TypeScript :3000) Backend (FastAPI/Python :8324)
─────────────────────────────┐ ┌──────────────────────────────┐
Dashboard │◄────►│ REST API (/api/*)
Agent Chat (per session) │ WebSocket (/ws/*)
Templates / Skills / Tools │ │ Agent Manager
Slash Command Picker │ │ └─ Claude Agent SDK
Keyboard Shortcuts │ │ Worktree Manager
Diff Viewer Library Storage (JSON/files)
└─────────────────────────────┘ └──────────────────────────────┘
Electron Shell (desktop wrapper, auto-updater)
├─────────────────────────────────────────────────────────────────────┐
Frontend (React/TypeScript :3000) Backend (FastAPI :8324)
┌───────────────────────────────┐ ┌───────────────────────┐
│ Spatial Dashboard Canvas │◄────►│ REST API (/api/*) │
│ Agent Chat (streaming) │ │ WebSocket (/ws/*)
│ Templates / Skills / Tools │ WS Agent Manager │ │
│ │ Modes / Views / Commands │◄────►│ └─ claude-agent-sdk│ │
│ │ Settings │ │ MCP Tool Discovery │ │
│ │ Redux Toolkit (state) │ │ JSON File Storage │ │
│ └───────────────────────────────┘ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Quick Start
<br>
### Prerequisites
## Configuration
- Python 3.11+
- Node.js 18+
- Git
- An Anthropic API key (for real agent usage) — `export ANTHROPIC_API_KEY=...`
The Anthropic API key is configured in-app via the **Settings** page — no environment variable needed for normal usage.
### Backend
For advanced configuration, copy `backend/.env.example` to `backend/.env`:
```bash
bash backend/run.sh
# API runs at http://localhost:8324
# Docs at http://localhost:8324/docs
```
| Variable | Purpose |
|----------|---------|
| `BACKEND_PORT` | Backend server port (default: `8324`) |
| `GOOGLE_OAUTH_CLIENT_ID` | Google Workspace integration (Gmail, Calendar, Drive) |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google Workspace integration |
| `APPLE_ID` | macOS code signing & notarization (release builds only) |
| `APPLE_APP_SPECIFIC_PASSWORD` | macOS notarization (release builds only) |
| `APPLE_TEAM_ID` | macOS code signing (release builds only) |
| `GH_TOKEN` | GitHub Releases publishing (release builds only) |
### Frontend
```bash
bash frontend/run.sh
# App runs at http://localhost:3000
```
### Mock Mode
If `claude-agent-sdk` is not installed, the backend runs in **mock mode** — agents simulate tool calls and responses so you can develop and test the UI without an API key.
<br>
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `d` | Go to Dashboard |
| `t` | Go to Templates |
| `D` | Go to Dashboard |
| `T` | Go to Templates |
| `1` `9` | Open agent by position |
| `Shift+A` | Approve all pending requests |
| `Shift+D` | Deny all pending requests |
| `?` | Show shortcuts help |
## Slash Commands
Type `/` in the chat input to invoke prompt templates and skills as slash commands.
In the chat input, type `/` to invoke templates and skills:
- `/template-name` — Opens the template's input modal
- `/skill-name` — Inserts the skill content into the message
<br>
## Project Structure
```
backend/
apps/
agents/ Agent lifecycle, WebSocket, worktree management
templates/ — Prompt template CRUD (JSON file storage)
skills/ — Skills CRUD (synced to ~/.claude/skills/)
tools_lib/ — Tool definitions CRUD (JSON file storage)
health/ — Health check endpoint
config/ — FastAPI app configuration
data/ — Persistent JSON file storage (sessions, dashboards, settings, templates, tools, etc.)
agents/ Agent lifecycle, streaming, worktree management
dashboards/ Dashboard CRUD and layout persistence
dashboard_layout/ Card positions and spatial canvas state
templates/ Prompt template CRUD
skills/ Skills CRUD (synced to ~/.claude/skills/)
tools_lib/ MCP tool configuration and discovery
modes/ Agent mode definitions
outputs/ Views/outputs, vibe coding, Python executor
settings/ App settings and file browser
health/ Health check endpoint
mcp_registry/ MCP server registry proxy
skill_registry/ Anthropic skills marketplace proxy
config/ FastAPI app configuration
data/ Persistent JSON file storage
frontend/
src/
app/
components/ AppShell, NewAgentModal, SlashCommandPicker, KeyboardShortcutsHelp
components/ AppShell, Layout, shared UI
pages/
Dashboard/ — Agent overview grid with live status
AgentChat/ — Full chat UI with streaming, HITL, branching, diff viewer
Templates/ Template library with editor
Skills/ Skills library with editor
Tools/ Tools library with editor
Dashboard/ Spatial canvas with agent/view/browser cards
AgentChat/ Streaming chat, HITL approvals, branching, diff viewer
Templates/ Template library with structured input fields
Skills/ Skills library, skill builder, registry browser
Tools/ Tool config, MCP discovery, OAuth, registry browser
Modes/ Mode definitions with system prompts
Views/ Output artifacts, code editor, vibe coding
Commands/ Keyboard shortcuts reference
Settings/ App configuration
shared/
state/ Redux slices (agents, templates, skills, tools)
ws/ WebSocket manager
hooks/ Custom hooks (keyboard shortcuts)
state/ Redux slices (agents, dashboards, templates, skills, tools, modes, etc.)
ws/ WebSocket manager
hooks/ Custom hooks
styles/ Theme tokens, global styles
electron/
main.js Electron main process, auto-updater, Python env management
scripts/ Build and notarization scripts
scripts/
build-app.sh Desktop app packaging (electron-builder)
build-python-env.sh Standalone Python 3.13 environment bundler
```
<br>
## Tech Stack
**Frontend** — React 18, TypeScript, Redux Toolkit, Material UI v7, CodeMirror 6, Framer Motion, React Router v7, Webpack 5
**Backend** — FastAPI, Python 3.11+, Pydantic v2, claude-agent-sdk, Anthropic SDK, WebSockets, httpx
**Desktop** — Electron 33, electron-builder, electron-updater (auto-updates via GitHub Releases)
**Bundled Runtime** — Standalone Python 3.13 (via python-build-standalone) so end users don't need Python installed
<br>
## Contributing
Contributions are welcome. To get started:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Make your changes
4. Submit a pull request
Please open an issue first for larger changes so we can discuss the approach.
<br>
## Community
- [Twitter / X](https://twitter.com/openswarm_ai)
- [Discord](https://discord.gg/openswarm)
- [Website](https://openswarm.ai)
<br>
## License
MIT — see [LICENSE](LICENSE) for details.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+12 -1
View File
@@ -3,6 +3,17 @@
# =============================================================================
BACKEND_PORT=8324
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
# =============================================================================
# macOS Code Signing & Notarization (required for ./scripts/build-app.sh --publish)
# =============================================================================
APPLE_ID=your-apple-id@example.com
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
APPLE_TEAM_ID=ABCDE12345
# =============================================================================
# GitHub Releases (required for --publish)
# =============================================================================
GH_TOKEN=ghp_your-github-personal-access-token
+227
View File
@@ -0,0 +1,227 @@
# Environment Variables Setup Guide
Copy `.env.example` to `.env` and fill in the values below. This guide walks you through getting every single one.
```bash
cp .env.example .env
```
---
## `BACKEND_PORT`
The port the backend server runs on. The default is fine — only change it if something else is already using port 8324.
```
BACKEND_PORT=8324
```
---
## `GOOGLE_OAUTH_CLIENT_ID` & `GOOGLE_OAUTH_CLIENT_SECRET`
These let users sign in with their Google account.
### Step 1 — Go to Google Cloud Console
1. Open https://console.cloud.google.com/
2. Sign in with your Google account (or create one).
### Step 2 — Create a project
1. Click the project dropdown at the very top of the page (it says "Select a project" or shows your current project name).
2. Click **New Project** in the top-right of the popup.
3. Name it something like `OpenSwarm`.
4. Click **Create**.
5. Wait a few seconds, then click the project dropdown again and select your new `OpenSwarm` project.
### Step 3 — Enable the Google+ API (required for OAuth)
1. In the left sidebar, click **APIs & Services** > **Library**.
2. Search for `Google+ API` (or `Google Identity`).
3. Click on it, then click **Enable**.
### Step 4 — Configure the OAuth consent screen
1. In the left sidebar, click **APIs & Services** > **OAuth consent screen**.
2. Select **External** (unless you're inside a Google Workspace org and only want internal users).
3. Click **Create**.
4. Fill in the required fields:
- **App name**: `OpenSwarm`
- **User support email**: your email
- **Developer contact email**: your email
5. Click **Save and Continue**.
6. On the **Scopes** page, click **Add or Remove Scopes**.
- Check `email` and `profile` (the `openid` scope is added automatically).
- Click **Update**, then **Save and Continue**.
7. On the **Test users** page, click **Add Users**, enter your own email, click **Add**, then **Save and Continue**.
8. Click **Back to Dashboard**.
### Step 5 — Create OAuth credentials
1. In the left sidebar, click **APIs & Services** > **Credentials**.
2. Click **+ Create Credentials** at the top.
3. Select **OAuth client ID**.
4. For **Application type**, select **Web application**.
5. **Name**: `OpenSwarm` (or anything you want).
6. Under **Authorized redirect URIs**, click **+ Add URI** and add:
```
http://localhost:8324/api/auth/google/callback
```
(Replace `8324` with your `BACKEND_PORT` if you changed it.)
7. Click **Create**.
### Step 6 — Copy the values
A popup appears with your credentials:
- **Client ID** — copy this into `GOOGLE_OAUTH_CLIENT_ID`
- **Client Secret** — copy this into `GOOGLE_OAUTH_CLIENT_SECRET`
```
GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx
```
---
## `APPLE_ID`
This is the email address you use to sign in to your Apple Developer account.
1. If you don't have one, go to https://developer.apple.com/programs/ and click **Enroll**. It costs $99/year.
2. Once enrolled, your `APPLE_ID` is just the email you signed up with.
```
APPLE_ID=you@example.com
```
---
## `APPLE_TEAM_ID`
Your 10-character Apple Developer team identifier.
1. Go to https://developer.apple.com/account
2. Sign in.
3. Look at the top-right — your name is shown. Click it, or scroll down.
4. Under **Membership Details** (or at https://developer.apple.com/account#MembershipDetailsCard), you'll see **Team ID**.
5. It looks like `ABCDE12345`. Copy it.
```
APPLE_TEAM_ID=ABCDE12345
```
---
## `APPLE_APP_SPECIFIC_PASSWORD`
Apple doesn't let you use your regular password for automated tools. You need to generate a special one-time password.
### Step 1 — Turn on two-factor authentication (if you haven't already)
1. On your Mac, go to **System Settings** > **[your name]** > **Sign-In & Security** > **Two-Factor Authentication**.
2. Turn it on and follow the prompts.
### Step 2 — Generate the app-specific password
1. Go to https://account.apple.com/
2. Sign in with your Apple ID.
3. In the **Sign-In and Security** section, click **App-Specific Passwords**.
4. Click **Generate an app-specific password** (or the **+** button).
5. Enter a label like `OpenSwarm Notarization`.
6. Click **Create**.
7. Apple shows you a password in the format `xxxx-xxxx-xxxx-xxxx`. **Copy it now** — you can't see it again.
```
APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop
```
---
## macOS Signing Certificate (no env var, but required)
Before you can sign and notarize, you need a **Developer ID Application** certificate installed in your macOS Keychain. This is what Apple uses to verify that the app was built by you.
### Step 1 — Open Xcode
1. Open **Xcode** on your Mac (install it from the Mac App Store if you don't have it).
2. Go to **Xcode** menu > **Settings** (or **Preferences** on older versions).
3. Click the **Accounts** tab.
4. Click **+** in the bottom-left and sign in with your Apple ID.
### Step 2 — Create the certificate
1. Select your account in the list, then click **Manage Certificates...** in the bottom-right.
2. Click the **+** in the bottom-left of the popup.
3. Select **Developer ID Application**.
4. Click **Create**.
That's it — the certificate is now in your macOS Keychain. `electron-builder` will auto-discover it during builds. You don't need to set any env var for this.
### Alternative — manual method (without Xcode)
1. Go to https://developer.apple.com/account/resources/certificates/list
2. Click the **+** button.
3. Select **Developer ID Application**, click **Continue**.
4. You'll be asked to upload a **Certificate Signing Request (CSR)**:
- Open **Keychain Access** on your Mac.
- In the menu bar: **Keychain Access** > **Certificate Assistant** > **Request a Certificate From a Certificate Authority**.
- Enter your email, leave CA Email blank, select **Saved to disk**, click **Continue**.
- Save the `.certSigningRequest` file.
5. Upload that file on the Apple Developer page, click **Continue**.
6. Download the `.cer` file.
7. Double-click it — it installs into your Keychain.
---
## `GH_TOKEN`
A GitHub Personal Access Token that lets the build script upload release artifacts to GitHub Releases.
### Step 1 — Go to GitHub token settings
1. Go to https://github.com/settings/tokens
2. Sign in if needed.
### Step 2 — Create a token
1. Click **Generate new token** > **Generate new token (classic)**.
2. **Note**: `OpenSwarm Releases` (or whatever you want).
3. **Expiration**: pick a duration (90 days, or "No expiration" if you don't want to rotate it).
4. **Scopes**: check the **`repo`** checkbox (this gives full access to your repositories, which is needed to create releases and upload assets).
5. Click **Generate token** at the bottom.
6. **Copy the token now** — it starts with `ghp_` and you won't be able to see it again.
```
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
---
## Final `.env` example
```
BACKEND_PORT=8324
GOOGLE_OAUTH_CLIENT_ID=123456789-xxxxxxxxx.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxx
APPLE_ID=you@example.com
APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop
APPLE_TEAM_ID=ABCDE12345
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
---
## Running a production build
Once your `.env` is filled in, just run:
```bash
./scripts/build-app.sh --publish
```
The build script automatically loads `backend/.env`, so you don't need to source it yourself. This will build the app, sign it with your certificate, notarize it with Apple, and upload the `.dmg` and `.zip` to a GitHub Release.
+71 -1
View File
@@ -13,6 +13,7 @@ from backend.apps.dashboards.models import (
DashboardLayout,
CardPosition,
ViewCardPosition,
BrowserCardPosition,
)
from fastapi import HTTPException
@@ -112,6 +113,7 @@ async def list_dashboards():
items.append({
"id": dumped["id"],
"name": dumped.get("name", "Untitled"),
"auto_named": dumped.get("auto_named", False),
"created_at": dumped.get("created_at"),
"updated_at": dumped.get("updated_at"),
})
@@ -125,6 +127,69 @@ async def create_dashboard(body: DashboardCreate):
return dashboard.model_dump(mode="json")
@dashboards.router.post("/{dashboard_id}/generate-name")
async def generate_name(dashboard_id: str):
dashboard = _load(dashboard_id)
if not dashboard.auto_named and dashboard.name != "Untitled Dashboard":
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
from backend.apps.agents.agent_manager import agent_manager
prompts = []
for session in agent_manager.sessions.values():
if getattr(session, "dashboard_id", None) != dashboard_id:
continue
for msg in session.messages:
if msg.role == "user" and isinstance(msg.content, str) and msg.content.strip():
prompts.append(msg.content.strip()[:200])
break
if not prompts:
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
fallback = prompts[0][:40]
try:
import anthropic
from backend.apps.settings.settings import load_settings
global_settings = load_settings()
if not global_settings.anthropic_api_key:
raise ValueError("API key not configured")
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
if len(prompts) == 1:
system = (
"Generate a concise 2-5 word workspace name for a project based on this task. "
"Return only the name, nothing else."
)
user_content = prompts[0]
else:
system = (
"Generate a concise 2-5 word workspace name that captures the overall theme of these tasks. "
"Return only the name, nothing else."
)
user_content = "\n".join(f"- {p}" for p in prompts)
resp = await client.messages.create(
model="claude-haiku-4-20250414",
max_tokens=30,
system=system,
messages=[{"role": "user", "content": user_content}],
)
generated = resp.content[0].text.strip().strip('"\'')
if generated:
fallback = generated
except Exception as e:
logger.warning(f"Dashboard name generation failed, using fallback: {e}")
dashboard.name = fallback
dashboard.auto_named = True
dashboard.updated_at = datetime.now()
_save(dashboard)
return {"name": dashboard.name, "auto_named": True}
@dashboards.router.get("/{dashboard_id}")
async def get_dashboard(dashboard_id: str):
dashboard = _load(dashboard_id)
@@ -136,6 +201,7 @@ async def update_dashboard(dashboard_id: str, body: DashboardUpdate):
dashboard = _load(dashboard_id)
if body.name is not None:
dashboard.name = body.name
dashboard.auto_named = False
if body.layout is not None:
dashboard.layout = body.layout
dashboard.updated_at = datetime.now()
@@ -188,7 +254,11 @@ async def duplicate_dashboard(dashboard_id: str):
"name": f"{source_data.get('name', 'Untitled')} (copy)",
"created_at": now,
"updated_at": now,
"layout": {"cards": {}, "view_cards": source_data.get("layout", {}).get("view_cards", {})},
"layout": {
"cards": {},
"view_cards": source_data.get("layout", {}).get("view_cards", {}),
"browser_cards": source_data.get("layout", {}).get("browser_cards", {}),
},
}
with open(os.path.join(DATA_DIR, f"{new_id}.json"), "w") as f:
json.dump(new_dashboard, f, indent=2)
+12
View File
@@ -20,14 +20,26 @@ class ViewCardPosition(BaseModel):
height: float = 360
class BrowserCardPosition(BaseModel):
browser_id: str
url: str = ""
x: float = 0
y: float = 0
width: float = 640
height: float = 480
class DashboardLayout(BaseModel):
cards: dict[str, CardPosition] = Field(default_factory=dict)
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict)
expanded_session_ids: list[str] = Field(default_factory=list)
class Dashboard(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
name: str = "Untitled Dashboard"
auto_named: bool = False
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
layout: DashboardLayout = Field(default_factory=DashboardLayout)
+6
View File
@@ -11,6 +11,7 @@ let backendPort = null;
const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
const iconPath = path.join(__dirname, 'build', 'icon.png');
function getResourcePath(...segments) {
if (isPackaged) {
@@ -118,6 +119,7 @@ function createWindow() {
minWidth: 800,
minHeight: 600,
title: 'OpenSwarm',
icon: iconPath,
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
@@ -185,6 +187,10 @@ function killBackend() {
}
app.whenReady().then(async () => {
if (process.platform === 'darwin' && !isPackaged) {
try { app.dock.setIcon(iconPath); } catch (_) {}
}
try {
if (isDev) {
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
+37 -45
View File
@@ -12,6 +12,7 @@
"get-port": "^5.1.1"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"electron": "^33.0.0",
"electron-builder": "^25.1.0"
}
@@ -106,57 +107,17 @@
}
},
"node_modules/@electron/notarize": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
"integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz",
"integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"fs-extra": "^9.0.1",
"debug": "^4.4.0",
"promise-retry": "^2.0.1"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/@electron/notarize/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@electron/notarize/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@electron/notarize/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
"node": ">= 22.12.0"
}
},
"node_modules/@electron/osx-sign": {
@@ -956,6 +917,37 @@
"electron-builder-squirrel-windows": "25.1.8"
}
},
"node_modules/app-builder-lib/node_modules/@electron/notarize": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
"integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"fs-extra": "^9.0.1",
"promise-retry": "^2.0.1"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/app-builder-lib/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+48 -10
View File
@@ -7,6 +7,7 @@
"start": "electron .",
"dev": "ELECTRON_DEV=1 electron .",
"dist": "electron-builder --mac --publish never",
"dist:publish": "electron-builder --mac --publish always",
"dist:all": "electron-builder --mac --win --linux"
},
"dependencies": {
@@ -14,6 +15,7 @@
"get-port": "^5.1.1"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"electron": "^33.0.0",
"electron-builder": "^25.1.0"
},
@@ -27,9 +29,19 @@
"mac": {
"icon": "build/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
},
{
"target": "zip",
"arch": ["arm64"]
"arch": [
"arm64",
"x64"
]
}
],
"category": "public.app-category.developer-tools",
@@ -40,37 +52,63 @@
"dmg": {
"title": "OpenSwarm",
"contents": [
{ "x": 130, "y": 220 },
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"extraResources": [
{
"from": "../frontend/dist",
"to": "frontend",
"filter": ["**/*"]
"filter": [
"**/*"
]
},
{
"from": "../backend",
"to": "backend",
"filter": ["**/*", "!__pycache__/**", "!**/__pycache__/**", "!.venv/**", "!*.pyc"]
"filter": [
"**/*",
"!__pycache__/**",
"!**/__pycache__/**",
"!.venv/**",
"!*.pyc"
]
},
{
"from": "../debugger",
"to": "debugger",
"filter": ["**/*", "!__pycache__/**", "!**/__pycache__/**", "!*.pyc", "!.venv/**", "!**/node_modules/**"]
"filter": [
"**/*",
"!__pycache__/**",
"!**/__pycache__/**",
"!*.pyc",
"!.venv/**",
"!**/.venv/**",
"!**/node_modules/**"
]
},
{
"from": "python-env",
"to": "python-env",
"filter": ["**/*"]
"filter": [
"**/*"
]
}
],
"publish": {
"provider": "github",
"owner": "clusterlabs",
"repo": "openswarm"
"owner": "openswarm-ai",
"repo": "production"
},
"afterSign": null
"afterSign": "scripts/notarize.js"
}
}
+31
View File
@@ -0,0 +1,31 @@
const { notarize } = require('@electron/notarize');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') return;
if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') {
console.log('Skipping notarization (CSC_IDENTITY_AUTO_DISCOVERY=false)');
return;
}
if (!process.env.APPLE_ID || !process.env.APPLE_TEAM_ID) {
console.log('Skipping notarization (APPLE_ID or APPLE_TEAM_ID not set)');
return;
}
const appName = context.packager.appInfo.productFilename;
const appPath = `${appOutDir}/${appName}.app`;
console.log(`Notarizing ${appPath}...`);
await notarize({
appBundleId: 'com.clusterlabs.openswarm',
appPath,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
});
console.log('Notarization complete.');
};
+32 -2
View File
@@ -13,6 +13,7 @@ import {
launchAndSendFirstMessage,
generateTitle,
resumeSession,
setExpandedSessionIds,
} from '@/shared/state/agentsSlice';
import type { AgentConfig } from '@/shared/state/agentsSlice';
import {
@@ -26,6 +27,7 @@ import {
resetLayout,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import AgentCard from './AgentCard';
import DashboardViewCard from './DashboardViewCard';
@@ -70,6 +72,7 @@ const DashboardInner: React.FC = () => {
const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards);
const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards);
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds);
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut);
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
@@ -88,6 +91,7 @@ const DashboardInner: React.FC = () => {
const [toolbarOpen, setToolbarOpen] = useState(false);
const spawnOriginsRef = useRef<Record<string, { x: number; y: number }>>({});
const hasFittedRef = useRef(false);
const restoredExpandedRef = useRef(false);
// ---- Multi-drag coordination ----
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
@@ -157,6 +161,7 @@ const DashboardInner: React.FC = () => {
useEffect(() => {
if (!dashboardId) return;
hasFittedRef.current = false;
restoredExpandedRef.current = false;
dispatch(resetLayout());
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchHistory({ dashboardId }));
@@ -173,6 +178,14 @@ const DashboardInner: React.FC = () => {
return () => clearTimeout(timer);
}, [layoutInitialized, canvas.actions]);
useEffect(() => {
if (!layoutInitialized || restoredExpandedRef.current) return;
restoredExpandedRef.current = true;
if (persistedExpandedSessionIds.length > 0) {
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
}
}, [layoutInitialized, persistedExpandedSessionIds, dispatch]);
const prevSessionIdsRef = useRef<string>('');
useEffect(() => {
@@ -189,6 +202,7 @@ const DashboardInner: React.FC = () => {
const cardsJson = JSON.stringify(cards);
const viewCardsJson = JSON.stringify(viewCards);
const browserCardsJson = JSON.stringify(browserCards);
const expandedJson = JSON.stringify(expandedSessionIds);
const skipInitialSave = useRef(true);
useEffect(() => {
if (!layoutInitialized || !dashboardId) return;
@@ -196,8 +210,8 @@ const DashboardInner: React.FC = () => {
skipInitialSave.current = false;
return;
}
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards }));
}, [cardsJson, viewCardsJson, browserCardsJson, layoutInitialized, dashboardId]);
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, expandedSessionIds }));
}, [cardsJson, viewCardsJson, browserCardsJson, expandedJson, layoutInitialized, dashboardId]);
useEffect(() => {
const parts = newAgentShortcut.toLowerCase().split('+');
@@ -276,6 +290,22 @@ const DashboardInner: React.FC = () => {
dispatch(generateTitle({ sessionId: realId, prompt }));
spawnOriginsRef.current[realId] = spawnOriginsRef.current[draftId];
delete spawnOriginsRef.current[draftId];
if (dashboardId) {
const currentSessions = store.getState().agents.sessions;
const agentCount = Object.values(currentSessions).filter(
(s) => s.status !== 'draft' && s.dashboard_id === dashboardId,
).length;
const NAME_GEN_TRIGGERS = [1, 3, 6];
const currentDash = store.getState().dashboards.items[dashboardId];
const canAutoName =
currentDash &&
(currentDash.auto_named || currentDash.name === 'Untitled Dashboard');
if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) {
dispatch(generateDashboardName(dashboardId));
}
}
} else {
delete spawnOriginsRef.current[draftId];
}
+3 -4
View File
@@ -681,8 +681,7 @@ const Tools: React.FC = () => {
try { parsedConfig = JSON.parse(mcpConfigJson); } catch { setMcpConfigError('Invalid JSON'); return; }
const f = serverToToolForm(mcpConfigServer);
const isStdioConfig = parsedConfig.type === 'stdio' || !!parsedConfig.command;
const authStatus = (mcpAuthType !== 'none' || isStdioConfig) ? 'configured' : 'none';
const authStatus = 'configured';
await dispatch(createTool({
name: f.name,
@@ -1038,7 +1037,7 @@ const Tools: React.FC = () => {
const isExpanded = expandedToolId === tool.id;
const isMcp = tool.mcp_config && Object.keys(tool.mcp_config).length > 0;
const isStdio = isMcp && (tool.mcp_config.type === 'stdio' || !!tool.mcp_config.command);
const canDiscover = isMcp && (isStdio || tool.auth_status !== 'none');
const canDiscover = isMcp;
const perms = tool.tool_permissions || {};
const services = perms._services as Record<string, { read?: string[]; write?: string[] }> | undefined;
const descriptions = (perms._tool_descriptions || {}) as Record<string, string>;
@@ -1303,7 +1302,7 @@ const Tools: React.FC = () => {
Discover Tools
</Button>
{!canDiscover && (
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Connect the tool first to discover available permissions</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable tool discovery</Typography>
)}
</Box>
) : (
+5
View File
@@ -455,6 +455,10 @@ const agentsSlice = createSlice({
state.expandedSessionIds = [];
},
setExpandedSessionIds(state, action: PayloadAction<string[]>) {
state.expandedSessionIds = action.payload;
},
updateSessionName(state, action: PayloadAction<{ sessionId: string; name: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -839,6 +843,7 @@ export const {
expandSession,
collapseSession,
collapseAllSessions,
setExpandedSessionIds,
updateSessionName,
updateGroupMeta,
setDraftSystemPrompt,
@@ -43,6 +43,7 @@ export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
persistedExpandedSessionIds: string[];
loading: boolean;
initialized: boolean;
}
@@ -51,6 +52,7 @@ const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
persistedExpandedSessionIds: [],
loading: false,
initialized: false,
};
@@ -59,6 +61,7 @@ interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
expandedSessionIds: string[];
}
export const fetchLayout = createAsyncThunk(
@@ -71,6 +74,7 @@ export const fetchLayout = createAsyncThunk(
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: (layout.browser_cards ?? {}) as Record<string, BrowserCardPosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
},
);
@@ -91,7 +95,12 @@ export const saveLayout = createAsyncThunk(
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layout: { cards: payload.cards, view_cards: payload.viewCards, browser_cards: payload.browserCards },
layout: {
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
expanded_session_ids: payload.expandedSessionIds,
},
}),
});
resolve(payload);
@@ -379,6 +388,7 @@ const dashboardLayoutSlice = createSlice({
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.persistedExpandedSessionIds = [];
state.initialized = false;
},
@@ -394,6 +404,7 @@ const dashboardLayoutSlice = createSlice({
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
})
.addCase(fetchLayout.rejected, (state) => {
state.loading = false;
+25 -1
View File
@@ -6,6 +6,7 @@ const DASHBOARDS_API = `${API_BASE}/dashboards`;
export interface Dashboard {
id: string;
name: string;
auto_named: boolean;
created_at: string;
updated_at: string;
}
@@ -66,6 +67,17 @@ export const duplicateDashboard = createAsyncThunk(
},
);
export const generateDashboardName = createAsyncThunk(
'dashboards/generateName',
async (dashboardId: string) => {
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}/generate-name`, {
method: 'POST',
});
const data = await res.json();
return { id: dashboardId, name: data.name as string, auto_named: data.auto_named as boolean };
},
);
const dashboardsSlice = createSlice({
name: 'dashboards',
initialState,
@@ -92,7 +104,12 @@ const dashboardsSlice = createSlice({
.addCase(renameDashboard.fulfilled, (state, action) => {
const d = action.payload;
if (state.items[d.id]) {
state.items[d.id] = { ...state.items[d.id], name: d.name, updated_at: d.updated_at };
state.items[d.id] = {
...state.items[d.id],
name: d.name,
auto_named: d.auto_named ?? false,
updated_at: d.updated_at,
};
}
})
.addCase(deleteDashboard.fulfilled, (state, action) => {
@@ -100,6 +117,13 @@ const dashboardsSlice = createSlice({
})
.addCase(duplicateDashboard.fulfilled, (state, action) => {
state.items[action.payload.id] = action.payload;
})
.addCase(generateDashboardName.fulfilled, (state, action) => {
const { id, name, auto_named } = action.payload;
if (state.items[id]) {
state.items[id].name = name;
state.items[id].auto_named = auto_named;
}
});
},
});
+41 -9
View File
@@ -3,21 +3,50 @@ set -euo pipefail
# Master build script for the OpenSwarm desktop app.
#
# Steps:
# 1. Build the React frontend (webpack production build)
# 2. Set up the embedded Python environment
# 3. Package everything with electron-builder
#
# Output: electron/dist/OpenSwarm-<version>.dmg
# Usage:
# bash scripts/build-app.sh Local dev build (unsigned)
# bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/backend/.env"
if [[ -f "$ENV_FILE" ]]; then
set -a
source "$ENV_FILE"
set +a
fi
PUBLISH_MODE=false
if [[ "${1:-}" == "--publish" ]]; then
PUBLISH_MODE=true
fi
echo "========================================"
echo " OpenSwarm Desktop App Builder"
if $PUBLISH_MODE; then
echo " Mode: PRODUCTION (sign + notarize + publish)"
else
echo " Mode: LOCAL (unsigned)"
fi
echo "========================================"
echo ""
if $PUBLISH_MODE; then
missing_vars=()
[[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID")
[[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD")
[[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID")
[[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN")
if [[ ${#missing_vars[@]} -gt 0 ]]; then
echo "ERROR: Missing required environment variables for --publish mode:"
printf ' - %s\n' "${missing_vars[@]}"
echo ""
echo "See script header for details."
exit 1
fi
fi
# Step 1: Build frontend
echo "[1/3] Building frontend..."
cd "$PROJECT_ROOT/frontend"
@@ -47,10 +76,12 @@ echo "[3/3] Packaging with electron-builder..."
cd "$PROJECT_ROOT/electron"
npm install
# Skip code signing for local builds (set CSC_LINK for production signing)
if $PUBLISH_MODE; then
npx electron-builder --mac --publish always
else
export CSC_IDENTITY_AUTO_DISCOVERY=false
npx electron-builder --mac --publish never
fi
echo ""
echo "========================================"
@@ -58,5 +89,6 @@ echo " Build Complete!"
echo "========================================"
echo ""
echo "Output files:"
ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || echo " (no .zip found — check electron/dist/)"
ls -lh "$PROJECT_ROOT/electron/dist/"*.dmg 2>/dev/null || true
ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || true
echo ""