Compare commits

..

79 Commits

Author SHA1 Message Date
Jiayuan Zhang
509faab19f feat(cli): enhance version command with JSON output and build info
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:12:35 +08:00
Jiayuan Zhang
29f7959db7 fix(cli): fix install script failing on repeated runs (#738)
The install script crashed silently on repeated `--local` runs due to
three issues:

1. `REPO_URL` includes `.git` suffix which returns 404 when used for
   GitHub releases API — `grep` found no match, exited 1, and
   `set -euo pipefail` killed the script with no error message.

2. `multica version` outputs "multica 0.1.26 (commit: ...)" but the
   version comparison used the full string, so it never matched the
   release tag and always attempted unnecessary upgrades.

3. Interrupted previous clones left a non-empty directory without
   `.git/`, causing `git clone` to fail on retry.
2026-04-12 01:53:39 +08:00
Jiayuan Zhang
bd1a7eb680 fix(cli): add upgrade logic to install script (#736)
When multica CLI is already installed, the install script now checks
for a newer version on GitHub Releases and upgrades automatically.
Homebrew installs use `brew upgrade`; binary installs re-download
the latest release. If already up to date, it skips.
2026-04-12 01:37:34 +08:00
Jiayuan Zhang
3198972d15 docs: add "Switching to Multica Cloud" section to self-hosting guides (#735)
Self-host users had no documented way to reconfigure their CLI for
multica.ai. Add a section after "Stopping Services" in both
SELF_HOSTING.md and self-hosting.mdx explaining the two options:
manual `config set` or re-running the install script without --local.
2026-04-12 01:35:50 +08:00
Jiayuan Zhang
d78be3b621 fix(cli): ensure cloud URLs are configured when not using local mode (#733)
After installing via `curl | bash` (default/cloud mode) or running
`multica setup` without a local server, the CLI config could retain
stale localhost URLs from a previous `multica config local` or
`--local` install. This caused `multica login` to connect to
localhost instead of multica.ai.

Fix: explicitly write cloud URLs (api.multica.ai / multica.ai) to
the config in both the install script's cloud mode and the setup
command's cloud fallback path.
2026-04-12 01:09:17 +08:00
Jiayuan Zhang
b0ee214154 feat: streamline self-hosting with one-click setup (#724)
* feat: streamline self-hosting experience with one-click setup

- Add `make selfhost` / `make selfhost-stop` for one-command Docker deployment
- Add `multica setup` CLI command (auto-detect local server, configure, login, start daemon)
- Add `multica config local` CLI command (configure for localhost defaults)
- Restructure SELF_HOSTING.md: simplified 4-step guide, moved advanced config to SELF_HOSTING_ADVANCED.md
- Add SELF_HOSTING_AI.md for AI agents to follow
- Document 888888 master verification code for non-production environments
- Document how to stop services
- Fix brew install typo: `multica-cli` → `multica` in SELF_HOSTING.md and self-hosting.mdx
- Update README.md and README.zh-CN.md with simplified self-host instructions
- Update CLI_AND_DAEMON.md with new setup/config local commands

* feat: add one-command installer script (curl | bash)

Add scripts/install.sh that handles the full setup in one command:

Self-host (default):
  curl -fsSL .../install.sh | bash
  → Checks Docker, clones repo, starts services, installs CLI, configures

Cloud (CLI only):
  curl -fsSL .../install.sh | bash -s -- --cloud
  → Installs CLI via Homebrew or binary download

Features:
- OS detection (macOS/Linux) with architecture support (amd64/arm64)
- Homebrew install with binary download fallback
- Idempotent: re-running updates existing installation
- Colored output with non-TTY fallback
- Docker availability check with helpful error messages

Updated docs (README, SELF_HOSTING, self-hosting.mdx, SELF_HOSTING_AI) to
show curl | bash as the primary install method.

* refactor: default install to cloud mode, add --local for self-host

- install.sh default is now cloud (CLI only, connects to multica.ai)
- Self-host uses --local flag: curl ... | bash -s -- --local
- Restructured README following Hermes Agent style:
  - Quick Install section front and center with curl | bash
  - CLI command reference table
  - Self-host as a callout under Quick Install
  - Removed redundant "Multica Cloud" / "CLI" sections
- Updated all docs (SELF_HOSTING, self-hosting.mdx, SELF_HOSTING_AI,
  README.zh-CN) to use --local flag for self-host curl command

* docs: remove redundant AI agent install snippet from README CLI section

* docs: add daemon stop command to README quick install sections

* feat: add --stop flag to install.sh for easy self-host shutdown

Users who installed via `curl ... | bash -s -- --local` can now stop
all services with `curl ... | bash -s -- --stop`. The stop command
shuts down Docker Compose services and the daemon.

Also updated SELF_HOSTING.md stopping section to show both methods.
2026-04-12 00:50:17 +08:00
Jiayuan Zhang
02c9480f44 fix(views): show agent live card immediately without waiting for messages (#727)
When navigating to an issue where an agent is already working, the
"Agent is working" card was delayed because it waited for both
getActiveTasksForIssue() AND listTaskMessages() to complete before
rendering. Now the card renders immediately after active tasks are
fetched, and messages load progressively in the background. Also
properly merges HTTP-loaded messages with any WebSocket-delivered
messages to avoid race conditions.
2026-04-12 00:21:39 +08:00
Jiayuan Zhang
3e4ae17596 fix(views): display comment attachments uploaded via CLI (#726)
commentToTimelineEntry() was dropping the attachments field, and
comment-card never rendered entry.attachments. Attachments uploaded
through the CLI (not embedded in markdown) were invisible in the UI.

- Add attachments to commentToTimelineEntry() conversion
- Add AttachmentList component that renders standalone attachments
  (skipping those already referenced in the markdown content)
- Render AttachmentList in both CommentRow and CommentCard
2026-04-12 00:11:25 +08:00
Jiayuan Zhang
c95ee27991 feat(views): support inline property editing on project list page (#725)
Allow users to modify project priority, status, and lead directly from
the project list without navigating to the detail page. Only the project
name/icon column navigates to the detail view now.
2026-04-12 00:10:53 +08:00
Bohan Jiang
f9f061de4c Merge pull request #717 from woosolkim/fix/docker-google-oauth-build-arg
fix(docker): pass NEXT_PUBLIC_GOOGLE_CLIENT_ID as build arg for self-hosting
2026-04-11 23:08:51 +08:00
Bohan Jiang
d11824807a fix(agent): handle braces in stderr log lines before openclaw JSON result (#718)
processOutput() used strings.Index(raw, "{") to find the JSON start,
but error lines like `raw_params={"command":"..."}` contain braces that
get matched first, causing JSON parsing to fail and the entire raw
stderr (including internal metadata) to be returned as the agent comment.

Now tries each '{' position until one successfully unmarshals as a valid
openclawResult, skipping braces embedded in log/error lines.
2026-04-11 23:07:58 +08:00
woosolkim
7c063a0e6f fix(docker): pass NEXT_PUBLIC_GOOGLE_CLIENT_ID as build arg for self-hosting
NEXT_PUBLIC_* env vars must be available at Next.js build time to be
inlined into the client bundle. Without this, the Google OAuth button
never renders in self-hosted Docker deployments even when the env var
is correctly set in .env.
2026-04-11 23:35:59 +09:00
Bohan Jiang
e477d64548 fix(cli): poll health endpoint instead of fixed sleep in daemon start (#716)
* fix(cli): poll health endpoint instead of fixed sleep in daemon start

The daemon start command waited a fixed 2 seconds then checked the
health endpoint once. If the daemon took longer to initialize (auth,
workspace loading), the check failed and printed a misleading error
even though the daemon started successfully.

Replace the single check with a polling loop (500ms interval, 15s
timeout) so the CLI waits for the daemon to actually be ready.

* fix(agent): rewrite openclaw tests to match new backend API

The openclaw backend was rewritten in #715 to parse a single JSON blob
instead of streaming NDJSON events. The tests still referenced the old
types (openclawEvent) and methods (handleOCTextEvent, etc.), causing a
build failure in CI.

Rewrite all tests to exercise the new processOutput method and
openclawInt64 helper.
2026-04-11 22:25:19 +08:00
Bohan Jiang
2e33084097 fix(agent): rewrite openclaw backend to match actual CLI interface (#715)
* fix(agent): use --message flag for OpenClaw CLI invocation

OpenClaw CLI changed its prompt flag from `-p` to `--message`. The old
flag caused tasks to fail immediately with "required option '-m,
--message <text>' not specified".

Fixes #713, relates to #703.

* fix(agent): rewrite openclaw backend to match actual CLI interface

- Replace unsupported flags (-p, --output-format, --yes) with correct
  ones (--message, --json, --local, --session-id)
- Read JSON result from stderr (where openclaw writes it)
- Parse openclaw's actual output format ({payloads, meta})
- Auto-generate session ID for each task execution
- Show "live log not available" hint in agent live card when timeline
  is empty (openclaw doesn't support streaming)
2026-04-11 22:14:47 +08:00
Jiayuan Zhang
b3f98ef95d fix(server): skip auto-comment when agent already posted during task (#712)
* fix(server): skip auto-comment when agent already posted during task

In CompleteTask(), check if the agent already posted a comment on the
issue since the task started. If so, skip the automatic output comment
to avoid duplicates. This preserves the fallback for agents that don't
post comments via CLI.

Closes MUL-609

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(server): use StartedAt instead of CreatedAt for duplicate check

CreatedAt is the enqueue time, not execution start. If a previous task
posted a comment between enqueue and start of the next task, it would
incorrectly suppress the auto-comment for the later task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:27:02 +08:00
Jiayuan Zhang
ff241af8d7 fix(views): trim search input in assignee and filter pickers (#709)
Leading spaces in search queries caused `.includes()` to fail because
names don't contain leading whitespace. Apply `.trim()` before
`.toLowerCase()` in assignee-picker, actor filter, and project filter.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:06:06 +08:00
pradeep7127
d9be9465c3 fix(storage): support custom S3 endpoints for self-hosted deployments (MinIO) (#681)
* fix(storage): support custom S3 endpoints for self-hosted deployments

When AWS_ENDPOINT_URL is set, the S3 client now uses path-style
addressing and routes requests to the custom endpoint (e.g. MinIO).
Returns path-style URLs (endpoint/bucket/key) instead of virtual-hosted
URLs so attachments are accessible on local setups.

Also falls back to STANDARD storage class for custom endpoints since
MinIO and other S3-compatible stores do not support INTELLIGENT_TIERING.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(storage): handle custom endpoint URLs in KeyFromURL

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:35:31 +08:00
Bohan Jiang
5def4b62e0 fix(web): upgrade Next.js to ^16.2.3 for CVE-2026-23869 (#706)
High-severity DoS vulnerability (CVSS 7.5) in App Router — specially
crafted requests to RSC endpoints cause excessive CPU consumption.
Patched in Next.js 16.2.3.

Ref: https://github.com/multica-ai/multica/issues/701
2026-04-11 18:23:13 +08:00
Bohan Jiang
c72df9b127 Merge pull request #699 from multica-ai/agent/j/696a5ce1
docs: add v0.1.23 and v0.1.24 changelog (2026-04-11)
2026-04-11 15:34:46 +08:00
Jiang Bohan
1de88a9412 docs: add v0.1.23 and v0.1.24 changelog entries (2026-04-11) 2026-04-11 15:33:59 +08:00
Bohan Jiang
3cd26c1d82 Merge pull request #672 from pasmud/fix/selfhost-docker-build
Thanks for the thorough fix! 🎉
2026-04-11 14:58:42 +08:00
zerone0x
cc9a8ad6ec fix(daemon): make meta-skill workflow defer to agent Skills instead of hardcoding (#675)
Replaces the hardcoded assignment-triggered workflow in buildMetaSkillContent()
with a minimal version that defers to agent Skills and Identity. Keeps platform
capability docs and status management steps intact.

Fixes #669

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 14:48:58 +08:00
jayavibhavnk
41d4ac3877 fix(server): add missing WorkspaceID to agent comment creation (#688)
createAgentComment omitted WorkspaceID when calling CreateComment,
causing all agent comments (progress updates, completion messages) to
silently fail against the NOT NULL constraint on comment.workspace_id.
The issue variable is already fetched on the preceding line for mention
expansion, so this adds the missing field to match the handler path in
comment.go.
2026-04-11 14:38:40 +08:00
Zheng Li
a76194744a feat(cli): add --project filter to issue list (#691)
Co-authored-by: nocoo <nocoo@users.noreply.github.com>
2026-04-11 14:37:24 +08:00
Bohan Jiang
34695ad78b Merge pull request #692 from jwcastillo/fix/docker-web-chown-nextjs
fix(docker): chown runtime files to nextjs user in web image
2026-04-11 14:35:51 +08:00
Jiayuan Zhang
7008d03b02 feat: notify parent issue subscribers on sub-issue changes (#685)
* feat(notifications): notify parent issue subscribers on sub-issue changes

When a sub-issue receives a change (status, assignee, priority, comment, etc.),
parent issue subscribers are now also notified. Deduplicates against direct
subscribers to avoid double notifications. The inbox item still points to the
sub-issue so clicking the notification navigates to the actual change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(notifications): parent subscriber inbox items now point to sub-issue

Split notifyIssueSubscribers into subscriberIssueID (which issue's
subscribers to query) and targetIssueID (which issue the inbox item
links to). When notifying parent subscribers, the inbox item correctly
points to the sub-issue where the change occurred, so clicking the
notification navigates to the right place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:33:00 +08:00
Bohan Jiang
5956280d56 fix(server): don't inherit parent agent mentions when reply has its own mentions (#693)
When a reply explicitly @mentions anyone (agents or members), the user
is making a deliberate choice about who to involve. Previously, replying
with @AgentB under a comment mentioning @AgentA would trigger both agents.
Now parent mentions are only inherited when the reply has no mentions at all.
2026-04-11 14:29:01 +08:00
Wen
21fea91d23 fix(docker): chown runtime files to nextjs user in web image
public/ is mode 750 locally, so COPY into the runner stage landed files as
root and the nextjs user fell under other perms, causing EACCES on scandir
at startup. Add --chown=nextjs:nodejs to the standalone/static/public COPYs.
2026-04-11 01:29:45 -04:00
Jiayuan Zhang
82bbce98fd fix(security): add workspace ownership checks to daemon API routes (#684)
* fix(security): add workspace ownership checks to all daemon API routes

Switch daemon routes from middleware.Auth to middleware.DaemonAuth and
add per-handler workspace ownership verification. This prevents
cross-workspace access to runtimes, tasks, usage, and daemon lifecycle
endpoints (HIGH-1/2/3 + CHAIN-1/2/3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): support mdt_ daemon tokens in DaemonRegister + add regression tests

DaemonRegister now handles both auth paths:
- mdt_ daemon tokens: verify workspace match, skip member check, zero OwnerID
  (SQL COALESCE preserves existing owner on upsert)
- PAT/JWT: existing member check + OwnerID from member

Also adds WithDaemonContext helper and regression tests covering:
- Successful register with daemon token
- Workspace mismatch rejection
- Cross-workspace heartbeat rejection
- Cross-workspace task status rejection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:49:23 +08:00
Jiayuan Zhang
f4016fc721 fix(server): validate workspace ownership for attachment uploads and queries (#683)
Prevent cross-workspace attachment injection (CRIT-3) by verifying
issue_id/comment_id belong to the caller's workspace before creating
attachment records. Add workspace_id filter to ListAttachmentsByCommentIDs
query (MED-3) to prevent cross-workspace attachment data leakage.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 04:33:24 +08:00
Jiayuan Zhang
6c5879215d fix: sanitize markdown rendering in comments and shared renderers (#679)
* fix: sanitize markdown rendering in comments and shared renderers

Add rehype-sanitize to both ReadonlyContent and Markdown components so
that raw HTML parsed by rehype-raw is sanitized against a strict
allowlist before reaching the DOM. On the backend, add a bluemonday
sanitization pass when creating and updating comments to strip
dangerous tags as defense-in-depth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add mention:// protocol to sanitize allowlist and validate file card URLs

- Add mention:// to rehype-sanitize protocols.href in both ReadonlyContent
  and Markdown so @mention links survive sanitization
- Validate data-href on file cards to only allow http(s) URLs, blocking
  javascript: and data: schemes in both frontend click handler and backend
  bluemonday policy
- Narrow class attribute allowlist to specific elements (code, div, span, pre)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:44:30 +08:00
Jiayuan Zhang
2610d2dc3f chore: remove .pid files from repo and gitignore them (#680)
These are runtime artifacts created by Conductor for worktree process
management. They should never be tracked in git.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 03:37:20 +08:00
Jiayuan Zhang
faee939312 feat(issues): add project filter to Issues tab (#671)
Support filtering issues by project in the Issues tab filter dropdown,
including a "No project" option for issues without a project assigned.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:23:45 +08:00
pasmud
ea15f94341 fix(docker): fix self-hosting Docker build failures
The self-hosting Docker Compose setup fails to build on a clean clone due to several issues:

1. Dockerfile.web did not copy .npmrc into the deps stage. The project uses shamefully-hoist=true, so without it pnpm produces a different node_modules layout and module resolution breaks.

2. The builder stage copied individual node_modules directories from the deps stage (COPY --from=deps). This breaks pnpm's symlink structure -- especially on Windows where symlinks resolve to host paths. Additionally, packages/tsconfig has zero dependencies so its node_modules never exists, causing a hard COPY failure. Fixed by copying the full workspace from deps and running an offline pnpm install to re-link after source overlay.

3. next.config.ts imports dotenv but it was not declared as a direct dependency in apps/web/package.json. It resolves locally as a hoisted transitive dep but fails the TypeScript type check during next build in Docker.

4. docker/entrypoint.sh gets CRLF line endings on Windows due to git autocrlf, which breaks the shebang (container looks for /bin/sh\r). Added .gitattributes to enforce LF for shell scripts and a sed strip in the Dockerfile as a safety net.
2026-04-11 00:33:18 +10:00
Jiayuan Zhang
762bc92b2d fix(landing): replace "AI-Native Task Management" with landing page messaging (#670)
Use "Project Management for Human + Agent Teams" across all page titles,
OpenGraph metadata, and structured data to align with the actual landing
page hero and footer content.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:36:45 +08:00
Jiayuan Zhang
8db9099207 feat(search): add page navigation to cmd+k command palette (#665)
* feat(search): add page navigation to cmd+k command palette

Users can now search and navigate to sidebar pages (Inbox, My Issues,
Issues, Projects, Agents, Runtimes, Skills, Settings) directly from
the cmd+k dialog. Pages are shown in a dedicated "Pages" group and
filtered by query with keyword matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): only show pages when query is entered

Pages section was pushing down the Recent Issues list when the dialog
first opens. Now pages only appear when the user types a matching query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:15:16 +08:00
Jiayuan Zhang
904192b45c fix(web): correct project kanban issue counts (#667) 2026-04-10 21:13:25 +08:00
Jiayuan Zhang
0cceeee690 feat(projects): replace overview tab with sidebar properties panel (#662)
Removes the Overview/Issues tab system — clicking a project now shows
issues directly. Project properties (icon, title, status, priority,
lead, progress, description) are moved to a collapsible right sidebar,
matching the issue detail layout pattern.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:02:09 +08:00
Jiayuan Zhang
f1d81cdfaa feat(search): add project search support to Cmd+K search (#663)
Projects are now searchable alongside issues in the Cmd+K search dialog.
Results are grouped by type (Projects / Issues) with project icon, status,
and description snippet highlighting.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:59:32 +08:00
Jiayuan Zhang
2d4b959407 fix(docker): remove COPY for non-existent tsconfig/node_modules (#661)
* fix(docker): remove COPY for non-existent tsconfig/node_modules

The @multica/tsconfig package has zero dependencies, so pnpm install
never creates a node_modules directory for it. The COPY --from=deps
instruction fails with "not found" during docker compose build.

Closes #658

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(docker): add dotenv as explicit dependency for web app

next.config.ts imports dotenv to load .env for REMOTE_API_URL, but
dotenv was never declared as a dependency. It worked locally as a
hoisted transitive dep but fails in Docker's stricter module resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: fix daemon setup instructions for local Docker deployments

The daemon setup section in SELF_HOSTING.md had production URLs as the
active example and local Docker URLs commented out. Since this is a
self-hosting guide, local Docker should be the primary example.

Key changes:
- Make local Docker URLs the default in daemon setup examples
- Add explicit warning that CLI defaults to hosted service
- Add 'multica config set' instructions for persistent setup
- Add link from Quick Start to daemon setup section
- Clarify that daemon runs on host machine, not inside Docker
- Update CLI_AND_DAEMON.md self-hosted section similarly

Closes #660

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:58:32 +08:00
Jiayuan Zhang
54d452e20d feat(search): show recent issues in cmd+k dialog (#656)
* feat(search): show recent issues list when cmd+k opens

When opening the cmd+k search dialog, display a list of recently visited
issues instead of the empty placeholder. Visits are tracked via a
workspace-scoped persisted Zustand store (max 20 items).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): close cmd+k dialog on single ESC press

cmdk was consuming the first ESC to clear internal state, requiring a
second press to close the dialog. Intercept ESC on the CommandPrimitive
and close the dialog directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): move ESC handler to input to prevent double-ESC

The previous handler on CommandPrimitive didn't fire because cmdk
intercepts ESC at the input level. Moving the onKeyDown to
CommandPrimitive.Input ensures it fires before cmdk processes it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): use capture-phase ESC listener to close dialog reliably

The previous onKeyDown approach on the Input didn't work because
base-ui Dialog's internal focus management handled ESC before the
React synthetic event. Use a document-level capture-phase listener
that fires before all other handlers and stops propagation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(search): cover single-escape command palette close

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:48:43 +08:00
Jiayuan Zhang
9b62485a86 feat: add pin to sidebar for issues and projects (#653)
* feat: add pin to sidebar for issues and projects

Add per-user pinning of issues and projects to the sidebar for quick access.

- New `pinned_item` table with per-user, per-workspace scoping
- REST API: GET/POST /api/pins, DELETE /api/pins/{type}/{id}, PUT /api/pins/reorder
- Sidebar "Pinned" section between Personal and Workspace nav (hidden when empty)
- Pin/unpin actions in issue and project detail dropdown menus
- Optimistic mutations with WebSocket invalidation for real-time sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add drag-and-drop reordering and visible pin buttons

- Sidebar pinned items now support drag-and-drop reordering via @dnd-kit
- Add visible pin/unpin icon button in issue and project detail headers
- Add useReorderPins mutation with optimistic updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove drag handle and fix page refresh after reorder

- Remove GripVertical drag handle — whole item is now draggable, aligning
  with other sidebar elements
- Prevent link navigation after drag using wasDragged ref
- Remove onSettled invalidation from reorder mutation to prevent
  unnecessary refetch after optimistic update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:00:25 +08:00
Jiayuan Zhang
cce210ed3a feat(assign): sort members & agents by user's assignment frequency (#652)
The Assign dropdown now sorts members and agents by how frequently the
current user assigns issues to them. Frequency is computed from two
sources: assignee_changed activities in the activity log and initial
assignments on issues created by the user.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:45:08 +08:00
Jiayuan Zhang
356ff002dd feat(projects): show completion progress in project list (#651)
* feat(projects): show completion progress (done/total issues) in project list

Add a progress column to the projects list page that displays a mini progress
bar and done/total issue count for each project. Backend batch-fetches issue
stats per project using a single query for efficiency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(projects): show progress on project overview page

Add a progress bar with done/total (percentage) to the project detail
overview tab, computed from the already-loaded project issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:36:49 +08:00
Jiayuan Zhang
c234359857 feat(views): auto-fill project when creating issue via C shortcut on project page (#650)
When pressing "C" to create a new issue from a project detail page,
automatically set the project_id so the issue is linked to the current project.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:26:55 +08:00
Bohan Jiang
8bcb773304 fix(desktop): disable web security for CORS and fix dev server port (#648)
- Set webSecurity: false in BrowserWindow to bypass CORS when
  connecting to remote API (standard Electron practice)
- Fix renderer dev server to port 5173 so localStorage persists
  across restarts (prevents losing login state)
2026-04-10 18:20:30 +08:00
LinYushen
b52c048c8e fix(my-issues): use server-side filtering instead of client-side (#649)
* fix(my-issues): use server-side filtering instead of client-side

My Issues was fetching ALL workspace issues and filtering client-side,
causing the Done column to show wrong counts (269 vs user's actual
count) and only 2-3 done issues to appear from the first 50-item page.

Backend:
- Add creator_id and assignee_ids (uuid[]) filters to ListIssues,
  ListOpenIssues, and CountIssues SQL queries
- Parse creator_id and assignee_ids (comma-separated) query params

Frontend:
- Add myIssueListOptions with per-scope server-filtered queries
- Each tab now calls the API with the right filter:
  Assigned → assignee_id, Created → creator_id,
  My Agents → assignee_ids
- Add useLoadMoreMyDoneIssues for server-filtered done pagination
- WS events invalidate My Issues cache via issueKeys.myAll

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(my-issues): merge duplicate load-more hooks into one

Both board-view and list-view were unconditionally calling two hooks
(useLoadMoreDoneIssues + useLoadMoreMyDoneIssues) and picking one at
runtime. Merged into a single useLoadMoreDoneIssues with an optional
myIssues param so only one hook runs per render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:54:13 +08:00
LinYushen
f53cdf3157 fix(views): show user-scoped done count on My Issues page (#647)
The Done column on My Issues was displaying the workspace-wide total
(e.g. 269) instead of the current user's done issue count, because
BoardView/ListView read doneTotal directly from the shared cache.

Add an optional doneTotal prop to BoardView and ListView so the parent
can override the displayed count. MyIssuesPage now computes the count
from the client-filtered issue list and passes it through.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:00:18 +08:00
Bohan Jiang
8056c49909 docs: add v0.1.22 changelog with categorized sections
* docs: add v0.1.22 changelog (2026-04-10)

* docs: rewrite v0.1.22 changelog with categorized sections

- Add features/improvements/fixes categories to changelog type and component
- Remove desktop/Electron mentions (not yet released)
- Rewrite all entries with detailed descriptions based on actual commit messages
- Component renders category headers when present, falls back to flat list for older entries
- Both en and zh updated

* docs: trim v0.1.22 changelog entries for conciseness
2026-04-10 16:54:28 +08:00
Naiyuan Qing
d0edf2e4d5 Merge pull request #645 from multica-ai/feat/desktop-drag-reorder-tabs
feat(desktop): drag-to-reorder tabs via dnd-kit
2026-04-10 16:43:40 +08:00
Naiyuan Qing
6793f041ce Merge pull request #643 from multica-ai/agent/agent/d7add9d3
fix(desktop): add Geist font loading for consistent typography
2026-04-10 16:39:26 +08:00
Naiyuan Qing
b743db35af feat(desktop): drag-to-reorder tabs via dnd-kit
Adds horizontal drag-and-drop reordering for the desktop tab bar using
@dnd-kit/sortable, with axis + parent constraints so tabs only slide
horizontally within the bar. Order is persisted automatically through
the existing tab-store partialize.

Also brings tab-store into the standardized storage pipeline introduced
in 85cff154 — it was the last persist store still using vanilla zustand
persist instead of createPersistStorage(defaultStorage). Storage key
multica_tabs is unchanged so existing user data is preserved.

- apps/desktop: add @dnd-kit/{core,sortable,modifiers,utilities}
- tab-store: moveTab(from, to) action via arrayMove (preserves router refs)
- tab-store: persist storage → createJSONStorage(createPersistStorage(defaultStorage))
- tab-bar: DndContext + SortableContext(horizontalListSortingStrategy)
- tab-bar: restrictToHorizontalAxis + restrictToParentElement modifiers
- tab-bar: PointerSensor distance:5 to disambiguate click vs drag
- tab-bar: stopPropagation on close-button pointerdown to avoid drag start

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:38:27 +08:00
Naiyuan Qing
a3149858f5 fix(desktop): add Geist font loading for consistent typography
Desktop app was missing Geist font — the CSS variable `--font-sans` referenced
by `@theme inline` in tokens.css was never defined, causing fallback to the
Chromium default system font. Web app worked because Next.js `next/font/google`
injected the variable.

Fix: add @fontsource/geist-sans and @fontsource/geist-mono, import the font
CSS in main.tsx, and define --font-sans/--font-mono in globals.css.

Closes MUL-504

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:35:45 +08:00
Jiayuan Zhang
0f86611c41 fix: support multiline display for Master Agent input (#638)
* fix(views): support multiline display for agent text content

- TextRow in agent-live-card: show collapsible multiline content instead
  of only the last line
- Chat user message bubble: add whitespace-pre-wrap to preserve line breaks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: remove out-of-scope TextRow change in agent-live-card

Only the chat bubble multiline fix is needed for this issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:28:31 +08:00
Jiayuan Zhang
17ae320dd2 feat(docs): add documentation site with Fumadocs (#634)
Set up a documentation site at apps/docs using Fumadocs (Next.js App Router).
Migrated existing docs (README, SELF_HOSTING, CLI_AND_DAEMON, CLI_INSTALL,
CONTRIBUTING, AGENTS) into structured MDX content with sidebar navigation
and full-text search.

Content structure:
- Getting Started: Cloud quickstart, self-hosting guide
- CLI & Daemon: Installation, full command reference
- Guides: Quickstart, agents overview
- Developers: Contributing guide, architecture docs

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:28:23 +08:00
Bohan Jiang
6b8afb1d3d fix(views): use fake timers globally in login-page tests to prevent input-otp timer leak (#642)
input-otp sets internal timers that fire after jsdom tears down window,
causing "ReferenceError: window is not defined" unhandled errors in CI.
Using fake timers suite-wide ensures no real timers escape after cleanup.
2026-04-10 16:23:42 +08:00
Jiayuan Zhang
bf8abba24d fix(db): relax pending task unique index to per-(issue, agent) (#637)
The idx_one_pending_task_per_issue index only allowed one pending task
per issue across all agents, causing different agents' queued/dispatched
tasks to block each other. This mismatched the code-level dedup which
checks per (issue_id, agent_id). Replace with idx_one_pending_task_per_issue_agent
on (issue_id, agent_id) so each agent can independently have one pending task.

Fixes MUL-495

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:21:20 +08:00
LinYushen
63ca8d7d89 fix(views): improve daily token usage chart readability (#641)
* fix(views): improve daily token usage chart readability

- Fix Y-axis showing scrambled/truncated tick labels by computing
  explicit nice ticks and using compact number formatting (100M not 100.0M)
- Simplify token categories from 4 (Input/Output/Cache Read/Cache Write)
  to 3 (Input/Output/Cached) — cache write merged into input
- Replace noisy stacked area chart with clean single-area total trend,
  with a custom tooltip showing per-category breakdown and total
- Increase Y-axis width to prevent label clipping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(views): handle floating point edge case in formatTokens

Use modulo + threshold instead of Number.isInteger to avoid floating
point precision issues (e.g. 2.5M * 4 = 10.000000000000004).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(views): keep 4 token categories consistent between chart tooltip and summary cards

Revert the 3-category simplification (Cached/Input/Output) back to the
original 4 categories (Input/Output/Cache Read/Cache Write) so the chart
tooltip matches the summary cards on the same page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:20:03 +08:00
Bohan Jiang
28b9bf85ee feat(daemon): add minimum Claude Code version check (#625)
* feat(daemon): add minimum Claude Code version check during runtime registration

The daemon now validates the detected agent CLI version against a
minimum requirement before registering a runtime. Claude Code requires
>= 2.0.0 (when --output-format stream-json and --permission-mode
bypassPermissions were introduced). Older versions are skipped with a
warning log, preventing silent failures.

Closes #569

* feat(daemon): add minimum Codex CLI version check (>= 0.100.0)

The `codex app-server --listen stdio://` flag was introduced in v0.100.0.
Older versions lack this flag and fail silently. Add codex to the
MinVersions map so the daemon skips outdated codex CLIs with a clear
warning, matching the existing Claude version check.

Refs #490
2026-04-10 16:09:56 +08:00
Naiyuan Qing
de88219edc Merge pull request #640 from multica-ai/fix/drag-drop-overlay
feat(core): storage standardization + workspace isolation
2026-04-10 16:06:06 +08:00
Naiyuan Qing
1e0d2b8606 fix(auth): logout clears workspace_id and query cache
Previously logout only removed multica_token, leaving workspace_id
and TanStack Query cache intact — a security issue on shared devices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:00:04 +08:00
Naiyuan Qing
85cff15427 feat(core): standardize storage + workspace isolation for persist stores
Unify all client-side persistence through StorageAdapter and add
workspace-scoped key namespacing (${key}:${wsId}).

- createPersistStorage: bridge for Zustand persist → StorageAdapter DI
- createWorkspaceAwareStorage: dynamic namespace by current workspace
- Migrate 6 persist stores (navigation, draft, view, scope, my-issues-view, chat)
- Rehydration registry: stores auto-rehydrate on workspace switch
- clearWorkspaceStorage: cleanup on workspace delete / member removal
- Chat store: namespace keys + rehydrate on workspace switch
- Factory view stores (createIssueViewStore): auto-register for rehydration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:59:53 +08:00
Naiyuan Qing
ee46fd6064 fix(editor): address review — complete migration, fix imports, clean dead code
- Add showDropOverlay={false} to projects/ ContentEditors (no upload support)
- Use barrel exports from ../../editor instead of direct file imports
- Remove ring-brand/30 from CommentInput for visual consistency
- Remove dead internal overlay code from ContentEditor (dragOver state,
  drag handlers, overlay JSX, document listeners, showDropOverlay prop)
- Remove unused .editor-drop-overlay CSS
- Update issue-detail test mock with useFileDropZone/FileDropOverlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:49:40 +08:00
Jiayuan Zhang
b439cfe9ea feat: add 'C' keyboard shortcut for New Issue (#635)
* feat(views): add "C" keyboard shortcut to open new issue modal

Adds a global keyboard shortcut matching Linear's convention — pressing
"C" when not focused on an input/editor opens the create-issue modal.
Also displays the shortcut hint in the sidebar button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(views): match "C" shortcut badge style to search ⌘K badge

Use the same kbd styling (rounded border, bg-muted, font-mono) as the
search trigger so the two shortcut hints look consistent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:48:18 +08:00
Jiayuan Zhang
17ad3b2f3b Merge pull request #618 from multica-ai/agent/emacs/2023d753
feat(dx): simplify local dev and self-hosting setup
2026-04-10 15:46:27 +08:00
Bohan Jiang
ee3c849c52 fix(skills): detect GitHub default branch instead of hardcoding "main" for skills.sh imports (#632)
Repos hosted on GitHub can use any branch name as default (main, master, etc.).
The skills.sh import was hardcoding "main" in raw.githubusercontent.com URLs,
causing 404s when fetching SKILL.md from repos with a different default branch.

Now queries the GitHub API (/repos/{owner}/{repo}) to get the actual default
branch before fetching files.

Fixes #517
2026-04-10 15:44:18 +08:00
Bohan Jiang
5f888c75c4 feat(views): mobile-responsive layout for sidebar and inbox (#630)
* fix(layout): add mobile sidebar trigger for small screens

The sidebar already renders as a Sheet (drawer) on mobile via the
existing shadcn sidebar component, but there was no trigger button
for users to open it. This adds a mobile-only (md:hidden) header
bar with a SidebarTrigger in the DashboardLayout so users on phones
can access the sidebar navigation.

Closes #593

* feat(views): add mobile-responsive layout for inbox page

On mobile (<768px), switch from resizable two-panel layout to a
full-screen list/detail toggle. Tapping a notification shows the
detail view full-screen with a back button; the sidebar trigger
from the dashboard layout remains accessible.
2026-04-10 15:41:57 +08:00
LinYushen
a25886102a feat(agent): add Hermes Agent Provider via ACP protocol (#623)
* feat(agent): add Hermes Agent Provider via ACP protocol

Integrate Hermes as a new agent backend using the ACP (Agent
Communication Protocol) JSON-RPC 2.0 over stdio — the same pattern
as the Codex provider but with ACP-specific methods.

- New hermesBackend spawns `hermes acp` and drives initialize →
  session/new → session/prompt lifecycle
- Handles session/update notifications: agent_message_chunk,
  agent_thought_chunk, tool_call, tool_call_update, usage_update
- Auto-approves tool executions via HERMES_YOLO_MODE env var
- Supports session resume, model override, system prompt injection
- Token usage extracted from PromptResponse and usage_update events
- Auto-detected at daemon startup via MULTICA_HERMES_PATH env var

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): optimize runtime icons and fix create-agent dialog overflow

- Replace OpenClaw pixel-art icon (32 rects) with clean vector paths
- Add Hermes provider icon (NousResearch mascot, 48x48 webp data URI)
- Use provider-specific icons in runtime selector instead of generic Monitor
- Fix dialog overflow: add min-w-0 to grid item so truncate works

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(agent): add required mcpServers param to Hermes ACP session/new

ACP SDK v0.11.2 requires mcpServers as a mandatory field in
NewSessionRequest. Without it, Pydantic validation fails with
"Invalid params" and the agent immediately errors out.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:40:11 +08:00
Jiayuan Zhang
2c1d1d989c fix(daemon): symlink Codex sessions dir to shared home for discoverability (#627)
Per-task CODEX_HOME isolated session logs in per-task directories, making
them invisible from the global ~/.codex/sessions/ where users expect to
find them. Symlink the sessions directory back to the shared home so
Codex writes session logs to the global location while keeping skills
isolated per task.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:38:34 +08:00
Naiyuan Qing
4268b7891a feat(core): add workspace-aware storage for scoped persist stores
Create createWorkspaceAwareStorage that dynamically namespaces
localStorage keys by workspace ID (e.g. "multica_issue_draft:ws_abc").
Wire setCurrentWorkspaceId into workspace store lifecycle methods and
migrate all workspace-scoped stores (draft, view, scope) to use it.
Navigation store intentionally left user-scoped without namespace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:27:18 +08:00
Naiyuan Qing
cc672b8009 feat(core): add createPersistStorage utility for Zustand persist middleware
Bridge between Zustand persist middleware's StateStorage and the existing
StorageAdapter DI system, with optional workspace-scoped key namespacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:20:11 +08:00
Naiyuan Qing
66cb5d924a fix(editor): lift drag-drop overlay to outer container for better UX
When editors are empty, the internal drop overlay was too small to be
useful. Move the overlay to the parent container with a lighter style
so the drop target covers the full input area regardless of content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:18:38 +08:00
Jiayuan Zhang
c7e5aedb14 fix(server): add startup warnings for missing JWT_SECRET and RESEND_API_KEY
When these env vars are not configured, the server now prints clear
warning messages at startup so users know what to fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:17:53 +08:00
Bohan Jiang
66dec60f71 fix(core): invalidate parent children cache when sub-issues are deleted (#633)
useDeleteIssue and useBatchDeleteIssues only invalidated the main issues
list after deletion, leaving the parent issue's children cache stale.
This caused deleted sub-issues to remain visible in the parent issue view
until a full page refresh. Now both mutations look up the deleted issue's
parent_issue_id and invalidate the corresponding children query on settle,
matching the pattern already used in the WebSocket handler.
2026-04-10 15:15:03 +08:00
Jiayuan Zhang
ec71a41d8f feat(deploy): add full-stack Docker Compose for self-hosting
Add a one-command self-hosting setup: `docker compose -f docker-compose.selfhost.yml up -d`
starts PostgreSQL, backend (with auto-migration), and frontend.

Changes:
- docker-compose.selfhost.yml: full stack orchestration (postgres + backend + frontend)
- Dockerfile: add entrypoint.sh that auto-runs migrations before server start
- Dockerfile.web: multi-stage Next.js build with standalone output
- docker/entrypoint.sh: migration + server startup script
- .dockerignore: exclude unnecessary files from Docker builds
- apps/web/next.config.ts: conditional standalone output for Docker builds
- SELF_HOSTING.md: rewrite with Docker Compose as primary approach
- README.md: update self-host section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:11:18 +08:00
Bohan Jiang
ca7ba48934 fix(agents): invalidate runtimes cache on daemon events (#624)
The Agents page never received runtime cache updates when daemons
registered or deregistered, causing the Create Agent dialog to show
"No runtime available" even when runtimes existed. This happened because
daemon events were only handled by the Runtimes page component, not
globally.

- Add daemon:register to the centralized realtime sync refresh map
- Skip daemon:heartbeat in the generic handler to avoid excessive refetches
- Invalidate runtimes on WS reconnect alongside other workspace data
- Show a loading indicator in the Create Agent dialog while runtimes load
2026-04-10 14:50:34 +08:00
Yevanchen
63895343e3 Fix Claude stream-json startup hangs (#592) 2026-04-10 14:42:28 +08:00
Bohan Jiang
88982ad23f feat(issues): display token usage per issue in detail sidebar (#581)
* feat(issues): display token usage per issue in detail sidebar

Add a new "Token usage" section to the issue detail right sidebar that
shows aggregated input/output tokens, cache tokens, and run count across
all tasks for the issue. Backed by a new SQL query and API endpoint.

* fix(db): add index on agent_task_queue(issue_id) for usage queries

The GetIssueUsageSummary query joins agent_task_queue filtered by
issue_id across all statuses. The existing partial index (migration 022)
only covers queued/dispatched rows, so completed tasks require a
sequential scan. Add a general index to prevent performance degradation
as task volume grows.
2026-04-10 14:34:32 +08:00
Jiayuan Zhang
abe005b403 feat(dx): add make dev one-command local setup
Simplifies local development from 3+ commands to a single `make dev`
that auto-detects environment (main/worktree), creates env files,
installs dependencies, starts PostgreSQL, runs migrations, and launches
both backend and frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:51:07 +08:00
211 changed files with 11090 additions and 2109 deletions

38
.dockerignore Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
.next
dist
server/bin
server/tmp
# Git
.git
.gitignore
# Environment
.env
.env.*
!.env.example
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test
e2e/test-results
coverage
# Docs
docs/
# Desktop app (not needed for web self-hosting)
apps/desktop

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
# Ensure shell scripts always use LF line endings (needed for Docker on Windows)
*.sh text eol=lf
docker/entrypoint.sh text eol=lf
# Default behavior
* text=auto

3
.gitignore vendored
View File

@@ -41,6 +41,9 @@ apps/web/test-results/
# feature tracking
_features/
# runtime
*.pid
# platform specific
*.dmg
*.app

View File

@@ -11,6 +11,7 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:

View File

@@ -57,7 +57,10 @@ The architecture relies on a strict split between server state and client state.
## Commands
```bash
# One-click setup & run
# One-command dev (auto-setup + start everything)
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
# Explicit setup & run (if you prefer separate steps)
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
@@ -73,7 +76,7 @@ pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
# Backend (Go)
make dev # Run Go server (port 8080)
make server # Run Go server only (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
@@ -113,6 +116,8 @@ CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL serv
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
`make dev` auto-detects worktrees and handles everything. For explicit control:
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree

View File

@@ -30,6 +30,16 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
## Quick Start
```bash
# One-command setup: configure, authenticate, and start the daemon
multica setup
# For self-hosted (local) deployments:
multica setup --local
```
Or step by step:
```bash
# 1. Authenticate (opens browser for login)
multica login
@@ -162,23 +172,31 @@ Agent-specific overrides:
### Self-Hosted Server
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
When connecting to a self-hosted Multica instance, the easiest approach is:
```bash
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
# One command — auto-detects local server, configures, authenticates, starts daemon
multica setup --local
```
Or configure manually:
```bash
# Configure for local Docker Compose (default ports)
multica config local
# Or set URLs individually:
# multica config set app_url http://localhost:3000
# multica config set server_url http://localhost:8080
# For production with TLS:
# multica config set app_url https://app.example.com
# multica config set server_url https://api.example.com
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.
@@ -306,6 +324,21 @@ multica issue run-messages <task-id> --since 42 --output json
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Setup
```bash
# One-command setup: configure, authenticate, and start the daemon
multica setup
# For local self-hosted deployments (auto-detects or forces local mode)
multica setup --local
# Custom ports
multica setup --local --port 9090 --frontend-port 4000
```
`multica setup` detects whether a local Multica server is running, configures the CLI, opens your browser for authentication, and starts the daemon — all in one step.
## Configuration
### View Config
@@ -316,10 +349,19 @@ multica config show
Shows config file path, server URL, app URL, and default workspace.
### Configure for Local Self-Hosted
```bash
multica config local # Uses default ports (8080/3000)
multica config local --port 9090 --frontend-port 4000 # Custom ports
```
Sets `server_url` and `app_url` for a local Docker Compose deployment in one command.
### Set Values
```bash
multica config set server_url wss://api.example.com/ws
multica config set server_url https://api.example.com
multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```

View File

@@ -94,59 +94,52 @@ FORCE=1 make worktree-env
## First-Time Setup
### Main Checkout
### Quick Start (recommended)
From the main checkout:
From any checkout (main or worktree):
```bash
make dev
```
This single command:
- auto-detects whether you're in a main checkout or a worktree
- creates the appropriate env file (`.env` or `.env.worktree`) if it doesn't exist
- checks that prerequisites (Node.js, pnpm, Go, Docker) are installed
- installs JavaScript dependencies
- ensures the shared PostgreSQL container is running
- creates the application database if it does not exist
- runs all migrations
- starts both backend and frontend
### Explicit Setup (advanced)
If you prefer separate control over setup and startup:
#### Main Checkout
```bash
cp .env.example .env
make setup-main
```
What `make setup-main` does:
- installs JavaScript dependencies with `pnpm install`
- ensures the shared PostgreSQL container is running
- creates the application database if it does not exist
- runs all migrations against that database
Start the app:
```bash
make start-main
```
Stop the app processes:
Stop:
```bash
make stop-main
```
This does not stop PostgreSQL.
### Worktree
From the worktree directory:
#### Worktree
```bash
make worktree-env
make setup-worktree
```
What `make setup-worktree` does:
- uses `.env.worktree`
- ensures the shared PostgreSQL container is running
- creates the worktree database if it does not exist
- runs migrations against the worktree database
Start the worktree app:
```bash
make start-worktree
```
Stop the worktree app processes:
Stop:
```bash
make stop-worktree
@@ -171,17 +164,15 @@ Use a worktree when you want isolated data and separate app ports.
```bash
git worktree add ../multica-feature -b feat/my-change main
cd ../multica-feature
make worktree-env
make setup-worktree
make start-worktree
make dev
```
After that, day-to-day commands are:
```bash
make start-worktree
make stop-worktree
make check-worktree
make dev # start (re-runs setup if needed, idempotent)
make stop-worktree # stop
make check-worktree # verify
```
## Running Main and Worktree at the Same Time
@@ -424,9 +415,7 @@ Warning:
### Stable Main Environment
```bash
cp .env.example .env
make setup-main
make start-main
make dev
```
### Feature Worktree
@@ -434,9 +423,7 @@ make start-main
```bash
git worktree add ../multica-feature -b feat/my-change main
cd ../multica-feature
make worktree-env
make setup-worktree
make start-worktree
make dev
```
### Return to a Previously Configured Worktree

View File

@@ -30,7 +30,9 @@ COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["./server"]
ENTRYPOINT ["./entrypoint.sh"]

70
Dockerfile.web Normal file
View File

@@ -0,0 +1,70 @@
# --- Dependencies ---
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy workspace config and all package.json files for dependency resolution
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
COPY apps/web/package.json apps/web/
COPY packages/core/package.json packages/core/
COPY packages/ui/package.json packages/ui/
COPY packages/views/package.json packages/views/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/
RUN pnpm install --frozen-lockfile
# --- Build ---
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy installed dependencies (preserves pnpm symlink structure)
COPY --from=deps /app ./
# Copy source
COPY package.json turbo.json pnpm-workspace.yaml ./
COPY apps/web/ apps/web/
COPY packages/ packages/
# Re-link after source overlay (fixes any symlinks overwritten by COPY)
RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)
RUN pnpm --filter @multica/web build
# --- Runtime ---
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy standalone output (includes traced node_modules)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
# Copy static files (not included in standalone)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
# Copy public assets
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/web/server.js"]

View File

@@ -1,4 +1,4 @@
.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,6 +36,53 @@ define REQUIRE_ENV
fi
endef
# ---------- Self-hosting (Docker Compose) ----------
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup --local"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
# Stop all Docker Compose self-host services
selfhost-stop:
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations
@@ -122,8 +169,12 @@ check-worktree:
# ---------- Individual commands ----------
# Go server
# One-command dev: auto-setup env/deps/db/migrations, then start all services
dev:
@bash scripts/dev.sh
# Go server only
server:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
@@ -139,10 +190,11 @@ multica:
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:

108
README.md
View File

@@ -47,57 +47,36 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
---
## Quick Install
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
After installation:
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the local agent runtime
multica daemon stop # Stop the daemon when done
```
> **Self-hosting?** Add `--local` to deploy a full Multica server on your machine:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---
## Getting Started
### Multica Cloud
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
### Self-Host with Docker
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
**Option A — paste this to your coding agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**Option B — install manually:**
```bash
# Install
brew tap multica-ai/tap
brew install multica
# Authenticate and start
multica login
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
## Quickstart
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
### 1. Log in and start the daemon
```bash
@@ -105,7 +84,7 @@ multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
### 2. Verify your runtime
@@ -121,7 +100,27 @@ Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team. 🎉
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
| Command | Description |
|---------|-------------|
| `multica login` | Authenticate (opens browser) |
| `multica daemon start` | Start the local agent runtime |
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup (configure + login + start daemon) |
| `multica setup --local` | Same, but for self-hosted deployments |
| `multica config local` | Configure CLI for a local self-hosted server |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference.
---
## Architecture
@@ -152,10 +151,9 @@ For contributors working on the Multica codebase, see the [Contributing Guide](C
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
```bash
pnpm install
cp .env.example .env
make setup
make start
make dev
```
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.

View File

@@ -47,52 +47,33 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
## 快速开始
---
### Multica 云服务
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
### Docker 自部署
## 快速安装
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# 编辑 .env — 至少修改 JWT_SECRET
docker compose up -d # 启动 PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
make start # 启动应用
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew没有则直接下载二进制
## CLI
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
**方式 A — 将以下指令粘贴给你的 coding agentClaude Code、Codex、OpenClaw、OpenCode 等):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**方式 B — 手动安装:**
安装完成后:
```bash
# 安装
brew tap multica-ai/tap
brew install multica
# 认证并启动
multica login
multica daemon start
multica login # 认证(打开浏览器)
multica daemon start # 启动本地 Agent 运行时
multica daemon stop # 停止 daemon
```
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> ```
>
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
---
## 快速上手

View File

@@ -1,10 +1,8 @@
# Self-Hosting Guide
This guide walks you through deploying Multica on your own infrastructure.
Deploy Multica on your own infrastructure in minutes.
## Architecture Overview
Multica has three components:
## Architecture
| Component | Description | Technology |
|-----------|-------------|------------|
@@ -12,16 +10,151 @@ Multica has three components:
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Prerequisites
## Quick Install (Recommended)
- Docker and Docker Compose (recommended), or:
- Go 1.26+ (to build from source)
- Node.js 20+ and pnpm 10.28+ (to build the frontend)
- PostgreSQL 17 with the pgvector extension
One command to set up everything — server, CLI, and configuration:
## Quick Start (Docker Compose)
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
Once complete, open http://localhost:3000, log in with any email + verification code **`888888`**, then:
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the agent daemon
```
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
---
## Step-by-Step Setup (Alternative)
If you prefer to run each step manually:
### Step 1 — Start the Server
**Prerequisites:** Docker and Docker Compose.
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
```
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
### Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
Each team member who wants to run AI agents locally needs to:
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
```
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
### b) One-command setup
```bash
multica setup --local
```
This automatically:
1. Configures the CLI to connect to `localhost` (ports 8080/3000)
2. Opens your browser for authentication
3. Discovers your workspaces
4. Starts the daemon in the background
To verify the daemon is running:
```bash
multica daemon status
```
> **Alternative:** If you prefer manual steps, see [Manual CLI Configuration](#manual-cli-configuration) below.
### Step 4 — Verify & Start Using
1. Open your workspace in the web app at http://localhost:3000
2. Navigate to **Settings → Runtimes** — you should see your machine listed
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
## Stopping Services
If you installed via the install script:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop
```
If you cloned the repo manually:
```bash
# Stop the Docker Compose services (backend, frontend, database)
make selfhost-stop
# Stop the local daemon
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
```
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Rebuilding After Updates
```bash
git pull
make selfhost
```
Migrations run automatically on backend startup.
---
## Manual Docker Compose Setup
If you prefer running Docker Compose steps manually instead of `make selfhost`:
```bash
git clone https://github.com/multica-ai/multica.git
@@ -29,258 +162,46 @@ cd multica
cp .env.example .env
```
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
Edit `.env` — at minimum, change `JWT_SECRET`:
```bash
# Start PostgreSQL
docker compose up -d
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server
JWT_SECRET=$(openssl rand -hex 32)
```
For the frontend:
Then start everything:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
docker compose -f docker-compose.selfhost.yml up -d
```
## Configuration
## Manual CLI Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using the Included Docker Compose
If you prefer configuring the CLI step by step instead of `multica setup`:
```bash
docker compose up -d postgres
# Point CLI to your local server
multica config local
# Or set URLs manually:
# multica config set app_url http://localhost:3000
# multica config set server_url http://localhost:8080
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
```
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
### Using Your Own PostgreSQL
Ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Running Migrations
Migrations must be run before starting the server:
For production deployments with TLS:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
multica config set app_url https://app.example.com
multica config set server_url https://api.example.com
multica login
multica daemon start
```
## Reverse Proxy
## Advanced Configuration
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Setting Up the Agent Daemon
Each team member who wants to run AI agents locally needs to:
1. **Install the CLI**
```bash
brew tap multica-ai/tap
brew install multica-cli
```
2. **Install an AI agent CLI** — at least one of:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
3. **Authenticate and start**
```bash
# Point CLI to your server
#
# For production deployments with TLS:
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
#
# For local deployments without TLS:
# export MULTICA_APP_URL=http://localhost:3000
# export MULTICA_SERVER_URL=ws://localhost:8080/ws
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
```
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
## Upgrading
1. Pull the latest code or image
2. Run migrations: `./server/bin/migrate up`
3. Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.
For environment variables, manual setup (without Docker), reverse proxy configuration, database setup, and more, see the [Advanced Configuration Guide](SELF_HOSTING_ADVANCED.md).

224
SELF_HOSTING_ADVANCED.md Normal file
View File

@@ -0,0 +1,224 @@
# Self-Hosting — Advanced Configuration
This document covers advanced configuration for self-hosted Multica deployments. For the quick start guide, see [SELF_HOSTING.md](SELF_HOSTING.md).
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using Docker Compose (Recommended)
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
### Using Your Own PostgreSQL
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
### Running Migrations Manually
The Docker Compose setup runs migrations automatically. If you need to run them manually:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

74
SELF_HOSTING_AI.md Normal file
View File

@@ -0,0 +1,74 @@
# Self-Hosting Setup (for AI Agents)
This document is designed for AI agents to execute. Follow these steps exactly to deploy a local Multica instance and connect to it.
## Prerequisites
- Docker and Docker Compose installed
- Homebrew installed (for CLI)
- At least one AI agent CLI on PATH: `claude` or `codex`
## One-Command Install
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This installs everything: clones the repo, starts Docker services, and installs the CLI.
Wait for the output `✓ Multica is installed and running!` before proceeding.
**Expected result:**
- Frontend at http://localhost:3000
- Backend at http://localhost:8080
- `multica` CLI installed and configured for localhost
## Alternative: Manual Setup
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
brew install multica-ai/tap/multica
multica setup --local
```
The `multica setup --local` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use verification code `888888` with any email
3. Discover workspaces automatically
4. Start the daemon in the background
## Verification
```bash
multica daemon status
```
Should show `running` with detected agents.
## Stopping
```bash
# Stop the daemon
multica daemon stop
# Stop all Docker services
cd multica
make selfhost-stop
```
## Custom Ports
If the default ports (8080/3000) are in use:
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
2. Run `make selfhost`
3. Run `multica setup --local --port <PORT> --frontend-port <FRONTEND_PORT>`
## Troubleshooting
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
- **Daemon issues:** `multica daemon logs`
- **Health check:** `curl http://localhost:8080/health`

View File

@@ -11,6 +11,10 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {

View File

@@ -15,19 +15,25 @@
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@types/node": "catalog:",
"@types/react": "catalog:",

View File

@@ -17,6 +17,7 @@ function createWindow(): void {
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
},
});

View File

@@ -10,6 +10,24 @@ import {
Plus,
type LucideIcon,
} from "lucide-react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import {
restrictToHorizontalAxis,
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
@@ -23,12 +41,28 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Settings,
};
function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tab.id });
const Icon = TAB_ICONS[tab.icon];
const style = {
transform: CSS.Transform.toString(transform),
transition,
WebkitAppRegion: "no-drag",
zIndex: isDragging ? 10 : undefined,
} as React.CSSProperties;
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
@@ -41,16 +75,25 @@ function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnl
// No navigate() — store handles activeTabId switch
};
// Stop pointer down on close so it doesn't start a drag on the parent button.
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
return (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
isActive
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
isDragging && "opacity-60",
)}
>
{Icon && <Icon className="size-3.5 shrink-0" />}
@@ -66,6 +109,7 @@ function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnl
{!isOnly && (
<span
onClick={handleClose}
onPointerDown={stopDragOnClose}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
@@ -100,12 +144,44 @@ function NewTabButton() {
export function TabBar() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const moveTab = useTabStore((s) => s.moveTab);
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const tabIds = tabs.map((t) => t.id);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
if (from !== -1 && to !== -1) moveTab(from, to);
};
return (
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
{tabs.map((tab) => (
<TabItem key={tab.id} tab={tab} isActive={tab.id === activeTabId} isOnly={tabs.length === 1} />
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
))}
</SortableContext>
</DndContext>
<NewTabButton />
</div>
);

View File

@@ -6,6 +6,13 @@
@custom-variant dark (&:is(.dark *));
/* Geist font: define CSS variables that tokens.css @theme inline references.
Web app gets these from next/font/google; desktop must set them explicitly. */
:root {
--font-sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
@source "../../../../../packages/ui/**/*.tsx";
@source "../../../../../packages/core/**/*.{ts,tsx}";
@source "../../../../../packages/views/**/*.{ts,tsx}";

View File

@@ -1,5 +1,11 @@
import ReactDOM from "react-dom/client";
import App from "./App";
import "@fontsource/geist-sans/400.css";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -1,5 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -33,6 +35,8 @@ interface TabStore {
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Update a tab's history tracking. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
moveTab: (fromIndex: number, toIndex: number) => void;
}
// ---------------------------------------------------------------------------
@@ -150,10 +154,16 @@ export const useTabStore = create<TabStore>()(
),
}));
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
}),
{
name: "multica_tabs",
version: 1,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
partialize: (state) => ({
tabs: state.tabs.map(
({ router, historyIndex, historyLength, ...rest }) => rest,

3
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.next/
.source/
node_modules/

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/app/layout.config";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Multica Documentation
</h1>
<p className="max-w-2xl text-lg text-fd-muted-foreground">
The open-source managed agents platform. Turn coding agents into real
teammates assign tasks, track progress, compound skills.
</p>
<div className="flex gap-4">
<Link
href="/docs"
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Get Started
</Link>
<Link
href="https://github.com/multica-ai/multica"
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
>
GitHub
</Link>
</div>
</main>
);
}

View File

@@ -0,0 +1,4 @@
import { source } from "@/lib/source";
import { createFromSource } from "fumadocs-core/search/server";
export const { GET } = createFromSource(source);

View File

@@ -0,0 +1,47 @@
import { source } from "@/lib/source";
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -0,0 +1,12 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

3
apps/docs/app/global.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";

View File

@@ -0,0 +1,25 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
export const baseOptions: BaseLayoutProps = {
nav: {
title: (
<span className="font-semibold text-base">Multica Docs</span>
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "GitHub",
url: "https://github.com/multica-ai/multica",
},
{
text: "Cloud",
url: "https://multica.ai",
},
],
};

23
apps/docs/app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
template: "%s | Multica Docs",
default: "Multica Docs",
},
description:
"Documentation for Multica — the open-source managed agents platform.",
};
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,90 @@
---
title: CLI Installation
description: Install the Multica CLI and start the agent daemon.
---
## Installation
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
```
### 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
```
### Download from GitHub Releases
If Homebrew is not available, download the binary directly:
```bash
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
ARCH=$(uname -m) # "x86_64" or "arm64"
# Normalize architecture name
if [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
# Get the latest release tag from GitHub
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest \
| grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" \
-o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz
```
### 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.
## Verify
```bash
multica daemon status
```
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
3. At least one workspace is being watched
If the agents list is empty, install at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
- [Codex](https://github.com/openai/codex) (`codex`)
Then restart the daemon:
```bash
multica daemon stop && multica daemon start
```

View File

@@ -0,0 +1,4 @@
{
"title": "CLI & Daemon",
"pages": ["installation", "reference"]
}

View File

@@ -0,0 +1,306 @@
---
title: CLI Reference
description: Complete command reference for the Multica CLI and agent daemon.
---
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.
## 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/<name>/`), 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 <workspace-id>
multica workspace unwatch <workspace-id>
```
### Get Details
```bash
multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
### List Members
```bash
multica workspace members <workspace-id>
```
## 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 <id>
multica issue get <id> --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 <id> --title "New title" --priority urgent
```
### Assign Issue
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --unassign
```
### Change Status
```bash
multica issue status <id> in_progress
```
Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`.
### Comments
```bash
# List comments
multica issue comment list <issue-id>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
# Reply to a specific comment
multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
# Delete a comment
multica issue comment delete <comment-id>
```
### Execution History
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --output json
# View messages for a specific execution run
multica issue run-messages <task-id>
multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
```
## 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 <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
```

View File

@@ -0,0 +1,75 @@
---
title: Architecture
description: Technical architecture of the Multica platform.
---
## Overview
Multica is a Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
## Project Structure
| Directory | Purpose | Technology |
|-----------|---------|------------|
| `server/` | Go backend | Chi router, sqlc for DB, gorilla/websocket |
| `apps/web/` | Next.js frontend | App Router |
| `apps/desktop/` | Electron desktop app | electron-vite |
| `apps/docs/` | Documentation site | Fumadocs |
| `packages/core/` | Headless business logic | Zero react-dom, all-platform reuse |
| `packages/ui/` | Atomic UI components | Zero business logic, shadcn-based |
| `packages/views/` | Shared business pages | Zero next/\*, zero react-router imports |
| `packages/tsconfig/` | Shared TypeScript config | — |
| `packages/eslint-config/` | Shared ESLint config | — |
## Backend Structure
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI + daemon), `migrate`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon)
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients, server broadcasts events
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256), middleware sets `X-User-ID` and `X-User-Email` headers
- **Task lifecycle** (`internal/service/task.go`): enqueue → claim → start → complete/fail
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex
- **Daemon** (`internal/daemon/`): Auto-detects CLIs, registers runtimes, polls for tasks
- **Database**: PostgreSQL 17 with pgvector, sqlc generates code from SQL in `pkg/db/queries/`
## Frontend Architecture
### Internal Packages Pattern
All shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
### Package Boundaries
- `packages/core/` — zero react-dom, zero localStorage, zero UI libs. All Zustand stores live here.
- `packages/ui/` — pure UI components, zero business logic.
- `packages/views/` — zero `next/*`, zero `react-router-dom`. Uses `NavigationAdapter` for routing.
### State Management
- **TanStack Query** owns all server state (issues, users, workspaces)
- **Zustand** owns all client state (UI selections, filters, drafts)
- **React Context** reserved for cross-cutting plumbing (`WorkspaceIdProvider`, `NavigationProvider`)
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
## Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.

View File

@@ -0,0 +1,178 @@
---
title: Contributing
description: Local development workflow for contributors working on the Multica codebase.
---
## Development Model
Local development uses one shared PostgreSQL container and one database per checkout.
- The main checkout usually uses `.env` and `POSTGRES_DB=multica`
- Each Git worktree uses its own `.env.worktree`
- Every checkout connects to the same PostgreSQL host: `localhost:5432`
- Isolation happens at the database level, not by starting a separate Docker Compose project
- Backend and frontend ports are still unique per worktree
## Prerequisites
- Node.js `v20+`
- `pnpm` `v10.28+`
- Go `v1.26+`
- Docker
## First-Time Setup
### Main Checkout
```bash
cp .env.example .env
make setup-main
```
What `make setup-main` does:
- Installs JavaScript dependencies with `pnpm install`
- Ensures the shared PostgreSQL container is running
- Creates the application database if it does not exist
- Runs all migrations against that database
Start the app:
```bash
make start-main
```
### Worktree
From the worktree directory:
```bash
make worktree-env
make setup-worktree
```
Start the worktree app:
```bash
make start-worktree
```
## Daily Workflow
### Main Checkout
```bash
make start-main
make stop-main
make check-main
```
### Feature Worktree
```bash
git worktree add ../multica-feature -b feat/my-change main
cd ../multica-feature
make worktree-env
make setup-worktree
make start-worktree
```
Day-to-day:
```bash
make start-worktree
make stop-worktree
make check-worktree
```
## Running Main and Worktree Simultaneously
This is a first-class workflow. Both checkouts use the same PostgreSQL container but different databases and ports:
| | Main | Worktree |
|---|---|---|
| Database | `multica` | `multica_my_feature_702` |
| Backend port | `8080` | generated (e.g. `18782`) |
| Frontend port | `3000` | generated (e.g. `13702`) |
## Commands
```bash
# Frontend (all commands go through Turborepo)
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check
pnpm lint # ESLint
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries
make test # Go tests
make sqlc # Regenerate sqlc code
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
```
## Testing
Run all local checks:
```bash
make check
```
This runs:
1. TypeScript typecheck
2. TypeScript unit tests
3. Go tests
4. Playwright E2E tests
## Troubleshooting
### Missing Env File
Create the expected env file:
```bash
# Main checkout
cp .env.example .env
# Worktree
make worktree-env
```
### Check Which Database a Checkout Uses
```bash
cat .env # or .env.worktree
```
Look for `POSTGRES_DB`, `DATABASE_URL`, `PORT`, `FRONTEND_PORT`.
### List All Local Databases
```bash
docker compose exec -T postgres psql -U multica -d postgres \
-At -c "select datname from pg_database order by datname;"
```
### Destructive Reset
Stop PostgreSQL and keep local databases:
```bash
make db-down
```
Wipe all local PostgreSQL data:
```bash
docker compose down -v
```
> **Warning:** This deletes the shared Docker volume and all databases. After that you must run `make setup-main` or `make setup-worktree` again.

View File

@@ -0,0 +1,4 @@
{
"title": "Developers",
"pages": ["contributing", "architecture"]
}

View File

@@ -0,0 +1,48 @@
---
title: Cloud Quickstart
description: Get started with Multica Cloud — no setup required.
---
The fastest way to get started with Multica — no setup required.
## 1. Sign up
Go to [multica.ai](https://multica.ai) and create an account.
## 2. Install the CLI and start the daemon
Give this instruction to your AI agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
Or install manually:
```bash
# Install
brew tap multica-ai/tap
brew install multica
# Authenticate and start
multica login
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
## 4. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 5. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team.

View File

@@ -0,0 +1,4 @@
{
"title": "Getting Started",
"pages": ["cloud-quickstart", "self-hosting"]
}

View File

@@ -0,0 +1,370 @@
---
title: Self-Hosting Guide
description: Deploy Multica on your own infrastructure.
---
## Architecture Overview
Multica has three components:
| Component | Description | Technology |
|-----------|-------------|------------|
| **Backend** | REST API + WebSocket server | Go (single binary) |
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Each user who wants to run AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Prerequisites
- Docker and Docker Compose
## Quick Install
One command to set up everything:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
1. Open http://localhost:3000 — log in with any email + code **`888888`**
2. Run `multica login` and `multica daemon start`
<Callout>
For a step-by-step setup, see below.
</Callout>
## Step-by-Step Setup
### Step 1 — Start the Server
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
```
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
<Callout>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
</Callout>
### Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
```
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
### b) One-command setup
```bash
multica setup --local
```
This automatically:
1. Configures the CLI to connect to `localhost`
2. Opens your browser for authentication
3. Discovers your workspaces
4. Starts the daemon in the background
Verify the daemon is running:
```bash
multica daemon status
```
<Callout>
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
</Callout>
### Step 4 — Verify & Start Using
1. Open your workspace at http://localhost:3000
2. Navigate to **Settings → Runtimes** — you should see your machine listed
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Stopping Services
```bash
# Stop Docker Compose services
make selfhost-stop
# Stop the local daemon
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
```
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
<Callout>
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Rebuilding After Updates
```bash
git pull
make selfhost
```
Migrations run automatically on backend startup.
---
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using the Included Docker Compose
```bash
docker compose up -d postgres
```
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
### Using Your Own PostgreSQL
Ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Running Migrations
Migrations must be run before starting the server:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Upgrading
1. Pull the latest code or image
2. Run migrations: `./server/bin/migrate up`
3. Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.

View File

@@ -0,0 +1,50 @@
---
title: Agents
description: How AI agents work in Multica — execution model, skills, and runtime guidelines.
---
## Agents as Teammates
In Multica, agents are first-class citizens. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
Assignees are polymorphic — an issue can be assigned to a member or an agent. The `assignee_type` + `assignee_id` fields on issues distinguish between the two. Agents render with distinct styling (purple background, robot icon).
## Agent Execution Model
When an agent is assigned a task in Multica:
1. The daemon detects the task assignment
2. It creates an isolated workspace directory
3. It spawns the appropriate agent CLI (Claude Code, Codex, OpenClaw, or OpenCode)
4. The agent executes autonomously, streaming progress back to Multica
5. Results are reported — success, failure, or blockers
The full task lifecycle is: **enqueue → claim → start → complete/fail**.
Real-time progress is streamed via WebSocket so you can follow along in the Multica UI.
## Supported Agent Providers
| Provider | CLI Command | Description |
|----------|-------------|-------------|
| Claude Code | `claude` | Anthropic's coding agent |
| Codex | `codex` | OpenAI's coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| OpenCode | `opencode` | Open-source coding agent |
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
## Reusable Skills
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
- Deployments
- Migrations
- Code reviews
- Common patterns
Skills are shared across the workspace, so any agent (or human) can leverage them.
## Multi-Workspace Support
Each workspace has its own set of agents, issues, and settings. The daemon can watch multiple workspaces simultaneously, routing tasks to the appropriate agent based on workspace configuration.

View File

@@ -0,0 +1,4 @@
{
"title": "Guides",
"pages": ["quickstart", "agents"]
}

View File

@@ -0,0 +1,31 @@
---
title: Quickstart
description: Assign your first task to an agent in under 5 minutes.
---
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
## 1. Log in and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
## 2. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
## 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team.

View File

@@ -0,0 +1,47 @@
---
title: Introduction
description: Multica — the open-source managed agents platform. Turn coding agents into real teammates.
---
## What is Multica?
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
## Features
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
## Architecture
| Layer | Stack |
|-------|-------|
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
## Next Steps
- [Cloud Quickstart](/docs/getting-started/cloud-quickstart)
- [Self-Hosting](/docs/getting-started/self-hosting)
- [CLI Installation](/docs/cli/installation)
- [Contributing](/docs/developers/contributing)

View File

@@ -0,0 +1,10 @@
{
"title": "Documentation",
"pages": [
"index",
"getting-started",
"cli",
"guides",
"developers"
]
}

7
apps/docs/lib/source.ts Normal file
View File

@@ -0,0 +1,7 @@
import { docs } from "@/.source";
import { loader } from "fumadocs-core/source";
export const source = loader({
baseUrl: "/docs",
source: docs.toFumadocsSource(),
});

6
apps/docs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

10
apps/docs/next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import { createMDX } from "fumadocs-mdx/next";
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
export default withMDX(config);

29
apps/docs/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@multica/docs",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 4000",
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && tsc --noEmit",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"fumadocs-core": "^15.5.2",
"fumadocs-mdx": "^12.0.3",
"fumadocs-ui": "^15.5.2",
"lucide-react": "catalog:",
"next": "^15.3.3",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,9 @@
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
export const docs = defineDocs({
dir: "content/docs",
});
export default defineConfig({
mdxOptions: {},
});

48
apps/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,48 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"noEmit": true,
"allowJs": true,
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
".source/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -4,11 +4,11 @@ import { AboutPageClient } from "@/features/landing/components/about-page-client
export const metadata: Metadata = {
title: "About",
description:
"Learn about Multica — multiplexed information and computing agent. An open-source AI-native task management platform.",
"Learn about Multica — multiplexed information and computing agent. An open-source project management platform for human + agent teams.",
openGraph: {
title: "About Multica",
description:
"The story behind Multica and why we're building AI-native task management.",
"The story behind Multica and why we're building project management for human + agent teams.",
url: "/about",
},
alternates: {

View File

@@ -6,7 +6,7 @@ export const metadata: Metadata = {
description:
"Multica — open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
openGraph: {
title: "Multica — AI-Native Task Management",
title: "Multica — Project Management for Human + Agent Teams",
description:
"Manage your human + agent workforce in one place.",
url: "/homepage",

View File

@@ -30,7 +30,7 @@ const jsonLd = {
applicationCategory: "ProjectManagement",
operatingSystem: "Web",
description:
"AI-native task management platform that turns coding agents into real teammates.",
"Open-source project management platform that turns coding agents into real teammates.",
offers: {
"@type": "Offer",
price: "0",

View File

@@ -3,12 +3,12 @@ import { MulticaLanding } from "@/features/landing/components/multica-landing";
export const metadata: Metadata = {
title: {
absolute: "Multica — AI-Native Task Management",
absolute: "Multica — Project Management for Human + Agent Teams",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
openGraph: {
title: "Multica — AI-Native Task Management",
title: "Multica — Project Management for Human + Agent Teams",
description:
"Manage your human + agent workforce in one place.",
url: "/",

View File

@@ -22,7 +22,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = {
metadataBase: new URL("https://www.multica.ai"),
title: {
default: "Multica — AI-Native Task Management",
default: "Multica — Project Management for Human + Agent Teams",
template: "%s | Multica",
},
description:

View File

@@ -4,8 +4,25 @@ import { LandingHeader } from "./landing-header";
import { LandingFooter } from "./landing-footer";
import { useLocale } from "../i18n";
function ChangeList({ items }: { items: string[] }) {
return (
<ul className="mt-2 space-y-2">
{items.map((change) => (
<li
key={change}
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
>
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
{change}
</li>
))}
</ul>
);
}
export function ChangelogPageClient() {
const { t } = useLocale();
const categoryLabels = t.changelog.categories;
return (
<>
@@ -20,32 +37,58 @@ export function ChangelogPageClient() {
</p>
<div className="mt-16 space-y-16">
{t.changelog.entries.map((release) => (
<div key={release.version} className="relative">
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{release.date}
</span>
{t.changelog.entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
return (
<div key={release.version} className="relative">
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{release.date}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
)}
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
</div>
) : (
<ChangeList items={release.changes} />
)}
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
<ul className="mt-4 space-y-2">
{release.changes.map((change) => (
<li
key={change}
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
>
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
{change}
</li>
))}
</ul>
</div>
))}
);
})}
</div>
</div>
</main>

View File

@@ -271,7 +271,94 @@ export const en: LandingDict = {
changelog: {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
categories: {
features: "New Features",
improvements: "Improvements",
fixes: "Bug Fixes",
},
entries: [
{
version: "0.1.24",
date: "2026-04-11",
title: "Security & Notifications",
changes: [],
features: [
"Parent issue subscribers notified on sub-issue changes",
"CLI `--project` filter for issue list",
],
improvements: [
"Meta-skill workflow defers to agent Skills instead of hardcoded logic",
],
fixes: [
"Workspace ownership checks on all daemon API routes",
"Workspace ownership validation for attachment uploads and queries",
"Reply mentions no longer inherit parent thread's agent mentions",
"Agent comment creation missing workspace ID",
"Self-hosting Docker build failures (file permissions, CRLF, missing deps)",
],
},
{
version: "0.1.23",
date: "2026-04-11",
title: "Pinning, Cmd+K & Projects",
changes: [],
features: [
"Pin issues and projects to sidebar with drag-and-drop reordering",
"Cmd+K command palette — recent issues, page navigation, and project search",
"Project detail sidebar with properties panel (replaces overview tab)",
"Project filter in Issues tab",
"Project completion progress in project list",
"Auto-fill project when creating issue via 'C' shortcut on project page",
"Assignee dropdown sorted by user's assignment frequency",
],
fixes: [
"Markdown XSS — sanitize HTML rendering in comments with rehype-sanitize and server-side bluemonday",
"Project kanban issue counts incorrect",
"Self-hosting Docker build missing tsconfig dependencies",
"Cmd+K requiring double ESC to close",
],
},
{
version: "0.1.22",
date: "2026-04-10",
title: "Self-Hosting, ACP & Documentation",
changes: [],
features: [
"Full-stack Docker Compose for one-command self-hosting",
"Hermes Agent Provider via ACP protocol",
"Documentation site with Fumadocs (Getting Started, CLI reference, Agents guide)",
"Mobile-responsive sidebar and inbox layout",
"Token usage display per issue in the detail sidebar",
"Switch agent runtime from the UI",
"'C' keyboard shortcut for quick issue creation",
"Chat session history panel for archived conversations",
"Minimum CLI version check in daemon for Claude Code and Codex",
"OpenClaw and OpenCode added to landing page",
"`make dev` one-command local development setup",
],
improvements: [
"Sidebar redesign — Personal / Workspace grouping, user profile footer, ⌘K search input",
"Search ranking — case-insensitive matching, identifier search (MUL-123), multi-word support",
"Search result keyword highlighting",
"Daily token usage chart with cleaner Y-axis and per-category tooltip",
"Master Agent multiline input support",
"Unified picker components (Status, Priority, DueDate, Project, Assignee) across all views",
"Workspace-scoped storage isolation with auto-rehydration on switch",
"Startup warnings for missing env vars in self-hosted deployments",
],
fixes: [
"Sub-issue deletion not invalidating parent's children cache",
"Search index compatibility with pg_bigm 1.2 on RDS",
"Create Agent showing \"No runtime available\" when runtimes exist",
"Claude stream-json startup hangs",
"Multiple agents unable to queue tasks for the same issue",
"Logout not clearing workspace and query cache",
"Drag-drop overlay too small on empty editors",
"Skills import hardcoding \"main\" as default branch",
"PAT authentication not working on WebSocket endpoint",
"Runtime deletion blocked when all bound agents are archived",
],
},
{
version: "0.1.21",
date: "2026-04-09",

View File

@@ -85,11 +85,19 @@ export type LandingDict = {
changelog: {
title: string;
subtitle: string;
categories: {
features: string;
improvements: string;
fixes: string;
};
entries: {
version: string;
date: string;
title: string;
changes: string[];
features?: string[];
improvements?: string[];
fixes?: string[];
}[];
};
};

View File

@@ -271,7 +271,94 @@ export const zh: LandingDict = {
changelog: {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
categories: {
features: "新功能",
improvements: "改进",
fixes: "问题修复",
},
entries: [
{
version: "0.1.24",
date: "2026-04-11",
title: "安全加固与通知",
changes: [],
features: [
"子 Issue 变更时通知父 Issue 的订阅者",
"CLI `--project` 筛选 Issue 列表",
],
improvements: [
"Meta-skill 工作流改为委托 Agent Skills 而非硬编码逻辑",
],
fixes: [
"Daemon API 路由新增工作区所有权校验",
"附件上传和查询新增工作区所有权验证",
"回复评论不再继承父级线程的 Agent 提及",
"Agent 创建评论缺少 workspace ID",
"自部署 Docker 构建问题修复文件权限、CRLF 换行、缺失依赖)",
],
},
{
version: "0.1.23",
date: "2026-04-11",
title: "置顶、Cmd+K 与项目增强",
changes: [],
features: [
"Issue 和项目置顶到侧边栏,支持拖拽排序",
"Cmd+K 命令面板——最近访问的 Issue、页面导航、项目搜索",
"项目详情侧边栏属性面板(替代原概览标签页)",
"Issues 列表新增项目筛选",
"项目列表显示完成进度",
"在项目页按 'C' 创建 Issue 时自动填充项目",
"指派人下拉按用户分配频率排序",
],
fixes: [
"Markdown XSS 漏洞——评论渲染增加 rehype-sanitize 和服务端 bluemonday 清洗",
"项目看板 Issue 计数不正确",
"自部署 Docker 构建缺少 tsconfig 依赖",
"Cmd+K 需要按两次 ESC 才能关闭",
],
},
{
version: "0.1.22",
date: "2026-04-10",
title: "自部署、ACP 与文档站",
changes: [],
features: [
"全栈 Docker Compose 一键自部署",
"通过 ACP 协议接入 Hermes Agent Provider",
"基于 Fumadocs 搭建文档站快速入门、CLI 参考、Agent 指南)",
"侧边栏和收件箱移动端响应式布局",
"Issue 详情侧边栏展示 Token 用量",
"支持在 UI 中切换 Agent 运行时",
"'C' 快捷键快速创建 Issue",
"聊天会话历史面板,查看已归档对话",
"Daemon 新增 Claude Code 和 Codex 最低版本检查",
"官网新增 OpenClaw 和 OpenCode 展示",
"`make dev` 一键本地开发环境搭建",
],
improvements: [
"侧边栏重新设计——个人/工作区分组、用户档案底栏、⌘K 搜索入口",
"搜索排序优化——大小写无关匹配、标识符搜索MUL-123、多词匹配",
"搜索结果关键词高亮",
"每日 Token 用量图表优化Y 轴标签更清晰,新增分类 Tooltip",
"Master Agent 支持多行输入",
"统一选择器组件(状态、优先级、截止日期、项目、指派人)",
"工作区级别存储隔离,切换工作区时自动加载对应数据",
"自部署环境变量缺失时给出启动警告",
],
fixes: [
"删除子 Issue 后父级列表未刷新",
"搜索索引兼容 RDS 上的 pg_bigm 1.2",
"创建 Agent 对话框错误显示「无可用运行时」",
"Claude stream-json 启动卡住",
"多个 Agent 无法同时为同一 Issue 排队任务",
"退出登录未清除工作区和查询缓存",
"编辑器为空时拖放区域过小",
"Skills 导入硬编码 main 分支导致 404",
"WebSocket 端点不支持 PAT 认证",
"所有 Agent 已归档时无法删除运行时",
],
},
{
version: "0.1.21",
date: "2026-04-09",

View File

@@ -22,6 +22,7 @@ const allowedDevOrigins = process.env.CORS_ALLOWED_ORIGINS
: undefined;
const nextConfig: NextConfig = {
...(process.env.STANDALONE === "true" ? { output: "standalone" as const } : {}),
transpilePackages: ["@multica/core", "@multica/ui", "@multica/views"],
...(allowedDevOrigins && allowedDevOrigins.length > 0
? { allowedDevOrigins }

View File

@@ -12,15 +12,15 @@
"test": "vitest run"
},
"dependencies": {
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.6",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tiptap/extension-code-block-lowlight": "^3.22.1",
@@ -43,13 +43,14 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.4.1",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"input-otp": "^1.4.2",
"linkify-it": "^5.0.0",
"lowlight": "^3.3.0",
"lucide-react": "catalog:",
"next": "^16.1.6",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",

View File

@@ -0,0 +1,73 @@
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
#
# Usage:
# cp .env.example .env
# # Edit .env — change JWT_SECRET at minimum
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:3000
# Backend: http://localhost:8080 (also used by CLI/daemon)
name: multica
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: ${POSTGRES_DB:-multica}
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
PORT: "8080"
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-us-west-2}
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
frontend:
build:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
volumes:
pgdata:

8
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running database migrations..."
./migrate up
echo "Starting server..."
exec ./server

View File

@@ -4,6 +4,7 @@ import type {
UpdateIssueRequest,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
UpdateMeRequest,
CreateMemberRequest,
UpdateMemberRequest,
@@ -30,10 +31,12 @@ import type {
CreatePersonalAccessTokenRequest,
CreatePersonalAccessTokenResponse,
RuntimeUsage,
IssueUsageSummary,
RuntimeHourlyActivity,
RuntimePing,
RuntimeUpdate,
TimelineEntry,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
ChatSession,
@@ -43,6 +46,10 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
@@ -181,6 +188,8 @@ export class ApiClient {
if (params?.status) search.set("status", params.status);
if (params?.priority) search.set("priority", params.priority);
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.open_only) search.set("open_only", "true");
return this.fetch(`/api/issues?${search}`);
}
@@ -193,6 +202,14 @@ export class ApiClient {
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
if (params.include_closed) search.set("include_closed", "true");
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
}
async getIssue(id: string): Promise<Issue> {
return this.fetch(`/api/issues/${id}`);
}
@@ -256,6 +273,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/timeline`);
}
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
@@ -418,6 +439,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/task-runs`);
}
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
return this.fetch(`/api/issues/${issueId}/usage`);
}
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
method: "POST",
@@ -681,4 +706,27 @@ export class ApiClient {
async deleteProject(id: string): Promise<void> {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
// Pins
async listPins(): Promise<PinnedItem[]> {
return this.fetch("/api/pins");
}
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
return this.fetch("/api/pins", {
method: "POST",
body: JSON.stringify(data),
});
}
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
}
async reorderPins(data: ReorderPinsRequest): Promise<void> {
await this.fetch("/api/pins/reorder", {
method: "PUT",
body: JSON.stringify(data),
});
}
}

View File

@@ -72,6 +72,7 @@ export function createAuthStore(options: AuthStoreOptions) {
logout: () => {
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();

View File

@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
@@ -39,12 +40,17 @@ export interface ChatStoreOptions {
export function createChatStore(options: ChatStoreOptions) {
const { storage } = options;
return create<ChatState>((set) => ({
const wsKey = (base: string) => {
const wsId = getCurrentWorkspaceId();
return wsId ? `${base}:${wsId}` : base;
};
const store = create<ChatState>((set) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: storage.getItem(SESSION_STORAGE_KEY),
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
pendingTaskId: null,
selectedAgentId: storage.getItem(AGENT_STORAGE_KEY),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
timelineItems: [],
setOpen: (open) =>
@@ -57,15 +63,15 @@ export function createChatStore(options: ChatStoreOptions) {
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
setActiveSession: (id) => {
if (id) {
storage.setItem(SESSION_STORAGE_KEY, id);
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
} else {
storage.removeItem(SESSION_STORAGE_KEY);
storage.removeItem(wsKey(SESSION_STORAGE_KEY));
}
set({ activeSessionId: id });
},
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
setSelectedAgentId: (id) => {
storage.setItem(AGENT_STORAGE_KEY, id);
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
@@ -80,4 +86,14 @@ export function createChatStore(options: ChatStoreOptions) {
}),
clearTimeline: () => set({ timelineItems: [] }),
}));
registerForWorkspaceRehydration(() => {
store.setState({
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
timelineItems: [],
});
});
return store;
}

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { Issue, IssueReaction } from "../types";
import type {
@@ -31,12 +31,15 @@ export type ToggleIssueReactionVars = {
// Done issue pagination
// ---------------------------------------------------------------------------
export function useLoadMoreDoneIssues() {
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const cache = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
const doneLoaded = cache
? cache.issues.filter((i) => i.status === "done").length
: 0;
@@ -51,8 +54,9 @@ export function useLoadMoreDoneIssues() {
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: doneLoaded,
...myIssues?.filter,
});
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
if (!old) return old;
const existingIds = new Set(old.issues.map((i) => i.id));
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
@@ -65,7 +69,7 @@ export function useLoadMoreDoneIssues() {
} finally {
setIsLoading(false);
}
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
return { loadMore, hasMore, isLoading, doneTotal };
}
@@ -180,24 +184,28 @@ export function useDeleteIssue() {
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = prevList?.issues.find((i) => i.id === id);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const deleted = old.issues.find((i) => i.id === id);
const d = old.issues.find((i) => i.id === id);
return {
...old,
issues: old.issues.filter((i) => i.id !== id),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList };
return { prevList, parentIssueId: deleted?.parent_issue_id };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
onSettled: (_data, _err, _id, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueId) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
}
},
});
}
@@ -245,9 +253,14 @@ export function useBatchDeleteIssues() {
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const idSet = new Set(ids);
const parentIssueIds = new Set(
prevList?.issues
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
.map((i) => i.parent_issue_id!) ?? [],
);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const idSet = new Set(ids);
const doneDeleted = old.issues.filter(
(i) => idSet.has(i.id) && i.status === "done",
).length;
@@ -258,13 +271,18 @@ export function useBatchDeleteIssues() {
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
};
});
return { prevList };
return { prevList, parentIssueIds };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueIds) {
for (const parentId of ctx.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
}
},
});
}

View File

@@ -1,9 +1,15 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { ListIssuesParams } from "../types";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
/** All "my issues" queries — use for bulk invalidation. */
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
/** Per-scope "my issues" list with filter identity baked into the key. */
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
[...issueKeys.myAll(wsId), scope, filter] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
@@ -12,8 +18,11 @@ export const issueKeys = {
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
usage: (issueId: string) => ["issues", "usage", issueId] as const,
};
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
export const CLOSED_PAGE_SIZE = 50;
/**
@@ -42,6 +51,37 @@ export function issueListOptions(wsId: string) {
});
}
/**
* Server-filtered issue list for the My Issues page.
* Each scope gets its own cache entry so switching tabs is instant after first load.
*/
export function myIssueListOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true, ...filter }),
api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: 0,
...filter,
}),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
});
}
export function issueDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.detail(wsId, id),
@@ -79,3 +119,10 @@ export function issueSubscribersOptions(issueId: string) {
queryFn: () => api.listIssueSubscribers(issueId),
});
}
export function issueUsageOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.usage(issueId),
queryFn: () => api.getIssueUsage(issueId),
});
}

View File

@@ -1,6 +1,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
interface IssueDraft {
title: string;
@@ -41,6 +43,11 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
return !!(draft.title || draft.description);
},
}),
{ name: "multica_issue_draft" },
{
name: "multica_issue_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useIssueDraftStore.persist.rehydrate());

View File

@@ -1,5 +1,6 @@
export { useIssueSelectionStore } from "./selection-store";
export { useIssueDraftStore } from "./draft-store";
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
export {
ViewStoreProvider,
useViewStore,

View File

@@ -1,7 +1,9 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type IssuesScope = "all" | "members" | "agents";
@@ -16,6 +18,11 @@ export const useIssuesScopeStore = create<IssuesScopeState>()(
scope: "all",
setScope: (scope) => set({ scope }),
}),
{ name: "multica_issues_scope" },
{
name: "multica_issues_scope",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useIssuesScopeStore.persist.rehydrate());

View File

@@ -7,6 +7,7 @@ import {
viewStoreSlice,
viewStorePersistOptions,
} from "./view-store";
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
export type MyIssuesScope = "assigned" | "created" | "agents";
@@ -17,7 +18,7 @@ export interface MyIssuesViewState extends IssueViewState {
const basePersist = viewStorePersistOptions("multica_my_issues_view");
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssuesViewState>()(
const _myIssuesViewStore = createStore<MyIssuesViewState>()(
persist(
(set) => ({
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
@@ -26,6 +27,7 @@ export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssu
}),
{
name: basePersist.name,
storage: basePersist.storage,
partialize: (state: MyIssuesViewState) => ({
...basePersist.partialize(state),
scope: state.scope,
@@ -33,3 +35,7 @@ export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssu
},
),
);
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = _myIssuesViewStore;
registerForWorkspaceRehydration(() => _myIssuesViewStore.persist.rehydrate());

View File

@@ -0,0 +1,52 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus } from "../../types";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
const MAX_RECENT_ISSUES = 20;
export interface RecentIssueEntry {
id: string;
identifier: string;
title: string;
status: IssueStatus;
visitedAt: number;
}
interface RecentIssuesState {
items: RecentIssueEntry[];
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
}
export const useRecentIssuesStore = create<RecentIssuesState>()(
persist(
(set) => ({
items: [],
recordVisit: (entry) =>
set((state) => {
const filtered = state.items.filter((i) => i.id !== entry.id);
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
return {
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
};
}),
}),
{
name: "multica_recent_issues",
storage: createJSONStorage(() =>
createWorkspaceAwareStorage(defaultStorage),
),
partialize: (state) => ({ items: state.items }),
},
),
);
registerForWorkspaceRehydration(() =>
useRecentIssuesStore.persist.rehydrate(),
);

View File

@@ -2,9 +2,11 @@
import { create } from "zustand";
import { createStore, type StoreApi } from "zustand/vanilla";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority } from "../../types";
import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
@@ -44,6 +46,8 @@ export interface IssueViewState {
assigneeFilters: ActorFilterValue[];
includeNoAssignee: boolean;
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
@@ -54,6 +58,8 @@ export interface IssueViewState {
toggleAssigneeFilter: (value: ActorFilterValue) => void;
toggleNoAssignee: () => void;
toggleCreatorFilter: (value: ActorFilterValue) => void;
toggleProjectFilter: (projectId: string) => void;
toggleNoProject: () => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
@@ -70,6 +76,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -121,6 +129,14 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
: [...state.creatorFilters, value],
};
}),
toggleProjectFilter: (projectId) =>
set((state) => ({
projectFilters: state.projectFilters.includes(projectId)
? state.projectFilters.filter((id) => id !== projectId)
: [...state.projectFilters, projectId],
})),
toggleNoProject: () =>
set((state) => ({ includeNoProject: !state.includeNoProject })),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
@@ -144,6 +160,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
@@ -164,6 +182,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
export const viewStorePersistOptions = (name: string) => ({
name,
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state: IssueViewState) => ({
viewMode: state.viewMode,
statusFilters: state.statusFilters,
@@ -171,6 +190,8 @@ export const viewStorePersistOptions = (name: string) => ({
assigneeFilters: state.assigneeFilters,
includeNoAssignee: state.includeNoAssignee,
creatorFilters: state.creatorFilters,
projectFilters: state.projectFilters,
includeNoProject: state.includeNoProject,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
@@ -180,9 +201,11 @@ export const viewStorePersistOptions = (name: string) => ({
/** Factory: creates a vanilla StoreApi for use with React Context. */
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
return createStore<IssueViewState>()(
const store = createStore<IssueViewState>()(
persist(viewStoreSlice, viewStorePersistOptions(persistKey))
);
registerForWorkspaceRehydration(() => store.persist.rehydrate());
return store;
}
/** Global singleton for the /issues page. */
@@ -190,6 +213,8 @@ export const useIssueViewStore = create<IssueViewState>()(
persist(viewStoreSlice, viewStorePersistOptions("multica_issues_view"))
);
registerForWorkspaceRehydration(() => useIssueViewStore.persist.rehydrate());
// Clear filters on all registered view stores when workspace switches.
const _syncedStores = new Set<StoreApi<IssueViewState>>();
let _workspaceSyncInitialized = false;

View File

@@ -17,6 +17,7 @@ export function onIssueCreated(
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
}
@@ -57,6 +58,7 @@ export function onIssueUpdated(
doneTotal: (old.doneTotal ?? 0) + doneDelta,
};
});
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
@@ -86,6 +88,7 @@ export function onIssueDeleted(
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
};
});
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });

View File

@@ -1,7 +1,9 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import { createPersistStorage } from "../platform/persist-storage";
import { defaultStorage } from "../platform/storage";
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
@@ -23,6 +25,7 @@ export const useNavigationStore = create<NavigationState>()(
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
partialize: (state) => ({ lastPath: state.lastPath }),
}
)

View File

@@ -45,6 +45,9 @@
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",

View File

@@ -0,0 +1,2 @@
export { pinKeys, pinListOptions } from "./queries";
export { useCreatePin, useDeletePin, useReorderPins } from "./mutations";

View File

@@ -0,0 +1,65 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
});
}

View File

@@ -0,0 +1,14 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
};
export function pinListOptions(wsId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId),
queryFn: () => api.listPins(),
});
}

View File

@@ -2,3 +2,6 @@ export { CoreProvider } from "./core-provider";
export type { CoreProviderProps } from "./types";
export { AuthInitializer } from "./auth-initializer";
export { defaultStorage } from "./storage";
export { createPersistStorage } from "./persist-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspaceId, getCurrentWorkspaceId, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";
import { createPersistStorage } from "./persist-storage";
import type { StorageAdapter } from "../types/storage";
function mockAdapter(): StorageAdapter {
const store = new Map<string, string>();
return {
getItem: vi.fn((k) => store.get(k) ?? null),
setItem: vi.fn((k, v) => store.set(k, v)),
removeItem: vi.fn((k) => store.delete(k)),
};
}
describe("createPersistStorage", () => {
it("delegates to StorageAdapter", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter);
storage.setItem("key", JSON.stringify("value"));
expect(adapter.setItem).toHaveBeenCalledWith(
"key",
JSON.stringify("value"),
);
const result = storage.getItem("key");
expect(adapter.getItem).toHaveBeenCalledWith("key");
expect(result).toEqual(JSON.stringify("value"));
});
it("returns null for missing keys", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter);
const result = storage.getItem("nonexistent");
expect(result).toBeNull();
});
it("removeItem delegates correctly", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter);
storage.removeItem("key");
expect(adapter.removeItem).toHaveBeenCalledWith("key");
});
});

View File

@@ -0,0 +1,14 @@
import type { StateStorage } from "zustand/middleware";
import type { StorageAdapter } from "../types/storage";
/**
* Bridge between Zustand persist middleware and our StorageAdapter DI system.
* For workspace-scoped stores, use createWorkspaceAwareStorage instead.
*/
export function createPersistStorage(adapter: StorageAdapter): StateStorage {
return {
getItem: (key) => adapter.getItem(key),
setItem: (key, value) => adapter.setItem(key, value),
removeItem: (key) => adapter.removeItem(key),
};
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect, vi } from "vitest";
import { clearWorkspaceStorage } from "./storage-cleanup";
describe("clearWorkspaceStorage", () => {
it("removes all workspace-scoped keys for given wsId", () => {
const adapter = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
};
clearWorkspaceStorage(adapter, "ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issue_draft:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issues_view:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issues_scope:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
});
});

View File

@@ -0,0 +1,27 @@
import type { StorageAdapter } from "../types/storage";
/**
* Keys that are namespaced per workspace (stored as `${key}:${wsId}`).
*
* IMPORTANT: When adding a new workspace-scoped persist store or storage key,
* add its key here so that workspace deletion and logout properly clean it up.
* Also ensure the store uses `createWorkspaceAwareStorage` for its persist config.
*/
const WORKSPACE_SCOPED_KEYS = [
"multica_issue_draft",
"multica_issues_view",
"multica_issues_scope",
"multica_my_issues_view",
"multica:chat:selectedAgentId",
"multica:chat:activeSessionId",
];
/** Remove all workspace-scoped storage entries for the given workspace. */
export function clearWorkspaceStorage(
adapter: StorageAdapter,
wsId: string,
) {
for (const key of WORKSPACE_SCOPED_KEYS) {
adapter.removeItem(`${key}:${wsId}`);
}
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { createWorkspaceAwareStorage, setCurrentWorkspaceId } from "./workspace-storage";
import type { StorageAdapter } from "../types/storage";
function mockAdapter(): StorageAdapter {
const store = new Map<string, string>();
return {
getItem: vi.fn((k) => store.get(k) ?? null),
setItem: vi.fn((k, v) => store.set(k, v)),
removeItem: vi.fn((k) => store.delete(k)),
};
}
afterEach(() => {
setCurrentWorkspaceId(null);
});
describe("workspace-aware storage", () => {
it("uses plain key when no workspace is set", () => {
const adapter = mockAdapter();
setCurrentWorkspaceId(null);
const storage = createWorkspaceAwareStorage(adapter);
storage.setItem("draft", "data");
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
});
it("namespaces key when workspace is set", () => {
const adapter = mockAdapter();
setCurrentWorkspaceId("ws_abc");
const storage = createWorkspaceAwareStorage(adapter);
storage.setItem("draft", "data");
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_abc", "data");
storage.getItem("draft");
expect(adapter.getItem).toHaveBeenCalledWith("draft:ws_abc");
});
it("follows workspace changes dynamically", () => {
const adapter = mockAdapter();
const storage = createWorkspaceAwareStorage(adapter);
setCurrentWorkspaceId("ws_1");
storage.setItem("draft", "v1");
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_1", "v1");
setCurrentWorkspaceId("ws_2");
storage.setItem("draft", "v2");
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_2", "v2");
});
it("removeItem uses current workspace", () => {
const adapter = mockAdapter();
setCurrentWorkspaceId("ws_x");
const storage = createWorkspaceAwareStorage(adapter);
storage.removeItem("draft");
expect(adapter.removeItem).toHaveBeenCalledWith("draft:ws_x");
});
});

View File

@@ -0,0 +1,40 @@
import type { StateStorage } from "zustand/middleware";
import type { StorageAdapter } from "../types/storage";
let _currentWsId: string | null = null;
const _rehydrateFns: Array<() => void> = [];
export function setCurrentWorkspaceId(wsId: string | null) {
_currentWsId = wsId;
}
/** Register a persist store's rehydrate function to be called on workspace switch. */
export function registerForWorkspaceRehydration(fn: () => void) {
_rehydrateFns.push(fn);
}
/** Rehydrate all registered workspace-scoped persist stores from the new namespace. */
export function rehydrateAllWorkspaceStores() {
for (const fn of _rehydrateFns) {
fn();
}
}
export function getCurrentWorkspaceId(): string | null {
return _currentWsId;
}
/**
* Storage that automatically namespaces keys with the current workspace ID.
* Reads _currentWsId at call time, so it follows workspace switches dynamically.
*/
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
const resolve = (key: string) =>
_currentWsId ? `${key}:${_currentWsId}` : key;
return {
getItem: (key) => adapter.getItem(resolve(key)),
setItem: (key, value) => adapter.setItem(resolve(key), value),
removeItem: (key) => adapter.removeItem(resolve(key)),
};
}

View File

@@ -7,8 +7,12 @@ import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
import type { WorkspaceStore } from "../workspace/store";
import { createLogger } from "../logger";
import { clearWorkspaceStorage } from "../platform/storage-cleanup";
import { defaultStorage } from "../platform/storage";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import { pinKeys } from "../pins/queries";
import { runtimeKeys } from "../runtimes/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -54,7 +58,8 @@ export interface RealtimeSyncStores {
*
* Per-issue events (comments, activity, reactions, subscribers) are handled
* both here (invalidation fallback) and by per-page useWSEvent hooks (granular
* updates). Daemon events are handled by individual components only.
* updates). Daemon register events invalidate runtimes globally; heartbeats
* are skipped to avoid excessive refetches.
*
* @param ws - WebSocket client instance (null when not yet connected)
* @param stores - Platform-created Zustand store instances for auth and workspace
@@ -95,6 +100,14 @@ export function useRealtimeSync(
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -118,6 +131,7 @@ export function useRealtimeSync(
"reaction:added", "reaction:removed",
"issue_reaction:added", "issue_reaction:removed",
"subscriber:added", "subscriber:removed",
"daemon:heartbeat",
]);
const unsubAny = ws.onAny((msg) => {
@@ -231,6 +245,7 @@ export function useRealtimeSync(
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
clearWorkspaceStorage(defaultStorage, workspace_id);
const currentWs = workspaceStore.getState().workspace;
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
@@ -243,6 +258,8 @@ export function useRealtimeSync(
const { user_id } = p as MemberRemovedPayload;
const myUserId = authStore.getState().user?.id;
if (user_id === myUserId) {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
logger.warn("removed from workspace, switching");
onToast?.("You were removed from this workspace", "info");
workspaceStore.getState().refreshWorkspaces();
@@ -300,6 +317,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {

View File

@@ -1,6 +1,12 @@
import type { Reaction } from "./comment";
import type { Attachment } from "./attachment";
export interface AssigneeFrequencyEntry {
assignee_type: string;
assignee_id: string;
frequency: number;
}
export interface TimelineEntry {
type: "activity" | "comment";
id: string;

View File

@@ -138,6 +138,14 @@ export interface RuntimePing {
updated_at: string;
}
export interface IssueUsageSummary {
total_input_tokens: number;
total_output_tokens: number;
total_cache_read_tokens: number;
total_cache_write_tokens: number;
task_count: number;
}
export interface RuntimeUsage {
runtime_id: string;
date: string;

View File

@@ -1,5 +1,6 @@
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
import type { MemberRole } from "./workspace";
import type { Project } from "./project";
// Issue API
export interface CreateIssueRequest {
@@ -35,6 +36,8 @@ export interface ListIssuesParams {
status?: IssueStatus;
priority?: IssuePriority;
assignee_id?: string;
assignee_ids?: string[];
creator_id?: string;
open_only?: boolean;
}
@@ -55,6 +58,16 @@ export interface SearchIssuesResponse {
total: number;
}
export interface SearchProjectResult extends Project {
match_source: "title" | "description";
matched_snippet?: string;
}
export interface SearchProjectsResponse {
projects: SearchProjectResult[];
total: number;
}
export interface UpdateMeRequest {
name?: string;
avatar_url?: string;

View File

@@ -50,7 +50,9 @@ export type WSEventType =
| "chat:done"
| "project:created"
| "project:updated"
| "project:deleted";
| "project:deleted"
| "pin:created"
| "pin:deleted";
export interface WSMessage<T = unknown> {
type: WSEventType;

View File

@@ -20,11 +20,12 @@ export type {
RuntimePingStatus,
RuntimeUpdate,
RuntimeUpdateStatus,
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { TimelineEntry } from "./activity";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
export type { IssueSubscriber } from "./subscriber";
export type * from "./events";
export type * from "./api";
@@ -32,3 +33,4 @@ export type { Attachment } from "./attachment";
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";

View File

@@ -0,0 +1,24 @@
export type PinnedItemType = "issue" | "project";
export interface PinnedItem {
id: string;
workspace_id: string;
user_id: string;
item_type: PinnedItemType;
item_id: string;
position: number;
created_at: string;
title: string;
identifier?: string;
icon?: string;
status?: string;
}
export interface CreatePinRequest {
item_type: PinnedItemType;
item_id: string;
}
export interface ReorderPinsRequest {
items: { id: string; position: number }[];
}

View File

@@ -14,6 +14,8 @@ export interface Project {
lead_id: string | null;
created_at: string;
updated_at: string;
issue_count: number;
done_count: number;
}
export interface CreateProjectRequest {

View File

@@ -7,6 +7,7 @@ export const workspaceKeys = {
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
};
export function workspaceListOptions() {
@@ -37,3 +38,10 @@ export function skillListOptions(wsId: string) {
queryFn: () => api.listSkills(),
});
}
export function assigneeFrequencyOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.assigneeFrequency(wsId),
queryFn: () => api.getAssigneeFrequency(),
});
}

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import type { Workspace, StorageAdapter } from "../types";
import type { ApiClient } from "../api/client";
import { createLogger } from "../logger";
import { setCurrentWorkspaceId, rehydrateAllWorkspaceStores } from "../platform/workspace-storage";
const logger = createLogger("workspace-store");
@@ -57,12 +58,16 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
if (!nextWorkspace) {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
storage?.removeItem("multica_workspace_id");
set({ workspace: null });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
setCurrentWorkspaceId(nextWorkspace.id);
rehydrateAllWorkspaceStores();
storage?.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
@@ -138,6 +143,8 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
clearWorkspace: () => {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
set({ workspace: null, workspaces: [] });
},
}));

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
@@ -49,6 +50,28 @@ export interface MarkdownProps {
renderMention?: (props: { type: string; id: string }) => React.ReactNode
}
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
// and the mention:// protocol used for @mentions.
const sanitizeSchema = {
...defaultSchema,
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), 'mention'],
},
attributes: {
...defaultSchema.attributes,
code: [
...(defaultSchema.attributes?.code ?? []),
['className', /^language-/],
['className', /^hljs/],
],
img: [
...(defaultSchema.attributes?.img ?? []),
'alt',
],
},
}
/**
* Custom URL transform that allows mention:// protocol (used for @mentions)
* while keeping the default security for all other URLs.
@@ -327,7 +350,7 @@ export function Markdown({
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
urlTransform={urlTransform}
components={components}
>

Some files were not shown because too many files have changed in this diff Show More