Local-first workspace for running coding agents in parallel across isolated git worktrees
funny is a local and team workspace for running coding agents side by side. It starts each thread in its own git worktree, streams the agent session into a web UI, and gives you the review, terminal, browser, and project controls needed to keep many tasks moving at once.
Claude is built in, but funny is no longer a Claude-only or cloud-only surface. You can run Claude, Codex, Gemini, Pi, Cursor, opencode, Deep Agent, and other ACP-compatible providers from the same project, with project defaults, provider keys, and Claude execution profiles for different accounts or subscriptions.
- Multi-provider agents — Run Claude, Codex, Gemini, Pi, Cursor, opencode, Deep Agent, or installed ACP providers from one workspace
- Claude execution profiles — Bind projects to different Claude config directories and subscription logins
- Parallel execution — Run multiple coding agents simultaneously on different branches
- Git worktree isolation — Each thread gets its own isolated working directory
- Real-time monitoring — WebSocket-based live updates for all agent activities
- Git integration — Built-in diff viewer, staging, commits, and PR creation
- Team collaboration — Share threads with project members as viewers, commenters, or editors
- Browser annotator panel — Open any URL, inspect DOM details, mark screenshots, and send the context into a thread
- Kanban board — Drag-and-drop task management with columns (backlog, in progress, review, done, archived)
- Search — Find threads by title, branch name, status, or message content with real-time filtering
- Analytics dashboard — Track task creation, completion rates, stage distribution, and cost metrics over time
- Visualizer plugins — Render Mermaid, CSV, and installable rich previews for files or fenced code blocks
- MCP support — Model Context Protocol integration
- Automation scheduling — Cron-based recurring tasks
- Mobile support — Responsive mobile view with touch-friendly navigation for on-the-go monitoring
No installation needed! Run directly with:
bunx @ironmussa/funny@latestThe app will start and open at http://localhost:3001
bun install -g @ironmussa/funny
funnygit clone https://github.com/ironmussa/funny.git
cd funny
bun install
bun run build
bun start- Bun >= 1.0.0 (install from bun.sh)
- Git installed and configured
- At least one authenticated agent provider:
- Claude: Claude CLI
- Codex: OpenAI API key and a Codex ACP binary
- Gemini: Gemini CLI / API key
- Pi, Cursor, opencode, Deep Agent, or another installed ACP provider
funny has two deployment shapes: local (solo, everything on your machine) and team (multiple users collaborating through a central server while agents still run on each user's runner).
This is the default. Everything runs on your machine — UI, database, git operations, browser sessions, and agent processes.
# Quick start (no installation)
bunx @ironmussa/funny@latest
# Or if installed globally
funny
# Custom port
funny --port 8080
# Show all options
funny --helpOpen http://localhost:3001 in your browser.
On first startup, funny creates a local admin account:
- Username:
admin - Password: generated automatically and written to
~/.funny/admin-password.txt
The credentials file is created with mode 0600. Delete it after first login and change the password. To choose the initial password yourself, set ADMIN_PASSWORD before the first startup; it must be at least 10 characters and include uppercase, lowercase, and numeric characters.
Team mode lets multiple users collaborate on shared projects. It requires two components:
- Central server (
funny-server) — Runs on a shared machine. Manages users, projects, memberships, and coordinates runners. - Local runner (
funny --team <url>) — Each team member runs a runner that connects to the central server.
In team mode, projects have memberships and threads can be shared with specific project members. The owner can grant Viewer, Commenter, or Editor access, revoke access, and copy an identity-gated deep link. Shared threads appear in Shared with me for the recipient.
Access levels are intentionally narrow:
- Viewer — read the thread and existing comments
- Commenter — read the thread and post comments
- Editor — comment, read git status/diff/log, and send follow-up messages to the agent
Git write actions such as commit, push, PR creation, stage changes, and destructive operations stay owner-only.
On a shared machine (or your own machine if your team is on the same network):
# Install
bun install -g @ironmussa/funny
# Start the central server
funny-server --port 3002On first start, a default admin account is created:
- Username:
admin - Password: generated automatically and written to the server data directory (
~/.funny/admin-password.txtby default)
The credentials file is created with mode 0600. Delete it after first login and change the password. To choose the initial password yourself, set ADMIN_PASSWORD before the first startup; it must be at least 10 characters and include uppercase, lowercase, and numeric characters. The admin can create additional user accounts from the central server's API.
Each team member runs funny locally with the --team flag:
funny --team http://<central-server-ip>:3002The runner prints a short device-link code. In the central server UI, open Settings > Runners, click Link a runner, enter the code, and approve it. A plain funny or bunx @ironmussa/funny@latest always starts local by default; every team run should pass --team <url> explicitly.
This starts a runner-only process. The UI, auth, projects, and thread state live on the central server; the runner connects back to:
- Receive dispatched tasks from the central server
- Run git operations and agent providers on the member's machine
- Stream terminal/browser/agent events back to the central server
Each member's git operations and agent providers run on their own machine, in their own local repos. The central server only coordinates — it never touches your filesystem.
Team member A Team member B
┌──────────────────┐ ┌──────────────────┐
│ funny --team URL │ │ funny --team URL │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Local git │ │ │ │ Local git │ │
│ │ Local agents │ │ │ │ Local agents │ │
│ └──────┬───────┘ │ │ └──────┬───────┘ │
└────────┼─────────┘ └────────┼─────────┘
│ ┌──────────┐ │
└────────►│ Central │◄─────────┘
│ Server │
│ (users, │
│ projects,│
│ teams) │
└──────────┘
funny (local app)
| Option | Description | Default |
|---|---|---|
-p, --port <port> |
Server port | 3001 |
-h, --host <host> |
Server host | 127.0.0.1 |
--auth-mode <mode> |
Authentication mode: local or multi |
local |
--team <url> |
Connect this machine as a runner to a central server | - |
--token <token> |
Runner invite token for team registration | - |
--local |
Start standalone; cannot combine with --team |
false |
--help |
Show help message | - |
funny-server (team coordination server)
| Option | Description | Default |
|---|---|---|
-p, --port <port> |
Server port | 3002 |
-h, --host <host> |
Server host | 0.0.0.0 |
--help |
Show help message | - |
Saved values in ~/.funny/.env do not activate team mode. A plain funny or bunx @ironmussa/funny@latest always starts in local mode by default. To connect to a central server, pass --team <url> explicitly.
# Local standalone
funny
# Team runner
funny --team http://192.168.1.10:3002Precedence order: CLI flags > shell environment variables > saved non-team values in ~/.funny/.env. Team mode requires --team <url>.
To change the server, pass --team with the new URL.
| Variable | Description | Default | Used by |
|---|---|---|---|
PORT |
Server port | 3001 / 3002 |
both |
HOST |
Server hostname | 127.0.0.1 |
both |
AUTH_MODE |
Authentication mode (local/multi) |
local |
funny |
ADMIN_USERNAME |
Bootstrap admin username | admin |
both |
ADMIN_EMAIL |
Bootstrap admin email | admin@local.host |
both |
ADMIN_PASSWORD |
Bootstrap admin password; generated if unset | - | both |
RUNNER_INVITE_TOKEN |
Runner invite token for --team classic flow |
- | funny |
RUNNER_AUTH_SECRET |
Shared secret for --team classic flow |
- | funny |
CORS_ORIGIN |
Custom CORS origins (comma-separated) | Auto-configured | both |
FUNNY_CENTRAL_DATA_DIR |
Central server data directory | ~/.funny-central |
funny-server |
LOG_LEVEL |
Log level (debug/info/warn/error) | info |
funny-server |
The scheduler is the standalone process that automatically claims eligible threads, dispatches them to the matching runner, and handles retries with exponential backoff. It runs in two loops:
- Poll loop — picks eligible candidates from the DB, sorts them by priority, and dispatches up to the global / per-user concurrency caps.
- Reconcile loop — reattaches in-flight runs after a restart, releases stalled claims, and clears retry entries that are past their backoff.
It runs as its own process — the @funny/thread-scheduler binary — and talks to the server over HTTP via /api/scheduler/system/*. This decouples its release cycle from the server, lets it scale or restart independently, and keeps the trust boundary explicit.
Migrating from in-process mode? Earlier versions hosted the scheduler inside the server gated by
SCHEDULER_ENABLED. That mode is gone. The brain now must run as a separate process;SCHEDULER_ENABLEDon the server is no longer read.
-
On the server, pick a shared secret (
openssl rand -hex 32) and set it before starting:SCHEDULER_AUTH_SECRET=<long-random-string>
The server uses this to authenticate the brain's HTTP calls.
-
On the scheduler process, set the same secret plus the server URL and run the binary:
FUNNY_SERVER_URL=http://localhost:3001 \ SCHEDULER_AUTH_SECRET=<same-secret> \ bun run --filter @funny/thread-scheduler start
Or equivalently from the workspace root:
FUNNY_SERVER_URL=... SCHEDULER_AUTH_SECRET=... bun packages/scheduler/src/bin/scheduler.ts
-
The brain auto-starts its loops and begins dispatching. Stop with
SIGTERM/SIGINTfor graceful shutdown.
Trust boundary. The shared secret grants the scheduler cross-tenant access to thread/run state across all users — only run it inside a network you trust (same host, private VPC) and treat the secret like a root credential. The server validates the secret and rejects calls without it.
All knobs are optional with sensible defaults — only override what you need. These all live on the scheduler process (the server doesn't read them):
| Variable | Description | Default |
|---|---|---|
FUNNY_SERVER_URL |
Required. Base URL of the funny server. | — |
SCHEDULER_AUTH_SECRET |
Required. Must match the server's value. | — |
SCHEDULER_POLL_MS |
Poll loop interval (how often eligible threads are scanned). | 5000 |
SCHEDULER_RECONCILE_MS |
Reconcile loop interval (stall detection, orphan recovery, backoff sweep). | 30000 |
SCHEDULER_MAX_GLOBAL |
Maximum concurrent dispatched runs across all users. | 16 |
SCHEDULER_MAX_PER_USER |
Maximum concurrent dispatched runs per user (tenant fairness cap). | 4 |
SCHEDULER_MAX_BACKOFF_MS |
Cap for exponential retry backoff between failed attempts. | 300000 |
SCHEDULER_STALL_MS |
Time without an event before a claimed run is considered stalled. | 1800000 |
SCHEDULER_PIPELINE_NAME |
Override the default pipeline name passed to the runner. | runner default |
SCHEDULER_LONG_POLL_MS |
Long-poll timeout for the events stream (the brain reacts to terminal runs via this loop). | 25000 |
SCHEDULER_LOG_FORMAT |
text (single-line, human-readable) or json (ndjson). |
text |
SCHEDULER_LOG_LEVEL |
debug / info / warn / error. |
info |
- Runner isolation is preserved. A user's threads only ever dispatch to that user's own runner. If the user's runner is offline, the scheduler skips the candidate (it does not fall back to another user's runner).
- Crash-safe. In-flight dispatches are tracked in the
scheduler_runstable. After a restart the reconcile loop reattaches them so work isn't double-dispatched or silently dropped. - Don't run two brains. Only one scheduler process should be active at a time — multiple brains will race to claim the same threads.
- Stop to debug. Stopping the scheduler process pauses new dispatches immediately; in-flight runs keep going on the runner side and are reconciled on next start.
Threads can be visualized and managed as a Kanban board with five columns:
- Backlog — Tasks waiting to be started
- In Progress — Tasks currently being worked on
- Review — Tasks ready for code review
- Done — Completed tasks
- Archived — Archived tasks
Drag and drop cards between columns to update their stage. Cards show thread status, git sync state, cost, and time since last update. Pinned threads appear first in each column. You can create new threads directly from the board and switch between list and board views.
Find threads quickly using the search bar. Search matches against:
- Thread title
- Branch name
- Thread status
- Message content (server-side full-text search with content snippets)
Results highlight matching text. Combine search with filters for status, git state, and mode to narrow results further. Filters sync to URL query parameters so you can share filtered views.
Thread sharing is available in team mode for normal project threads. From the thread header, owners can open Share, pick a project member, choose an access level, and copy a deep link. Links are not public URLs; the server checks the signed-in user and the thread share grant before showing the thread.
The Shared with me sidebar section lists threads another user has shared with you. Shared threads preserve the same thread UI, but unsafe controls are hidden or blocked according to your grant:
- Viewer grants read-only access to the conversation and comments.
- Commenter adds the ability to post in the thread comments panel.
- Editor adds follow-up messages to the agent plus read-only git views (
status,diff,log).
The comments panel is a flat, thread-level discussion shared by the owner and granted users. It shows a badge in the thread header, supports live updates, and keeps deletion owner-only.
The analytics dashboard provides an overview of task activity and costs:
- Metric cards — Tasks created, completed, moved to review/done/archived, and total cost
- Stage distribution chart — Pie chart showing current distribution of threads across stages
- Timeline chart — Bar chart showing task activity over time, grouped by day/week/month/year
Filter analytics by project and time range (day, week, month, or all-time).
funny includes a dedicated mobile view that automatically activates on screens narrower than 768px. The mobile interface provides a streamlined, touch-friendly experience for monitoring and interacting with your agents on the go.
Mobile features:
- Stack-based navigation — Projects → Threads → Chat, with back buttons for easy navigation
- Full chat interaction — Send messages, view agent responses, approve/reject tool calls, and monitor running agents
- Thread management — Create new threads with model and mode selection directly from your phone
- Status monitoring — Real-time status badges and agent activity indicators
- Auto-scrolling — Smart scroll behavior that follows new messages while preserving your scroll position
The sidebar automatically converts to a slide-out drawer on mobile via the shadcn/ui Sheet component.
A side panel that lets you load any URL, mark it up visually (pin / region / draw), and send the annotations as a new thread to the selected agent provider. The panel is per-project — open it from the AppWindow icon in the project header (next to Terminal / Review / Tests). Sends go to the project you opened it from; there's no project selector inside the panel.
How it works: the runner spawns a real Chromium subprocess via Playwright's bundled binary and streams JPEG frames via CDP Page.startScreencast to a <canvas>. Loads any URL (no X-Frame-Options limit). Input (mouse / keyboard) is forwarded via Input.dispatchMouseEvent / Input.dispatchKeyEvent. DOM inspection (selector / test-id / component name / computed styles) runs in the page context via Runtime.evaluate using helpers from @funny/shared/dom/extract — the same source the Chrome extension consumes.
Setup:
# If Playwright's executablePath resolves inside a sandbox (e.g. VSCode flatpak),
# point at the real Chromium binary location:
PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/ms-playwright
# Make sure Chromium is installed:
bunx playwright install chromiumTools:
- Browse — click / type / scroll forwarded to the embedded page via CDP
- Pin — single-click marker with a note, captures the DOM element under the cursor
- Region — drag a rectangle, captures every element that intersects
- Draw — 5-color free-hand annotations
- 🏷 Show test-ids — overlays every
[data-testid]with a label badge - ⏸ Pause animations — freezes CSS / Web Animations in the embedded page
- 🔍 Inspect mode — hover shows selector / testid / component name / dimensions; same affordance is active under the Pin tool so you see what you'll capture before clicking
- 📷 Screenshot viewport — copies the current frame as PNG to the clipboard
- ⏪ Back / Forward / Reload — run
history.back/forward()/location.reload()in the page context
Send → creates a thread with the URL + annotations as the first message + draw image attached. Annotations are formatted via browser-panel-markdown.ts.
The CDP implementation lives in packages/runtime/src/services/browser-session-manager.ts (runner side) and packages/client/src/components/browser-panel/ (client side). Background and design decisions: openspec/changes/archive/2026-05-24-browser-panel-cdp-runtime/. Screenshot follow-ups for non-CDP contexts: docs/design/browser-panel-screenshot.md.
funny renders rich views for fenced code blocks and file previews — diagrams, tables, and more — through visualizer plugins. The built-in Mermaid (diagrams) and CSV (tables) renderers use the same contract third-party plugins do, so the system is fully extensible without touching the core. Heavy/niche renderers ship as installable extensions instead.
funny ext list # List installed visualizer plugins
funny ext install github:ironmussa/funny-extensions --subdir visualizer-dbml # Install the DBML ER-diagram plugin
funny ext remove visualizer-dbml # Remove itOr manage them from Settings → Extensions in the UI. Plugins live on the server host at ~/.funny/extensions/.
Full trust, no sandbox — a plugin runs inside your authenticated session, like installing an npm package. Install only what you trust. Installing/removing is admin-only.
Full guide — installing, managing, and creating plugins (the @funny/plugin-sdk package, the VisualizerPlugin contract, building to ESM, the shared-React import map): docs/visualizer-plugins.md. Reference extensions live in their own repo: ironmussa/funny-extensions — e.g. visualizer-dbml (DBML → interactive ER diagram, React Flow — fully decoupled, bundles its own deps).
funny can drive multiple coding agents from the same project. Claude runs through the Claude Agent SDK, Deep Agent runs through its own integration, and ACP providers run through small declarative manifests that point at an agent CLI already installed on your runner. Bundled ACP providers include Codex, Gemini, Pi, Cursor, and opencode; third-party providers can be installed without touching the core. Claude is always available.
Manage everything from Settings → Providers (gear icon at the bottom of the sidebar → Providers, or open /preferences/providers directly):
- Built-in providers — toggle each bundled ACP provider (Codex / Gemini / Pi / Cursor / opencode) on or off with Enable / Disable. The change is live (no restart) and updates the model picker immediately. It's session-scoped — see
FUNNY_PROVIDERSbelow to make a lean set the default. - Installed providers — install a third-party provider from a git repo or a local path on the runner, or remove one. On install, funny discloses the exact binary the provider will launch.
- Provider keys — store per-user keys such as GitHub, OpenAI, Gemini, MiniMax, zAI, and xAI so runners can inject the right environment for the selected provider.
# On the RUNNER, choose which built-in ACP providers are active by default.
# Comma-separated ids; Claude is always on. Unset = all built-ins (no change).
FUNNY_PROVIDERS=codex,pi # only Codex and Pi appear in the picker alongside ClaudeWhere it shows: the model picker (in the New Thread dialog and the prompt input) lists only the active providers. Disable a provider — or leave it out of FUNNY_PROVIDERS — and it disappears from the picker. Re-enable it from Settings → Providers and it comes back, no restart.
Runner-owned & full trust — providers live on your runner and let funny spawn the binary they declare. Install only providers you trust. Built-in toggles are session-scoped; set
FUNNY_PROVIDERSon the runner to persist a lean default across restarts.
Claude profiles let you use different Claude subscription logins per project without swapping global credentials. Create profiles from Settings → Agent Profiles by giving each profile a name and CLAUDE_CONFIG_DIR; funny shows the matching login command so you can authenticate that directory. Then bind a project to a profile from the project's settings.
When a Claude thread starts, funny injects the bound profile's CLAUDE_CONFIG_DIR, snapshots the profile on the thread, and leaves other providers unaffected.
# Install dependencies
bun install
# Run in development mode (client + server with hot reload)
bun run dev
# Run only server (port 3001)
bun run dev:server
# Run only client (port 5173)
bun run dev:client
# Build for production
bun run build
# Database operations
bun run db:push # Push schema changes
bun run db:studio # Open Drizzle Studio
# Run tests
bun testWhen you run several apps locally it's easy to lose track of which one is on which port. portless puts a small local proxy in front of the dev servers so each gets a stable, named URL instead of a bare port:
| URL | → port | service |
|---|---|---|
http://funny.localhost |
5173 | client |
http://api.funny.localhost |
5002 | server |
http://runner.funny.localhost |
3003 | runtime |
We use portless in static-alias + HTTP (--no-tls) mode: the services keep
their fixed ports (so the cross-service references — Vite → :5002,
RUNNER_PUBLIC_MEDIA_URL → :3003 — stay intact) and everything stays on HTTP
(so the browser-reachable runner media URL doesn't trip mixed-content blocking).
portless just maps a name onto each existing port.
One-time setup:
# Install the CLI
npm install -g portless
# Start the HTTP proxy on port 80. Needs sudo once to bind :80.
# Use `service install` instead of `proxy start` to persist across reboots.
sudo portless proxy start --no-tls
# sudo portless service install --no-tls # auto-start on boot
# Register the routes (persisted in portless state; idempotent)
bun run portless:routesThe .env CORS_ORIGIN must include http://funny.localhost so the server and
better-auth accept the new origin (already configured in the sample .env).
Daily use:
bun run dev:portless # registers routes, then runs the full dev stack
bun run portless:list # show active routesThen open http://funny.localhost. To bypass portless and use raw ports,
run bun run dev as usual, or prefix any command with PORTLESS=0.
Without sudo, portless falls back to port
1355(http://funny.localhost:1355). The named domain still disambiguates your apps; only the clean:80URL requires the one-time sudo above.
packages/shared— Shared TypeScript types and runner protocol definitionspackages/core— Reusable agent orchestration and git logicpackages/runtime— Runner-side Hono HTTP server that starts local agent providers, browser sessions, terminals, and git operations (port 3001)packages/client— React 19 + Vite SPA (port 5173 in dev)packages/server— Team coordination server (users, projects, memberships, runner management)packages/runner— Runner module for connecting to the central server
Server:
- Hono (HTTP framework)
- Claude Agent SDK and ACP provider processes
- Drizzle ORM + SQLite
- WebSocket (real-time updates)
Client:
- React 19
- Vite
- Zustand (state management)
- shadcn/ui (components)
- Tailwind CSS
funny (local app) stores data in:
~/.funny/
├── .env # Optional local env file; team connection keys are ignored by the CLI
├── data.db # SQLite database (projects, threads, messages)
├── admin-password.txt # Generated first-run admin password; delete after rotating
├── auth-token # Bearer token for local auth
├── auth-secret # Session secret (multi-user mode)
└── encryption.key # AES-256-GCM key for GitHub token encryption
funny-server (team server) stores data separately in:
~/.funny-central/
├── central.db # SQLite database (users, projects, memberships, runners)
├── admin-password.txt # Generated first-run admin password; delete after rotating
├── auth-secret # Session secret
└── encryption.key # AES-256-GCM key for token encryption
Worktrees are created in .funny-worktrees/ adjacent to your project:
/your-project/
├── .git/
├── src/
└── ...
/your-project-worktrees/
├── feature-branch-1/
├── feature-branch-2/
└── ...
Each worktree is an isolated working directory allowing parallel agent work without conflicts.
The packages/chrome-extension package contains a Chrome extension for selecting and annotating UI elements, then sending them to Funny for AI-powered analysis and fixes.
# Install dependencies (from the repo root)
bun install
# Build the extension
cd packages/chrome-extension
bun run buildThis compiles the TypeScript source files (src/) into JavaScript files in the package root, ready for Chrome to load.
- Open Chrome and navigate to
chrome://extensions - Enable Developer mode (toggle in the top-right corner)
- Click Load unpacked
- Select the
packages/chrome-extensionfolder - The extension icon should appear in your toolbar
cd packages/chrome-extension
bun run watchThis watches for changes in src/ and rebuilds automatically. After each rebuild, click the reload button on chrome://extensions to pick up the changes.
See CLAUDE.md for detailed commands and architecture documentation.
MIT
- GitHub Issues
- Provider docs for the agent CLI you run on your runner
Contributions are welcome! Please read CLAUDE.md for development guidelines.