Compare commits

...

99 Commits

Author SHA1 Message Date
Naiyuan Qing
e4d7eca116 feat(docs-site): add bilingual flat content tree with i18n routing
Restructures the docs site from nested topic folders (cli/, getting-started/,
developers/, guides/) into a flat content tree, and adds Chinese alongside
English. The old nested structure forced contributors to think about both
the topic AND the user-journey grouping; the flat tree lets a single
meta.json control reading order with separator labels, and lets the same
slug serve both languages via the `foo.zh.mdx` parser convention.

Routing
- New `app/[lang]/` segment hosts layout, home, slug page, and not-found
- Self-contained basePath-aware middleware (fumadocs's built-in middleware
  isn't basePath-aware, so its rewrite/redirect targets break under /docs)
- `hideLocale: 'default-locale'` keeps English URLs prefix-less; Chinese
  lives under /docs/zh/
- Sitemap excluded from middleware matcher so crawlers don't get rewritten
  into a non-existent locale-prefixed sitemap route
- Default-language redirect preserves search string (UTM safety)
- Home page declares its own generateStaticParams (Next layout params
  don't cascade) so /docs/ and /docs/zh are SSG, not dynamic per request

SEO
- New app/sitemap.ts emits hreflang alternates for every page
- absoluteDocsUrl normalizes the home `/` so canonical URLs don't carry a
  trailing slash that mismatches the page's own canonical link
- apps/web/app/robots.ts now advertises the docs sitemap

Search
- CJK tokenizer registered for the zh locale (Orama's English regex strips
  Han characters; without this Chinese search either returns empty or
  throws)

Chrome
- Custom DocsSettings replaces fumadocs's default icon-only sidebar footer
  with two labelled buttons (language + theme), matching the editorial
  design language

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:20:50 +08:00
Naiyuan Qing
1faf5a214d docs(docs-site): rewrite welcome page as editorial showpiece
Welcome page now opens with an editorial hero (eyebrow + serif h1 with
brand-color em accent on "共处一方。" + serif lede), a ruled byline
strip carrying the section / updated / read-time metadata, and then
flows into prose.

The three deployment paths switch from fumadocs's <Cards> to
<NumberedCards> so each gets a No. 01/02/03 label, and the "next steps"
list becomes a <NumberedSteps> block with large serif counters. These
are the highest-impact visual moments on the page; the rest of the
guide pages still get the global editorial chrome without needing
per-page code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:57:55 +08:00
Naiyuan Qing
ba3ff7040a feat(docs-site): add editorial MDX components
New components/editorial.tsx exposes Byline, NumberedCards/NumberedCard,
and NumberedSteps/Step — the "wow moment" pieces from v2-editorial
(ruled-divider bylines, No. 01 serif card numbering, large serif step
counters). All escape prose via not-prose so they run their own type
scale.

DocsHero is rewritten as an editorial showpiece: title accepts ReactNode
so callers can pass a brand-color em accent, eyebrow becomes a small
uppercase sans label, lede uses serif at 20px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:57:29 +08:00
Naiyuan Qing
8ac0b2cd3f feat(docs-site): adopt v2 editorial palette and typography
Replace the Linear/Vercel-style cool-gray token override with a warm
editorial palette (bg matches landing #f7f7f5, brand-color primary via
Multica's existing --brand hue 255) and wire Source Serif 4 for heading
typography. Italic is avoided sitewide — Chinese italic renders as a
synthetic slant against upright-designed glyphs and reads as broken;
emphasis is carried by serif/sans contrast, brand color, and weight.

Sidebar adopts the product app's active-fill pattern (solid
sidebar-accent background, no ::before mark). Code blocks drop the
always-dark hero treatment and follow page theme so the reading column
stays coherent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:56:58 +08:00
Naiyuan Qing
4bc7e63c51 chore(docs-site): wire up client-side Mermaid rendering
Add a <Mermaid> React component under apps/docs/components/ that dynamic-
imports the mermaid package in useEffect and renders the resulting SVG.
Deps added: mermaid@^11.14.0 and next-themes@^0.4.6 (transitively present
via fumadocs-ui but needs explicit declaration to be importable).

Design choices:
- Client-side render (not build-time). No Playwright / browser automation
  in CI. Mermaid bundle (~400 KB) is loaded only on pages that use the
  component, thanks to the dynamic import.
- Theme flips automatically — useTheme() from next-themes re-invokes
  mermaid.initialize() with the correct theme on .dark toggle.
- SSR safe: the component returns a "Rendering diagram…" placeholder on
  the server; the SVG appears after hydration.
- securityLevel "strict" — diagrams render as static SVG with no inline
  script or event handlers.

Usage in mdx (explicit import, same pattern as Cards/Callout):

  import { Mermaid } from "@/components/mermaid";

  <Mermaid chart={`
    graph LR
      User --> Server
  `} />

Verified by a scratch /app/mermaid-test/ route that compiled to 4665
modules and returned HTTP 200 (cleanup done pre-commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:49:08 +08:00
Naiyuan Qing
5078d51637 docs(docs-site): write first Welcome page (zh) — §1.1
Implements §1.1 Welcome per docs/docs-outline.md. Chinese-first (per
outline language decision); terms translated to their clearest Chinese
equivalents (issue → 任务, agent → 智能体, daemon → 守护进程, etc.),
product proper nouns and commands kept in English.

Voice: reference-style, not marketing. Follows google-gemini/docs-writer
skill rules (BLUF opener, second-person, active voice, no hype, overview
prose before every list).

Content:
- Opens by describing Multica as a 任务协作 platform and how humans + AI
  智能体 share the same 工作区
- Two interaction modes: 分配任务 and 聊天
- 智能体在哪里运行: local daemon (today), cloud runtime (soon, waitlist).
  10 providers listed from source (server/pkg/agent/*.go).
- Three usage paths split into back-end (Cloud / Self-host) and client
  (Desktop) choices — Desktop bundles CLI and auto-starts daemon.
- Status: 👀 In review.

Also simplifies content/docs/meta.json to just ["index"] (placeholder
page entries removed; IA skeleton will be populated in Phase 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:29:54 +08:00
Naiyuan Qing
ff03ba25b1 docs(plan): add rewrite plan and outline tracker
Two planning documents for the docs site rewrite:

- docs/docs-rewrite-plan.md — strategic rationale (positioning, reader
  personas, design principles, visual direction, phase breakdown).
- docs/docs-outline.md — execution tracker. 25 v1 pages with per-page
  entries (source files, audience, what-to-write, what-not-to-write,
  ⚠️ verify-before-drafting). Workflow: claim via Owner + Status,
  read source, verify checklist, draft, review, ship.

Language: zh only for v1. Outline is the source of truth for scope and
status; the earlier "EN first, ZH as Phase 10" line in rewrite-plan.md
is superseded.

Welcome (§1.1) is claimed under this tracker and currently in 👀 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:29:41 +08:00
Naiyuan Qing
e7c4edee0b feat(docs-site): apply Multica tokens and pure-sans typography
Replace Fumadocs' neutral color preset with a @theme inline bridge that
maps the --color-fd-* chrome tokens to Multica's --background / --foreground
/ --border / --sidebar-* etc. Sidebar, nav, cards now pick up Multica's
cool-gray palette automatically, and switching Multica's .dark flips
Fumadocs chrome with it.

Typography: pure sans (36px / weight 600 / tight tracking h1, h2+h3 tuned
to match), landing continuity without serif display.

Code blocks: pinned to near-black (oklch(0.12 0.01 250)) regardless of
page theme so they read as a continuation of the landing hero surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:29:32 +08:00
Naiyuan Qing
0796f9cee3 chore(docs-site): add @multica/ui bridge and dev:docs script
Link @multica/ui as a workspace dep of @multica/docs so the docs app can
consume the shared design tokens (tokens.css, base.css) via a relative
import — same pattern the web and desktop apps use. Add a top-level
pnpm dev:docs script for a one-command docs dev server (port 4000).

Preparation for the docs site rewrite tracked in docs/docs-outline.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:29:21 +08:00
Mack
f2ba087f74 fix(editor): preserve nested ordered lists through readonly render (#1512)
Default @tiptap/markdown serializer emitted nested list items with 2-space indent, but CommonMark (remark-gfm) requires ≥3 spaces under a `1.` marker — so ReadonlyContent (autopilot detail / issue / comment) flattened nested ordered lists, with third-level items glued onto their parent line. Configure Markdown extension with indentation.size = 3.

Closes #1510
2026-04-23 07:08:19 +08:00
Naiyuan Qing
059356cce7 docs(claude-md): trim implementation archaeology, keep rules (#1540)
CLAUDE.md is loaded into context every conversation; verbose race-condition
post-mortems and code-organization rationales rot fast and crowd out the
actionable rules they were meant to support. Strip the archaeology, keep the
load-bearing constraints.

- Workspace identity singleton + destructive ops (~22 -> 11 lines): keep the
  "must call setCurrentWorkspace(null, null) when leaving context" rule and
  the 4-step destructive order; drop the three-way race autopsy (already
  documented inline in workspace-tab.tsx where it belongs).
- Drag region (~27 -> 3 lines): keep "every full-window desktop view must
  mount <DragStrip /> as first flex child"; drop hit-testing rationale,
  canonical-file inventory, and useImmersiveMode escape-hatch trivia.
- UX vs platform chrome (~3 -> 0 lines): delete entirely. The rule
  duplicates "Cross-Platform Development Rules" above; the rest is purely
  why-we-organized-it-this-way narrative.

Common Zustand footguns kept as-is - both items are real rules (stable
selector references, hooks accepting wsId as parameter), not archaeology.

Net: -36 lines, no rule lost.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 06:57:10 +08:00
Bohan Jiang
7375bda9b5 fix(landing): scope landing route to always-light palette (MUL-1277) (#1537)
* fix(landing): scope landing route to always-light palette

The landing page sections use hardcoded light colors (bg-white / #0a0d12),
but shared components rendered inside — notably CloudWaitlistExpand on
/download — use semantic tokens that flip to dark values under next-themes'
`.dark` class, producing a mismatched dark card on an otherwise light page
when the user's OS is in dark mode.

Add a `.landing-light` class on the landing layout wrapper that re-declares
all color tokens to their light values for the subtree, so nested
token-driven components stay in lockstep with the hardcoded palette.

* test(agent): serialize fake-executable writes to avoid ETXTBSY on CI

TestKimiBackendInvokesACPSubcommand (and its Kimi/Codex siblings) write a
shell script to a per-test TempDir and then fork/exec it. With t.Parallel()
enabled across the package, a concurrent goroutine's fork can inherit the
still-open write fd to another test's new executable; Linux then rejects
the subsequent exec with ETXTBSY (seen as
  fork/exec /tmp/.../kimi: text file busy
on GitHub Actions).

Introduce writeTestExecutable, which holds syscall.ForkLock.RLock across
OpenFile→Write→Close. Fork (which takes ForkLock.Lock) cannot run while we
hold RLock, so no sibling fork inherits our write fd. Ran the three callers
with -count=10 under -p=1 and the full package with no failures.
2026-04-23 01:52:46 +08:00
Bohan Jiang
9dcc082920 docs(handler): note that GetConfig is public-only and what may be returned (#1538)
Adds a doc comment on GetConfig spelling out that the endpoint is mounted on
the unauthenticated route group (so the login page can fetch GoogleClientID /
AllowSignup before the user is signed in) and that only instance-level public
fields may be added. Prevents accidentally returning user- or tenant-scoped
data from this handler in the future.
2026-04-23 01:51:59 +08:00
Black
98edc6b9ff fix(auth): make /api/config publicly accessible (#1530) 2026-04-23 01:49:21 +08:00
Jiayuan Zhang
88b892f1ca fix(desktop): preserve last-opened workspace on app start (MUL-1269) (#1515)
The workspace query defaults `data` to `[]` before the first fetch, so the
bootstrap effect ran with an empty valid-slug set, wiped the persisted
`activeWorkspaceSlug`, then fell back to `workspaces[0]` once the real list
arrived — dropping the user on the default workspace on every launch.

Gate the effect on `workspaceListFetched` so validation runs only against
the real list, and re-read the store after `validateWorkspaceSlugs` to
avoid acting on a stale snapshot.

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-23 00:20:38 +08:00
Bohan Jiang
2cced51d64 docs(changelog): publish v0.2.14 + v0.2.15 release notes (#1517)
* docs(changelog): publish v0.2.14 + v0.2.15 release notes

Summarises the 25 commits shipped today across both releases for the public changelog page, in English and Chinese.

* docs(changelog): merge v0.2.14+v0.2.15 into one entry, trim, reclassify Gemini as fix

Per review: today's two releases read better as one set of notes; tightened
bullets; moved the Gemini 3 runtime-list update from Features to Fixes.

* docs(changelog): drop last 3 features from v0.2.15 entry per review
2026-04-22 20:02:42 +08:00
Bohan Jiang
6717db1fad feat(agents): surface task source on AgentTaskResponse + use it in Tasks tab (#1455)
Follow-up to #1453. That PR fixed the Tasks tab crash by filtering empty
issue_id out of the detail lookup and rendering a neutral "Task without
linked issue" label, but every issue-less task — chat-spawned or
autopilot-spawned — looked the same. The server already stores the
origin in `agent_task_queue.chat_session_id` / `autopilot_run_id`; only
the HTTP serializer was dropping them.

Server:
- `taskToResponse` now populates `ChatSessionID` and the new
  `AutopilotRunID` on `AgentTaskResponse`. Backward compatible: both
  omit when UUID is invalid, and existing clients ignore unknown
  fields.

Types:
- `AgentTask` (TS) gains `chat_session_id?` + `autopilot_run_id?` and a
  comment clarifying when `issue_id` is empty.

Tasks tab:
- Row label for issue-less tasks is picked from the populated source
  field: "Chat session" for chat tasks, "Autopilot run" for autopilot
  tasks, "Task without linked issue" as the neutral fallback. Rows stay
  inert (no anchor) in all three cases; existing issue-linked path is
  unchanged.

Tests:
- Two new regression tests assert the chat and autopilot labels render
  correctly and neither row becomes an anchor. Existing neutral-label
  test stays as the "neither source populated" case.
2026-04-22 19:26:57 +08:00
Dhruv-89
2a248b8548 fix(openclaw): raise agent discovery timeout to 30s (#1495)
'discoverOpenclawAgents' runs several 'openclaw' subprocesses under one
context; 5s was too short on cold starts or under load, causing empty
listings in the model picker. Increase the per-discovery cap to 30s.
2026-04-22 19:24:57 +08:00
Naiyuan Qing
f84d216794 fix(views): restore issue-mention class on <a> for mention card (#1516)
PR #1502's IssueChip extraction moved the `issue-mention` class from the
outer <a> into IssueChip's inner <span>, breaking three consumers that
select on `<a>.issue-mention` directly:

- `.rich-text-editor a.issue-mention` underline-exemption in
  content-editor.css (stopped matching -> mentions in editor gained a
  spurious underline).
- `link-hover-card.tsx` classList check that suppresses the URL preview
  on issue mentions (stopped matching -> hover card wrongly pops up
  over mention chips).
- Tailwind Typography prose (`prose a { text-decoration: underline }`)
  covers a separate path — markdown bubbles in chat. prose's specificity
  (0,1,1) beats `.no-underline` (0,1,0), so `not-prose` is the right
  escape hatch on the AppLink.

Put `issue-mention` back on the <a> in both wrappers (IssueMentionCard
and the editor's MentionView), and add `not-prose` only to the markdown
wrapper. IssueChip's BASE_CLASS keeps `issue-mention` too (inert on the
span; removing it is a separate scope that needs a full consumer audit).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:13:14 +08:00
Naiyuan Qing
101da19b02 feat(download): fall back to previous release within 1h freshness window (#1514)
New /download visitors were seeing grayed-out macOS buttons in the 20-ish
minutes after a tag push because CI only builds Linux/Windows — Mac is
still packaged manually and uploads tens of minutes later. Swap the
`/releases/latest` fetch for `/releases?per_page=2` and, when the latest
release is under an hour old, render the previous (fully-populated)
release instead. After the freshness window, page auto-switches to latest.

Frontend-only change — GitHub "latest" marker, electron-updater, and
homebrew paths are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:05:36 +08:00
Bohan Jiang
dc8096fb6e fix(agent): expose Gemini 3 + CLI aliases in Gemini runtime model list (#1508)
Gemini CLI has no `models list` subcommand, so Multica can't do real
dynamic discovery. Instead, swap the static catalog from fixed version
names (2.0/2.5 only) to the CLI's own aliases (`auto`, `pro`, `flash`,
`flash-lite`, `auto-gemini-2.5`) plus explicit pins for Gemini 3
preview and 2.5 variants. Aliases are resolved inside the Gemini CLI
per user entitlement + quota, so new model releases light up without
a Multica redeploy. Default is `auto`, matching Google's recommended
selection.

Fixes multica-ai/multica#1503.
2026-04-22 19:02:07 +08:00
LinYushen
2dae42f58a Tighten Vercel ignore rules (#1513) 2026-04-22 19:00:35 +08:00
Naiyuan Qing
f6dd47c944 fix(chat): disable focus button on pages without an anchor (#1509)
The focus toggle was only disabled when focusMode was already ON
*and* the current page had no anchor. Off-state on the same page
stayed clickable — clicking turned it on, and the button instantly
greyed out, making the missing fourth state visible.

Decouple "clickable" from focusMode: the button is disabled whenever
the current page has no anchor, regardless of the persisted on/off
preference. Both the chip render (context-anchor.tsx:173) and send
path (chat-window.tsx:176) already guard on candidate presence, so
leaving focusMode=true on an unanchorable page has no side effects —
the preference is preserved for the next anchorable page.

Tooltip now reads "Nothing to share with Multica on this page"
whenever the button is disabled, regardless of focusMode.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:57:44 +08:00
LinYushen
f98a67dd90 ci(release): build docker images natively per arch and merge manifests (#1507)
Multi-arch images were built on a single amd64 runner with QEMU
emulating arm64. The Next.js build (Dockerfile.web) under emulation
took 30+ minutes per release and was the long pole of the workflow.

Split each image build across two native runners (amd64 on
ubuntu-latest, arm64 on ubuntu-24.04-arm), push by digest, then
merge into a manifest list with docker buildx imagetools. QEMU is
no longer needed.

Backend and web each become a (matrix build + merge) pair, replacing
the previous single docker-images job. Per-platform GHA cache scopes
avoid cross-arch cache eviction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 18:28:54 +08:00
devv-eve
90ccd97469 fix: add .vercelignore for Vercel web deploys (#1505)
* fix: ignore non-web files in vercel deploy

* fix: keep docs app in vercel uploads

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-22 18:05:15 +08:00
Naiyuan Qing
180a534511 chore(docs): remove shipped plan and proposal docs (#1504)
All 15 files deleted here correspond to already-merged PRs —
their execution is done, their rationale lives in commit
messages and PR descriptions, and nothing in the tree references
them (grep across *.ts / *.tsx / *.go / *.mjs / *.json returns
zero hits).

Removed:
  docs/download-redesign.md               → PR #1500
  docs/download-positioning.md            → PR #1500
  docs/onboarding-redesign-proposal.md    → PR #1411
  docs/workspace-url-refactor-proposal.md → PR #1131 / #1138
  docs/plans/2026-04-07-tanstack-query-migration.md
  docs/plans/2026-04-08-board-dnd-rewrite.md
  docs/plans/2026-04-08-drag-upload-enhancement.md
  docs/plans/2026-04-08-image-view-enhancement.md
  docs/plans/2026-04-08-monorepo-extraction.md
  docs/plans/2026-04-09-desktop-app.md
  docs/plans/2026-04-09-monorepo-extraction.md
  docs/plans/2026-04-09-upload-attachment-fixes.md
  docs/plans/2026-04-15-workspace-slug-url-refactor.md
  docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
  docs/plans/2026-04-16-unify-workspace-identity-resolver.md

Empty `docs/plans/` directory goes with them (git drops empty
dirs automatically). Active, non-plan docs stay: analytics.md,
design.md, product-overview.md, codex-sandbox-troubleshooting.md.

Any future plan can live on a feature branch under
`.claude/plans/` (harness-scoped, not committed) or as a PR
description. No need to land them in-tree.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:49:07 +08:00
Naiyuan Qing
2d0916ee38 feat(chat): focus mode — share current page as context (#1502)
* refactor(views): extract IssueChip shared primitive from mention card

IssueMention (in editor NodeView) and IssueMentionCard shared 95% of their
markup — StatusIcon + identifier + title inside a bordered chip. They drifted
into two parallel implementations so changes had to be made in two places.

Extract the presentational chip into IssueChip. The navigable variants
(IssueMentionCard, the editor NodeView) become thin shells that layer
routing + cmd/shift behaviour onto the shared chip.

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

* feat(chat): add focus mode to share current page as context

Adds a Focus button next to the chat submit. When on, the chat auto-attaches
whatever the user is viewing (issue, project, or inbox-selected issue) as a
context prefix on outgoing messages, so the agent knows what "this" refers
to without the user pasting ids.

The attached object is derived from the route + react-query cache on every
render — no separate copy in state. Only the boolean focusMode is persisted
(global to the user, not per-workspace), matching the "my preference"
mental model.

The button has three visual states driven by two dimensions (focusMode +
whether the current route resolves to an anchorable object):
  - off:         ghost + muted, click turns on
  - on  + anchor: secondary (bright), click turns off
  - on  + none:  disabled (nothing to attach here)

The derived anchor renders above the input as a chip — IssueChip for issues,
a new ProjectChip for projects — wrapped in AppLink so the visual target
matches the clickable target (mirrors IssueMentionCard's hover + navigation).

Prefix format reuses the editor's mention markdown:
  Context: [MUL-1](mention://issue/<uuid>) — "Fix login bug"
  Context: Project "Authentication"

so the agent sees an identical token whether the user @-mentioned inline or
focus-mode attached. Backend is untouched.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:25:58 +08:00
Naiyuan Qing
5335edd50d feat(web): /download page + desktop promotion across landing, login, onboarding (#1500)
* docs(download): add redesign plan and copy positioning source of truth

Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.

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

* feat(web): /download page with OS auto-detection

New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.

Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.

Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.

Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.

i18n copy sourced verbatim from docs/download-positioning.md.

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

* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA

Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.

Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.

cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.

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

* feat(web,auth): desktop promotion on login + solid landing hero download

LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.

Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.

Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.

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

* fix(web/download): anchor dark LandingHeader with relative wrapper

LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.

Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).

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

* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy

Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).

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

* test(onboarding): update platform-fork assertions for /download route

The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.

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

* Merge origin/main into NevilleQingNY/download-redesign

Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.

Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.

typecheck passes across all 6 packages; all 166 views tests
green.

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

* feat(analytics): instrument download funnel across 5 surfaces + /download

Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.

Three new events, wired via `@multica/core/analytics`:

1. `download_intent_expressed` fires on any CTA pointing at
   /download. `source` splits the five surfaces cleanly; every
   authenticated emission also writes `platform_preference=desktop`
   on the person (same convention Step 3 already uses).

2. `download_page_viewed` fires once per /download mount after OS
   detect resolves. Carries `detected_os`, `detected_arch`,
   `detect_confident` (Chromium userAgentData vs UA fallback), and
   `version_available` so the Safari-on-Mac arm64-default cohort
   and GitHub-rate-limited degraded sessions are each isolable.
   Also $set_once's `first_detected_os/arch` on the person so every
   downstream event gains a platform dimension without re-emitting.

3. `download_initiated` fires on every installer click — Hero's
   primary CTA and each All Platforms matrix row. `primary_cta`
   splits hero-recommended from manual picks; `matched_detect`
   quantifies detect accuracy from the single event (no cross-join
   to download_page_viewed needed).

Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.

New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.

docs/analytics.md update lands in the follow-up commit.

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

* docs(analytics): document download_intent_expressed / page_viewed / initiated

Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:25:01 +08:00
obsession
153e2b6245 Enhance OS architecture detection methods in install.ps1 (#1498) 2026-04-22 17:14:47 +08:00
Bohan Jiang
205e8c1e9c feat(analytics): client_type super-property + Desktop $pageview (MUL-1253) (#1490)
* feat(analytics): client_type super-property + Desktop $pageview (MUL-1253)

Register a `client_type` super-property ("desktop" | "web") plus optional
`app_version` inside `initAnalytics`, so every PostHog event from the
renderer can be split by client without relying on `$lib` (both Electron
and Next.js report "web"). `appVersion` flows in from `ClientIdentity`
via `CoreProvider` → `AuthInitializer`.

Add a Desktop `PageviewTracker` mounted in `DesktopShell` that fires
`$pageview` whenever the active tab's path changes, mirroring the Web
tracker. Restores the `/ → signup → workspace_created` funnel for the
desktop client and enables web-vs-desktop breakdowns.

* fix(analytics): preserve super-props on reset + cover overlay/login pageviews

Two blockers from PR review:

1. `posthog.reset()` wipes persisted super-properties, so after logout or
   account switch the next session's events silently dropped `client_type`
   and `app_version` until a full reload. Cache the set at init time and
   re-register it inside `resetAnalytics()` so the breakdown survives the
   auth transition. Added unit tests to pin the invariant.

2. Desktop `PageviewTracker` only watched the active tab path, which
   missed pre-workspace overlays (`/onboarding`, `/workspaces/new`,
   `/invite/<id>`) — those aren't tab routes on desktop — and also missed
   the logged-out `/login` state. Move the tracker to the app root and
   derive the visible path from `(user, overlay, activeTabPath)` with
   overlay > tab precedence so the `$pageview` stream matches the
   surface the user actually sees.
2026-04-22 17:02:58 +08:00
Naiyuan Qing
cd6bb48283 feat(autopilots): unified create/edit dialog with issue-modal layout (#1501)
Replace separate CreateAutopilotDialog / EditAutopilotDialog with a single
shared <AutopilotDialog mode="create"|"edit"> that mirrors the issue create
modal — dynamic sizing, expand/collapse, richtext Prompt, pill toolbar.

- Tiptap ContentEditor replaces plain textarea for Prompt; detail page
  renders description via ReadonlyContent for visual parity.
- Pills: Agent, Priority, Execution Mode, Schedule (Popover hosting
  TriggerConfigSection). 0/1/N trigger strategy: add on 0, edit inline on
  1, disabled with tooltip on 2+ (power users edit in detail page).
- Exposes priority + execution_mode at creation time (backend always
  supported them; old UI only offered them in Edit).
- parseCronExpression reverse-parses stored cron back to TriggerConfig for
  Edit-time prefill (with round-trip tests).
- PillButton extracted to packages/views/common for reuse across modals.
- DialogContent uses showCloseButton={false} so the shared header renders
  the Maximize + Close buttons next to the Rocket-prefixed breadcrumb.
- Conditional mount at call sites (`{open && <AutopilotDialog/>}`) keeps
  state fresh on each open.
- Schedule dirty detection compares cron+timezone payloads vs mount
  snapshot, and edit-mode submits against a snapshotted trigger id so
  concurrent WS refetches can't mis-target the update.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:59:37 +08:00
devv-eve
fbf41bde73 feat(selfhost): ship public GHCR deployment flow
Publish stable GHCR self-host images, switch self-host deploys to official image pulls with a source-build fallback, and move self-host signup / Google OAuth config onto runtime /api/config.
2026-04-22 16:58:42 +08:00
Bohan Jiang
936df59fa1 feat(analytics): instrument onboarding funnel (MUL-1250) (#1489)
* feat(analytics): capture onboarding funnel events + person-property $set

Closes the visibility gap introduced by the Onboarding relaunch: the
five new steps between signup and workspace_created were invisible to
PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist
intent, or starter-content acceptance at all.

Server-side events (see docs/analytics.md for full contracts):
- onboarding_questionnaire_submitted — fires once when all three
  answers first land; also $set's role/use_case/team_size on the
  person so every subsequent event is cohortable
- agent_created — not onboarding-specific; is_first_agent_in_workspace
  isolates the Step 4 signal
- onboarding_completed — fires on the actual NULL → timestamp flip
  with completion_path (full / runtime_skipped / cloud_waitlist /
  skip_existing / unknown) + joined_cloud_waitlist
- cloud_waitlist_joined — sizes hosted-runtime interest
- starter_content_decided — imported vs dismissed, split by
  agent_guided / self_serve branch on both sides

Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so
the same events can carry mutable cohort signals without a separate
identify round-trip.

* feat(analytics): wire frontend onboarding events + completion_path

- captureEvent / setPersonProperties helpers in @multica/core/analytics,
  with the same pre-init buffering as identify/pageview so config races
  don't drop step transitions
- onboarding_runtime_path_selected fires from step-platform-fork for
  the three web-fork choices (download desktop / CLI / cloud waitlist),
  plus platform_preference on person properties for downstream splits
- completeOnboarding now takes an OnboardingCompletionPath; the
  onboarding shell derives full / runtime_skipped / cloud_waitlist
  from runtime + waitlist state (lifted to the shell so StepFirstIssue
  can see both), and handleWelcomeSkip passes skip_existing
- saveQuestionnaire mirrors team_size/role/use_case into person
  properties via $set so every event on this user becomes cohortable
- StepAgent sends the template slug, StarterContentPrompt passes
  workspace_id on dismiss so the server can mirror the branch label

* docs(analytics): document onboarding funnel events + $set person properties
2026-04-22 16:28:08 +08:00
Joey
fa7e4cbdca Feat/la te x (#1365)
* 排除提交文件

* feat(editor): 添加数学公式渲染支持

- 集成 KaTeX 库用于数学公式渲染
- 在编辑器样式中添加数学节点相关 CSS 样式
- 实现 BlockMathExtension 和 InlineMathExtension 两个数学公式扩展
- 为 Markdown 组件添加 remarkMath 和 rehypeKatex 插件支持
- 在 package.json 中添加 katex、remark-math、rehype-katex 依赖
- 更新 pnpm-lock.yaml 文件以包含新的依赖包
- 为只读内容组件添加数学公式渲染功能
- 创建 math.tsx 文件实现数学公式节点的完整功能
- 添加只读内容的数学公式渲染测试用例
2026-04-22 16:04:34 +08:00
Bohan Jiang
747d9492cf feat(changelog): surface release notes from sidebar menu + update prompt (#1485)
Two entry points to multica.ai/changelog so users actually find out
what shipped:

- Sidebar user menu (both expanded popover + collapsed dropdown
  variants) gains a "What's new" item with a Sparkles icon, sitting
  above Log out. Plain `<a target="_blank">` works on both surfaces:
  web opens a new tab, desktop's main-process
  setWindowOpenHandler intercepts and routes through
  openExternalSafely. The shared view doesn't need to branch.
- Desktop's UpdateNotification "ready to restart" card grows a
  secondary "See changes" button next to "Restart now", giving the
  user a reason to actually restart instead of dismissing. Mirrors
  Conductor's update prompt pattern. The "available" / "downloading"
  states stay action-only — the changelog isn't useful before the
  download finishes.

No version-detection / unread-tracking yet. Web users still need to
click into the menu to see the changelog; that's a follow-up if the
team wants Linear-style "new" dot.
2026-04-22 15:15:18 +08:00
Naiyuan Qing
c787546ede refactor(pin): drop server-side enrichment, derive sidebar fields client-side (#1484)
`ListPins` used to join `issues` / `projects` so each pin row carried a
`title`, `status`, `identifier`, and `icon`. Convenient for the sidebar
but architecturally wrong: those fields live on a different cache key
than the pin query, so an `issue:updated` WS event invalidates
`issueKeys` and never touches `pinKeys`. The sidebar therefore showed
stale issue status / titles on pinned rows until a hard refresh —
and the same shape would silently re-emerge for any new enriched
field added later.

This refactor moves the join to the client so display data flows from
its real source of truth:

Server (`server/internal/handler/pin.go`):
- `PinnedItemResponse` keeps only pin-owned columns (id, workspace_id,
  user_id, item_type, item_id, position, created_at).
- `ListPins` no longer fetches issues / projects in the loop and no
  longer hides orphaned pins; the client decides how to render a pin
  whose target was deleted.
- `formatIdentifier` helper deleted (was only used by the enrichment
  branch); `strconv` import dropped along with it.

Types (`packages/core/types/pin.ts`):
- `PinnedItem` interface now mirrors the bare server shape. The four
  enriched fields are removed.

Sidebar (`packages/views/layout/app-sidebar.tsx`):
- New smart wrapper `PinRow` resolves each pin's display data via
  `useQuery(issueDetailOptions(...))` or `useQuery(projectDetailOptions(...))`
  with `enabled` gates on `pin.item_type` so the hook order stays
  stable. Loading renders a flat skeleton; error / 404 renders null
  (orphan pins hide themselves).
- `SortablePinItem` becomes purely presentational: it now takes
  `label` and `iconNode` as props instead of reading them off the pin
  object. dnd-kit / navigation wiring untouched.
- Same pattern as `packages/views/search/search-command.tsx:151`,
  which already uses per-row detail queries for Recent issues.

WS sync layer is unchanged: `onIssueUpdated` already patches
`issueKeys.detail`, so changing an issue's status now flows directly
into the sidebar without any cross-entity invalidate. The `pin:*`
prefix handler still invalidates `pinKeys` for create / delete /
reorder — that's still the correct signal for the pin LIST itself.

Verified: views typecheck + core typecheck + web typecheck +
desktop typecheck + go test ./internal/handler/... + vitest
(views: 165 tests, core: 83 tests) all pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:08:16 +08:00
Bohan Jiang
14a9b5293e feat(slugs): reserve homepage + expand reserved slug list (MUL-961) (#1483)
* feat(slugs): reserve homepage + expand reserved slug list (MUL-961)

- Fix: `homepage` was a live `/homepage` landing route in apps/web but not
  in the reserved list, so a user could register a workspace slug that
  shadowed the landing page. Now reserved on both backend and frontend.
- Add likely-future global routes (home, dashboard, profile, account,
  billing, notifications, search, members) so we don't have to do another
  audit/rename pass when these get wired up.
- Add API/ops prefixes (v1, v2, graphql, webhooks, sdk, tokens, cli,
  health, ws, metrics, ping) as defense-in-depth against collision with
  API aliases and ops endpoints.
- Clarify in both source files that the dotted/underscored entries in the
  "Next.js / web standards" section are currently unreachable under the
  slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` and are kept as defense-in-depth
  in case the regex is ever relaxed.
- Add audit migration 056 following the 047/049 pattern to fail loud if
  any production workspace slug collides with the newly reserved set.

* fix(slugs): rename prod conflicts in migration 056 (home → home-1, dashboard → dashboard-1)

Per db-boy's prod audit in the MUL-961 thread, two §3 slugs had live prod
workspaces at reservation time. Decision on MUL-961: force-rename both in
the audit migration (scheme 1), same playbook as MUL-972 for admin/multica/
new/www.

- `home` → `home-1`  (68a982da, zzlye, 2026-04-14)
- `dashboard` → `dashboard-1`  (ea5a332f, 王争, 2026-04-22)

Targeted UPDATEs land first, followed by a generic `<slug>-N` fallback that
handles any row that slips in between the audit snapshot and deploy. A
post-condition block re-queries the reserved set and fails loud if anything
slipped through.

Down migration reverts the two targeted renames deterministically (they're
keyed by workspace_id, so rollback is safe).

Owner outreach (email zzlye@ + 王争@ about the URL change) is tracked as a
follow-up outside this PR.
2026-04-22 15:08:06 +08:00
Bohan Jiang
b8b38381bb feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189) (#1481)
* feat(notifications): only bubble status_changed from sub-issue to parent subscribers (MUL-1189)

Subscribing to a parent issue used to surface every event from every
sub-issue in the inbox — comments, priority/due-date tweaks, assignee
shuffles, the lot — which drowned out the signal that actually matters
to a parent watcher: "did the sub-task move forward?".

notifySubscribers now consults a small allowlist (parentBubbleNotifTypes)
before walking up to the parent's subscriber list. Only status_changed
bubbles today; sub-issue subscribers themselves still get every event.
Direct notifications (issue_assigned, mentioned, task_failed targeted at
specific recipients) are unaffected — they go through notifyDirect, not
the parent-bubble path.

Tests cover the three behaviors that matter:
- status_changed on a sub-issue reaches the parent's subscriber, with
  the inbox item still pointing at the sub-issue (so the user lands on
  the actual change).
- new_comment on a sub-issue does NOT bubble.
- priority_changed on a sub-issue does NOT bubble.

* fix(test): pick next per-workspace issue number in test helpers

Both createTestIssue and createTestSubIssue inserted with the default
number=0, which collides with the uq_issue_workspace_number unique
constraint as soon as a single test creates two issues in the same
workspace (e.g. parent + sub-issue). The first failure also leaked the
parent row because t.Cleanup hadn't been registered yet, breaking every
subsequent test in the package.

Both helpers now compute number as MAX(number)+1 for the workspace, and
the parent-bubble tests register cleanup right after each insert so a
mid-test failure can't leave orphans.
2026-04-22 14:47:42 +08:00
Naiyuan Qing
3036c6418e fix(onboarding): pin sync, welcome layout, runtime bootstrap state (#1482)
Follow-ups on the onboarding flow shipped in #1411.

Pin state synchronization:
- ImportStarterContent now publishes pin:created after commit so the
  sidebar refreshes without a hard reload (previously the pins landed
  in the DB but no event was fired).
- ReorderPins publishes pin:reordered, keeping order in sync across
  web + desktop sessions.
- StarterContentPrompt.onImport invalidates queries locally, mirroring
  the useCreatePin / useDeletePin / useReorderPins onSettled pattern,
  so the originating session's refresh doesn't depend on the WS
  round-trip (WS is the signal for OTHER sessions).
- ImportStarterContent rejects malformed workspace_id up front with
  400 instead of falling through to a misleading 403.

Welcome step layout:
- Switch the two-column hero from CSS Grid to a flex row. Both
  columns share the container's full height via items-stretch +
  justify-center, so the bg-muted/40 backdrop fills edge-to-edge on
  tall viewports and left/right content stays vertically centred.

Desktop runtime bootstrap state:
- New DesktopRuntimesPage wrapper subscribes to window.daemonAPI and
  forwards a `bootstrapping` prop to RuntimeList. While the bundled
  daemon is booting, the empty state renders "Starting local
  runtime…" instead of the misleading "Run multica daemon start"
  hint. Web leaves the prop undefined — behaviour unchanged.

Small polish:
- CLI install dialog caps at 85vh with an internal scroll so the
  Connect button stays reachable when multiple runtimes are
  registered.
- Drop the env-aware CLI setup command; onboarding always targets
  cloud, so `multica setup` is enough — no need to thread apiUrl /
  appUrl through the dialog.

Developer tooling:
- pnpm dev:desktop:staging — parallel dev command that loads
  .env.staging (copilothub backend) via `electron-vite --mode
  staging`, so switching between local and staging no longer
  requires hand-editing env files.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:47:00 +08:00
Ark
26a2db2540 feat(transcript): add multi-select tool filter to agent execution dialog (#1460)
* feat(transcript): add multi-select tool filter to agent execution dialog

Adds a Filter dropdown to AgentTranscriptDialog that lets users
multi-select tool types (e.g. tool:Bash, tool:Edit) to narrow down
the event list, timeline bar, and copy output. Non-tool items (text,
thinking, error) are also filterable. Clear filters is placed at the
bottom of the dropdown with a separator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): address review comments on tool filter

- Replace index-based selection with seq-based (selectedSeq) to fix
  highlight/scroll jumping when toggling filters
- Use full tool count for the "X tool calls" chip (task-level stat)
  instead of filtered count
- Title-case filter labels: Thinking, Error (was lowercase)
- "Copy all" → "Copy filtered" when filter is active
- Replace raw <button> with DropdownMenuItem for Clear filters
  so it participates in keyboard navigation
- Drop redundant idx from row key (seq is already unique/stable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): use proportional width in timeline bar

After removing segment grouping, the timeline bar lost proportional
widths. Restore proportional width per item (1/items.length * 100%)
so each event's width reflects its share of the timeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): show individual event dividers in timeline bar

When filtering by a single type (e.g. Agent), all events share the
same color and blend into one solid bar. Split each event into its own
clickable button so users can see and click individual events, while
keeping proportional widths based on item count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): simplify timeline bar to segment-level buttons

Remove per-item nested buttons in timeline segments; each segment is now
a single clickable area. Reduces DOM nodes and aligns with the original
design where segments are coarse color blocks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(transcript): reuse getEventLabel for filter option display

Replace the manual displayMap with getEventLabel() so filter option
labels stay in sync with row labels automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(transcript): address round-2 review comments

- Remove dead `onClick` prop from TranscriptEventRow (caused
  TS6133 under noUnusedParameters; row never wired a click handler)
- Align `itemFilterKey` guard with `filterOptions` derivation
  (tool_use/tool_result type check)
- Fix TimelineBar `isSelected` from seq range to actual membership
  via `.some()` — avoids false highlight when a filtered-out seq
  falls within a segment's range

Note: `DropdownMenuItem` uses `onClick` not `onSelect` because this
codebase uses Base UI, not Radix. Base UI's Item.Props has no
`onSelect`; the inbox/members-tab code uses `onClick` as the pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Ark <lifangzhou@shizhuang-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:41:46 +08:00
Bohan Jiang
aa9932e4e1 fix(skills): unify Add Skill UX + surface every local skill with real file count (#1480)
* fix(skills): unify Add Skill UX + surface every local skill with real file count

Iterating on the local-skill import flow that just landed. Three fixes
shipped together because they all surfaced while testing the same code
path on the Skills page.

UX — fold runtime import into the existing "+ Add Skill" dialog
- Drop the standalone HardDrive icon button + the empty-state
  "Import From Runtime" buttons. Adding a skill is now a single entry
  point: the "+" header button (or empty-state button) opens one dialog
  with three tabs: Create / Import URL / From Runtime.
- Extract the runtime-import body into RuntimeLocalSkillImportPanel so
  it can mount inline as a tab. The standalone Dialog wrapper stays for
  the per-runtime "Import this skill" flow on the agent skills tab,
  which preselects runtime + skill and benefits from its own modal.
- Cap the dialog at max-h-[85vh] with a scrollable tabs body so the
  From-Runtime tab (runtime selector + skill list + name/description
  form) no longer overflows the screen on shorter displays.
- Filter the runtime selector to runtimes the caller owns. Other users'
  runtimes were listed but the import endpoint rejects them anyway,
  matching the Runtimes page's "Mine" default.
- The selected-runtime label in the trigger now shows the runtime name
  (`Claude (MacBook-Air.local) (claude)`) instead of the raw UUID — the
  shadcn SelectValue needs explicit children when items don't render
  the bare value as their label.
- Drop the placeholder Sparkles icon to the left of the skill name /
  description inputs in the detail header — it was decorative noise.

Daemon — surface every installed local skill and report the right count
- listRuntimeLocalSkills used filepath.WalkDir, which silently dropped
  every symlinked skill via the os.ModeSymlink early return. Skill
  installers like lark-cli ship every skill at ~/.agents/skills/<name>
  and symlink each one into ~/.claude/skills/, so users with dozens of
  skills only saw the few they had cloned in place. Switch to ReadDir
  + os.Stat (which follows symlinks) on the runtime root.
- collectLocalSkillFiles also failed for symlinked skill dirs because
  filepath.WalkDir does not descend into a symlinked root, so every
  such skill reported 0 files. Resolve the skill dir via EvalSymlinks
  before walking.
- Bundle file count purposely excludes SKILL.md (it travels in the
  bundle's `Content` field to avoid duplication on import). The summary
  now adds 1 back so the user-facing count matches the real file total
  — every skill has SKILL.md, we just required it to be parseable.

Tests
- New TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs seeds a
  shared installer dir, symlinks one skill into the runtime root, and
  asserts both regular and symlinked skills come back with the right
  source path (~/.claude/...) and metadata.
- TestListRuntimeLocalSkills_Claude updated to expect file_count = 2
  (one supporting file + SKILL.md) and a comment explains the +1 split.

* test(skills): drive new Add Skill dialog flow in skills-page test

Old test asserted the standalone "Import From Runtime" button. The PR
folded that into the unified "+ Add skill" dialog as the third tab, so
the test now opens the dialog, switches to the "From Runtime" tab, and
asserts the same end state.

Also stub useAuthStore so the runtime panel's "Mine"-only filter sees
the seeded runtime owner (user-1).

* fix(daemon): list nested skills, not just depth-1 entries

Per #1480 review (MUL-1246): switching listRuntimeLocalSkills from
filepath.WalkDir to flat ReadDir lost coverage for nested skill
layouts. opencode stores skills as e.g. `release/reporter/SKILL.md`,
and loadRuntimeLocalSkillBundle accepts that slash-delimited key, so
the import dialog could no longer surface skills the load endpoint
was perfectly happy to fetch.

Replace the flat ReadDir with a recursive enumerator that:

- Follows symlinks at every level (so installer-style symlinked skill
  trees still work — that was the original reason for moving off
  WalkDir).
- Short-circuits at every SKILL.md: a directory that qualifies as a
  skill is registered, and its children are NOT scanned for further
  skills. Stale nested SKILL.md files inside a parent skill's bundle
  stay part of that bundle.
- Caps recursion at maxLocalSkillDirDepth=4 (covers opencode's depth=2
  with headroom) and tracks visited resolved paths so a cyclic symlink
  can't loop forever.

New regression test seeds both a top-level skill (with a decoy
SKILL.md inside its templates dir) and a depth-2 nested skill, and
asserts the walker registers exactly two keys — "top" and
"release/reporter" — with the inner templates SKILL.md correctly
ignored.
2026-04-22 14:38:27 +08:00
Bohan Jiang
4a7de91ddf docs(make): add help description for db-reset target (#1479)
Follow-up to #1434. The merge-in of db-reset from main happened during
#1434's conflict resolution and didn't get a `##` description, so it
doesn't appear in `make help`. Add the one-line description so the
target surfaces under the Database grouping alongside the other `db-*`
commands.
2026-04-22 14:10:52 +08:00
yihong
3b426d21ee feat: add awk style make help (#1434)
* feat: add awk style make help

Signed-off-by: yihong0618 <zouzou0208@gmail.com>

* Address review nits for make help

- Add ##@ Help section so help / makehelp targets are no longer orphaned
  between the intro blurb and ##@ Self-hosting.

- Explicitly set .DEFAULT_GOAL := help and document that bare `make` now
  prints help instead of launching selfhost. This is a safer default for
  onboarding, but it is a behavior change from the previous first-target
  default (selfhost).

---------

Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2026-04-22 14:07:19 +08:00
LinYushen
b624cd98ad feat: identify clients via X-Client-Platform/Version/OS (#1477)
* feat: identify clients via X-Client-Platform/Version/OS

Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.

- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}

Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).

Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.

CORS allowlist extended for the new headers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: address client-identity PR nits

- Memoize the CoreProvider identity object on Web and Desktop, and key
  WSProvider's effect on identity primitives instead of the object
  reference, so unrelated parent re-renders no longer tear down and
  reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
  clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
  on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
  client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
  and asserts the "websocket connected" log entry surfaces them as
  structured attributes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:36:13 +08:00
gezilinll
f247a4f544 feat(skills): import runtime local skills into workspace (#1431)
* feat(skills): import runtime local skills into workspace

* fix(skills): address runtime local skill review feedback

* docs(skills): annotate local provider skill paths

---------

Co-authored-by: zhangliang <zhangliang@gaoding.com>
2026-04-22 13:16:51 +08:00
LinYushen
0b1333fb00 feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128) (#1476)
* feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128)

When the daemon process crashed mid-task the issue was stuck at
in_progress for up to 2.5h: the in-flight task timeout was the only
mechanism that ever moved the row, and the runtime heartbeat sweeper
only fires after the runtime stays offline for 45s — a quick restart
beats both windows.

This change implements the A+B plan from the issue thread:

A. lifecycle hygiene
- migration 055 adds attempt / max_attempts / parent_task_id /
  failure_reason / last_heartbeat_at to agent_task_queue
- new daemon-auth endpoint POST /runtimes/{id}/recover-orphans:
  daemon calls it on every register so the server fails any
  dispatched/running tasks the previous process left behind
- new daemon-auth endpoint POST /tasks/{id}/session: persists the
  agent's session_id + work_dir mid-flight so a crash doesn't
  lose the resume pointer (claude+codex emit MessageStatus with
  SessionID; daemon forwards on the first one it sees)
- FailAgentTask / FailStaleTasks / FailTasksForOfflineRuntimes
  now set failure_reason ('agent_error' / 'timeout' /
  'runtime_offline')

B. auto-retry with resume context
- TaskService.MaybeRetryFailedTask spawns a fresh queued attempt
  carrying parent's session_id/work_dir when the failure reason
  is infrastructure-shaped (timeout, runtime_offline,
  runtime_recovery) and attempt < max_attempts; skips autopilot
- wired into the runtime sweeper paths and TaskService.FailTask
  so the user transparently sees a new in_progress run instead of
  a stuck row
- new user-auth POST /api/issues/{id}/rerun + multica issue rerun
  CLI for the manual escape hatch

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): address PR review for orphan-task recovery (MUL-1128)

Three review-must-fix items on top of the A+B implementation:

1. recover-orphans now funnels through TaskService.HandleFailedTasks,
   the same shared post-failure pipeline used by the runtime sweeper.
   This guarantees task:failed events are emitted, agent status is
   reconciled, and issues stuck in_progress with no remaining active
   task are reset to todo even when no auto-retry is created
   (max_attempts exhausted, autopilot, non-retryable reason).

2. RerunIssue now uses CancelAgentTasksByIssueAndAgent, scoped to the
   issue's current assignee. The previous implementation called
   CancelAgentTasksByIssue, which would collateral-cancel parallel
   @-mention agents on the same issue.

3. GetLastTaskSession now considers both completed and failed tasks
   (mirroring GetLastChatTaskSession), ordering by the most recent
   timestamp. With UpdateAgentTaskSession pinning session_id/work_dir
   mid-flight, an auto-retry or manual rerun of a daemon-crash failure
   now actually resumes the prior conversation context instead of
   starting fresh — matching the stated B-branch behaviour.

go build / go vet pass; the existing service and agent test suites pass.
runtime_sweeper / handler integration tests require a local DB with the
055 migration (and the pre-existing 050 first_executed_at column).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:08:37 +08:00
Bohan Jiang
387f76d328 fix(agents): tasks tab crashes when agent has autopilot run_only tasks (#1453)
* fix(agents): tasks tab crashes when agent has autopilot run_only tasks

Autopilot `run_only` tasks have no linked issue; the server serializes
that as `issue_id: ""` (not null) via `uuidToString` on an invalid
pgtype.UUID. The agent detail Tasks tab assumed every task had a real
issue id and fed `""` into `api.getIssue(id)` → `/api/issues/` and into
`paths.issueDetail("")`, crashing the whole tab as soon as one such
task existed on the agent.

Handle the empty-issue case explicitly:

- Filter empty ids out of `issueIds` so `useQueries` doesn't fire
  `/api/issues/` for a nonexistent issue.
- Render run_only rows as non-link `<div>`s labeled "Autopilot run"
  instead of clickable issue links.

No server-side change — the `""` serialization stays as-is; callers
just need to treat it as "no issue".

* fix(agents): neutral label for issue-less tasks + regression test

Review feedback on #1453: not every task without a linked issue is an
autopilot run. `ListAgentTasks` returns the agent's full queue; both
autopilot `run_only` runs and chat-spawned tasks persist with NULL
issue_id, which arrives here as "". Labeling both as "Autopilot run"
mislabels chat tasks.

Swap the label to the neutral "Task without linked issue" and update
surrounding comments. A follow-up will surface the real task source
once the server populates it on AgentTaskResponse.

Adds a regression test that empty issue_id rows render the neutral
label, aren't wrapped in an anchor, and don't trigger a detail fetch.
2026-04-21 21:03:25 +08:00
Naiyuan Qing
3fd2fb2ae3 feat(onboarding): redesigned flow + post-landing starter content opt-in (#1411)
* docs(onboarding): add redesign proposal

Captures motivation (two activation funnels), research-backed principles,
final 5-step flow (welcome+questionnaire → workspace → runtime → agent →
first-issue), Q1/Q2/Q3 personalization matrix, backend user_onboarding
schema, API design, resume policy, and development ordering
(frontend-first with Zustand stub, backend-last, server swap).

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

* feat(onboarding): scaffold redesigned flow and state foundation

Work-in-progress scaffold toward the redesign documented in
docs/onboarding-redesign-proposal.md. This commit is intentionally
broad — subsequent commits will replace step content and wire real
personalization. Not ready for merge.

Included:
- packages/views/onboarding/: flow orchestrator + 5 step components
  (welcome/workspace/runtime/agent/complete) and the CLI install card.
  Step content is the placeholder version; Step 1 (questionnaire) and
  Step 5 (first issue) are the next changes.
- packages/core/onboarding/: dev-phase Zustand store + types. Not
  persisted — every page refresh starts at Step 1 so each step can be
  iterated in isolation. Will swap to TanStack Query + PATCH
  /api/me/onboarding once the backend user_onboarding table ships
  (keeps the exported hook surface stable).
- packages/core/paths/resolve.ts + .test.ts: centralized
  resolvePostAuthDestination. Priority is flipped so !hasOnboarded
  wins over workspace presence — during frontend development every
  login re-enters /onboarding. useHasOnboarded() reads from the store
  so the real onboarded_at semantic lands automatically once the
  backend ships.
- Post-auth wiring: callback page, login page, landing redirect,
  dashboard guard, realtime workspace-loss handler, settings leave/
  delete, invite acceptance, and desktop app shell all delegate to
  the shared resolver instead of inline logic.
- Desktop overlay: 'onboarding' added as a WindowOverlay type
  alongside new-workspace / invite, with a navigation-adapter
  interception so push('/onboarding') opens the overlay.
- packages/core/package.json / packages/views/package.json: add new
  subpath exports.

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

* docs(onboarding): revise questionnaire to role-driven 3-question form

Aligns the proposal with the corrected product positioning: Multica is an
AI agent orchestration platform for diverse users (developers, product
leads, writers, founders), not a coding-focused tool.

Key changes:
- Drop Q1 "which agents do you already use?" — daemon auto-detects
  installed CLIs on PATH; asking is both redundant and less accurate
- Add Q2 "what best describes you?" (role) to drive Step 4 template
  default and Onboarding Project sub-issue filtering
- Keep Q1 team_size, refine Q3 use_case (recover writing/research
  option); all three now have "Other" with an 80-char text field
- Q3 use_case_other is embedded into Step 5 first issue prompt so
  Other users get maximally personalized aha moments, not generic ones
- Agent templates: 3 → 4 (Coding / Planning / Writing / Assistant),
  matrix driven by Q2 × Q3
- Onboarding Project sub-issues: surface Autopilot and Workspace
  Context (product differentiators), replace "orchestration" wording
- Schema JSONB example and §5/§9 execution plan updated to match

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

* refactor(onboarding): align questionnaire shape with role-driven redesign

Prepares the core state layer for the Step 1 questionnaire rewrite.
Type-only and initial-value changes; no behavior changes (nothing was
reading the removed `existing_agents` field, since no questionnaire UI
exists yet).

- Add `Role` type (Q2: developer / product_lead / writer / founder / other)
- Add `*_other` sibling fields for team_size / role / use_case so each
  question's "Other" selection can carry 80-char free text
- Drop `existing_agents` — daemon auto-detects CLIs on PATH at Step 3,
  so the signal no longer belongs in the questionnaire
- Extend `TeamSize` / `UseCase` unions with `"other"` member
- Refine `UseCase` option label (`writing` → `writing_research`) so
  it matches the widened Q3 scope in the proposal

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

* feat(onboarding): implement Step 1 questionnaire

Replaces the placeholder welcome step with the 3-question questionnaire
defined in docs/onboarding-redesign-proposal.md §3.4. Answers land in
the core onboarding store for later use by Steps 4 and 5.

Added:
- packages/views/onboarding/components/option-card.tsx — OptionCard +
  OtherOptionCard. Radio-group ARIA semantics; Enter/Space select;
  Other variant reveals an 80-char input that auto-focuses on mount.
- packages/views/onboarding/steps/step-questionnaire.tsx — merges
  welcome + Q1/Q2/Q3 into one screen. Local draft state for
  responsiveness; writes to the core store only on submit. Skip/
  Continue CTA swap driven by "any answered?"; the only disabled
  case is "picked Other but the text box is blank".
- Test coverage for the CTA rules, Other-clear-on-switch behavior,
  initial-answers pre-fill, and full payload shape.

Modified:
- packages/views/onboarding/onboarding-flow.tsx — render
  questionnaire as the first step; persist answers and advance the
  stored current_step on submit. Other steps still run off local
  useState for now; full store-driven orchestration follows when
  Step 5 lands.

Removed:
- packages/views/onboarding/steps/step-welcome.tsx — superseded.

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

* fix(onboarding): split welcome + questionnaire, unblock scroll, drop Q1 evaluating

Three fixes prompted by first real browser testing of the Step 1
questionnaire. All three are about making the flow usable before
pursuing visual polish.

1. Split Welcome and Questionnaire into two screens
   The previous merge-welcome-into-questionnaire decision dropped
   Multica's product introduction entirely. For a product with no
   established mental model (AI agents as first-class teammates in a
   task platform), first-time users need 5 seconds of framing before
   the questionnaire makes sense. StepWelcome carries that framing;
   it's UI-only (not a persisted step), shown only on first entry
   (pristine store), and skipped automatically on resume.

2. Remove `my-auto` vertical centering from both platform shells
   Long questionnaire content pushed the centered block's top above
   the scroll origin, making Continue/Skip unreachable. Top-alignment
   + natural body/overlay scroll is the boring-but-correct baseline
   for content of variable height.

3. Drop Q1 "Just exploring for now" option
   Q1 asks about team structure, not attitude. "Evaluating" was a
   category error. Low-commitment users already have a zero-friction
   path (skip all questions). Removing the option simplifies the
   question and the downstream mapping table.

Types, store initial value, proposal doc (§3.1 flow diagram, §3.4
options, §3.5 sub-issue sorting, §3.6 conditionals, §4.1 JSONB
schema, §5.2 file list, §7 decisions row, §9.2 execution order)
all synced.

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

* fix(onboarding): center short steps, scroll long ones — correctly this time

Previous attempt removed `my-auto` thinking it was responsible for
blocked scrolling. That diagnosis was wrong: the real blocker was
the root layout's \`body { overflow: hidden }\` (an app-shell
convention so sidebar/topbar stay put while the inner content
region scrolls). Removing `my-auto` broke vertical centering of
short steps (Welcome) without fixing the scroll issue.

Correct fix:
- Web: page now owns its own scroll container — `h-full
  overflow-y-auto` on the outermost div decouples from the body's
  overflow-hidden.
- Desktop: the overlay's existing `flex-1 overflow-auto` container
  already provided scroll; just restoring `my-auto` was sufficient.
- Both platforms: inner `flex min-h-full flex-col items-center` +
  content `my-auto` gives the "short centers, long top-aligns and
  overflows down" behavior. Per the flex spec, auto margins are
  ignored on overflowing boxes (they overflow in the end direction),
  so Continue/Skip remain reachable via scroll even on long steps
  like the questionnaire.

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

* feat(onboarding): add progress indicator + stable header anchor

Adds a consistent visual anchor at the top of every step (except
Welcome), so transitioning between steps of different content heights
no longer shifts the vertical baseline.

- packages/core/onboarding/step-order.ts — single source of truth for
  step order; indicator math reads from here so adding/reordering a
  step touches only one line
- packages/views/onboarding/components/step-header.tsx — dot row +
  "Step N of M" counter; three dot states (done/current/pending);
  accessible progressbar semantics
- onboarding-flow.tsx — non-welcome steps now render under a shared
  `<div flex flex-col gap-8>` wrapper with StepHeader on top. Maps
  the local `complete` render step to the store's `first_issue`
  until Step 5 lands (one-line function, self-deleting).
- step-welcome.tsx — keeps its own min-h-[60vh] + justify-center so
  the short intro still feels centered once the shell drops my-auto
- apps/web + apps/desktop shells — removed `my-auto`. Every
  non-welcome step now anchors to the same top position, so only the
  content below the header changes during transitions. Welcome's own
  internal centering handles its "short content, no header" case.

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

* feat(onboarding): add web Step 3 platform fork (Desktop / CLI / waitlist)

Web users now see a three-way choice at the runtime step instead of
being dropped directly into CLI install instructions:
- Primary CTA: Download Multica Desktop (bundled runtime)
- Alternate: install the CLI (reveals existing StepRuntimeConnect)
- Alternate: join the cloud waitlist (captures email, completes
  onboarding early with cloud_waitlist_email set)

Desktop unchanged — its platform shell doesn't pass cliInstructions,
so OnboardingFlow routes it straight to StepRuntimeConnect for the
bundled-daemon auto-connect path.

Rename step-runtime.tsx → step-runtime-connect.tsx to reflect its
new single responsibility (connect UI only; platform choice lives
in StepPlatformFork).

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

* feat(onboarding): capture optional use-case on cloud waitlist

Adds a textarea to the waitlist form asking what the user wants to
use Multica for. Optional (submit still works with email alone) but
surfaces a clear prompt + placeholder example so most users will fill
it in. Stored as cloud_waitlist_description alongside the email.

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

* fix(onboarding): make !hasOnboarded a first-class gate on both platforms

Triggering condition was wrong on both sides. Web's dashboard-guard
only checked hasOnboarded when the URL slug failed to resolve; desktop's
App.tsx effect returned early when wsCount > 0 before even looking at
hasOnboarded. Users with existing workspaces never got routed into
onboarding regardless of their flag state.

Also wire store.complete() into the happy-path finish — previously only
the waitlist branch wrote onboarded_at, so every normal completion
left the flag false and (now that triggers work) would loop users back
into onboarding on refresh.

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

* feat(onboarding): Step 5 auto-bootstrap — welcome issue + Getting Started project

After agent creation, the flow transitions to a loader screen that
runs the bootstrap in the background:
- Creates a welcome issue with a Q3-driven prompt, assigned to the
  new agent (so it starts working immediately)
- Creates a "Getting Started" project with tutorial sub-issues
  filtered by Q1/Q2/Q3
- Stores first_issue_id + onboarding_project_id via store.complete()
- Navigates the user straight into the welcome issue detail page,
  where they see the agent already responding

Degraded path: if welcome issue fails, shows error with Retry /
Continue anyway. If project or sub-issues fail, logs and proceeds
with just the welcome issue — the aha moment still happens.

No-agent paths (runtime skip, agent skip) short-circuit to onComplete
without bootstrap.

Local flow step union now aligns with the store enum; removed the
mapLocalToStoreStep bridge and deleted the old step-complete.tsx
placeholder.

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

* refactor(onboarding): converge all no-agent paths to a single bootstrap step

Before: skip-runtime, skip-agent, and waitlist each finished onboarding
independently, bypassing Step 5 entirely. Users without an agent landed
in an empty workspace with no tutorial project — the "self-serve" case
had no bootstrap at all.

Now: all three paths converge on the first_issue step with agent=null.
Bootstrap branches on agent presence:
- agent ✓ → welcome issue (assigned to agent) + project + agent-guided
  sub-issues ("watch your agent do X"). Lands on the welcome issue.
- agent ✗ → project only + self-serve sub-issues ("try X yourself" —
  configure runtime, create agent, write first issue, etc.). Lands on
  the workspace issues list with the Getting Started project in the
  sidebar.

Both web and desktop shells already handle firstIssueId=undefined →
fall back to /<slug>/issues, so no shell-side change was needed.

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

* feat(onboarding): pin starter project + assign sub-issues to the user

Bootstrap now also:
- Pins the Getting Started project so users see it in the sidebar
  immediately (both paths)
- Pins the welcome issue too (path A only) so the first conversation
  with the agent stays one click away
- Assigns every sub-issue to the current user (via their workspace
  member record). Only the welcome issue stays assigned to the agent —
  that's the aha-moment hand-off; everything else is for the user to
  work through

Pin calls are fire-and-forget (failure logged but non-blocking).
Member lookup is defensive — if listMembers fails or the user isn't
found, sub-issues gracefully fall back to unassigned rather than
breaking the bootstrap.

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

* refactor(onboarding): remove cloud waitlist option

Cloud runtime is not on the immediate roadmap and there's no backend
table to persist emails. Keeping the UI around would silently drop
user submissions — small trust leak. Revisit once cloud product lands
alongside a proper waitlist table + notification pipeline.

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

* feat(onboarding): persist onboarded_at end-to-end

Phase 1 of bringing onboarding from dev stub to production. A single
persisted column drives every trigger — no separate user_onboarding
table yet (that's a later phase for questionnaire persistence, cloud
waitlist, analytics).

Backend
- Migration 050: ALTER TABLE "user" ADD COLUMN onboarded_at TIMESTAMPTZ
  (no backfill — existing users see onboarding next login, Skip
  affordance lands later)
- sqlc: MarkUserOnboarded with COALESCE for idempotency
- UserResponse DTO + userToResponse now emit onboarded_at via
  existing util.TimestampToPtr helper — single edit covers GetMe,
  VerifyCode, GoogleLogin, LoginWithToken
- New handler POST /api/me/onboarding/complete
- Route registered in the authenticated user-scoped group

Frontend
- User type gets onboarded_at: string | null
- api.markOnboardingComplete()
- Auth store adds refreshMe() — lightweight getMe + setUser,
  complements existing initialize()
- useHasOnboarded switches source from onboarding-store (dev stub)
  to auth-store (user.onboarded_at). Every call site — dashboard
  guard, desktop App.tsx, invite page fallback, realtime
  workspace-loss handler, settings leave/delete — picks up the
  real signal without any direct change
- onboarding-store.complete() now hits the server: POST + refreshMe
  before local state update, so the next router effect sees the
  non-null timestamp and won't bounce the user back

Triggers + route guards
- StepWorkspace drops the Skip button — every onboarding user
  must create their own workspace even if invited into one
- /onboarding page redirects already-onboarded users away (guards
  against manual URL access)
- login page + auth callback: onboarding wins over ?next= for
  unonboarded users; invite links are revisitable after onboarding

Tests
- apps/web callback tests updated: mocks now return User objects
  so onboarded_at is readable; new "onboarded user honors next"
  scenario added, "unonboarded ignores next" scenario kept
- test/helpers mockUser gets onboarded_at field
- questionnaire already-existing strict-required tests bundled in
  from a prior uncommitted change

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

* fix(onboarding): review findings — dead state, error recovery, cache races

From independent review of the prior onboarded_at commit.

- Remove the dead OnboardingState.onboarded_at field, its INITIAL_STATE
  entry, and its write in store.complete(). useHasOnboarded now reads
  auth-store exclusively; leaving a parallel field here violates the
  "don't duplicate server data in Zustand" rule and risks drifting into
  a second source of truth.
- Wrap handleBootstrapDone/handleBootstrapSkip in try/catch with toast
  recovery. complete() is idempotent server-side (COALESCE), so a
  retry after a failed POST/refreshMe is free — letting the error
  bubble into the React error boundary trapped the user with no way
  forward.
- RedirectIfAuthenticated: swap `!list` for `isFetched`-gated check,
  matching the pattern added on the /onboarding page. Same one-tick
  race where a stale cache [] could fire a premature replace before
  the fresh list settles.
- (Self-review fixups picked up along the way) /onboarding page now
  waits for workspacesFetched before redirecting already-onboarded
  users, and login handleSuccess reads useAuthStore.getState() so the
  hasOnboarded value is fresh after setUser (the closure captured a
  stale pre-login value otherwise).

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

* refactor(onboarding): shrink store surface + firm up flow invariants

Post-review cleanup. End-to-end flow is already complete (user.onboarded_at
is the single source of truth); these are quality-of-life fixes on top.

Store surface
- Drop six dead fields from OnboardingState (workspace_id, runtime_id,
  agent_id, first_issue_id, onboarding_project_id, platform_preference)
  and the PlatformPreference type. None had readers — they were stub
  placeholders for a future user_onboarding table that isn't coming
  this phase. CLAUDE.md "don't design for hypothetical future".
- store.complete() signature simplifies to () — no more patch arg,
  since the only patch fields were the ones just deleted.

Welcome as a first-class step
- Add "welcome" to OnboardingStep enum and make it INITIAL_STATE's
  current_step. Removes the pristine-heuristic "did user see welcome?"
  check, which could misfire on remount.
- pickInitialStep() collapses to `state.current_step ?? "welcome"`.
- ONBOARDING_STEP_ORDER stays unchanged (welcome isn't a progress point).

advance() chain
- Every transition handler now persists the new current_step to the
  store (handleWorkspaceCreated, handleRuntimeNext, handleAgentCreated,
  handleAgentSkip). Refresh lands on the right step instead of
  jumping back to Step 2.

Invariants
- OnboardingFlow throws on null user instead of spreading defensive
  `?? ""` and `if (userId)` that silently degraded to unassigned
  sub-issues. Shell guards already ensure user is present.
- Desktop WindowOverlay's onComplete gains a paths.root() fallback
  when workspace is undefined — matches web's symmetry.

docs/product-overview.md: committed from untracked.

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

* feat(onboarding): persist questionnaire + current_step; resume + Back

End-to-end questionnaire persistence + resume capability. User answers
are now server-side (analytics-ready); refreshing or revisiting lands
on the furthest reached step with previous answers pre-filled; a Back
button on each step lets users edit earlier answers without losing
progress.

Backend
- Migration 051: ALTER TABLE "user" ADD onboarding_current_step TEXT,
  onboarding_questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb
- sqlc: new PatchUserOnboarding with sqlc.narg for optional fields
  (COALESCE preserves unspecified columns). MarkUserOnboarded also
  clears current_step — once complete, the step pointer has no meaning
- Handler PATCH /api/me/onboarding accepting partial {current_step,
  questionnaire}. Questionnaire passthrough via json.RawMessage, no
  server-side validation of inner shape (keeps schema evolution free)
- UserResponse DTO emits both new fields; userToResponse coalesces
  JSONB to '{}' defensively

Frontend
- User type gains onboarding_current_step + onboarding_questionnaire
- api.patchOnboarding(payload)
- Delete Zustand onboarding store — replaced with plain async
  advanceOnboarding() / completeOnboarding() that call the API and
  sync auth store. Source of truth is the user object, no client-side
  shadow state that could drift
- pickInitialStep reads user.onboarding_current_step; StepQuestionnaire
  initial pre-fills from user.onboarding_questionnaire
- Monotonic furthestStepRef: Back edits don't regress server-side
  progress, and re-submit returns the user to where they were
- Back buttons on Steps 2/3/4. Back is local-only — just changes the
  rendered step, no PATCH
- Loading indicator on Welcome + Questionnaire submit buttons while
  PATCH is in flight
- CreateWorkspaceForm.onSuccess accepts Promise<void> so the flow can
  await advance() from its onCreated handler

Test mocks (helpers + callback test) updated with new User fields.

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

* fix(onboarding): resume to Step 3+ needs workspace/runtime fallback

Self-review caught: resume lands the user on their saved step, but
React state (workspace, runtime, agent) is empty on fresh mount. The
render conditions gate on those — without fallbacks the page stays
blank.

- workspaceListOptions() query fills runtimeWorkspace from cache when
  stepping past Step 2. Only one workspace exists during onboarding
  (StepWorkspace always creates one), so [0] is unambiguous.
- StepWorkspace accepts an `existing` prop. On resume / Back to Step 2
  with a pre-existing workspace, render a "Continue with <name>"
  confirmation instead of the create form, which would otherwise hit a
  slug conflict the moment the user clicks Create.
- runtimeListOptions(wsId, "me") similarly seeds Step 4's runtime —
  prefer first online, fall back to first.

Step 5 resume path unchanged: if `agent` React state is null on
re-entry, bootstrap runs the self-serve branch. Not ideal (user may
have actually created an agent), but bootstrap's list-check approach
(future work) will handle orphan detection symmetrically.

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

* refactor(onboarding): delete all skip/resume jump logic

Flow always starts from Welcome. Questionnaire answers still pre-fill
from user.onboarding_questionnaire. current_step is still PATCHed for
future analytics but no UI code reads it for navigation.

Removed from onboarding-flow.tsx:
- pickInitialStep + isOnboardingStep (no server-driven entry point)
- furthestStepRef + resolveNextStep (no edit-vs-first-pass branching)
- runtimes useQuery + stepRuntime fallback (user walks through Step 3
  linearly, so runtime React state is always populated by Step 4)
- workspace resume fallback in runtimeWorkspace (same reasoning)

Kept:
- advanceOnboarding({ current_step, questionnaire? }) — server
  persistence, analytics-ready
- StepQuestionnaire's initial prop from stored answers
- workspaces useQuery (gated to step === "workspace" only) for
  existing-workspace detection on Step 2 to prevent slug conflicts
  when a previous onboarding was abandoned
- Back buttons + handleBack (local-only navigation)
- Error recovery on completeOnboarding via try/catch + toast

Every transition handler is now a straight advance + setStep line.
Users who close mid-flow and return walk the full flow from Welcome
again — slight extra clicks, but each step shows meaningful confirm
UI (existing workspace, connected runtimes, etc.) so it doesn't feel
like repeated work.

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

* fix(onboarding): grandfather existing users in the onboarded_at migration

Folded the backfill into 050 itself (branch has not shipped to prod,
so editing the migration in place is clean). Without this, once this
branch deploys, every pre-existing user would be walled off into
onboarding on their next login — a real production incident.

Uses created_at rather than NOW() so analytics like "signup →
onboarded interval" read correctly for pre-launch users.

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

* feat(onboarding): Step 1 questionnaire — two-column editorial layout

Matches the onboarding(3) design spec: full-bleed two-column on lg+
(main + "Why we ask" side rail), collapses to single column below.

- StepQuestionnaire rewritten with:
  - Mono 01/02/03 markers per question
  - Serif question headings (22px)
  - Editorial serif title ("Three answers. We'll handle the rest.")
  - Right-side rationale panel explaining what each answer unlocks
  - Sticky footer with hint + Continue CTA
  - Embeds StepHeader on the left column so it escapes the flow's
    narrow max-w-xl wrapper, same pattern Welcome uses
- OptionCard redesigned: radio-dot marker + inset ring on select,
  matches design's .opt pattern
- OtherOptionCard: text input appears below the row (not inside the
  card) with bottom-border-only styling, aligned under the label
- onboarding-flow: questionnaire now early-returns full-bleed,
  joining Welcome as a hero-layout step

Placeholder copy updated to match design examples; tests adjusted.

* fix(onboarding): questionnaire uses 3-region app-shell layout

Previous version had everything in a single scroll container with a
sticky footer. As the user scrolled into the questions, the Back
button and StepHeader progress indicator scrolled out of view, and
sticky-bottom had edge cases with width-constrained flex nesting.

Classic 3-region shell now:
- Fixed header row: Back button (left) + StepHeader progress
  indicator — persistently visible regardless of scroll position
- Scrollable middle: eyebrow / serif title / lede / 3 question
  blocks. Uses `flex-1 overflow-y-auto min-h-0` — the min-h-0 is
  the critical bit that lets a flex-1 child shrink below content
  height inside a flex column
- Fixed footer row: hint (hidden < sm) + Continue CTA — always
  reachable, never scrolled off

Right "Why we ask" panel is now an independent grid column with its
own overflow, so the two columns scroll independently instead of the
whole page having one shared scrollbar.

Side panel width reduced 520 → 480 to give the question column more
room on 1280/1366 screens where 1fr_520 left ~760px for content;
1fr_480 gives ~800-900px which comfortably fits the 620px max-w
content column plus breathing room.

* fix(onboarding): questionnaire needs DragStrip like every full-window view

Traffic lights were overlapping the StepHeader progress dots because
Step 1 escaped onboarding-flow's non-welcome wrapper (which renders
<DragStrip />) without rendering its own. The codebase convention per
packages/views/platform/drag-strip.tsx is: every full-window view
places a DragStrip as the first flex child of each visible column.

Adds DragStrip at the top of both the left (shell) and right
("Why we ask") columns, matching step-welcome.tsx which already did
this. Traffic lights now land in the 48px transparent strip with no
content collision; dragging from any top edge moves the window on
Electron; border-l between columns runs edge-to-edge.

Also made the right column's scroll container use
`min-h-0 flex-1 overflow-y-auto` so its internal scroll activates
independently of the left column.

(Separately investigated: useImmersiveMode is no longer called
anywhere in production code — the codebase has fully committed to
the DragStrip pattern. No action needed on the hook itself.)

* style(onboarding): drop top/bottom borders on questionnaire shell

* style(onboarding): use chat-style scroll fade mask instead of border

The questionnaire's scroll area now fades softly at top/bottom edges
via `useScrollFade` (already used by chat-message-list.tsx) — the
same mask-image linear-gradient pattern that fades content under the
header/footer based on scroll position:

- At top: only bottom fades (hint: more content below)
- At bottom: only top fades (hint: content above)
- In middle: both fade
- Fits entirely: no mask

This replaces the removed border-b/border-t on the header/footer with
a softer, more editorial visual separation while giving an actual
scroll-position affordance the border can't.

* feat(onboarding): show "n of 3 answered" progress next to Continue

Gives the user a glance-able progress signal as they fill the
questionnaire. Static text, no extra UI primitives, no dynamic
state variants — just `{n} of 3 answered` updating in place,
left of the Continue button.

Replaces the static "Your answers shape the next screens..." hint,
which was always there regardless of progress and added noise.

Same canContinue gate as before (all 3 answered), just derived
from the new per-question check so we don't compute validity twice.

* style(onboarding): drop redundant lede under questionnaire title

The title already conveys the "we'll handle the rest for you"
promise — the lede just rephrased it at length. Removed; bumped the
question-list top margin (mt-8 → mt-10) to keep breathing room.

* feat(onboarding): land redesigned flow + post-landing starter content opt-in

This commit bundles the final onboarding-redesign work that sat in the
working tree with today's architectural reshape of how starter content
is handled. Splitting across sqlc-regenerated files would be fragile,
so it ships as one logical unit — "onboarding is ready for production".

Flow redesign (Steps 1–5)
-------------------------
- Editorial two-column shells on Steps 1/2/3/4 (DragStrip + hero column
  + aside panel) — Welcome, Questionnaire, Workspace, Runtime, Agent
- Web-only Step 3 fork (Download desktop / Install CLI / Cloud waitlist)
  lives alongside desktop's direct runtime picker; cloud path is
  interest-capture only, doesn't advance the flow
- DragStrip extracted to packages/views/platform as a cross-platform
  component — 48px transparent drag row, no-op on web
- recommend-template.ts + test: Q1–Q3 → AgentTemplate mapping

Cloud waitlist
--------------
- Migration 052: cloud_waitlist_email VARCHAR(254) + cloud_waitlist_reason TEXT
- Handler: net/mail.ParseAddress + length bounds + reason trim
- Frontend: CloudWaitlistExpand component + api.joinCloudWaitlist

Drop persisted onboarding_current_step
--------------------------------------
- The interim implementation persisted the user's furthest-reached step;
  the final design starts every entry at Welcome, so the column is dead
- Migration 051 no longer adds it; migration 053 drops it IF EXISTS on
  any environment that ran the interim 051 — schema converges cleanly
- UserResponse / User type / patchOnboarding signature all drop the field

Post-landing starter content (new architecture)
-----------------------------------------------
Why: the old design ran bootstrap inside Step 5 (welcome issue + Getting
Started project + sub-issues, all in one try block). That had three
defects — (1) non-idempotent: Retry after partial failure created
duplicates; (2) sub-issue assignee raced listMembers → showed as
"Unknown"; (3) skipped users (paths A/C/D) never got any starter
content. All three are structural, not patchable.

New design: onboarding ends at completeOnboarding() as before (gate is
unchanged for useDashboardGuard). The 4 completion paths (Welcome skip
/ full flow / Runtime skip / Error recover) all just call
completeOnboarding() and navigate to workspace. On landing, a
StarterContentPrompt dialog renders exactly once per user
(starter_content_state == null) with Import / No thanks. The dialog is
mandatory — no X, no ESC, no outside-click — so state always ends in a
terminal value.

- Migration 054: starter_content_state TEXT, backfill 'skipped_legacy'
  for pre-feature onboarded users so they're never prompted
- Server POST /api/me/starter-content/import: transactional claim
  (NULL → 'imported') + bulk create project + optional welcome issue +
  sub-issues + pins, all in one tx. 409 Conflict on second call
- Server POST /api/me/starter-content/dismiss: transactional NULL → 'dismissed'
- Import decides agent-guided vs self-serve by inspecting the workspace's
  agent list at dialog time — fixes path A (Welcome skip + existing
  agent) which was previously excluded from starter content
- starter-content-templates.ts replaces bootstrap.ts: pure template
  builders, no API calls. Copy is reviewed as UI; server owns atomicity
- StepFirstIssue is now just completeOnboarding() + navigate; error
  surface collapses to a Retry button (no more "Continue anyway" branch)
- OnboardingCelebration + just-completed.ts removed (replaced by
  StarterContentPrompt which reads server state, not sessionStorage)

Handler hardening
-----------------
- PatchOnboarding: MaxBytesReader 16KB so the JSONB column can't be
  weaponized as bulk storage (every /api/me read returns the payload)
- JoinCloudWaitlist: net/mail format check + explicit 254-char cap
- ImportStarterContent: MaxBytesReader 64KB (templates are markdown-heavy
  but still bounded); welcome issue's agent_id verified in-workspace

Tests
-----
- Existing onboarding_test.go (waitlist) passes
- step-platform-fork.test.tsx + recommend-template.test.ts (new)
- apps/web test helpers updated for User.starter_content_state

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

* fix(onboarding): resolve Unknown assignee/creator + tighten prompt copy

Two surface issues on the post-landing starter content dialog:

1. Unknown assignee & Created by
-------------------------------
ImportStarterContent stored `member.id` (the membership row UUID) in
`assignee_id` and `creator_id` for sub-issues. That mismatched the rest
of the codebase — AssigneePicker and resolveActor in issue.go both
store `user_id` for type="member", and `useActorName.getMemberName`
looks members up by `user_id`. The mismatch meant the lookup never
matched any member and fell through to the "Unknown" fallback.

Fix: use `parseUUID(userID)` for both fields. The existing membership
check stays for the 403 signal; we just no longer need the returned
`member.ID`.

2. Dialog copy too long, button labels unclear
----------------------------------------------
Old copy was 3–4 paragraphs of instruction; users need to read less
than that to make a binary choice. Buttons "Import starter tasks" and
"No thanks" also didn't make it clear what "No thanks" actually does —
it starts a blank workspace, so say so.

New:
  - Title: "Welcome — add starter tasks?"
  - Body: one sentence describing the seeded content
  - Left button: "Start blank workspace"
  - Right button: "Add starter tasks"

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

* refactor(onboarding): server decides starter content branch

Problem: the old ImportStarterContent gated the agent-guided vs
self-serve branch on a client-supplied `welcome_issue.agent_id` or
null `welcome_issue`. The client made that decision by reading its
React Query cache of the workspace's agent list — any timing quirk
(cache not populated, stale, race with WS event) could lie to the
server, and there was no way for the server to disagree. Users with
an agent in the DB could still end up on the self-serve branch.

Fix: the server is now authoritative. The client always sends both
template arrays (agent_guided_sub_issues, self_serve_sub_issues) and
a welcome_issue_template (title + description + priority, NO agent_id).
Inside the import transaction the server runs ListAgents on the
workspace — if there's at least one agent, it picks agents[0] (same
ordering the client used: created_at ASC), uses agent_guided_sub_issues,
and creates the welcome issue assigned to that agent. Otherwise it
uses self_serve_sub_issues and skips the welcome issue.

Side effect: the Unknown assignee/creator bug is structurally gone —
no client-supplied id flows into assignee_id/creator_id for type=
"member". The server uses actorID = parseUUID(userID) everywhere,
matching resolveActor in issue.go.

Client surface also simplifies: StarterContentPrompt drops
useQuery(agentListOptions), the hasAgent check, the agentsFetched
button gate, and the branch-specific copy. Dialog description is a
single generic line ("If you already have an agent, we'll also seed
a welcome issue it replies to right away"). buildImportPayload no
longer takes an agentId parameter — one unconditional return shape.

Payload grows ~15 KB (both sub-issue arrays always present); still
well under the 64 KB MaxBytesReader cap.

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

* docs(onboarding): clarify runtime prerequisite, revert dialog agent list

Step 3 runtime (desktop step-runtime-connect.tsx) — scanning and empty
subtitles now name the local AI coding tools Multica drives (Claude
Code, Codex, Cursor, and others), so users understand a runtime alone
isn't enough: they also need one of those tools installed on the
machine. Uses "and others" rather than a closed list so we don't lock
the copy to exactly three integrations.

StarterContentPrompt dialog — reverted the short-lived "try Coding,
Planning, Writing agents and more" rewrite. That was a misread of
feedback meant for the Step 3 prerequisite, not the dialog. The
dialog's current single-sentence "how agents, issues, and context
work in Multica" is enough.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:32:33 +08:00
Kagura
1a565a221a fix(server): handle race in CompleteTask and FailTask for parallel agents 2026-04-21 19:23:31 +08:00
Bohan Jiang
536f4286f1 docs: add v0.2.11 changelog (2026-04-21) (#1447)
* docs: add v0.2.11 changelog (2026-04-21)

Combines the v0.2.9 / v0.2.10 / v0.2.11 releases (plus post-v0.2.11
main commits) into a single landing-page entry, covering the PostHog
pipeline, desktop cross-platform packaging, board pagination and the
recent inbox / agent-task / markdown fixes.

* docs: trim v0.2.11 changelog to user-visible highlights

Drop minor fixes and CLI/daemon polish items — keep only the headline
features and the visible user-facing fixes.

* docs: reprioritize v0.2.11 changelog for external readers

Drop internal MUL-/#PR references, swap in the higher-impact fixes
(daemon workspace isolation, multica update + Windows daemon, board
card description, PostHog default off) that a self-hosted user
actually notices.

* docs: drop PostHog items from v0.2.11, promote multica update to feature

Analytics plumbing is not user-perceivable; replace the PostHog feature
and the PostHog default-off fix with multica update (CLI self-update)
as a feature and keep the Windows daemon persistence as a fix.

* docs: add OpenClaw model read fix to v0.2.11 changelog
2026-04-21 17:41:27 +08:00
Bohan Jiang
c6d54e8ce5 fix(ui): replace smiley with check mark in quick emoji list (#1446)
Swap the 4th quick reply emoji 😄 for  so approval-style
acknowledgements are one tap away.
2026-04-21 17:27:40 +08:00
yushen
20c9d985f5 ci: clarify release tag filters 2026-04-21 17:24:20 +08:00
Bohan Jiang
6366e2f4ba fix(inbox): don't archive after deleting an issue (#1444)
* fix(inbox): don't archive after deleting an issue

Deleting an issue from the Inbox page was calling the archive API on the
inbox item right after deleteIssue succeeded. Because the inbox_item row
has ON DELETE CASCADE on issue_id, it was already gone by then and the
archive call 404'd with "inbox item not found", surfacing a "Failed to
archive" toast.

Drop the redundant archive call and invalidate the inbox cache through
the issue:deleted WS handler so every tab stays in sync without an extra
round trip.

* fix(inbox): keep stale selection on /inbox instead of the deleted issue

When another tab deletes the selected inbox issue, onInboxIssueDeleted
prunes the cache and `selected` becomes null. The existing fallback then
redirected to the issue detail page — which is also gone, so the user
landed on a "This issue does not exist..." screen instead of back in the
inbox list.

Track the last key that actually resolved against the inbox list. If it
used to be in the list and just disappeared, clear the selection and
stay on /inbox. Only shared links that were never in the user's inbox
continue to fall back to the issue detail page.

Also add ws-updaters tests covering onInboxIssueDeleted and
onInboxIssueStatusChanged.
2026-04-21 17:12:53 +08:00
Bohan Jiang
642844c736 feat(issues): paginate every status column, not just done (#1422)
* feat(issues): paginate every status column, not just done

Previously the workspace issues list fetched all non-done/cancelled
issues in a single unbounded `open_only=true` request and only
paginated the done column. In workspaces with many open issues this
ballooned the initial payload and skipped pagination entirely.

Restructure the issue list cache into per-status buckets
(`{ byStatus: { [status]: { issues, total } } }`) fetched in parallel,
generalize `useLoadMoreDoneIssues` into `useLoadMoreByStatus(status,
myIssuesOpts?)`, and render an infinite-scroll sentinel inside every
accordion group and kanban column. Sort and filter stay client-side,
matching the done column's existing behavior.

Backend `ListIssues` already supports per-status pagination, so no
API changes are required.

* fix(issues): handle project / hidden-column / lookup regressions from paginated list cache

After bucketing the issue list cache by status, three consumers that
treated `issueListOptions()` as a complete local index broke:

- `project-detail.tsx` filtered the workspace list by `project_id`
  client-side, so projects whose issues sat past the first 50-per-status
  page rendered empty. Switch to `myIssueListOptions(wsId,
  'project:<id>', { project_id })` so the server returns only this
  project's issues; add `project_id` to `ListIssuesParams` /
  `MyIssuesFilter` / api client.
- `board-view.tsx` HiddenColumnsPanel read counts from the in-memory
  `issues` array — a paginated fragment. Pass `myIssuesOpts` through to
  a per-row subcomponent that reads the real per-status total from the
  cache.
- `tasks-tab.tsx` and `search-command.tsx` used the list as a global
  lookup for task titles / Recent items / current-issue chrome. Switch
  both to per-id `issueDetailOptions` via `useQueries` so they're
  independent of which page the issue lands on.

Drop the now-redundant `doneTotal` override prop on BoardView/ListView
and the `allIssues` prop on BoardView (only HiddenColumnsPanel consumed
it).

Tests updated: tasks-tab now mocks `api.getIssue`; search-command mocks
`issueDetailOptions` + `useQueries`; project-issue-metrics drops the
`doneColumnCount` assertion.
2026-04-21 16:48:55 +08:00
yushen
6ecf15e62c ci: add desktop smoke build workflow 2026-04-21 16:44:19 +08:00
devv-eve
52c9bd72cb fix(desktop): unblock Windows + Linux release packaging (#1443)
Two unrelated bugs were preventing the GitHub-hosted runner desktop
release matrix from succeeding:

1. Windows job failed with `spawnSync electron-vite ENOENT`. On
   Windows the package-local binaries are `.cmd` shims and Node's
   `spawnSync` does not consult PATHEXT unless going through a shell.
   Pass `shell: true` for both the electron-vite and electron-builder
   spawns; on POSIX hosts these are real executables so the shell hop
   is harmless.

2. Linux `.deb`/`.rpm` job failed with electron-builder errors:
   `Please specify project homepage` and `Please specify author
   'email'`. fpm requires a maintainer when generating .deb, and
   electron-builder derives it from the app package.json metadata. Add
   `description`, `homepage`, `repository`, `author` (with
   email) and `license` to apps/desktop/package.json so the Linux
   targets have the metadata they need.

Refs: https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
Refs: https://www.electron.build/configuration.html#metadata

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 16:37:53 +08:00
Bohan Jiang
7ada72faa6 fix(server/task): synthesize result comment for comment-triggered tasks too (#1440)
Agents can end a comment-triggered run without calling `multica issue comment
add` — the final reply stays in terminal / run-log text and never reaches
the user, even though the run panel shows "Completed". PR #1372 addressed
this via prompt wording, but compliance is inherently best-effort.

The server already had an exact fix for the assignment-triggered branch:
`HasAgentCommentedSince` + fallback synthesis from `payload.Output`. The
comment-triggered branch was explicitly exempted on the theory that the
agent "replies via CLI with --parent, so posting here would create a
duplicate" — but that is precisely the path that's failing.

Remove the `!task.TriggerCommentID.Valid` guard so the invariant "every
completed issue task has at least one agent comment on the issue" holds for
both branches. The existing `HasAgentCommentedSince` check still prevents
duplicates for compliant agents, and `createAgentComment` already threads
the synthesized comment under `task.TriggerCommentID` when present.

Regression tests cover both:
  - comment-triggered + silent agent → synthesized comment threaded under trigger
  - comment-triggered + agent already posted → no duplicate
2026-04-21 16:09:59 +08:00
Bohan Jiang
df86f559e0 fix(desktop): default shareable URL to localhost web in dev (#1438)
The renderer's navigation adapter fell back to https://multica.ai when
VITE_APP_URL was unset (i.e. desktop dev builds), so "Copy link" in a dev
build produced a production URL instead of one pointing at the running
dev web frontend. Match the fallback used by pages/login.tsx
(http://localhost:3000) so dev links stay on the dev host.
2026-04-21 16:06:32 +08:00
Bohan Jiang
d5071abb75 fix(inbox): stop remounting IssueDetail on new comment/reaction (MUL-1199) (#1439)
The inbox detail panel keyed `<IssueDetail>` by `selected.id` (inbox-item
id). `deduplicateInboxItems` picks the most recent inbox notification per
issue, so every new `comment:created` / `reaction:added` event for the
currently open issue produced a fresh inbox item with a new id — flipping
the React key and forcing a full unmount/remount of `IssueDetail`. That
wiped the comment composer draft, dropped focus, and reset scroll.

Key on `selected.issue_id` instead: stable for the life of an open issue
(so input + scroll survive incoming events) and still changes when the
user picks a different issue (so state resets between issues, as before).
2026-04-21 16:05:23 +08:00
Naiyuan Qing
ba003eee83 fix(server/comment): remove HTML sanitizer that was corrupting Markdown (#1387) (#1436)
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:

  - "> quote"   -> "&gt; quote"     (blockquote lost, see #1303)
  - '"foo"'     -> '&#34;foo&#34;'    (literal entities visible)
  - "\n\n2." -> " 2."             (ordered list items merged into prose)

Comment content is stored as Markdown source. XSS is already handled at
two layers:

  - Render: rehype-sanitize in packages/ui/markdown and
    packages/views/editor/readonly-content (mention:// allowlist,
    data-href restricted to http(s), class restricted to
    code/div/span/pre).
  - Edit: @tiptap/markdown is configured with html:false, so Markdown
    source containing raw HTML tags is treated as plain text.

Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.

The PR #1342 workaround in the editor serializer can be dropped once
this lands.

Co-authored-by: devv-eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 15:40:30 +08:00
devv-eve
a3a6158d96 fix: harden desktop packaging PATH lookup (#1435)
Co-authored-by: Eve <eve@multica.ai>
2026-04-21 15:35:26 +08:00
Bohan Jiang
9481350ef0 fix(analytics): disable posthog-js default autocapture and recording (#1433)
posthog-js ships with autocapture, heatmaps, dead-click detection,
session recording, exception capture, and surveys all on by default.
Staging verification showed the Activity view flooded with "clicked
button" / "clicked span with text \"…\"" events — they leak
user-typed content into PostHog, burn the billed event budget, and
dilute the explicit funnel. Our product analytics surface is narrow
and intentional (see docs/analytics.md): only the events we emit
server-side plus one manual $pageview belong. Opt all the auto
surfaces off at init time so the Activity view reflects the funnel.
2026-04-21 15:11:00 +08:00
devv-eve
637bdc8eb3 feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping

Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.

Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.

* feat(server): emit signup and workspace_created analytics events

Wires analytics.Client through handler.New and main, then emits the first
two funnel events:

- signup fires from findOrCreateUser (which now reports isNew), covering
  both the verification-code and Google OAuth entry points — a single
  emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
  with is_first_workspace computed from a post-commit ListWorkspaces count
  so we can distinguish fresh-user activation from returning-user
  expansion.

Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.

* refactor(analytics): drop is_first_workspace from workspace_created

Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.

PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.

* docs(analytics): document self-host safety defaults

Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.

* feat(analytics): emit runtime_registered, issue_executed, team_invite_*

Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:

- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
  reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
  and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
  UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
  first_executed_at IS NULL flips the column for the first time. Retries,
  re-assignments, and comment-triggered follow-up tasks hit the WHERE
  clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
  buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
  from AcceptInvitation, closing the expansion funnel.

Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.

* feat(config): surface PostHog key via /api/config

Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.

* feat(analytics): wire posthog-js identify + UTM capture on the client

Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.

captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.

Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
  cookie and token paths. A getConfig/getMe race is handled by buffering
  a pending identify inside the analytics module and flushing it once
  initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
  loginWithToken and resetAnalytics() on logout so the next login merges
  cleanly without bleeding events.

* docs(analytics): describe runtime_registered, issue_executed, invite events

Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.

* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed

Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.

Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.

* fix(analytics): wire $pageview and harden signup_source cookie payload

Two frontend fixes from the PR review:

- PageviewTracker, mounted under WebProviders, fires capturePageview on
  every Next.js App Router path / query-string change. Without this the
  capturePageview helper in @multica/core/analytics was never called and
  the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
  *before* JSON.stringify, and drops the whole cookie when the serialised
  payload still exceeds 512 chars. Previously the overall slice(0, 256)
  could leave a half-JSON string on the wire that neither the backend nor
  PostHog could parse.

Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.

* fix(analytics): URL-decode signup_source cookie on read

Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.

* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding

Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.

---------

Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-04-21 14:42:52 +08:00
LinYushen
6f63fae41a feat(desktop): support macOS cross-platform packaging (#1262)
* feat(desktop): support macOS cross-platform packaging

* fix(desktop): use releaseType instead of publishingType in electron-builder publish config

publishingType is not a valid electron-builder key; the correct GitHub
provider option is releaseType. The previous value was silently ignored,
causing uploads to be skipped and breaking auto-update.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): standardize artifact naming across desktop and CLI

Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a
filename alone reveals kind, version, platform, and CPU arch.

Desktop (apps/desktop/electron-builder.yml):
  mac     → multica-desktop-<v>-mac-<arch>.{dmg,zip}
  linux   → multica-desktop-<v>-linux-<arch>.{deb,AppImage}
    (fixes `\${name}` expanding the scoped `@multica/desktop` into a
    broken `@multica/desktop-*` filename path)
  windows → multica-desktop-<v>-windows-<arch>.exe

CLI (.goreleaser.yml):
  multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz
  (adds `-cli` marker + version; switches `_` to `-` for consistency)

Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`:
  - drop mac x64 (Intel not a target yet)
  - add linux arm64
  Final: mac arm64, win x64/arm64, linux x64/arm64.

Downstream updates so install paths match the new CLI names:
  - scripts/install.sh
  - scripts/install.ps1 (URL + checksum regex)
  - CLI_INSTALL.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): use multica_{os}_{arch} CLI archive naming

Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}'
asset names. Install scripts and the desktop CLI bootstrap now resolve
assets via checksums.txt so they work without hardcoding versions.

The Go self-update path queries the GitHub release API and accepts
either the new or legacy 'multica-cli-<version>-...' names so existing
releases keep updating cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): ship both legacy and versioned CLI archive names

GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and
'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every
release. The legacy name keeps already-released CLIs self-updating; the
versioned name is what new clients should use going forward.

Self-update / install paths flipped to prefer the versioned name and
fall back to legacy:
  - server/internal/cli/update.go (multica update)
  - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap)
  - scripts/install.sh, scripts/install.ps1 (fresh install)

Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(desktop): also build Linux .rpm packages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1

Address review feedback on PR #1262:

- .github/workflows/release.yml: add a 'desktop' job that runs after the
  CLI 'release' job and packages the Desktop installers for Linux
  (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes
  them to the same GitHub Release via electron-builder. macOS Desktop
  continues to ship through the manual release-desktop skill so it can
  be signed and notarized with Apple Developer credentials.

- scripts/install.ps1: detect Windows ARM64 hosts via
  RuntimeInformation::OSArchitecture so the new windows-arm64 CLI
  archive is downloaded on ARM64 machines instead of always falling
  back to amd64.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision

electron-builder's update metadata file is hardcoded to `latest.yml` for
Windows regardless of arch (only Linux gets an arch-suffixed name; see
app-builder-lib's getArchPrefixForUpdateFile). With two separate
electron-builder invocations for Windows x64 and arm64, both publish
`latest.yml` to the same GitHub Release and the second upload silently
overwrites the first — leaving one of the two architectures with auto-
update metadata pointing at the other arch's installer.

Route Windows arm64 to its own `latest-arm64` channel:

* scripts/package.mjs appends `-c.publish.channel=latest-arm64` only
  for the Windows arm64 invocation, so x64 keeps producing `latest.yml`
  and arm64 produces `latest-arm64.yml` alongside it.
* updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows
  arm64 clients so they fetch the matching metadata file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 23:33:41 -07:00
Bohan Jiang
c5a00d8b8c fix(agent/openclaw): extract real model from meta.agentMeta.model (#1426)
OpenClaw's `--json` result blob carries the actual LLM identifier in
`meta.agentMeta.model` (e.g. `deepseek-chat`, `claude-sonnet-4`),
alongside `provider` and the usage breakdown. The backend was reading
the surrounding `agentMeta.usage` and `agentMeta.sessionId` but skipping
the `model` field entirely, then attributing every run's tokens to
`opts.Model` — which for openclaw is the *agent name* passed via
`--agent`, not a real model identifier — falling all the way through to
"unknown" when no agent.model was configured.

Surface the runtime-reported model:

- `openclawEventResult` gains a `model` string.
- `buildOpenclawEventResult` reads `agentMeta.model` (trimmed; empty
  string when absent for forward-compat with older runtimes / partial
  outputs).
- `processOutput` propagates it through the result-blob branch.
- `Execute`'s usage map prefers `scanResult.model`, falling back to
  `opts.Model` then `"unknown"` — preserving the prior behavior path
  for any runtime that doesn't surface its own model yet.

Two unit tests cover both the populated and missing cases.

Refs: #1395
2026-04-21 14:32:31 +08:00
Bohan Jiang
4ac43e9e49 feat(daemon): log agent invocation at info level (#1428)
Surface the actual exec path + argv for every agent backend at INFO
so operators can see the exact command without flipping to debug.
Also add the missing log line in pi.go for consistency with the
other nine backends.
2026-04-21 14:30:07 +08:00
devv-eve
03e21aee80 Fix skills.sh nested directory imports (#1423)
Co-authored-by: Eve <eve@multica.ai>
2026-04-20 23:11:33 -07:00
Bohan Jiang
632fdde700 fix(cli): keep Windows daemon alive after terminal closes + unblock multica update (#1420)
* fix(cli): detach daemon from parent console on Windows

CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.

* fix(cli): make `multica update` work while the binary is running on Windows

On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.

Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.

Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).

* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events

With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.

Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
2026-04-21 13:03:48 +08:00
Bohan Jiang
cc1ccedaf3 test(storage): lock S3 upload URL behavior across all env combos (#1421)
Extract the URL assembly at the end of S3Storage.Upload into a helper
(uploadedURL) so the four env-var combinations can be covered by a
table-driven test without mocking s3.PutObject. This locks in the fix
from #1300 — cdn > endpoint > bucket — so future refactors can't
silently regress the CDN-wins-over-custom-endpoint case.

No behavior change.
2026-04-21 12:57:36 +08:00
Bohan Jiang
8eb81aa396 fix(daemon): enforce workspace isolation for agent execution (#1235) (#1260)
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027
/ #1235: an agent running for workspace A ended up commenting on (and
renaming) a two-day-old issue in workspace B.

#1249/#1259 fixed resolution for autopilot tasks and consolidated the
task-workspace resolver, and #1294 populated workspace_id in the claim
response for run_only autopilot tasks. Those closed the known fallthroughs
but the failure mode is still broader: whenever the daemon or server fails
to supply a workspace, the CLI silently falls back to
`~/.multica/config.json`, which is user-global, not workspace-scoped. On a
host running daemons for multiple workspaces, a single gap in workspace
propagation is enough to leak writes across workspaces.

This PR adds three coordinated guards so no single layer's bug can cause a
cross-workspace write:

1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the
   agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env,
   both daemon-only markers) and in that context refuses to fall back to
   the user-global CLI config. Human / script usage (no agent env) is
   unchanged: flag → env → config fallback chain still applies.

2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now
   captures the runtime's workspace from `requireDaemonRuntimeAccess` and
   enforces `resolved_task_workspace == runtime_workspace` after the
   existing issue/chat/autopilot branches. On mismatch or empty, the
   handler explicitly cancels the just-dispatched task (via
   `TaskService.CancelTask`, which also reconciles agent status) and
   returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had
   already transitioned the task to 'dispatched' and the agent status to
   'working', so a plain 500 would leave both stuck for the ~5 min
   stale-task sweep window.

3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the
   agent when `task.WorkspaceID` is empty (defense-in-depth against
   server bugs and reused workdirs).

Tests:
- `cmd/multica/cmd_agent_test.go`:
  `TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests
  covering the full fallback matrix (outside agent context still reads
  config; agent context uses env; agent context with empty env returns
  empty; task-id-only marker also counts; requireWorkspaceID surfaces the
  agent-context error message).
- `internal/handler/daemon_test.go`:
  `TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` —
  constructs a data-inconsistent task (runtime_id in workspace A,
  issue_id in workspace B) and asserts the handler returns 500 AND
  leaves the task in 'cancelled' state (not 'dispatched').

Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup
workspace filter, cross-workspace audit of agent-facing endpoints,
observability) are out of scope for this PR and tracked separately.
2026-04-21 12:55:12 +08:00
Matthew Lal
965bf731ab Prefer CDN domain over raw endpoint URL in attachment links (#1300)
When both AWS_ENDPOINT_URL and CLOUDFRONT_DOMAIN are configured, the
uploaded file URL returned by S3Storage.Upload now uses the CDN domain
instead of the raw S3-compatible endpoint.

This enables S3-compatible backends (MinIO, R2, B2, Wasabi, etc.) to be
paired with a separate public-read domain — previously the CDN domain was
silently ignored whenever a custom endpoint was set, forcing clients to
hit the raw S3 API endpoint which typically requires signed requests.

No behavior change for deployments that set only one of the two vars:
pure AWS S3 with CloudFront, AWS S3 without a CDN, and MinIO/R2 without
a CDN all continue to return the same URLs as before.
2026-04-21 12:49:32 +08:00
Kagura
0db7d2fb64 fix(issues): include description in list queries for board card display (#1375) (#1377)
The ListIssues and ListOpenIssues SQL queries omitted the description
column, so the API response never included description data. Board cards
checked issue.description (always null) and never rendered it, even when
the Description card property was enabled.

Add description to both SQL queries, the generated Go structs/scan calls,
and the response mapping functions.
2026-04-21 12:20:10 +08:00
Jiayuan Zhang
4368e1be18 docs: add v0.2.8 changelog (2026-04-20) (#1418)
Summarizes recent releases (v0.2.7 → v0.2.8) on the landing page
Change Log in both en and zh.

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-21 11:45:19 +08:00
Naiyuan Qing
bb31afbbce Revert "fix(server/comment): remove HTML sanitizer that was corrupting Markdo…" (#1413)
This reverts commit 4a25b91590.
2026-04-21 09:56:58 +08:00
devv-eve
4a25b91590 fix(server/comment): remove HTML sanitizer that was corrupting Markdown (#1387)
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:

  - "> quote"   -> "&gt; quote"     (blockquote lost, see #1303)
  - '"foo"'     -> '&#34;foo&#34;'    (literal entities visible)
  - "\n\n2." -> " 2."             (ordered list items merged into prose)

Comment content is stored as Markdown source. XSS is already handled at
two layers:

  - Render: rehype-sanitize in packages/ui/markdown and
    packages/views/editor/readonly-content (mention:// allowlist,
    data-href restricted to http(s), class restricted to
    code/div/span/pre).
  - Edit: @tiptap/markdown is configured with html:false, so Markdown
    source containing raw HTML tags is treated as plain text.

Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.

The PR #1342 workaround in the editor serializer can be dropped once
this lands.

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 09:37:43 +08:00
devv-eve
9e47b83f02 feat(agent): add Kimi CLI as agent runtime (#1400)
* feat(agent): add Kimi CLI as agent runtime

Adds support for Moonshot AI's Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli)
as a new agent runtime, alongside Claude, Codex, OpenCode, OpenClaw, Hermes,
Gemini, Pi, Cursor and Copilot.

Kimi Code CLI implements the standard Agent Client Protocol (ACP) via the
`kimi acp` subcommand, so the new `kimiBackend` reuses the existing
hermesClient JSON-RPC transport in the agent package — only the binary,
client identity, log prefix, and tool-name extraction differ.

Wiring:
- server/pkg/agent: new kimiBackend + kimi_test.go; registered in New(),
  LaunchHeader map, and the supported-types coverage test.
- server/internal/daemon/config.go: probes `kimi` (overridable via
  MULTICA_KIMI_PATH / MULTICA_KIMI_MODEL).
- server/internal/daemon/execenv: writes AGENTS.md as the runtime context
  file (Kimi reads AGENTS.md natively via /init), and writes skills under
  `.kimi/skills/` so they are auto-discovered by the project-level skill
  loader.
- packages/views/runtimes: ProviderLogo gains a Kimi mark.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(agent/kimi): support per-agent model selection via ACP set_model

Wire Kimi into the model dropdown introduced in #1399:

- ListModels gets a 'kimi' case that drives the same ACP
  initialize + session/new handshake as Hermes; both share a new
  discoverACPModels helper and parseACPSessionNewModels parser
  so future ACP backends only need a small provider entry.
- kimiBackend now issues session/set_model after session/new when
  opts.Model is non-empty, mirroring the Hermes flow. Failures
  fail the task instead of silently falling back to Kimi's
  default model — silent fallback would hide that the dropdown
  pick wasn't honoured.

Verified: go build ./..., go test ./pkg/agent/... ./internal/daemon/... ./internal/handler/..., pnpm typecheck and pnpm test (138 passed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(agent): address code review feedback on Kimi runtime

- Share ACP provider-error sniffer between hermes and kimi. Previously
  only hermes promoted stderr-observed 4xx/5xx into a failed task;
  kimi would report "completed + empty output" when the Moonshot
  upstream rejected a request (expired token, rate limit, …). Rename
  hermesProviderErrorSniffer → acpProviderErrorSniffer and parameterise
  the provider name; wire it into kimiBackend.Execute the same way.
- Rename extractHermesSessionID → extractACPSessionID (shared by all
  ACP backends) so the name matches parseACPSessionNewModels.
- Drop the redundant second argument to kimiToolNameFromTitle; the
  Message struct has only one relevant field (Tool), so passing it
  twice was a dead fallback. Document that the function normalises
  residual capitalised kimi titles not caught by hermesToolNameFromTitle.
- Remove kimi-only cmd.WaitDelay override; the hermes baseline is
  fine for both and divergence adds noise.
- Add TestKimiBackendSetModelFailureFailsTask: fake `kimi acp` binary
  that returns a JSON-RPC error for session/set_model, asserts that
  the task result surfaces status=failed with the model name + upstream
  message and preserves the session id.
- Fix stale agent listings in agent.go / daemon/config.go doc comments
  (missing cursor, gemini, copilot).

All: `go build ./...`, `go vet ./...`, `go test ./pkg/agent/...
./internal/daemon/... ./internal/handler/...` green.

* fix(agent/kimi): pass --yolo so Shell tools don't hang on approval

Kimi's default config has `default_yolo = false`. Every Shell/file-mutating
tool call causes kimi acp to send a `session/request_permission` request
and block (up to 300s) waiting for a response. The daemon's hermesClient
only handles `session/update` notifications — permission requests go
unanswered, the tool call times out, and the UI loop eventually dies
("UI loop timed out"). Observed with the first real kimi task: agent sat
as Live for ~7 minutes before the daemon killed it.

The fix mirrors hermes' HERMES_YOLO_MODE=1 override: pass `--yolo` to
`kimi` so it auto-approves everything. `--yolo` is a top-level flag on
the `kimi` CLI (not a flag on `kimi acp`), so it must come before the
`acp` subcommand in argv. Added to kimiBlockedArgs so user custom_args
can't strip it.

While here, fix a related bug that made kimi tool names show up empty
in the daemon log ("tool #1: "): hermesToolNameFromTitle's fallback
returned `kind` when neither title-with-colon nor kind matched a known
tool. Kimi's ACP `tool_call` emits bare titles like "Shell" or "Read
file" with no `kind` at all, so we'd drop the title on the floor before
kimiToolNameFromTitle ever got a chance to map it. Now: preserve the
title when kind is unclassified; hermes titles always carry a colon so
this branch never fires for hermes.

Tests:
- TestKimiBackendPassesYoloFlag — fake binary that records its argv,
  asserts --yolo comes before acp.
- TestHermesToolNameFromTitle rows for bare kimi-style titles.
- Existing suite green: go build, go vet, full pkg/agent + daemon +
  handler test packages.

* fix(agent/acp): auto-approve session/request_permission from agent

The previous attempt (`kimi --yolo acp`) was a no-op. Inspected the
kimi-cli source: the `acp` Typer subcommand takes no parameters, so
flags on the root `kimi` command are dropped before `acp_main()` runs
— it's impossible to opt into YOLO mode through CLI flags for ACP.

The real fix is on our side: respond to session/request_permission.

ACP is bidirectional. When kimi runs a Shell or file-write tool, it
sends `session/request_permission` (agent → client, JSON-RPC request
with id + method) and waits up to 300s for a response. Our existing
hermesClient.handleLine only dispatched: (id + result/error) →
handleResponse, and (no id + method) → handleNotification. A request
with BOTH id and method fell through and got silently dropped — kimi
timed out, UI loop died, task sat stuck for 7 minutes.

Add handleAgentRequest: for session/request_permission, echo the id
and respond with outcome=selected, optionId=approve_for_session. The
daemon is headless; there's no user to prompt. `approve_for_session`
lets the agent remember the action so subsequent identical calls
(every Shell, every file write) skip the round-trip entirely. For any
other agent → client method, reply with standard -32601 method-not-
found so the agent doesn't block.

Also:
- Add writeMu so request() (main goroutine) and handleAgentRequest
  (reader goroutine) don't interleave JSON frames on stdin.
- Revert the `--yolo acp` flag — it's a no-op, and carrying it in
  kimiBlockedArgs gives the wrong impression that it does something.
  Comment in kimi.go now points at handleAgentRequest as the real fix.

Tests:
- TestHermesClientAutoApprovesPermissionRequest: inject a
  session/request_permission, assert the reply echoes the id and
  carries {outcome: selected, optionId: approve_for_session}.
- TestHermesClientReplesMethodNotFoundForUnknownAgentRequest: confirm
  unknown agent → client methods get JSON-RPC -32601 instead of silence.
- TestKimiBackendInvokesACPSubcommand replaces the yolo-flag assertion
  with a negative assertion: no dead --yolo / --auto-approve / -y on
  argv, since they'd pretend to do something they can't.

All: go build ./..., go vet ./..., go test ./pkg/agent/... green.

* fix(agent/acp): surface kimi tool input/output via content blocks

Kimi-cli emits tool_call and tool_call_update ACP frames with the
input/output inside a `content` array of ContentToolCallContent
blocks (shape: {type:"content", content:{type:"text", text:"..."}}),
not in the hermes-style `rawInput` map / `rawOutput` string. Our
parser only looked at rawInput/rawOutput, so the daemon recorded
empty Input and Output for every kimi tool — the execution-history
UI showed blank terminal panels even for commands that ran fine.

Add extractACPToolCallText() and a fallback in handleToolCallStart /
handleToolCallUpdate: when rawInput is nil / rawOutput is empty, pull
the text out of the content blocks. rawInput / rawOutput still take
precedence so hermes' behaviour is untouched. Terminal /
FileEditToolCallContent blocks are skipped (we have nothing to render
them as — kimi only emits TerminalToolCallContent when the client
advertises terminal capability, which we don't).

Tests:
- TestHermesClientHandleToolCallStartKimiContent — content array →
  Input.text populated.
- TestHermesClientHandleToolCallCompleteKimiContent — multi-block
  content → Output concatenated with newline separator.
- TestHermesClientHandleToolCallRawOutputTakesPrecedence — hermes
  rawOutput still wins when both are present.
- TestExtractACPToolCallText — unit coverage for the helper
  (single/multiple text blocks, terminal-block skip, empty input).

* fix(agent/acp): buffer streaming tool args so Input isn't empty in UI

kimi-cli streams tool args token-by-token via tool_call_update frames
— the initial tool_call carries an empty content block and each
subsequent in_progress update carries the cumulative JSON so far
(`{`, `{"comma`, `{"command": "echo`, …). The final completed update
then carries the tool's stdout, not the args. Observed per kimi-cli
acp/session.py::_send_tool_call{,_part,_result} and confirmed by
driving a real Shell call end-to-end: 10 in_progress frames, last
with `{"command": "echo hello world"}`, then completed with `hello
world\n`.

Our previous handleToolCallStart emitted MessageToolUse on the first
tool_call frame, capturing the empty content — so every kimi tool
appeared in the execution-history UI with a blank input. Output was
correct (fix 4335c198) but command was missing.

Changes:
- hermesClient now tracks pending tool calls per toolCallId. Hermes
  path is unchanged — rawInput is present at tool_call time, so
  emit-immediately-then-flag-emitted still fires on the initial frame.
- kimi path defers MessageToolUse until status=completed / failed.
  tool_call_update in_progress frames update the buffered argsText
  (cumulative, so overwrite); on completion we parse the accumulated
  JSON into Message.Input. Malformed JSON falls back to `{"text": …}`
  so non-JSON tool args still render.
- Orphan completion frames (no matching tool_call seen — e.g. daemon
  restarted mid-task) synthesise ToolUse from the update's own
  title/kind/rawInput so the UI still gets a header.
- extractACPToolCallText now also renders FileEditToolCallContent
  blocks as a compact header ("--- path / +++ path / (edited: N → M
  bytes)"). kimi emits these for Write / StrReplaceFile / Patch when
  the tool's display block is a DiffDisplayBlock.

Tests:
- TestHermesClientKimiStreamingToolCall: empty tool_call + 5 streaming
  in_progress + completed. Asserts no emission until complete, then
  [ToolUse(Input.command="echo hi"), ToolResult(Output="hi\n")].
- TestHermesClientKimiMalformedArgsFallback: non-JSON argsText → falls
  back to Input.text.
- TestHermesClientHandleToolCallCompleteOrphan: completed frame
  without a start → ToolUse synthesised from update's rawInput.
- TestExtractACPToolCallText: diff + new-file-diff cases.

All agent / daemon / handler test packages green.

---------

Co-authored-by: Eve <8b0578a3-cf72-4394-9e38-b328eca92463@users.noreply.multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-21 02:18:30 +08:00
Jiayuan Zhang
b291db11c2 feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.

Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.

Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.

Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
2026-04-21 00:06:34 +08:00
Bohan Jiang
824d943848 fix(auth): derive cookie Secure flag from FRONTEND_ORIGIN scheme (#1390)
The session cookie's Secure flag was tied to APP_ENV, and the
docker-compose self-host stack defaults APP_ENV to "production". On
plain-HTTP self-host deployments (LAN IP, private network) the browser
silently drops Secure cookies, leaving every subsequent /api/* call
anonymous and surfacing as 401 "auth: no token found" right after a
successful login.

Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get
Secure cookies and plain-HTTP origins get non-secure cookies the
browser will actually store. Also harden cookieDomain() against the
other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and
browsers reject. Log a one-shot warning and fall back to host-only.

Docs: correct the COOKIE_DOMAIN description (it was labelled as
CloudFront-only but applies to session cookies too) and call out the
IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and
.env.example.

Refs #1321
2026-04-20 19:53:15 +08:00
Bohan Jiang
779c72e835 fix(views): clear agent live state when switching issues (#1389)
AgentLiveCard kept its taskStates map across issueId prop changes, and
its merge logic only added newly-fetched tasks without removing stale
ones. Navigating from Issue A (with a running agent) to Issue B via
cmd+k left A's sticky agent status card pinned on B's page.

Key AgentLiveCard and TaskRunHistory by issue id so React remounts
them when the issue changes, guaranteeing fresh state per issue.

Closes MUL-1147
2026-04-20 18:47:46 +08:00
Jiayuan Zhang
e830575efc feat(issues): add expand toggle to comment and reply editors (#1386)
Mirrors the new-issue modal's expand behavior on the inline comment and
reply editors so users can compose long text without feeling cramped.
2026-04-20 18:19:40 +08:00
Bohan Jiang
193046fabc docs: add v0.2.7 changelog (2026-04-18) (#1385)
* docs: add v0.2.7 changelog entry (2026-04-18)

* docs: trim v0.2.7 changelog to headline items
2026-04-20 17:49:22 +08:00
Bohan Jiang
c76c790b32 fix(daemon/execenv): make posting result comment an explicit workflow step (#1372)
Agents were silently finishing tasks without ever posting results to the
issue — their final reply stayed in terminal/log output only. See MUL-1124.

Root cause: the injected CLAUDE.md / AGENTS.md put "post a comment with
results" inside the body of step 4 (a nested clause in the default workflow
description), so skill-driven flows jumped straight from "do the work" to
`status in_review`.

- Hoist posting the result comment into its own explicit, numbered step in
  both assignment-triggered and comment-triggered workflows, with the exact
  `multica issue comment add` invocation inlined.
- Add a hard warning at the top of the Output section that terminal / chat
  text is never delivered to the user.
- Add regression test covering both workflow branches.
2026-04-20 17:48:06 +08:00
LinYushen
07034f4455 feat(server): configurable pgxpool size with sane defaults (#1381)
* feat(server): configurable pgxpool size with sane defaults

pgxpool.New(ctx, url) silently sets MaxConns = max(4, NumCPU). On the
prod pods that resolved to 4, which got fully saturated by daemon
claim/heartbeat traffic (~3800 acquires/s) and showed up as ~900ms
acquire waits on every query — the actual root cause of the 3s+
/tasks/claim tail latency. The db pool stats logging from #1378
confirmed this with empty_acquire_delta == acquire_count_delta.

Switch to pgxpool.ParseConfig + NewWithConfig and apply per-pod
defaults of MaxConns=25 / MinConns=5, both overridable via env vars
(DATABASE_MAX_CONNS / DATABASE_MIN_CONNS) so the size can be tuned
in prod without a redeploy.

The defaults follow the standard 'small pool, lots of waiters' guidance
for Postgres (PG community / HikariCP formula
`(core_count * 2) + effective_spindle_count`); 25 leaves headroom for
bursts and occasional long queries while staying safely under typical
managed-Postgres max_connections ceilings when multiplied across pods.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): respect DATABASE_URL pool_* params; add precedence tests

Address review feedback on #1381:

- Configuration precedence is now explicit: DATABASE_MAX_CONNS env >
  pool_max_conns query param on DATABASE_URL > built-in default. Same
  for min_conns. Previously the env-empty path unconditionally
  overwrote whatever ParseConfig had read from the URL — a silent
  regression for deployments that already tuned pool size via the
  connection string.
- Add unit tests in dbstats_test.go covering each precedence branch
  (defaults, URL-only, env-over-URL, partial URL, invalid env,
  min>max clamp).
- Move pool tuning vars out of 'Required Variables' into a new
  'Database Pool Tuning (Optional)' section in SELF_HOSTING_ADVANCED.md
  so self-hosters don't think they need to set them.
- Add commented entries in .env.example.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(server): invalid pool env falls back to URL/code default, never pgx 4

Address second round of review on #1381:

Previous code passed cfg.MaxConns / cfg.MinConns as the envInt32 fallback,
which meant an invalid DATABASE_MAX_CONNS value silently fell back to
ParseConfig's value — i.e. pgx's built-in default of 4/0 when the URL had
no pool_* params. That's exactly the bad value this PR exists to remove,
and the previous test (TestPoolSizing_InvalidEnvFallsBack) accidentally
locked it in.

Compute the non-env fallback first (URL pool_* if present, else code
default 25/5) and pass that to envInt32. Misconfigured env now lands on
the same value as if the env were unset — never on the pgx default.

Replace the loose 'max > 0' assertion with two precise tests:
- invalid env + no URL param → code default (25/5)
- invalid env + URL param    → URL value

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 17:07:19 +08:00
LinYushen
9fa08fb16a chore(server): log pgxpool config + periodic stats to confirm pool exhaustion (#1378)
After merging the per-phase claim slow-logs (#1376), the prod data showed
the smoking gun: unrelated endpoints (claim, heartbeat, /api/workspaces,
ping) all completed at the *same wall-clock instant* with durations
clustered at ~1.4s and ~2.88s — and within the claim breakdown,
list_pending_ms was 713ms even when list_pending_count=0.

A 0-row indexed scan can't take 713ms, and unrelated endpoints don't
synchronize their completion by accident. The only explanation that fits
is requests blocking on a shared resource and being released together.
The most likely culprit is pgxpool connection-acquire wait: pgxpool.New
is called with no config, so MaxConns defaults to max(4, NumCPU) — under
the daemon poll fan-in this is trivially exhausted.

This change adds the observability needed to confirm/refute that without
changing pool sizing yet (pool sizing is a follow-up once we have data):

- logPoolConfig: prints MaxConns / MinConns / MaxConnLifetime /
  MaxConnIdleTime / HealthCheckPeriod once at startup. Surfacing the
  effective limit is critical because the default is surprisingly small
  and easy to mistake for 'database is slow'.

- runDBStatsLogger: samples pool.Stat() every 15s (matches daemon
  heartbeat cadence for easy correlation). Emits INFO with TotalConns /
  AcquiredConns / IdleConns and per-window deltas (acquire_count,
  empty_acquire, canceled_acquire, avg_acquire_ms). Auto-upgrades to
  WARN whenever empty_acquire_delta > 0 or canceled_acquire_delta > 0
  — those are the direct symptom of a request having to wait because
  no idle connection was available.

If on prod we see 'db pool pressure' WARN lines coincident with the
claim_endpoint slow lines, the hypothesis is confirmed and the fix
becomes a one-liner (pool config tuning + the existing N+1 reduction
ideas to lower demand).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:36:49 +08:00
LinYushen
cf74327aa6 chore(server): add slow-path timing logs for /tasks/claim (#1376)
* chore(server): add slow-path timing logs for /tasks/claim

We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.

Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:

- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
  build_ms so we can tell whether the slowness is in the actual claim
  query or the post-claim response assembly (GetAgent, LoadAgentSkills,
  GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
  branch's 4 queries).

- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
  list_pending_count, agents_tried, claim_loop_ms — directly validates
  the suspected N+1 amplification (one ListPendingTasksByRuntime + one
  ClaimTask per unique agent).

- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
  claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
  LOCKED cost from the surrounding metadata reads.

Pure observability change. No behavior change in the request path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(server): widen claim slow-log to cover post-claim DB work and error paths

Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.

Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
  and dispatch_ms phase fields. Error paths now also emit a slow log
  with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
  outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
  error paths now also carry phase timings (outcome=unauth / error_claim).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:21:32 +08:00
Bohan Jiang
951f51408a fix(agent/comments): prevent resumed sessions from reusing stale --parent UUID (#1374)
* fix(agent/comments): re-emit trigger comment id every turn + server-side parent_id guard

Resumed Claude sessions keep prior turns' tool calls in context, so a
comment-triggered task could reuse the PREVIOUS turn's --parent UUID
instead of the current trigger's. The reply landed in the wrong thread
(MUL-1125): backend stored exactly what the agent sent, but the agent
pulled a stale UUID from its own conversation memory.

Two layers of defense:

1. Extract BuildCommentReplyInstructions so daemon.buildCommentPrompt
   and execenv.InjectRuntimeConfig emit the same "use this exact
   --parent, do not reuse values from previous turns" block. The
   per-turn prompt now carries the current TriggerCommentID, which it
   previously relied on CLAUDE.md for (and CLAUDE.md isn't re-read
   mid-session).

2. Handler-side guard in CreateComment: when an agent posts from inside
   a comment-triggered task (X-Agent-ID + X-Task-ID, task has
   TriggerCommentID), require parent_id == task.TriggerCommentID or
   return 409. Assignment-triggered tasks are untouched.

* fix(agent/comments): scope parent_id guard to the task's own issue

Two issues from CI + GPT-Boy's review:

1. Guard was too broad: the CLI stamps X-Task-ID on every request, so an
   agent legitimately commenting on a different issue while its current
   task was comment-triggered would get 409'd with the wrong issue's
   trigger comment id. Narrow the guard to fire only when the request's
   issue matches the task's own issue — cross-issue agent activity
   stays unblocked.

2. The integration test tried to insert a second queued task for the
   same (agent, issue), which hits the idx_one_pending_task_per_issue_agent
   unique index. Replace the assignment-triggered-task sub-case with a
   cross-issue regression test (the scenario we now need to cover anyway):
   post on issue B while X-Task-ID points at a comment-triggered task on
   issue A, expect 201.
2026-04-20 15:56:16 +08:00
Bohan Jiang
be78b66e4e feat(autopilot): multi-select days in weekly trigger config (#1368)
Replace the single day picker in the "Weekly" frequency with a multi-select
so users can schedule on any combination of weekdays (e.g. Mon/Wed/Fri)
in addition to the existing "Weekdays" Mon-Fri preset.

The backend already accepts any day-of-week list in the cron expression,
so this is a frontend-only change. Relabels the tab to "Days" to reflect
the new behavior.
2026-04-20 15:01:36 +08:00
Bohan Jiang
ec73710dd2 fix(agent/codex): surface stderr tail in initialize / turn startup errors (#1314)
* fix(agent/codex): surface stderr tail in initialize / turn startup errors

When codex app-server exits before the JSON-RPC handshake completes —
e.g. because the user put a flag in custom_args that the subcommand
rejects — the Result.Error users see is `codex initialize failed:
codex process exited`, while codex's actual complaint (typically
something like `error: unexpected argument '-m' found`) only lives in
daemon logs.

Wrap the stderr writer with a bounded stderrTail that still forwards
to the slog logWriter but also retains the last 2 KiB of bytes
written. Include that tail on the three startup failure paths
(initialize, startOrResumeThread, turn/start). Runtime cancellation
paths are left untouched — they're our own abort and the stderr
context isn't a clear signal there.

Refs #1308. Complement to #1310 / #1312 — lets "bad custom_args fail
loudly" actually be workable by giving the failure a real message.

* fix(agent/codex): join cmd.Wait() before sampling stderr tail

Addressing review of #1314: reading stderrBuf.Tail() right after
c.request returns "codex process exited" was racy. Nothing in that
path synchronizes with os/exec's internal stderr copy goroutine —
cmd.Wait() is the only documented join point. The original defer ran
cmd.Wait() later, but by then we had already built Result.Error from
a potentially-empty Tail().

Replace the ad-hoc deferred stdin.Close()/cmd.Wait() with a
sync.Once-wrapped drainAndWait closure. Call it explicitly on the
three startup failure paths before sampling the tail; keep it as the
cleanup defer so the success path behaves identically.

Also add TestCodexExecuteSurfacesStderrWhenChildExitsEarly: spawns a
real subprocess that prints to stderr and exits before responding to
initialize, runs it through Execute, and asserts Result.Error
contains the stderr hint. This covers the full timing path the
reviewer flagged, which the helper-level tests in this PR did not.
2026-04-20 14:38:32 +08:00
Bohan Jiang
62a7c05589 feat(desktop): hourly update poll + manual check button in settings (#1366)
* feat(desktop): hourly update poll + manual check button in settings

The previous updater only ran one check 5s after launch, so a missed
or failed initial check meant the user had to fully restart the app to
see a new release. Add a 1h background poll for long sessions and a
"Check now" button under a new Updates tab in Settings so the user can
trigger a check on demand without waiting.

The button reuses the existing autoUpdater pipeline — when an update is
available the existing corner notification still drives the download
flow; the settings tab only surfaces the immediate check result
(up-to-date / available / error).

* fix(desktop): trust electron-updater's isUpdateAvailable for the manual check

Per review: deriving `available` from a version-string compare is wrong —
`updateInfo.version` can differ from `app.getVersion()` while
electron-updater still suppresses `update-available` (pre-release channels,
staged rollouts, downgrade scenarios, min-system-version gates). In those
cases the settings tab would say "vX is available" but no corner download
prompt would ever appear. Use `result?.isUpdateAvailable` instead, which is
electron-updater's own answer.
2026-04-20 14:32:54 +08:00
devv-eve
c0be1b7ce9 fix(slugs): audit admin/multica/new/www + reserve in slug list (MUL-972) (#1359)
Follow-up to PR #1188 / migration 047, which intentionally omitted the
five historical conflict slugs (admin / multica / new / setup / www) from
the reserved-slug audit because each had one production workspace using
it at the time and we did not want to block deploy on owner outreach.

MUL-972 closed that loop on prd for four of the five:

  * admin   (99cd10e4-…) → renamed to legacy-admin-99cd10e4
  * multica (dcd796aa-…) → renamed to legacy-multica-dcd796aa
  * new     (e391e3ed-…) → renamed to legacy-new-e391e3ed
  * www     (5e8d38b2-…) → workspace deleted (was empty: 0 issues /
                           projects / agents, owner-only member; 18
                           workspace-FK relations all CASCADE)

This PR:

1. Adds migration 049_audit_legacy_reserved_slugs which audits those
   four slugs against workspace.slug at startup. If any future workspace
   slips in with one of them, startup fails loudly via RAISE EXCEPTION
   instead of being silently shadowed by a global route. Mirrors the
   structure of 047.

2. Adds 'multica' / 'www' / 'new' to the reserved-slug allow-deny list
   in both the Go handler and the shared TS list (admin was already in
   both). Keeps the two lists in lockstep per the convention enforced
   in workspace_reserved_slugs.go header.

setup is STILL exempt from the audit and is intentionally NOT added to
the reserved list. The setup workspace (b43f0bc2-…) is a real production
user (owner: Roberto Betancourth, building a chants/Alabanzas app) and
is being handled out-of-band via owner outreach. A separate follow-up
migration will fold setup into the audit once that workspace's slug has
been migrated.

Migration is intentionally shipped AFTER the prd data fix (not before):
049 will RAISE EXCEPTION on any remaining conflict, so we want the data
state clean first. Rollout order:
  prd data fix (done by db-boy on 2026-04-20) → this PR.

Tested:
  - go test ./server/internal/handler/ -run TestReserved → pass
  - pnpm --filter @multica/core test consistency → pass (4/4 in
    consistency.test.ts; global-prefix↔reserved invariant holds)

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-19 23:21:31 -07:00
Bohan Jiang
4ce3e5ddf4 fix(auth): hand off session to Desktop when web is already logged in (#1364)
When Desktop opens /login?platform=desktop in the browser and the user
already has a valid web session, the page previously bounced them to
their workspace and Desktop never received a token. Now we mint a bearer
token via issueCliToken and redirect through the multica:// deep link so
Desktop completes sign-in without a second Google round-trip.

Refs: MUL-1080
2026-04-20 14:12:32 +08:00
Bohan Jiang
bd445782d5 fix(openclaw): stop passing unsupported flags and actually deliver AgentInstructions (#1362)
Fixes #1332.

Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:

1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
   any agent configured with a Model field crashed in ~700ms with
   `exit status 1`. Remove both forwards, and add them to
   openclawBlockedArgs so custom_args can't reintroduce the crash.
   Model is bound at registration time via `openclaw agents
   add/update --model`.

2. AgentInstructions were written to `{workDir}/AGENTS.md` by
   execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
   from its own workspace dir — the file was never read, so every
   agent's Instructions field was silently discarded. Populate
   opts.SystemPrompt for the openclaw provider in runTask and
   prepend it to the `--message` payload in the backend so the
   model actually receives the instructions.

Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.

Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
2026-04-20 14:01:41 +08:00
devv-eve
5fa1da448f fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures

The chat 'forgets earlier messages' bug came from PriorSessionID being
silently lost in several edge cases:

- UpdateChatSessionSession unconditionally overwrote chat_session.session_id,
  so any task that completed without a session_id (early agent crash,
  missing result) wiped the resume pointer to NULL.
- CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A
  follow-up chat message claimed in between resumed against a stale (or
  NULL) session and started over.
- FailAgentTask never wrote session_id back, so a task that established
  a real session before failing lost its resume pointer.
- ClaimTaskByRuntime only trusted chat_session.session_id and never
  fell back to the existing GetLastChatTaskSession query, so a single
  bad turn could permanently drop the conversation memory.

This change:

- Use COALESCE in UpdateChatSessionSession so empty inputs preserve the
  existing pointer; surface DB errors instead of swallowing them.
- Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside
  the same transaction (TaskService now takes a TxStarter).
- Extend FailAgentTask + the daemon FailTask path (client, handler,
  service) to forward session_id/work_dir, so failed/blocked tasks that
  built a real session still record it.
- Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the
  chat_session pointer is missing, and include failed tasks in that
  lookup so a single failure can't lose the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): forward session_id/work_dir on blocked + timeout paths

runTask previously dropped result.SessionID and env.WorkDir on the
non-completed return paths:

- timeout returned a naked error, so handleTask called FailTask with
  empty session info and the chat resume pointer was either left stale
  or eventually overwritten with NULL.
- blocked / failed (default branch) returned a TaskResult without
  SessionID / WorkDir, so even though FailTask now COALESCEs into
  chat_session, there was no value to write through.
- the empty-output completion path was the same: it raised an error
  even when a real session_id had been built.

All three paths now return a TaskResult that carries the SessionID /
WorkDir the backend produced. Combined with the COALESCE-based update
in UpdateChatSessionSession and the FailTask plumbing introduced in
PR #1360, the next chat turn can always resume from the latest agent
session — even when the previous turn timed out, was rate-limited, or
returned an empty completion — instead of starting over with no memory
of the conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): capture session id from session.start as fallback

The Copilot backend only read sessionId from the synthetic 'result'
event, ignoring the one already present on session.start. When the CLI
was killed before result arrived (timeout, cancel, crash, or a
session.error mid-turn), the daemon reported SessionID="" and the
chat-session resume pointer could not advance — causing the chat to
silently drop conversation memory on the next turn.

Capture session.start.sessionId into state up front, and only let
'result' overwrite it when it actually carries one. result still wins
when present (it is the authoritative end-of-turn record).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): parse premiumRequests as float to preserve session id

Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5),
not an integer. Our copilotResultUsage struct typed it as int, which
made the entire 'result' line fail json.Unmarshal — silently dropping
sessionId on every turn.

This was the real cause of chat memory loss: the daemon reported
SessionID="" to the server, chat_session.session_id stayed NULL, and
the next chat turn never received --resume <id>, so each turn started
a fresh Copilot session with no prior context.

Add a regression test using the real JSON line from CLI v1.0.32 that
asserts sessionId is preserved when premiumRequests is fractional.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-19 22:50:33 -07:00
mike.xu
556c68292f fix(cli): use rundll32 instead of cmd start on Windows (#1202)
Windows 下 CLI 登录用 cmd /c start 打开浏览器,cmd.exe 会把 URL 中的 & 解释为命令分隔符,导致 OAuth 回调 URL 中 &state=... 和 &cli_state=... 参数被截断。

改用 rundll32 url.dll,FileProtocolHandler,将 URL 原样传递给操作系统 shell 处理程序,不对特殊字符做任何解释。

Authored-by: xushi-mike <xushi_1983@hotmail.com>
2026-04-20 13:43:23 +08:00
Bohan Jiang
96ee5bba52 docs(selfhost): surface APP_ENV + 888888 gating in .env.example (#1361)
The v0.2.6 self-host security fix (#1307) defaults APP_ENV to "production"
in docker-compose.selfhost.yml, which disables the 888888 master verification
code. The follow-up docs PR (#1313) covered SELF_HOSTING.md and the
installers, but `.env.example` — the file users actually copy and edit —
still makes no mention of APP_ENV, so operators who don't read the prose
docs hit the exact same "888888 stopped working after upgrade" confusion
reported in #1331.

- Add APP_ENV= to .env.example with a comment block that spells out the three
  cases (Docker default, local dev, evaluation) and warns against enabling
  the bypass on public instances. Keeping the value empty preserves the
  current `make dev` UX (Go server reads empty → treats as non-production →
  888888 works locally) while `${APP_ENV:-production}` in the compose file
  still ensures public Docker deployments are safe by default.
- Update the existing 888888 mention under # Email so it no longer
  contradicts the new gating rule.
- Update the `make selfhost` post-start banner, which still told operators
  to "Log in with any email + verification code: 888888" even after #1307
  disabled that path by default.
2026-04-20 13:26:42 +08:00
Jiayuan Zhang
2ab89d4690 feat(editor): create sub-issue from selected text in bubble menu (#1348)
Adds a "Create sub-issue from selection" button to the editor bubble
menu. When an issue context is present (description editor, comment
input, reply input, comment edit), selecting text and clicking the
button creates a new issue parented under the current issue and
replaces the selection with a mention link to the new issue.
2026-04-20 12:40:20 +08:00
Azaan Ali Raza
b428f36ca6 feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930

- Added environment variables to control signups
- Updated frontend to hide signup text when disabled
- Added backend check to block new user creation via magic link
- Updated .env.example
2026-04-19 21:02:42 -07:00
Jiayuan Zhang
239ce3d40f fix(editor): blur ContentEditor on Escape (#1338)
ESC did nothing inside the issue description editor because browsers
don't blur contenteditable elements by default, leaving users stuck in
the editor with no keyboard escape hatch.

Add a blur-shortcut extension mirroring TitleEditor's behavior and wire
it into ContentEditor's edit-mode extension set.
2026-04-20 10:17:32 +08:00
Jiayuan Zhang
a7e9801c83 feat(views): show issue title in detail page header (#1344)
Previously the issue detail top bar only showed 'workspace name > identifier'.
Add the issue title next to the identifier so users can see what issue they're
viewing without scrolling.
2026-04-20 00:36:10 +08:00
467 changed files with 38946 additions and 11981 deletions

View File

@@ -4,8 +4,23 @@ POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
# You can also set pool_max_conns / pool_min_conns as query params on
# DATABASE_URL; env vars below take precedence over URL params.
# DATABASE_MAX_CONNS=25
# DATABASE_MIN_CONNS=5
# Server
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
# "production" by default, so 888888 is DISABLED — a public instance can't
# be logged into with any email + 888888.
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
# - Docker self-host on a private network you fully control, or evaluation
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
# enable on a publicly reachable instance.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
@@ -21,17 +36,28 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Self-host image channel
# Default stable release channel. Pin to an exact release like v0.2.4 if you
# want to stay on a specific version. If the selected tag has not been
# published to GHCR yet, use make selfhost-build / the build override instead.
MULTICA_IMAGE_TAG=latest
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
# changing it only requires restarting the backend / compose stack. No web
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=
@@ -40,6 +66,13 @@ CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
# Leave empty for single-host deployments (localhost, LAN IP, or a single
# hostname) — session cookies become host-only, which is what the browser
# wants. Only set it when the frontend and backend sit on different
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
# to an IP address: RFC 6265 forbids IP literals in the cookie Domain
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
@@ -63,3 +96,25 @@ NEXT_PUBLIC_WS_URL=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this
# only requires restarting the backend / compose stack — not rebuilding web.
# It is not hot-reloaded.
# Optional: Only allow emails from these domains (comma-separated)
ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=
# ==================== Analytics (PostHog) ====================
# Product analytics events feed the acquisition → activation → expansion funnel.
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=

59
.github/workflows/desktop-smoke.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Desktop Smoke Build
on:
workflow_dispatch:
permissions:
contents: read
jobs:
desktop:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish never
- name: Upload Desktop artifacts (${{ matrix.target }})
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.target }}
path: apps/desktop/dist
if-no-files-found: error

View File

@@ -3,15 +3,21 @@ name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
# GitHub Actions uses glob patterns here, not regex. Match versioned
# tags broadly at the trigger layer, then enforce strict semver below.
- "v*.*.*"
- "!v*-dirty*"
permissions:
contents: write
packages: write
jobs:
release:
verify:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.release_meta.outputs.tag_name }}
is_stable: ${{ steps.release_meta.outputs.is_stable }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -19,13 +25,25 @@ jobs:
fetch-depth: 0
- name: Validate tag name
id: release_meta
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
exit 1
fi
if [[ "$tag" == *-dirty* ]]; then
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
if [[ "$tag" == *-* ]]; then
echo "is_stable=false" >> "$GITHUB_OUTPUT"
else
echo "is_stable=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
uses: actions/setup-go@v5
@@ -36,6 +54,21 @@ jobs:
- name: Run tests
run: cd server && go test ./...
release:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -44,3 +77,298 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# Multi-arch images are built natively per platform on dedicated runners
# (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm) and merged into a
# manifest list. This avoids QEMU emulation, which was making the Next.js
# arm64 build run for 30+ minutes per release.
docker-backend-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute backend image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
labels: |
org.opencontainers.image.title=Multica Backend
org.opencontainers.image.description=Multica self-hosted backend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-backend-${{ env.PLATFORM_PAIR }}
build-args: |
VERSION=${{ needs.verify.outputs.tag_name }}
COMMIT=${{ github.sha }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-backend,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-backend-merge:
needs: [verify, docker-backend-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-backend-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute backend image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-backend@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-backend:${{ steps.meta.outputs.version }}
docker-web-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute web image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
labels: |
org.opencontainers.image.title=Multica Web
org.opencontainers.image.description=Multica self-hosted web frontend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-web-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-web-${{ env.PLATFORM_PAIR }}
build-args: |
REMOTE_API_URL=http://backend:8080
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-web,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-web-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-web-merge:
needs: [verify, docker-web-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-web-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-web-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute web image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-web@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it
# can be signed + notarized with Apple Developer credentials that are
# not (yet) wired into CI.
desktop:
needs: release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# electron-builder's GitHub publisher reads this:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable code signing on Linux/Windows for now — the public
# release is unsigned for these platforms, the CLI carries the
# trust boundary. Set CSC_LINK in repo secrets to enable
# Windows signing later.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always

1
.gitignore vendored
View File

@@ -57,3 +57,4 @@ _features/
server/server
data/
.kilo
.idea

View File

@@ -21,12 +21,12 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: default
# Legacy archive name kept so already-released CLIs (whose `multica update`
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
# once those versions are no longer in use.
- id: legacy
formats:
- tar.gz
format_overrides:
@@ -34,6 +34,16 @@ archives:
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
# Versioned archive name used by current CLI / install scripts /
# desktop bootstrap going forward.
- id: versioned
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
checksum:
name_template: "checksums.txt"
@@ -48,6 +58,8 @@ changelog:
brews:
- name: multica
ids:
- versioned
repository:
owner: multica-ai
name: homebrew-tap

85
.vercelignore Normal file
View File

@@ -0,0 +1,85 @@
# Deploy the frontend apps from the monorepo root.
# Keep apps/web, apps/docs, shared packages, and root workspace metadata.
# Exclude unrelated workspaces and local artifacts that can make
# `vercel deploy` upload far more than the app needs.
.agent_context
.claude
.context
.env*
.envrc
.tool-versions
_features
.kilo
.idea
.DS_Store
.husky
.vscode
/.dockerignore
/.goreleaser.yml
/AGENTS.md
/CLAUDE.md
/CLI_AND_DAEMON.md
/CLI_INSTALL.md
/CONTRIBUTING.md
/Dockerfile
/Dockerfile.web
/HANDOFF_ARCHITECTURE_AUDIT.md
/Makefile
/README.md
/README.zh-CN.md
/SELF_HOSTING.md
/SELF_HOSTING_ADVANCED.md
/SELF_HOSTING_AI.md
/docker-compose*.yml
/playwright.config.ts
/skills-lock.json
/.github/
/docker/
/docs/
/e2e/
/server/
/apps/desktop/
/scripts/
*.log
*.pid
*.tsbuildinfo
.cache
.next
.pnpm-store
.turbo
.vercel
coverage
test-results
playwright-report
data
node_modules
bin
dist
out
build
dist-electron
# Deployment-only trims: tests and lint configs are not used by `next build`.
**/__tests__/**
**/test/**
**/*.test.*
**/*.spec.*
/packages/eslint-config/
/apps/web/components.json
/apps/web/eslint.config.mjs
/apps/web/vitest.config.ts
# Root repo metadata not needed in the deployment source.
/.env.example
/.gitattributes
/.gitignore
/LICENSE
*.app
*.dmg

View File

@@ -106,6 +106,7 @@ pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
```
### CI Requirements
@@ -190,55 +191,28 @@ Every path in the desktop app falls into exactly one category. Choosing the wron
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace identity singleton
### Workspace context
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
1. API client's `X-Workspace-Slug` header.
2. Zustand per-workspace storage namespace.
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order:
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list (no extra fetch).
1. Read destination from cached workspace list.
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
3. `navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
Reversing step 4 with steps 13 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS window-move)
### Drag region (macOS)
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via `useImmersiveMode` in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
**Pattern**: flex child at top, not absolute overlay.
```tsx
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
{/* page content — interactive elements need their own "no-drag" */}
</div>
</div>
```
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Height matches `MainTopBar` (48px / `h-12`) for consistency.
Canonical examples: `components/window-overlay.tsx`, `pages/login.tsx`.
### UX vs platform chrome
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (drag strip, `useImmersiveMode`, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules

View File

@@ -76,7 +76,8 @@ fi
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
VERSION="${LATEST#v}"
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${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

View File

@@ -592,6 +592,19 @@ If you want to stop PostgreSQL and keep your local databases:
make db-down
```
If you want a fresh database for the current checkout only (drops the
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
```bash
make stop # stop backend/frontend first
make db-reset
make start
```
- only affects the current env's database; other worktree databases are untouched
- refuses to run if `DATABASE_URL` points at a remote host
- pass `ENV_FILE=.env.worktree` to target a specific worktree
If you want to wipe all local PostgreSQL data for this repo:
```bash

View File

@@ -36,11 +36,11 @@ 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
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_APP_VERSION=dev
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

166
Makefile
View File

@@ -1,4 +1,4 @@
.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
.PHONY: help makehelp 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 db-reset selfhost selfhost-build selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,10 +36,23 @@ define REQUIRE_ENV
fi
endef
# ---------- Self-hosting (Docker Compose) ----------
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
##@ Help
help: ## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
makehelp: help ## Alias for `make help`
# ---------- Self-hosting (Docker Compose) ----------
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -51,8 +64,16 @@ selfhost:
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
echo " make selfhost-build"; \
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml up -d
@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 \
@@ -66,7 +87,11 @@ selfhost:
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in with any email + verification code: 888888"; \
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -77,16 +102,57 @@ selfhost:
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
# Stop all Docker Compose self-host services
selfhost-stop:
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
@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 "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.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: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
##@ One-click
# First-time setup: install deps, start DB, run migrations
setup:
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
@@ -97,8 +163,7 @@ setup:
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
# Start all services (backend + frontend)
start:
start: ## Start backend and frontend for the current checkout and run migrations first
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@@ -112,8 +177,7 @@ start:
pnpm dev:web & \
wait
# Stop all services
stop:
stop: ## Stop backend and frontend processes for the current checkout
$(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@@ -125,33 +189,52 @@ stop:
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
# Full verification: typecheck + unit tests + Go tests + E2E
check:
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up:
db-up: ## Start the shared PostgreSQL container used by main and worktrees
@$(COMPOSE) up -d postgres
db-down:
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
@$(COMPOSE) down
worktree-env:
# Drop + recreate the current env's database, then run all migrations.
# Use for a clean slate in local dev. Only affects the DB named in
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
# worktree DBs are untouched. Refuses to run against a remote host.
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
$(REQUIRE_ENV)
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
esac
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
@bash scripts/init-worktree-env.sh .env.worktree
setup-main:
setup-main: ## Prepare the main checkout using .env
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main:
start-main: ## Start the main checkout using .env
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main:
stop-main: ## Stop the main checkout processes defined by .env
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main:
check-main: ## Run the full verification pipeline for the main checkout
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
@@ -160,65 +243,68 @@ setup-worktree:
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
start-worktree: ## Start this worktree using .env.worktree
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree:
stop-worktree: ## Stop this worktree's backend and frontend processes
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree:
check-worktree: ## Run the full verification pipeline for this worktree
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
##@ Individual commands
# One-command dev: auto-setup env/deps/db/migrations, then start all services
dev:
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
@bash scripts/dev.sh
# Go server only
server:
server: ## Run only the Go server for the current checkout
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon:
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
cli:
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
multica:
multica: ## Run the multica CLI entrypoint directly from the Go source tree
cd server && go run ./cmd/multica $(MULTICA_ARGS)
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:
build: ## Build the server, CLI, and migrate binaries into server/bin
cd server && go build -o bin/server ./cmd/server
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:
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database
migrate-up:
##@ Database
migrate-up: ## Create the target DB if needed, then apply database migrations
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down:
migrate-down: ## Create the target DB if needed, then roll back database migrations
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc:
sqlc: ## Regenerate sqlc code
cd server && sqlc generate
# Cleanup
clean:
##@ Cleanup
clean: ## Remove generated server binaries and temp files
rm -rf server/bin server/tmp

View File

@@ -85,7 +85,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
---

View File

@@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
@@ -54,6 +54,10 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
@@ -69,6 +73,8 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
### Step 3 — Install CLI & Start Daemon
@@ -156,14 +162,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Rebuilding After Updates
## Upgrading
```bash
git pull
make selfhost
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -186,6 +193,7 @@ JWT_SECRET=$(openssl rand -hex 32)
Then start everything:
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```

View File

@@ -14,6 +14,15 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `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` |
### Database Pool Tuning (Optional)
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
@@ -33,6 +42,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -44,7 +65,14 @@ For file uploads and attachments, configure S3 and CloudFront:
| `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 |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server
@@ -218,7 +246,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -234,15 +262,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then rebuild:
Then restart the stack:
```bash
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml up -d
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
## Health Check
@@ -258,8 +286,9 @@ Use this for load balancer health checks or monitoring.
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.

View File

@@ -21,23 +21,26 @@ mac:
- zip
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
# `${name}` produces for scoped package names.
artifactName: multica-desktop-${version}-${arch}.${ext}
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
# so the filename alone surfaces kind, version, platform, and CPU arch.
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
# unaffected because `pnpm package` already requires the Developer ID
# signing cert — notarization is a strict superset.
notarize: true
dmg:
artifactName: multica-desktop-${version}-${arch}.${ext}
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
linux:
target:
- AppImage
- deb
artifactName: ${name}-${version}-${arch}.${ext}
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
publish:
provider: github
owner: multica-ai

View File

@@ -2,17 +2,31 @@
"name": "@multica/desktop",
"version": "0.1.0",
"private": true,
"description": "Multica Desktop — native desktop client for the Multica platform.",
"homepage": "https://multica.ai",
"repository": {
"type": "git",
"url": "https://github.com/multica-ai/multica.git",
"directory": "apps/desktop"
},
"author": {
"name": "Multica",
"email": "support@multica.ai"
},
"license": "UNLICENSED",
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "node scripts/package.mjs",
"package:all": "node scripts/package.mjs --all-platforms --publish never",
"lint": "eslint .",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
@@ -25,6 +39,7 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/geist-mono": "^5.2.7",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",

View File

@@ -13,7 +13,7 @@
// skip the build and fall through to auto-install at runtime. A genuine
// Go compile error is fatal — you want that to block dev, not hide.
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
@@ -23,8 +23,54 @@ const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
const binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const PLATFORM_TO_GOOS = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
function runtimePlatformFromArgs(argv) {
const flagIndex = argv.indexOf("--target-platform");
if (flagIndex === -1) return process.platform;
return argv[flagIndex + 1] ?? "";
}
function runtimeArchFromArgs(argv) {
const flagIndex = argv.indexOf("--target-arch");
if (flagIndex === -1) return process.arch;
return argv[flagIndex + 1] ?? "";
}
function normalizeRuntimePlatform(platform) {
if (platform in PLATFORM_TO_GOOS) return platform;
throw new Error(
`[bundle-cli] unsupported target platform: ${platform}. ` +
"Use darwin, linux, or win32.",
);
}
function normalizeRuntimeArch(arch) {
if (SUPPORTED_ARCHS.has(arch)) return arch;
throw new Error(
`[bundle-cli] unsupported target architecture: ${arch}. ` +
"Use x64 or arm64.",
);
}
function binaryNameForPlatform(platform) {
return platform === "win32" ? "multica.exe" : "multica";
}
const targetPlatform = normalizeRuntimePlatform(
runtimePlatformFromArgs(process.argv.slice(2)),
);
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
const goos = PLATFORM_TO_GOOS[targetPlatform];
const goarch = targetArch === "x64" ? "amd64" : targetArch;
const binName = binaryNameForPlatform(targetPlatform);
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
@@ -61,8 +107,9 @@ if (hasGo()) {
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
);
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
execFileSync(
"go",
[
@@ -70,10 +117,19 @@ if (hasGo()) {
"-ldflags",
ldflags,
"-o",
join("bin", binName),
srcBinary,
"./cmd/multica",
],
{ cwd: serverDir, stdio: "inherit" },
{
cwd: serverDir,
stdio: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
GOOS: goos,
GOARCH: goarch,
},
},
);
} else {
console.warn(
@@ -88,9 +144,11 @@ if (!(await exists(srcBinary))) {
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
`auto-installing the latest release at runtime.`,
);
await rm(destDir, { recursive: true, force: true });
process.exit(0);
}
await rm(destDir, { recursive: true, force: true });
await mkdir(destDir, { recursive: true });
await copyFile(srcBinary, destBinary);
await chmod(destBinary, 0o755);

View File

@@ -5,11 +5,11 @@
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
// into resources/bin/), then `electron-vite build` to produce the
// main/preload/renderer bundles under out/, then invokes electron-builder
// with `-c.extraMetadata.version=<derived>` so the override applies at
// build time without mutating the tracked package.json.
// Builds the Electron bundles once, then for each requested target
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
// the override applies at build time without mutating the tracked
// package.json.
//
// The electron-vite step is important: electron-builder only packages
// whatever is already in out/, so skipping it (or relying on stale
@@ -25,11 +25,50 @@
// version-derivation logic without shelling out.
import { execFileSync, spawnSync, execSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { delimiter, dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(here, "..");
const bundleCliScript = resolve(here, "bundle-cli.mjs");
const PLATFORM_CONFIG = {
mac: {
aliases: new Set(["--mac", "--macos", "-m"]),
builderFlag: "--mac",
runtimePlatform: "darwin",
label: "macOS",
},
win: {
aliases: new Set(["--win", "--windows", "-w"]),
builderFlag: "--win",
runtimePlatform: "win32",
label: "Windows",
},
linux: {
aliases: new Set(["--linux", "-l"]),
builderFlag: "--linux",
runtimePlatform: "linux",
label: "Linux",
},
};
const ARCH_FLAGS = new Map([
["--x64", "x64"],
["--arm64", "arm64"],
["--ia32", "ia32"],
["--armv7l", "armv7l"],
["--universal", "universal"],
]);
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
const MAC_ALL_PLATFORM_TARGETS = [
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
];
function sh(cmd) {
try {
@@ -77,20 +116,231 @@ function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function main() {
// Step 1: build + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
function uniqueOrdered(values) {
return [...new Set(values)];
}
// Step 2: build the Electron main/preload/renderer bundles. Without
export function envWithLocalBins(env = process.env, root = desktopRoot) {
const pathKey =
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
const existingPath = env[pathKey] ?? "";
const localBins = uniqueOrdered([
resolve(root, "node_modules", ".bin"),
resolve(root, "..", "..", "node_modules", ".bin"),
]);
const mergedPath = uniqueOrdered([
...localBins,
...String(existingPath)
.split(delimiter)
.filter(Boolean),
]).join(delimiter);
return { ...env, [pathKey]: mergedPath };
}
function hostPlatformKey(platform = process.platform) {
if (platform === "darwin") return "mac";
if (platform === "win32") return "win";
if (platform === "linux") return "linux";
throw new Error(`[package] unsupported host platform: ${platform}`);
}
function hostArchKey(arch = process.arch) {
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
throw new Error(
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
);
}
function expandPlatformShorthand(token) {
if (!/^-[mwl]{2,}$/.test(token)) return null;
const expanded = [];
for (const char of token.slice(1)) {
if (char === "m") expanded.push("mac");
if (char === "w") expanded.push("win");
if (char === "l") expanded.push("linux");
}
return uniqueOrdered(expanded);
}
function platformKeyForToken(token) {
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
if (config.aliases.has(token)) return platform;
}
return null;
}
function platformTargetsTemplate() {
return { mac: [], win: [], linux: [] };
}
export function parsePackageArgs(argv) {
const sharedArgs = [];
const platformTargets = platformTargetsTemplate();
const requestedPlatforms = [];
const requestedArchs = [];
let allPlatforms = false;
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--all-platforms") {
allPlatforms = true;
continue;
}
const expandedPlatforms = expandPlatformShorthand(token);
if (expandedPlatforms) {
requestedPlatforms.push(...expandedPlatforms);
continue;
}
const platform = platformKeyForToken(token);
if (platform) {
requestedPlatforms.push(platform);
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
platformTargets[platform].push(argv[i + 1]);
i += 1;
}
continue;
}
const arch = ARCH_FLAGS.get(token);
if (arch) {
requestedArchs.push(arch);
continue;
}
sharedArgs.push(token);
}
return {
allPlatforms,
sharedArgs,
platformTargets,
requestedPlatforms: uniqueOrdered(requestedPlatforms),
requestedArchs: uniqueOrdered(requestedArchs),
};
}
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
if (parsed.allPlatforms) {
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
throw new Error(
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
);
}
if (platform !== "darwin") {
throw new Error(
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
);
}
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
}
const platforms =
parsed.requestedPlatforms.length > 0
? parsed.requestedPlatforms
: [hostPlatformKey(platform)];
const archs =
parsed.requestedArchs.length > 0
? parsed.requestedArchs
: [hostArchKey(arch)];
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
if (unsupported.length > 0) {
throw new Error(
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
"Use --x64 or --arm64.",
);
}
return platforms.flatMap((targetPlatform) =>
archs.map((targetArch) => ({
platform: targetPlatform,
arch: targetArch,
})),
);
}
function formatTarget(target) {
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
}
export function builderArgsForTarget(
target,
parsed,
version,
{
disableMacNotarize = false,
hostPlatform = process.platform,
useScopedOutputDir = false,
} = {},
) {
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
const requestedTargets = parsed.platformTargets[target.platform];
if (
target.platform === "linux" &&
hostPlatform !== "linux" &&
requestedTargets.length === 0
) {
// electron-builder only guarantees AppImage/Snap when cross-building
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
// to AppImage unless the caller explicitly requests Linux targets.
builderArgs.push("AppImage");
} else {
builderArgs.push(...requestedTargets);
}
builderArgs.push(`--${target.arch}`);
builderArgs.push(...parsed.sharedArgs);
if (useScopedOutputDir) {
builderArgs.push(
`-c.directories.output=dist/${target.platform}-${target.arch}`,
);
}
// electron-builder's update metadata file is `latest.yml` for Windows
// regardless of arch (only Linux gets an arch suffix automatically — see
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
// channel override, building Windows x64 and arm64 in two invocations
// makes both publish `latest.yml` to the same GitHub Release, so the
// second upload overwrites the first and one of the two architectures
// ends up with no auto-update metadata. Route Windows arm64 to its own
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
// the renderer-side updater pins the matching channel per arch.
if (target.platform === "win" && target.arch === "arm64") {
builderArgs.push("-c.publish.channel=latest-arm64");
}
return builderArgs;
}
function main() {
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const parsed = parsePackageArgs(passthrough);
const buildMatrix = resolveBuildMatrix(parsed);
console.log(
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
);
// Step 1: build the Electron main/preload/renderer bundles. Without
// this step electron-builder silently packages whatever is already in
// out/, which on a fresh checkout (or after a partial build) ships an
// app that white-screens because the renderer bundle is missing.
//
// CI invokes this script via `node scripts/package.mjs`, so we cannot
// rely on pnpm/npm to inject package-local binaries into PATH.
//
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
// PATHEXT when spawning a bare command without a shell — it would fail
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
// through the shell is harmless. See
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
const viteResult = spawnSync("electron-vite", ["build"], {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (viteResult.error) {
console.error(
@@ -103,7 +353,7 @@ function main() {
process.exit(viteResult.status ?? 1);
}
// Step 3: derive the version that should be written into the app.
// Step 2: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -113,43 +363,62 @@ function main() {
);
}
// Step 4: assemble electron-builder args.
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
// credentials, and would otherwise hit a hard failure at the notarize
// step. Detect the missing env and flip notarize off for this run only.
if (!process.env.APPLE_TEAM_ID) {
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
if (disableMacNotarize) {
console.warn(
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
);
builderArgs.push("-c.mac.notarize=false");
}
builderArgs.push(...passthrough);
const useScopedOutputDir = buildMatrix.length > 1;
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
// Step 3: for each requested target, build the matching CLI into
// resources/bin/ and package that target in isolation.
for (const target of buildMatrix) {
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
execFileSync(
"node",
[
bundleCliScript,
"--target-platform",
PLATFORM_CONFIG[target.platform].runtimePlatform,
"--target-arch",
target.arch,
],
{
stdio: "inherit",
cwd: desktopRoot,
},
);
process.exit(1);
const builderArgs = builderArgsForTarget(target, parsed, version, {
disableMacNotarize,
hostPlatform: process.platform,
useScopedOutputDir,
});
// Step 4: invoke electron-builder for the current target only.
// `shell: true` for the same Windows `.cmd` shim reason as the
// electron-vite invocation above.
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.

View File

@@ -1,5 +1,13 @@
import { delimiter, resolve } from "node:path";
import { describe, it, expect } from "vitest";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
import {
builderArgsForTarget,
envWithLocalBins,
normalizeGitVersion,
parsePackageArgs,
resolveBuildMatrix,
stripLeadingSeparator,
} from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -59,3 +67,207 @@ describe("stripLeadingSeparator", () => {
expect(stripLeadingSeparator([])).toEqual([]);
});
});
describe("parsePackageArgs", () => {
it("collects per-platform targets and shared args", () => {
expect(
parsePackageArgs([
"--win", "nsis",
"--mac", "dmg", "zip",
"--arm64",
"--publish", "never",
]),
).toEqual({
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: {
mac: ["dmg", "zip"],
win: ["nsis"],
linux: [],
},
requestedPlatforms: ["win", "mac"],
requestedArchs: ["arm64"],
});
});
it("expands combined short flags", () => {
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
"mac",
"win",
]);
});
it("tracks the all-platforms shortcut", () => {
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
});
});
describe("resolveBuildMatrix", () => {
it("defaults to the current host platform and arch", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([{ platform: "mac", arch: "arm64" }]);
});
it("expands all-platforms on macOS", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: true,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
]);
});
it("rejects unsupported architectures", () => {
expect(() =>
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["universal"],
},
"darwin",
"arm64",
),
).toThrow(/unsupported Desktop CLI architecture/);
});
});
describe("builderArgsForTarget", () => {
it("adds scoped output directories for multi-target builds", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "arm64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["arm64"],
},
"1.2.3",
{
disableMacNotarize: true,
hostPlatform: "darwin",
useScopedOutputDir: true,
},
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"-c.mac.notarize=false",
"--win",
"nsis",
"--arm64",
"--publish",
"never",
"-c.directories.output=dist/win-arm64",
"-c.publish.channel=latest-arm64",
]);
});
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "always"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "win32", useScopedOutputDir: true },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--win",
"nsis",
"--x64",
"--publish",
"always",
"-c.directories.output=dist/win-x64",
]);
});
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
expect(
builderArgsForTarget(
{ platform: "linux", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["linux"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "darwin" },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--linux",
"AppImage",
"--x64",
"--publish",
"never",
]);
});
});
describe("envWithLocalBins", () => {
it("prepends desktop-local binary directories to PATH", () => {
const desktopRoot = "/repo/apps/desktop";
const result = envWithLocalBins(
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
desktopRoot,
);
expect(result.PATH.split(delimiter)).toEqual([
resolve(desktopRoot, "node_modules", ".bin"),
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
"/usr/local/bin",
"/usr/bin",
]);
});
it("preserves an existing Path key and avoids duplicate entries", () => {
const desktopRoot = "/repo/apps/desktop";
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
const result = envWithLocalBins(
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
desktopRoot,
);
expect(result).not.toHaveProperty("PATH");
expect(result.Path.split(delimiter)).toEqual([
desktopBin,
workspaceBin,
"runner-bin",
]);
});
});

View File

@@ -8,35 +8,15 @@ import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
// Desktop prefers the bundled `multica` CLI shipped inside the app for
// same-repo builds, but it can also repair or bootstrap a managed copy in
// userData on first launch when the bundled binary is missing or unusable.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
@@ -92,14 +72,8 @@ async function sha256OfFile(path: string): Promise<string> {
async function verifyChecksum(
archivePath: string,
assetName: string,
expected: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
@@ -118,7 +92,14 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
async function installFresh(): Promise<string> {
const target = managedCliPath();
const assetName = platformAssetName();
const checksums = await fetchChecksums();
const assetName = selectPlatformReleaseAssetName(checksums.keys());
const expectedChecksum = checksums.get(assetName);
if (!expectedChecksum) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
@@ -130,7 +111,7 @@ async function installFresh(): Promise<string> {
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName);
await verifyChecksum(archivePath, assetName, expectedChecksum);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
@@ -143,6 +124,7 @@ async function installFresh(): Promise<string> {
}
await mkdir(dirname(target), { recursive: true });
await rm(target, { force: true }).catch(() => {});
await rename(extractedBin, target);
await chmod(target, 0o755);
@@ -166,8 +148,10 @@ async function installFresh(): Promise<string> {
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(): Promise<string> {
export async function ensureManagedCli(
options: { forceInstall?: boolean } = {},
): Promise<string> {
const target = managedCliPath();
if (existsSync(target)) return target;
if (existsSync(target) && !options.forceInstall) return target;
return installFresh();
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
describe("selectPlatformReleaseAssetName", () => {
it("prefers the versioned archive name when both exist", () => {
const assetNames = [
"checksums.txt",
"multica_darwin_amd64.tar.gz",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("falls back to the legacy archive name when only legacy is present", () => {
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica_darwin_amd64.tar.gz",
);
});
it("matches the renamed darwin archive from release assets", () => {
const assetNames = [
"checksums.txt",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
"multica-cli-1.2.3-darwin-arm64.tar.gz",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("matches the renamed windows zip archive", () => {
const assetNames = [
"multica-cli-1.2.3-windows-amd64.zip",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
"multica-cli-1.2.3-windows-amd64.zip",
);
});
it("fails when the current platform asset is missing", () => {
expect(() =>
selectPlatformReleaseAssetName(
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
"darwin",
"arm64",
),
).toThrow(/no release asset found/);
});
});

View File

@@ -0,0 +1,62 @@
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
function platformArchiveDescriptor(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): { os: string; arch: string; ext: string } {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[platform];
const mappedArch = archMap[arch];
if (!os || !mappedArch) {
throw new Error(
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
);
}
const ext = platform === "win32" ? "zip" : "tar.gz";
return { os, arch: mappedArch, ext };
}
export function selectPlatformReleaseAssetName(
assetNames: Iterable<string>,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): string {
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
platform,
arch,
);
const names = [...assetNames];
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
// only ship the legacy archive keep working.
const suffix = `-${os}-${mappedArch}.${ext}`;
const matches = names.filter(
(name) =>
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
);
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
throw new Error(
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
);
}
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
if (names.includes(legacyName)) {
return legacyName;
}
throw new Error(`no release asset found for current platform: ${suffix}`);
}

View File

@@ -316,6 +316,36 @@ function bundledCliPath(): string {
);
}
async function probeCliBinary(
bin: string,
source: "bundled" | "managed" | "path",
): Promise<string | null> {
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
if (typeof parsed.version === "string" && parsed.version.length > 0) {
return parsed.version;
}
console.warn(
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
);
return null;
} catch (err) {
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
return null;
}
}
/**
* Returns a usable `multica` binary path. Priority:
* 1. Cached result from a previous successful resolve.
@@ -339,27 +369,55 @@ async function resolveCliBinary(): Promise<string | null> {
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
const version = await probeCliBinary(bundled, "bundled");
if (version) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
cachedCliBinaryVersion = version;
return bundled;
}
}
const managed = managedCliPath();
if (existsSync(managed)) {
cachedCliBinary = managed;
return managed;
const version = await probeCliBinary(managed, "managed");
if (version) {
cachedCliBinary = managed;
cachedCliBinaryVersion = version;
return managed;
}
}
try {
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
const installed = await ensureManagedCli({
forceInstall: existsSync(managed),
});
const version = await probeCliBinary(installed, "managed");
if (version) {
cachedCliBinary = installed;
cachedCliBinaryVersion = version;
return installed;
}
console.warn(
`[daemon] managed CLI at ${installed} failed validation after install`,
);
} catch (err) {
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
const onPath = findCliOnPath();
cachedCliBinary = onPath;
return onPath;
}
const onPath = findCliOnPath();
if (onPath) {
const version = await probeCliBinary(onPath, "path");
if (version) {
cachedCliBinary = onPath;
cachedCliBinaryVersion = version;
return onPath;
}
}
cachedCliBinary = null;
cachedCliBinaryVersion = null;
return null;
})();
try {
@@ -370,11 +428,10 @@ async function resolveCliBinary(): Promise<string | null> {
}
/**
* Reads the version of the currently resolved CLI binary by invoking
* `multica version --output json`. Cached for the process lifetime — the
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
* Reads the version of the currently resolved CLI binary. Cached for the
* process lifetime — the bundled binary doesn't change after bundle time.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* etc.) so callers can fail open.
* wrong-arch bundled binary, etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
@@ -383,24 +440,7 @@ async function getCliBinaryVersion(): Promise<string | null> {
cachedCliBinaryVersion = null;
return null;
}
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
cachedCliBinaryVersion = parsed.version ?? null;
} catch (err) {
console.warn("[daemon] failed to read CLI binary version:", err);
cachedCliBinaryVersion = null;
}
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
return cachedCliBinaryVersion;
}

View File

@@ -193,6 +193,16 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer
// already carries X-Client-Version and X-Client-OS.
ipcMain.on("app:get-info", (event) => {
const p = process.platform;
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
event.returnValue = { version: app.getVersion(), os };
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.

View File

@@ -1,9 +1,31 @@
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Windows arm64 ships its own update metadata channel because
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
// arches would otherwise collide on the same file in the GitHub Release.
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
// of this pact. Pin the channel here so arm64 clients fetch
// `latest-arm64.yml` instead of the x64 metadata.
if (process.platform === "win32" && process.arch === "arm64") {
autoUpdater.channel = "latest-arm64";
}
const STARTUP_CHECK_DELAY_MS = 5_000;
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export type ManualUpdateCheckResult =
| {
ok: true;
currentVersion: string;
latestVersion: string;
available: boolean;
}
| { ok: false; error: string };
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
@@ -37,10 +59,42 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.quitAndInstall(false, true);
});
// Check for updates after a short delay to avoid blocking startup
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
try {
const result = await autoUpdater.checkForUpdates();
const currentVersion = app.getVersion();
// Trust electron-updater's own decision rather than re-deriving it from
// a version-string compare. The two diverge for pre-release channels,
// staged rollouts, downgrades, and minimum-system-version gates — in
// those cases updateInfo.version differs from app.getVersion() but no
// `update-available` event fires, so showing "available" here would
// promise a download prompt that never appears.
return {
ok: true,
currentVersion,
latestVersion: result?.updateInfo.version ?? currentVersion,
available: result?.isUpdateAvailable ?? false,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
});
// Initial check shortly after startup so we don't block boot.
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 5000);
}, STARTUP_CHECK_DELAY_MS);
// Background poll so long-running sessions still pick up new releases
// without requiring the user to restart the app.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Periodic update check failed:", err);
});
}, PERIODIC_CHECK_INTERVAL_MS);
}

View File

@@ -1,6 +1,11 @@
import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
appInfo: {
version: string;
os: "macos" | "windows" | "linux" | "unknown";
};
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
@@ -53,6 +58,10 @@ interface UpdaterAPI {
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
checkForUpdates: () => Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
>;
}
declare global {

View File

@@ -1,7 +1,32 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
// (async ipc.invoke) would race the ApiClient construction in initCore and
// the first few HTTP requests would go out without X-Client-Version/OS.
function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" | "unknown" } {
try {
const info = ipcRenderer.sendSync("app:get-info") as
| { version: string; os: "macos" | "windows" | "linux" | "unknown" }
| undefined;
if (info && typeof info.version === "string" && typeof info.os === "string") return info;
} catch {
// fall through
}
// Fallback: derive OS from process.platform; version unknown.
const p = process.platform;
const os: "macos" | "windows" | "linux" | "unknown" =
p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
return { version: "unknown", os };
}
const appInfo = fetchAppInfo();
const desktopAPI = {
/** App version + normalized OS. Read once at preload time so the renderer
* can use it synchronously when initializing the API client. */
appInfo,
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
@@ -96,6 +121,10 @@ const updaterAPI = {
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
checkForUpdates: (): Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
> => ipcRenderer.invoke("updater:check"),
};
if (process.contextIsolated) {

View File

@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { PageviewTracker } from "./components/pageview-tracker";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
@@ -90,11 +92,28 @@ function AppContent() {
// account switches (user A logout → user B login) should not trigger a
// daemon restart here — daemon-manager already restarts on user change
// via syncToken.
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const wsCount = workspaces?.length ?? 0;
const wsCount = workspaces.length;
const hasOnboarded = useHasOnboarded();
// Onboarding and zero-workspace both resolve to an overlay, but
// onboarding wins: a user who hasn't completed it gets the onboarding
// overlay regardless of how many workspaces already exist.
useEffect(() => {
if (!user || !workspaceListFetched) return;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return;
if (!hasOnboarded) {
open({ type: "onboarding" });
return;
}
if (wsCount === 0) {
open({ type: "new-workspace" });
}
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
@@ -104,32 +123,22 @@ function AppContent() {
// warning because `switchWorkspace` is a Zustand setState that the
// TabBar is subscribed to. useLayoutEffect flushes both renders before
// the user sees anything, so there's no visible flicker.
//
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
// the first fetch, so without this guard we'd run validation against an
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
// back to `workspaces[0]` once the real list arrives — losing the user's
// last-opened workspace on every app start.
useLayoutEffect(() => {
if (!workspaces) return;
const validSlugs = new Set(workspaces.map((w) => w.slug));
const tabStore = useTabStore.getState();
tabStore.validateWorkspaceSlugs(validSlugs);
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
tabStore.switchWorkspace(workspaces[0].slug);
}
}, [workspaces]);
// Bidirectional new-workspace overlay: visible when there are no
// workspaces to enter, hidden as soon as one exists. Gated on
// `workspaceListFetched` so the initial render doesn't flash the
// overlay before the list arrives. The overlay's own `invite` type is
// not touched here — that's an in-flight task owned by the user.
useEffect(() => {
if (!user) return;
if (!workspaceListFetched) return;
const { overlay, open, close } = useWindowOverlayStore.getState();
const isEmpty = wsCount === 0;
if (isEmpty) {
if (!overlay) open({ type: "new-workspace" });
} else if (overlay?.type === "new-workspace") {
close();
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (!activeWorkspaceSlug && workspaces.length > 0) {
switchWorkspace(workspaces[0].slug);
}
}, [user, workspaceListFetched, wsCount]);
}, [workspaces, workspaceListFetched]);
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
@@ -158,8 +167,15 @@ function AppContent() {
);
}
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
// Pageview tracker sits at the app root so it covers every visible
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
// would miss the logged-out and overlay states.
return (
<>
<PageviewTracker />
{user ? <DesktopShell /> : <DesktopLoginPage />}
</>
);
}
// Backend the daemon should connect to — same URL the renderer talks to.
@@ -187,12 +203,20 @@ async function handleDaemonLogout() {
}
export default function App() {
const { version, os } = window.desktopAPI.appInfo;
// Stable identity reference so downstream effects (WS reconnect) don't
// tear down on every parent render.
const identity = useMemo(
() => ({ platform: "desktop", version, os }),
[version, os],
);
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
identity={identity}
>
<AppContent />
</CoreProvider>

View File

@@ -13,6 +13,7 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
@@ -134,6 +135,7 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so its empty
* state can distinguish "no runtime registered" from "runtime is on its
* way" — without the bundled daemon's status, the page shows a
* misleading "Run multica daemon start" hint during the few seconds
* between page load and the daemon's first registration.
*
* `bootstrapping` is true while the daemon is installing, starting, or
* already running but hasn't surfaced as a server-side runtime yet.
* RuntimeList only shows the spinner when the runtime list is also
* empty, so once the daemon registers (and the list fills) the flag
* has no visible effect.
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
useEffect(() => {
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
status.state === "installing_cli" ||
status.state === "starting" ||
status.state === "running";
return (
<RuntimesPage
topSlot={<DaemonRuntimeCard />}
bootstrapping={bootstrapping}
/>
);
}

View File

@@ -0,0 +1,69 @@
import { useEffect } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes.
*
* Desktop has three layers that can own the visible page:
*
* 1. Logged-out state → `/login`. No workspace context, no tabs.
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
* that match the equivalent web routes. Overlays are NOT tab routes on
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
* tab path alone would either miss them or mislabel them as "/".
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
*
* The overlay takes precedence over the tab path because it is visually in
* front of the tab system; the logged-out state shadows both because the
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
* with what the user actually sees.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
* implementation in `apps/web/components/pageview-tracker.tsx`.
*/
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const activeTabPath = useTabStore((s) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
});
const path = resolvePath(user, overlay, activeTabPath);
useEffect(() => {
if (!path) return;
capturePageview(path);
}, [path]);
return null;
}
function resolvePath(
user: unknown,
overlay: WindowOverlay | null,
activeTabPath: string | null,
): string | null {
if (!user) return "/login";
if (overlay) return overlayPath(overlay);
return activeTabPath;
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":
return "/workspaces/new";
case "onboarding":
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
}
}

View File

@@ -110,12 +110,25 @@ export function UpdateNotification() {
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<button
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
<div className="mt-2 flex items-center gap-1.5">
{/* Secondary "See changes" — gives the user a reason to
restart by surfacing what they're about to get. Opens
in the default browser via the shared openExternal
bridge so the URL hits the same allow-list as every
other outbound link. */}
<button
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
See changes
</button>
<button
onClick={handleInstall}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,86 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
type CheckState =
| { status: "idle" }
| { status: "checking" }
| { status: "up-to-date"; currentVersion: string }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const [state, setState] = useState<CheckState>({ status: "idle" });
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
const result = await window.updater.checkForUpdates();
if (!result.ok) {
setState({ status: "error", message: result.error });
return;
}
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date", currentVersion: result.currentVersion },
);
}, []);
return (
<div>
<h2 className="text-lg font-semibold">Updates</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch.
</p>
<div className="mt-6 divide-y">
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates appear as a notification in the corner.
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
You&apos;re on the latest version (v{state.currentVersion}).
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is available see the download prompt
in the corner.
</p>
)}
{state.status === "error" && (
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
<AlertCircle className="size-3.5" />
{state.message}
</p>
)}
</div>
<div className="shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleCheck}
disabled={state.status === "checking"}
>
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking
</>
) : (
"Check now"
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useImmersiveMode } from "@multica/views/platform";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { OnboardingFlow } from "@multica/views/onboarding";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
@@ -9,18 +9,21 @@ import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Window-level transition overlay: renders above the tab system when the
* user is in a pre-workspace flow (create workspace, accept invite).
* user is in a pre-workspace flow (onboarding, create workspace, accept
* invite).
*
* This component is a thin **platform shell**:
* - Hands the window-drag strip and macOS traffic-light hiding
* (`useImmersiveMode`) — both are platform-specific, web has neither
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
* doesn't leak through
* This component is intentionally thin — just a fixed positioning shell
* that covers the tab system. It does NOT hide traffic lights or provide
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
* native macOS traffic lights stay visible and the page content can fill
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
* pre-dashboard flows and keeps platform chrome consistent across every
* "not-in-dashboard" surface.
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
* components under `packages/views/`, so web and desktop render identical
* content. The platform split is: UX in shared code, chrome here.
* card) live inside the shared view components under `packages/views/`,
* so web and desktop render identical content.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
@@ -34,8 +37,6 @@ function WindowOverlayInner() {
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
useImmersiveMode();
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
@@ -44,42 +45,35 @@ function WindowOverlayInner() {
const onBack = wsList.length > 0 ? close : undefined;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Window-drag strip. Rendered as a flex *child* (not absolute
overlay) so it owns its own 48px of real layout space — the
prior absolute-positioned approach relied on z-index stacking
to beat the content wrapper's no-drag, which in practice didn't
hit-test reliably for `-webkit-app-region` on the welcome
screen. A real flex row with nothing else in it has no such
ambiguity: any pixel at top-48 is drag, full stop.
Height matches `MainTopBar` (48px) so the drag-to-grab area
feels consistent with the rest of the app. The strip is
invisible; macOS traffic lights would normally sit here but
`useImmersiveMode` has hidden them for the overlay's lifetime. */}
<div
aria-hidden
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div
className="flex-1 min-h-0 overflow-auto"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
</div>
<div className="fixed inset-0 z-50 flex flex-col overflow-auto bg-background">
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {
close();
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
/>
)}
</div>
);
}

View File

@@ -25,6 +25,8 @@
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-serif;
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
"Apple Garamond", Baskerville, "Times New Roman", serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}

View File

@@ -4,6 +4,11 @@ import App from "./App";
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
import "@fontsource-variable/inter";
// Editorial serif — matches web's next/font Source_Serif_4. Loaded app-wide so
// onboarding headings and any future editorial surface can use `font-serif`
// (see tokens.css @theme inline). Variable font = one file covers all weights.
import "@fontsource-variable/source-serif-4";
import "@fontsource-variable/source-serif-4/wght-italic.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";

View File

@@ -1,4 +1,5 @@
import { LoginPage } from "@multica/views/auth";
import { DragStrip } from "@multica/views/platform";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
@@ -14,11 +15,7 @@ export function DesktopLoginPage() {
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
<div
className="h-[38px] shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<DragStrip />
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
onSuccess={() => {

View File

@@ -15,10 +15,11 @@ import {
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
// that actually points somewhere a teammate can open.
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
// Public web app URL — injected at build time via .env.production. In dev
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
// link" in a dev build yields a URL that points at the running dev
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
/**
* Extract the leading workspace slug from a path, or null if the path isn't
@@ -53,6 +54,13 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
}
return true;
}
if (path === "/onboarding") {
overlay.open({ type: "onboarding" });
if (router && router.state.location.pathname !== "/") {
router.navigate("/", { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {

View File

@@ -13,14 +13,14 @@ import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { Server } from "lucide-react";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
@@ -113,7 +113,7 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
element: <DesktopRuntimesPage />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
@@ -130,6 +130,12 @@ export const appRoutes: RouteObject[] = [
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),

View File

@@ -14,7 +14,8 @@ import { create } from "zustand";
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string };
| { type: "invite"; invitationId: string }
| { type: "onboarding" };
interface WindowOverlayStore {
overlay: WindowOverlay | null;

View File

@@ -8,12 +8,13 @@ import {
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
export default async function Page(props: {
params: Promise<{ slug: string[] }>;
params: Promise<{ lang: string; slug: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
const MDX = page.data.body;
@@ -34,14 +35,15 @@ export function generateStaticParams() {
}
export async function generateMetadata(props: {
params: Promise<{ slug: string[] }>;
params: Promise<{ lang: string; slug: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
alternates: docsAlternates(params.slug),
};
}

View File

@@ -0,0 +1,118 @@
import "../global.css";
import { RootProvider } from "fumadocs-ui/provider";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { cn } from "@multica/ui/lib/utils";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
import { i18n, type Lang } from "@/lib/i18n";
import { uiTranslations, localeLabels } from "@/lib/translations";
import { DocsSettings } from "@/components/docs-settings";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"sans-serif",
],
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-mono",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
});
// Editorial serif used for headings and showpiece elements. Italic style is
// deliberately NOT loaded — italic in CJK is a synthetic slant that breaks
// glyph design. Emphasis in docs is carried by brand color + weight, never
// font-style. Mirrors apps/web/app/layout.tsx for the upright family.
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
style: ["normal"],
variable: "--font-serif",
fallback: [
"ui-serif",
"Iowan Old Style",
"Apple Garamond",
"Baskerville",
"Times New Roman",
"serif",
],
});
export const metadata: Metadata = {
title: {
template: "%s | Multica Docs",
default: "Multica Docs",
},
description:
"Documentation for Multica — the open-source managed agents platform.",
};
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
export default async function Layout({
params,
children,
}: {
params: Promise<{ lang: string }>;
children: ReactNode;
}) {
const { lang: rawLang } = await params;
const lang = (i18n.languages as readonly string[]).includes(rawLang)
? (rawLang as Lang)
: (i18n.defaultLanguage as Lang);
const locales = i18n.languages.map((l) => ({
locale: l,
name: localeLabels[l],
}));
return (
<html
lang={lang}
suppressHydrationWarning
className={cn(
"antialiased",
inter.variable,
geistMono.variable,
sourceSerif.variable,
)}
>
<body className="font-sans">
<RootProvider
i18n={{
locale: lang,
locales,
translations: uiTranslations[lang],
}}
search={{ options: { api: "/docs/api/search" } }}
>
<DocsLayout
tree={source.getPageTree(lang)}
// Suppress Fumadocs's default sidebar-footer icons (theme +
// language + search). Our custom <DocsSettings> is mounted as
// the sidebar footer instead — two labelled buttons, not three
// icons.
themeSwitch={{ enabled: false }}
searchToggle={{ enabled: false }}
sidebar={{ footer: <DocsSettings locale={lang} /> }}
{...baseOptions}
>
{children}
</DocsLayout>
</RootProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,84 @@
import { source } from "@/lib/source";
import { DocsPage, DocsBody } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { DocsHero } from "@/components/hero";
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
? (lang as Lang)
: (i18n.defaultLanguage as Lang);
}
// A layout's `generateStaticParams` does NOT cascade — every page that
// wants SSG must declare its own. Without this, both `/docs/` and
// `/docs/zh` (the busiest URLs on the site) render dynamically on every
// request.
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
export default async function Page({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang: rawLang } = await params;
const lang = asLang(rawLang);
const page = source.getPage([], lang);
if (!page) notFound();
const MDX = page.data.body;
const copy = homeCopy[lang];
return (
<DocsPage toc={page.data.toc}>
<DocsHero
eyebrow={copy.eyebrow}
title={
<>
{copy.titleLead}
<em className="font-medium not-italic text-[var(--primary)]">
{copy.titleAccent}
</em>
</>
}
subtitle={page.data.description}
/>
<Byline items={[...copy.byline]} />
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
NumberedCards,
NumberedCard,
NumberedSteps,
Step,
}}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang: rawLang } = await params;
const lang = asLang(rawLang);
const page = source.getPage([], lang);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
alternates: docsAlternates([]),
};
}

View File

@@ -1,4 +1,32 @@
import { source } from "@/lib/source";
import { createFromSource } from "fumadocs-core/search/server";
export const { GET } = createFromSource(source);
// Orama doesn't ship a Chinese tokenizer and its built-in English regex
// strips Han characters entirely, so `locale=zh` would either return empty
// results or throw. Tokenize CJK input character-by-character and keep
// Latin/digit runs whole — gives serviceable recall for Chinese docs while
// letting Romanized terms (product names, CLI commands) still match.
function tokenizeCJK(raw: string): string[] {
const tokens: string[] = [];
const regex = /[一-鿿㐀-䶿]|[A-Za-z0-9]+/g;
const lower = raw.toLowerCase();
let match: RegExpExecArray | null;
while ((match = regex.exec(lower)) !== null) {
tokens.push(match[0]);
}
return tokens;
}
export const { GET } = createFromSource(source, {
localeMap: {
zh: {
components: {
tokenizer: {
language: "english",
normalizationCache: new Map(),
tokenize: tokenizeCJK,
},
},
},
},
});

View File

@@ -1,3 +1,679 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "../../../packages/ui/styles/tokens.css";
@custom-variant dark (&:is(.dark *));
@source "../../../packages/ui/**/*.{ts,tsx}";
/* ---------------------------------------------------------------------------
* Multica Docs — editorial visual identity (v2)
*
* Docs site is intentionally distinct from the product app: warm-paper
* background, editorial serif headings (Source Serif 4), indigo accent,
* ruled dividers. Product app keeps its cool-gray dense Linear-style; docs
* reads like a literary publication. Same split as Stripe, Cursor, Linear.
*
* Implementation: docs-scoped token override on top of Multica tokens
* (whose @theme inline references read --background / --foreground / etc
* at runtime, so re-pointing the vars cascades through fumadocs's full
* --color-fd-* bridge below).
* ------------------------------------------------------------------------- */
:root {
--fd-page-width: 1080px;
}
/* ---------------------------------------------------------------------------
* Editorial palette — light
* ------------------------------------------------------------------------- */
:root {
--background: oklch(0.972 0.003 85); /* near-white, faint warm — matches landing #f7f7f5 */
--foreground: oklch(0.182 0.012 50); /* warm ink */
--muted: oklch(0.955 0.006 85); /* hairline, slightly warmer than bg */
--muted-foreground: oklch(0.482 0.012 65); /* warm muted */
--card: oklch(0.99 0.002 85); /* paper — near white */
--card-foreground: oklch(0.182 0.012 50);
--popover: oklch(0.99 0.002 85);
--popover-foreground: oklch(0.182 0.012 50);
--primary: oklch(0.55 0.16 255); /* Multica brand */
--primary-foreground: oklch(0.985 0.008 85);
--secondary: oklch(0.945 0.012 85);
--secondary-foreground: oklch(0.182 0.012 50);
--accent: oklch(0.945 0.022 255); /* brand soft wash */
--accent-foreground: oklch(0.46 0.16 255); /* brand ink */
--border: oklch(0.91 0.014 85); /* ruled lines */
--input: oklch(0.91 0.014 85);
--ring: oklch(0.55 0.16 255);
--sidebar: oklch(0.99 0.002 85); /* paper — same as card */
--sidebar-foreground: oklch(0.182 0.012 50);
--sidebar-accent: oklch(0.945 0.006 85); /* subtle cream, hover/active fill */
--sidebar-accent-foreground: oklch(0.182 0.012 50);
--sidebar-border: oklch(0.91 0.014 85);
/* Docs-only extras (not bridged to fumadocs slots) */
--docs-rule: oklch(0.835 0.018 85); /* heavier rule */
--docs-faint: oklch(0.72 0.018 75); /* faintest accent */
--docs-code-bg: oklch(0.94 0.018 85); /* warm beige code surface */
--docs-code-border: oklch(0.89 0.018 85);
--docs-terminal-bg: oklch(0.18 0.012 50); /* terminal warm dark */
--docs-terminal-fg: oklch(0.92 0.012 80);
--docs-terminal-accent: oklch(0.65 0.16 255);
}
/* ---------------------------------------------------------------------------
* Editorial palette — dark (warm dark, NOT Multica's cool dark)
* ------------------------------------------------------------------------- */
.dark {
--background: oklch(0.18 0.008 50);
--foreground: oklch(0.95 0.012 85);
--muted: oklch(0.22 0.008 50);
--muted-foreground: oklch(0.65 0.012 75);
--card: oklch(0.21 0.008 50);
--card-foreground: oklch(0.95 0.012 85);
--popover: oklch(0.22 0.008 50);
--popover-foreground: oklch(0.95 0.012 85);
--primary: oklch(0.7 0.15 255); /* Multica brand — dark */
--primary-foreground: oklch(0.18 0.008 50);
--secondary: oklch(0.24 0.008 50);
--secondary-foreground: oklch(0.95 0.012 85);
--accent: oklch(0.3 0.05 255); /* brand soft wash — dark */
--accent-foreground: oklch(0.78 0.14 255); /* brand ink — dark */
--border: oklch(0.28 0.012 50);
--input: oklch(0.28 0.012 50);
--ring: oklch(0.7 0.15 255);
--sidebar: oklch(0.21 0.008 50);
--sidebar-foreground: oklch(0.95 0.012 85);
--sidebar-accent: oklch(0.26 0.01 50); /* warm neutral, hover/active fill — dark */
--sidebar-accent-foreground: oklch(0.95 0.012 85);
--sidebar-border: oklch(0.28 0.012 50);
--docs-rule: oklch(0.36 0.012 50);
--docs-faint: oklch(0.42 0.012 50);
--docs-code-bg: oklch(0.165 0.008 50);
--docs-code-border: oklch(0.26 0.012 50);
--docs-terminal-bg: oklch(0.155 0.012 50);
--docs-terminal-fg: oklch(0.92 0.012 80);
--docs-terminal-accent: oklch(0.78 0.14 255);
}
/* ---------------------------------------------------------------------------
* Fumadocs slot bridge
*
* Map fumadocs's --color-fd-* slots to our (now warm) Multica tokens.
* @theme inline keeps the var() reference live so the cascade resolves
* at runtime — same pattern tokens.css uses.
* ------------------------------------------------------------------------- */
@theme inline {
--color-fd-background: var(--background);
--color-fd-foreground: var(--foreground);
--color-fd-muted: var(--muted);
--color-fd-muted-foreground: var(--muted-foreground);
--color-fd-popover: var(--popover);
--color-fd-popover-foreground: var(--popover-foreground);
--color-fd-card: var(--card);
--color-fd-card-foreground: var(--card-foreground);
--color-fd-border: var(--border);
--color-fd-primary: var(--primary);
--color-fd-primary-foreground: var(--primary-foreground);
--color-fd-secondary: var(--secondary);
--color-fd-secondary-foreground: var(--secondary-foreground);
--color-fd-accent: var(--accent);
--color-fd-accent-foreground: var(--accent-foreground);
--color-fd-ring: var(--ring);
}
/* Sidebar uses dedicated --sidebar-* tokens so it sits a hair off the main
* canvas. Fumadocs renders it as #nd-sidebar (desktop) and
* #nd-sidebar-mobile (mobile drawer); both IDs need the override. */
#nd-sidebar,
#nd-sidebar-mobile {
--color-fd-background: var(--sidebar);
--color-fd-foreground: var(--sidebar-foreground);
--color-fd-muted: var(--sidebar-accent);
--color-fd-muted-foreground: var(--sidebar-foreground);
--color-fd-accent: var(--sidebar-accent);
--color-fd-accent-foreground: var(--sidebar-accent-foreground);
--color-fd-border: var(--sidebar-border);
}
/* ---------------------------------------------------------------------------
* Editorial typography
*
* Body keeps Inter for legibility (especially CJK where serif Latin clashes
* with sans CJK). Headings switch to Source Serif 4 for the editorial
* signature. Italic is intentionally avoided — Chinese italic is a CSS
* synthetic slant against upright-designed glyphs and reads as broken.
* Emphasis is carried by serif/sans contrast, brand color, and weight.
*
* Sizing:
* - DocsHero h1 (welcome page only): 44px serif, brand-color em accent
* - prose h1 (guide / reference pages): 30px serif
* - prose h2: 26px serif (no italic)
* - prose h3: 13px sans uppercase label
* - body: 15.5px (kept from previous build — proven reading size for CN)
* ------------------------------------------------------------------------- */
article:has(.prose),
.prose {
font-size: 0.96875rem; /* 15.5px */
line-height: 1.7;
}
/* DocsTitle h1 (Fumadocs hardcodes text-[1.75em] font-semibold — utility
* specificity 0,1,0 beats plain article > h1 0,0,2; !important wins). */
article > h1 {
font-family: var(--font-serif), ui-serif, serif !important;
font-size: 1.875rem !important; /* 30px guide-page heading */
font-weight: 400 !important;
letter-spacing: -0.018em;
line-height: 1.15;
margin-bottom: 0.5em;
color: var(--foreground);
}
/* Lead paragraph below DocsTitle */
article > p.text-lg {
font-family: var(--font-serif), ui-serif, serif;
font-size: 1.125rem; /* 18px serif lede */
line-height: 1.55;
margin-bottom: 2rem;
color: var(--muted-foreground);
}
/* Paragraph rhythm */
.prose :where(p):not(:where([class~="not-prose"] *)) {
margin-top: 0;
margin-bottom: 0.875rem;
color: oklch(from var(--foreground) calc(l + 0.06) c h);
}
.prose :where(p):not(:where([class~="not-prose"] *)):last-child {
margin-bottom: 0;
}
.prose :where(p) strong {
color: var(--foreground);
font-weight: 600;
}
.prose :where(ul, ol) {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
.prose h1 {
font-family: var(--font-serif), ui-serif, serif;
font-size: 1.875rem; /* 30px */
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 0.5em;
color: var(--foreground);
}
/* Italic is avoided sitewide (Chinese italic = synthetic slant, looks broken).
* Force any italicized element to non-italic in prose. Tailwind Typography
* defaults blockquote to italic; we also undo it here. Emphasis is carried
* by brand color + font-weight in headings, foreground+weight in body. */
.prose em,
.prose i,
.prose cite,
.prose blockquote,
.prose blockquote p {
font-style: normal;
}
.prose h1 em {
color: var(--primary);
font-weight: 500;
}
.prose p em,
.prose li em {
color: var(--foreground);
font-weight: 600;
}
.prose h2 {
font-family: var(--font-serif), ui-serif, serif;
font-size: 1.625rem; /* 26px */
font-weight: 400;
letter-spacing: -0.015em;
line-height: 1.3;
margin-top: 2em;
margin-bottom: 0.5em;
color: var(--foreground);
scroll-margin-top: 80px;
}
/* h3 = small uppercase sans label, ruled-bottom — v2 editorial signature */
.prose h3 {
font-family: var(--font-sans), system-ui, sans-serif;
font-size: 0.8125rem; /* 13px */
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted-foreground);
margin-top: 2.25em;
margin-bottom: 0.75em;
padding-bottom: 0.25em;
border-bottom: 1px solid var(--border);
}
.prose h4 {
font-family: var(--font-serif), ui-serif, serif;
font-size: 1.0625rem; /* 17px */
font-weight: 500;
letter-spacing: -0.005em;
line-height: 1.4;
margin-top: 1.5em;
margin-bottom: 0.375em;
color: var(--foreground);
}
/* Description paragraph (fumadocs adds text-lg + muted) */
.prose > p:first-of-type:has(+ *) {
line-height: 1.6;
}
/* ---------------------------------------------------------------------------
* Links — Vercel-style hairline underline, reveal brand on hover
*
* Markdown-heavy prose can put 4+ inline links in a single sentence; a
* permanent brand-color underline on every one turns the paragraph into
* highlighter spam. The trick isn't "no underline" — it's underlining
* in the hairline border color so the line exists but visually recedes.
* Hover swaps both text and underline to brand color (no thickness
* change) — the link "arrives" as a single color shift.
* ------------------------------------------------------------------------- */
.prose a:not([data-card]):not(.not-prose) {
color: var(--foreground);
font-weight: 500;
text-decoration: underline;
text-decoration-color: var(--border);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: text-decoration-color 150ms, color 150ms;
}
.prose a:not([data-card]):not(.not-prose):hover {
color: var(--primary);
text-decoration-color: var(--primary);
}
/* Callout already carries four visual signals (left brand bar, brand-wash
* bg, uppercase NOTE label, body). Another decoration over-loads it — so
* links inside a callout drop the underline entirely. Color shift on
* hover is the full affordance. */
.prose div.shadow-md:has(> [role="none"]) a:not([data-card]):not(.not-prose),
.prose div.shadow-md:has(> [role="none"]) a:not([data-card]):not(.not-prose):hover {
text-decoration: none;
}
/* Inline code — warm beige chip, accent-color text */
.prose :not(pre) > code {
background: var(--docs-code-bg);
color: var(--accent-foreground);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: var(--font-mono), ui-monospace, monospace;
font-size: 0.875em;
font-weight: 500;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.prose :not(pre) > code::before,
.prose :not(pre) > code::after {
content: none;
}
/* Lists */
.prose :where(ul, ol) > li {
margin-top: 0.375em;
margin-bottom: 0.375em;
padding-inline-start: 0.375em;
}
.prose :where(ul) > li::marker {
color: var(--docs-faint);
content: "— ";
font-family: var(--font-serif), serif;
}
.prose :where(ol) > li::marker {
color: var(--muted-foreground);
}
/* Blockquote — editorial accent rule, serif voice */
.prose blockquote {
font-family: var(--font-serif), ui-serif, serif;
font-weight: 400;
font-size: 1.0625rem;
line-height: 1.55;
color: var(--foreground);
border-inline-start-width: 2px;
border-inline-start-color: var(--primary);
padding-inline-start: 1.25em;
margin-block: 1.5em;
quotes: none;
}
.prose blockquote p::before,
.prose blockquote p::after {
content: none;
}
/* Tables — hairline below thead only, no outer frame (Stripe / Linear
* docs convention). The heavier ink-color top rule v2 used on its API
* reference block is intentionally not applied here — that treatment is
* "this is a formal declaration"; regular guide tables want quiet. */
.prose table {
font-size: 0.9375em;
border-collapse: collapse;
margin-block: 1.5em;
}
.prose thead {
border-bottom: 1px solid var(--border);
}
.prose thead th {
font-family: var(--font-sans), system-ui, sans-serif;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
padding-block: 0.5rem 0.625rem;
text-align: start;
}
.prose tbody tr {
border-bottom: 1px solid var(--border);
}
.prose tbody td {
padding-block: 0.875rem;
}
/* HR — heavier ruled separator */
.prose hr {
border: none;
border-top: 1px solid var(--docs-rule);
margin-block: 3em;
}
/* ---------------------------------------------------------------------------
* Callout — editorial 2px accent bar + soft accent wash
* ------------------------------------------------------------------------- */
.prose div.shadow-md:has(> [role="none"]) {
box-shadow: none !important;
border-radius: 0 4px 4px 0 !important;
background: var(--accent) !important;
border: none !important;
border-inline-start: 2px solid var(--primary) !important;
padding: 0.875rem 1.125rem !important;
gap: 0.625rem !important;
align-items: flex-start;
margin-block: 1.5rem;
}
.prose div.shadow-md:has(> [role="none"]) > [role="none"] {
display: none;
}
.prose div.shadow-md:has(> [role="none"]) > div:last-child > p {
font-family: var(--font-sans), system-ui, sans-serif;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--primary);
margin-bottom: 0.375rem;
}
.prose div.shadow-md:has(> [role="none"]) > div:last-child > div {
color: var(--foreground) !important;
font-size: 0.9375rem;
line-height: 1.6;
}
/* ---------------------------------------------------------------------------
* Cards — fallback editorial treatment for fumadocs's <Cards>/<Card>
* (NumberedCards is the showpiece; this keeps non-showpiece pages on tone)
* ------------------------------------------------------------------------- */
.prose [data-card]:not(.peer) {
border-radius: 4px !important;
border: 1px solid var(--border) !important;
background: var(--card);
padding: 1.125rem !important;
transition: border-color 150ms, background-color 150ms !important;
}
.prose [data-card]:not(.peer):hover {
border-color: var(--primary) !important;
background: var(--card) !important;
}
.prose [data-card]:not(.peer) > div:first-child {
box-shadow: none !important;
border-radius: 0 !important;
padding: 0 !important;
background: transparent !important;
border: none !important;
color: var(--accent-foreground) !important;
margin-bottom: 0.75rem !important;
}
.prose [data-card]:not(.peer) > div:first-child svg {
color: var(--accent-foreground);
}
.prose [data-card]:not(.peer) h3 {
font-family: var(--font-serif), serif !important;
font-size: 1.125rem !important;
font-weight: 500 !important;
font-style: normal !important;
letter-spacing: -0.01em;
margin-bottom: 0.25rem !important;
margin-top: 0 !important;
text-transform: none !important;
border-bottom: none !important;
padding-bottom: 0 !important;
color: var(--foreground) !important;
}
.prose [data-card]:not(.peer) p {
color: var(--muted-foreground) !important;
line-height: 1.6;
font-size: 0.9375rem !important;
}
/* ---------------------------------------------------------------------------
* Sidebar — editorial chrome
*
* Section headers: small uppercase sans label, ruled bottom border.
* Items: muted-foreground at rest, foreground on hover.
* Active: solid background fill (mirrors product app's app-sidebar.tsx —
* data-active:bg-sidebar-accent / data-active:text-sidebar-accent-foreground).
* ------------------------------------------------------------------------- */
#nd-sidebar p,
#nd-sidebar-mobile p {
font-family: var(--font-sans), system-ui, sans-serif;
font-size: 0.6875rem; /* 11px */
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted-foreground);
height: auto;
display: block;
margin-top: 1.5rem;
margin-bottom: 0.375rem;
padding-block: 0 0.375rem;
padding-inline-start: 0.5rem;
border-bottom: 1px solid var(--border);
}
#nd-sidebar p:first-child,
#nd-sidebar-mobile p:first-child {
margin-top: 0;
}
#nd-sidebar a[data-active],
#nd-sidebar-mobile a[data-active] {
height: auto;
padding: 0.375rem 0.625rem;
font-size: 0.84375rem; /* 13.5px */
border-radius: var(--radius-sm);
font-weight: 400;
line-height: 1.4;
letter-spacing: -0.005em;
display: flex;
align-items: center;
}
#nd-sidebar a[data-active="false"],
#nd-sidebar-mobile a[data-active="false"] {
color: var(--muted-foreground);
}
#nd-sidebar a[data-active="false"]:hover,
#nd-sidebar-mobile a[data-active="false"]:hover {
background: color-mix(in oklab, var(--sidebar-accent) 70%, transparent);
color: var(--foreground);
}
/* Active — solid background fill, no left mark (matches product app) */
#nd-sidebar a[data-active="true"],
#nd-sidebar-mobile a[data-active="true"] {
background: var(--sidebar-accent) !important;
color: var(--sidebar-accent-foreground) !important;
font-weight: 500;
}
/* Sidebar footer — drop the hard top rule. The scroll viewport already
* fades content into the footer, so a 1px line on top reads as a
* double-weight edge. Fumadocs hardcodes `border-t p-4 pt-2` on its
* SidebarFooter div; target that exact class trio inside the sidebar IDs
* so we don't touch any other border-t in the app. */
#nd-sidebar .border-t.p-4.pt-2,
#nd-sidebar-mobile .border-t.p-4.pt-2 {
border-top-width: 0;
}
/* ---------------------------------------------------------------------------
* Top nav — quiet, ruled bottom
* ------------------------------------------------------------------------- */
#nd-nav,
#nd-subnav {
border-bottom: 1px solid var(--border);
background: var(--card);
}
#nd-nav a,
#nd-subnav a {
font-size: 0.875rem;
color: var(--muted-foreground);
transition: color 150ms;
}
#nd-nav a:hover,
#nd-subnav a:hover {
color: var(--foreground);
}
/* ---------------------------------------------------------------------------
* TOC (right rail) — quiet sans, brand-color when active
* ------------------------------------------------------------------------- */
#nd-toc a {
font-size: 0.84375rem;
color: var(--muted-foreground);
padding-block: 0.3125rem;
letter-spacing: -0.005em;
transition: color 150ms;
}
#nd-toc a:hover {
color: var(--foreground);
}
#nd-toc a[data-active="true"] {
color: var(--primary);
font-weight: 500;
}
/* TOC heading (Fumadocs renders "On this page" as an h3 / first p) */
#nd-toc h3,
#nd-toc > p:first-child {
font-family: var(--font-sans), system-ui, sans-serif;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted-foreground);
margin-bottom: 0.625rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--border);
}
/* ---------------------------------------------------------------------------
* Code blocks — warm beige (light) / warm dark (dark), NOT pinned
*
* Removes the previous "always-dark hero black" treatment. Code surface
* now follows page theme so it harmonizes with the warm-paper background
* in light mode and warm-dark in dark mode. Terminal-style blocks
* (handled by the custom <Terminal> component, not here) stay pinned to
* the deeper warm dark for the "shell session" feel.
* ------------------------------------------------------------------------- */
article figure.shiki {
background: var(--docs-code-bg) !important;
border: 1px solid var(--docs-code-border) !important;
border-radius: 4px !important;
box-shadow: none !important;
margin-block: 1.25rem !important;
color: var(--foreground);
}
article figure.shiki pre {
background: transparent !important;
border: none !important;
border-radius: 0 !important;
color: inherit !important;
margin: 0 !important;
}
article figure.shiki > div[class*="overflow-auto"] {
font-size: 0.84375rem !important;
line-height: 1.7;
padding: 1rem 1.125rem !important;
}
/* Header bar (filename via ```lang filename="x.ts") */
article figure.shiki > div[class*="border-b"] {
border-bottom-color: var(--docs-code-border) !important;
background: var(--muted) !important;
color: var(--muted-foreground) !important;
font-family: var(--font-mono), ui-monospace, monospace;
font-size: 0.75rem;
letter-spacing: -0.005em;
}
/* Shiki tokens — pick the palette that matches page theme.
* Default (light): use --shiki-light. Override under .dark to --shiki-dark.
* Specificity: article figure.shiki code span (0,1,4) beats fumadocs's
* default, so no !important needed for the light path. */
article figure.shiki code span {
color: var(--shiki-light);
}
.dark article figure.shiki code span {
color: var(--shiki-dark);
}
/* Copy button on code blocks */
article figure.shiki button {
color: var(--muted-foreground) !important;
background: transparent !important;
}
article figure.shiki button:hover {
color: var(--foreground) !important;
background: var(--muted) !important;
}

View File

@@ -1,4 +1,57 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { ArrowUpRight } from "lucide-react";
// Docs-local stateless Multica mark — matches @multica/ui's MulticaIcon
// visually (same 8-pointed-asterisk clip-path), but without useState/
// useEffect so it's safe to render from Server Components such as
// layout.config.tsx / layout.tsx. Keep in sync with
// packages/ui/components/common/multica-icon.tsx if the mark changes.
const MULTICA_CLIP = `polygon(
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
)`;
function MulticaMark() {
return (
<span className="inline-block size-[1em]" aria-hidden="true">
<span
className="block size-full bg-current"
style={{ clipPath: MULTICA_CLIP }}
/>
</span>
);
}
// GitHub mark — inlined SVG (lucide-react dropped the Github icon for brand
// trademark reasons). Path matches apps/web/features/landing/components/
// shared.tsx GitHubMark.
function GitHubMark() {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="size-[1em]"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.65 7.65 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}
// External links shown at the top of the sidebar (and in the top nav on
// desktop). Leading icon = brand identity (GitHub mark / Multica asterisk);
// trailing ArrowUpRight = "opens externally" glyph, same pattern as
// `packages/views/layout/help-launcher.tsx` from PR #1560.
const externalLinkText = (label: string) => (
<span className="inline-flex items-center gap-1">
{label}
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/60" />
</span>
);
export const baseOptions: BaseLayoutProps = {
nav: {
@@ -8,12 +61,16 @@ export const baseOptions: BaseLayoutProps = {
},
links: [
{
text: "GitHub",
icon: <GitHubMark />,
text: externalLinkText("GitHub"),
url: "https://github.com/multica-ai/multica",
external: true,
},
{
text: "Cloud",
icon: <MulticaMark />,
text: externalLinkText("Multica"),
url: "https://multica.ai",
external: true,
},
],
};

View File

@@ -1,30 +0,0 @@
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
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>
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
</RootProvider>
</body>
</html>
);
}

View File

@@ -1,37 +0,0 @@
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 function Page() {
const page = source.getPage([]);
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 function generateMetadata(): Metadata {
const page = source.getPage([]);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

50
apps/docs/app/sitemap.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { MetadataRoute } from "next";
import { source } from "@/lib/source";
import { i18n } from "@/lib/i18n";
import { absoluteDocsUrl } from "@/lib/site";
/**
* Dynamic sitemap — pulls the full page list from Fumadocs' source at build
* time. Each logical page emits one entry; all available language variants
* are declared as hreflang alternates so Google treats them as the same
* article, not as duplicates.
*
* Served at `/docs/sitemap.xml` (because of basePath). The root
* `apps/web/app/robots.ts` references this URL so crawlers discover it.
*/
export default function sitemap(): MetadataRoute.Sitemap {
// Group pages by canonical slug so multiple locales collapse to one entry.
const bySlug = new Map<string, Map<string, string>>();
for (const { language, pages } of source.getLanguages()) {
for (const page of pages) {
const slugKey = page.slugs.join("/");
const languages = bySlug.get(slugKey) ?? new Map<string, string>();
languages.set(language, page.url);
bySlug.set(slugKey, languages);
}
}
const entries: MetadataRoute.Sitemap = [];
for (const languages of bySlug.values()) {
// Canonical is the default-language URL when available, otherwise the
// first available locale (covers pages still mid-translation).
const canonicalRelative =
languages.get(i18n.defaultLanguage) ?? languages.values().next().value;
if (!canonicalRelative) continue;
const alternates: Record<string, string> = {};
for (const [lang, relative] of languages) {
alternates[lang] = absoluteDocsUrl(relative);
}
alternates["x-default"] = absoluteDocsUrl(canonicalRelative);
entries.push({
url: absoluteDocsUrl(canonicalRelative),
alternates: { languages: alternates },
});
}
return entries;
}

View File

@@ -0,0 +1,157 @@
/**
* Multica architecture diagram for §1.2 "How Multica Works".
*
* Boundary-style layout: one large panel for "Your side" (where all the
* interesting stuff happens — code, keys, compute), one smaller panel for
* "Multica" (metadata store and coordinator). The asymmetric sizes and the
* brand-tinted left panel visually argue Multica's core thesis: AI runs on
* your machine, not ours.
*
* No SVG arrows. Relationships are carried by the layout itself — client
* side vs. server side is the universal mental model, readers don't need
* arrows to understand it.
*/
export function ArchitectureDiagram() {
return (
<div className="not-prose my-8">
{/* Desktop: asymmetric two-panel with connector */}
<div className="hidden md:grid md:grid-cols-[1.7fr_auto_1fr] md:gap-4 md:items-stretch">
<YourSide />
<Connector horizontal />
<MulticaSide />
</div>
{/* Mobile: stacked */}
<div className="md:hidden space-y-4">
<YourSide />
<Connector horizontal={false} />
<MulticaSide />
</div>
</div>
);
}
function YourSide() {
return (
<div className="rounded-lg border border-brand/30 bg-brand/[0.03] p-6 flex flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-brand mb-5">
Your side
</div>
<div className="flex-1 space-y-5">
{/* Client surfaces */}
<div>
<SectionLabel>Client</SectionLabel>
<div className="flex flex-wrap gap-2">
<Pill>Web app</Pill>
<Pill>CLI</Pill>
</div>
</div>
{/* Horizontal separator */}
<div className="h-px bg-brand/15" />
{/* Daemon + local tools */}
<div>
<SectionLabel>Daemon</SectionLabel>
<div className="text-xs text-muted-foreground mb-2.5">
Polls work from Multica. Invokes local AI coding tools:
</div>
<div className="flex flex-wrap gap-1.5">
<Pill>Claude Code</Pill>
<Pill>Codex</Pill>
<Pill>Cursor</Pill>
<Pill>Copilot</Pill>
<Pill muted>+ 6 more</Pill>
</div>
</div>
</div>
{/* Tagline */}
<div className="mt-6 pt-4 border-t border-brand/20 flex items-center justify-center gap-3 text-[13px] font-medium text-brand">
<span>Your code.</span>
<span className="text-brand/40">·</span>
<span>Your keys.</span>
<span className="text-brand/40">·</span>
<span>Your CPU.</span>
</div>
</div>
);
}
function MulticaSide() {
return (
<div className="rounded-lg border border-border/70 bg-muted/25 p-6 flex flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground mb-5">
Multica
</div>
<div className="flex-1 flex flex-col">
<SectionLabel>Server</SectionLabel>
<div className="text-xs text-muted-foreground mb-4">
Cloud or self-hosted
</div>
<div className="text-xs space-y-1.5 text-foreground/80">
<div>Workspaces</div>
<div>Issues &amp; tasks</div>
<div>Agent definitions</div>
<div>Realtime (WebSocket)</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-border/60 text-[11px] text-muted-foreground text-center uppercase tracking-[0.08em]">
No AI execution here.
</div>
</div>
);
}
function Connector({ horizontal }: { horizontal: boolean }) {
if (horizontal) {
return (
<div
className="flex items-center justify-center text-muted-foreground/50 text-xl select-none px-1"
aria-hidden="true"
>
</div>
);
}
return (
<div
className="text-center text-muted-foreground/50 text-xl select-none"
aria-hidden="true"
>
</div>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70 mb-1.5">
{children}
</div>
);
}
function Pill({
children,
muted = false,
}: {
children: React.ReactNode;
muted?: boolean;
}) {
return (
<span
className={`inline-flex items-center rounded-md border px-2 py-1 text-[11px] font-medium ${
muted
? "border-border/50 bg-background/50 text-muted-foreground"
: "border-border/70 bg-background text-foreground"
}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { cn } from "@multica/ui/lib/utils";
import { i18n } from "@/lib/i18n";
import { localeLabels } from "@/lib/translations";
// Sidebar-footer chrome: a language switch on the left and a theme switch
// on the right. Replaces Fumadocs's default icon-only row, which buried
// the language option behind a tiny globe. Each control shows the current
// value as a label so the affordance is obvious at a glance.
const BASE_PATH = "/docs";
function switchLocalePath(pathname: string, target: string): string {
// Next strips basePath before the router, so `pathname` starts at `/`
// or `/<locale>/...`. Default-locale URLs are prefix-less.
const segments = pathname.split("/").filter(Boolean);
const first = segments[0];
const hasLocalePrefix =
first && i18n.languages.some((l) => l === first && l !== i18n.defaultLanguage);
const rest = hasLocalePrefix ? segments.slice(1) : segments;
const prefixed =
target === i18n.defaultLanguage ? rest : [target, ...rest];
return "/" + prefixed.join("/");
}
const THEME_OPTIONS: { value: string; label: string; icon: ReactNode }[] = [
{ value: "light", label: "Light", icon: <Sun className="size-4" /> },
{ value: "dark", label: "Dark", icon: <Moon className="size-4" /> },
{ value: "system", label: "System", icon: <Monitor className="size-4" /> },
];
export function DocsSettings({ locale }: { locale: string }) {
const router = useRouter();
const pathname = usePathname();
const { theme, setTheme } = useTheme();
// Gate theme reads until mount — next-themes is SSR-incompatible and
// would otherwise cause a hydration flash of the wrong icon.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const activeTheme = mounted ? (theme ?? "system") : "system";
const activeThemeOption =
THEME_OPTIONS.find((o) => o.value === activeTheme) ?? THEME_OPTIONS[2]!;
const handleLocaleChange = (next: string) => {
if (next === locale) return;
const internal = pathname.startsWith(BASE_PATH)
? pathname.slice(BASE_PATH.length) || "/"
: pathname;
router.push(switchLocalePath(internal, next));
};
return (
<div className="flex w-full items-center justify-end gap-2">
{/* Language — left pill. Shows current language name. */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
className="font-normal text-muted-foreground"
aria-label="Switch language"
>
{localeLabels[locale as keyof typeof localeLabels] ?? locale}
</Button>
}
/>
<DropdownMenuContent align="start" side="top" className="min-w-[140px]">
{i18n.languages.map((lang) => (
<DropdownMenuItem
key={lang}
onClick={() => handleLocaleChange(lang)}
className={cn(lang === locale && "bg-accent")}
>
{localeLabels[lang as keyof typeof localeLabels]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Theme — right icon button. Matched height to the sm pill via
the icon-sm size token; without this the icon variant defaults
to 32px while size="sm" is 28px, misaligning them. */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
aria-label="Switch theme"
>
{activeThemeOption.icon}
</Button>
}
/>
<DropdownMenuContent align="end" side="top" className="min-w-[140px]">
{THEME_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
"gap-2",
opt.value === activeTheme && "bg-accent",
)}
>
{opt.icon}
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import Link from "next/link";
import type { ReactNode } from "react";
/**
* Byline — editorial metadata strip with ruled top + bottom borders.
*
* Sits below DocsHero on showpiece pages (welcome). Carries the small
* uppercase metadata: section · updated · read time. Mirrors the v2
* editorial pattern of a "by-line" between title and body, separating
* the heading hero from the article proper.
*/
export function Byline({ items }: { items: string[] }) {
return (
<div className="not-prose mb-9 flex items-center gap-3.5 border-y border-[var(--docs-rule)] py-3.5 text-xs uppercase tracking-[0.08em] text-muted-foreground">
{items.map((item, i) => (
<span key={i} className="flex items-center gap-3.5">
{i > 0 ? (
<span className="size-[3px] rounded-full bg-[var(--docs-faint)]" />
) : null}
<span>{item}</span>
</span>
))}
</div>
);
}
/**
* NumberedCards — three-column ruled-divider grid with No.01/02/03 serif
* numbers. Showpiece component; replaces fumadocs's <Cards> on the welcome
* page. Top + bottom heavy rules frame the row.
*/
export function NumberedCards({ children }: { children: ReactNode }) {
return (
<div className="not-prose my-9 grid grid-cols-1 border-y border-[var(--docs-rule)] md:grid-cols-3">
{children}
</div>
);
}
/**
* NumberedCard — child of NumberedCards. Internally numbered by CSS counter,
* but we also accept an explicit `number` prop in case the consumer wants
* to override (e.g. start at "03").
*/
export function NumberedCard({
number,
title,
href,
tag,
children,
}: {
number?: string;
title: string;
href: string;
tag?: string;
children: ReactNode;
}) {
return (
<Link
href={href}
className="group flex flex-col gap-2.5 border-r border-border px-0 py-5 pr-4 no-underline last:border-r-0 md:px-4 md:first:pl-0 md:last:pr-0"
>
<div className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted-foreground">
{number ? `No. ${number}` : null}
</div>
<div className="font-[family-name:var(--font-serif)] text-[1.375rem] leading-[1.25] tracking-[-0.015em] text-foreground transition-colors group-hover:text-[var(--primary)]">
{title}
</div>
<div className="text-[0.84375rem] leading-[1.55] text-muted-foreground">
{children}
</div>
{tag ? (
<div className="mt-1 font-mono text-[0.625rem] uppercase tracking-[0.06em] text-[var(--primary)]">
{tag}
</div>
) : null}
</Link>
);
}
/**
* NumberedSteps — large serif step numbers, ruled-row separators.
* Use for sequential walkthroughs (install → login → start → assign).
*/
export function NumberedSteps({ children }: { children: ReactNode }) {
return <div className="not-prose my-7 border-t border-border">{children}</div>;
}
export function Step({
number,
title,
children,
}: {
number: string;
title: string;
children: ReactNode;
}) {
return (
<div className="grid grid-cols-[3.5rem_1fr] gap-5 border-b border-border py-5">
<div className="font-[family-name:var(--font-serif)] text-[2rem] font-normal leading-none tracking-[-0.02em] text-[var(--primary)]">
{number}
</div>
<div>
<div className="mb-1 font-[family-name:var(--font-serif)] text-[1.25rem] leading-[1.3] tracking-[-0.01em] text-foreground">
{title}
</div>
<div className="text-[0.9375rem] leading-[1.6] text-muted-foreground">
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import type { ReactNode } from "react";
/**
* DocsHero — editorial showpiece header for landing-style pages.
*
* Escapes prose scope to run its own type scale. Title accepts ReactNode so
* callers can pass <em> spans for brand-color emphasis (italic is avoided —
* Chinese italic is a synthetic slant and reads as broken).
*/
export function DocsHero({
eyebrow,
title,
subtitle,
}: {
eyebrow?: string;
title: ReactNode;
subtitle?: ReactNode;
}) {
return (
<section className="not-prose mb-7 pt-2">
{eyebrow ? (
<p className="mb-5 text-[0.6875rem] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{eyebrow}
</p>
) : null}
<h1 className="mb-5 font-[family-name:var(--font-serif)] text-[2.25rem] font-normal leading-[1.05] tracking-[-0.025em] text-foreground sm:text-[2.75rem]">
{title}
</h1>
{subtitle ? (
<p className="max-w-[36rem] font-[family-name:var(--font-serif)] text-[1.25rem] leading-[1.5] tracking-[-0.005em] text-[oklch(from_var(--foreground)_calc(l+0.06)_c_h)]">
{subtitle}
</p>
) : null}
</section>
);
}
/**
* DocsFeatureGrid / DocsFeatureCard — kept for back-compat with any pages
* still using the old card grid before the editorial migration. Prefer
* <NumberedCards>/<NumberedCard> from editorial.tsx for showpiece pages.
*/
export function DocsFeatureGrid({ children }: { children: ReactNode }) {
return (
<div className="not-prose my-8 grid grid-cols-1 gap-3 md:grid-cols-3">
{children}
</div>
);
}
export function DocsFeatureCard({
icon,
title,
description,
href,
}: {
icon: ReactNode;
title: string;
description: string;
href: string;
}) {
return (
<Link
href={href}
className="group flex flex-col gap-3 rounded-[4px] border border-border bg-card p-5 no-underline transition-all hover:border-[var(--primary)]"
>
<div className="flex size-9 items-center justify-center text-[var(--accent-foreground)] [&_svg]:size-[20px]">
{icon}
</div>
<div className="flex flex-col gap-1.5">
<span className="font-[family-name:var(--font-serif)] text-[1.0625rem] font-medium tracking-[-0.01em] text-foreground">
{title}
</span>
<p className="text-sm leading-[1.55] text-muted-foreground">
{description}
</p>
</div>
</Link>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { useEffect, useId, useState } from "react";
import { useTheme } from "next-themes";
/**
* Client-side Mermaid diagram renderer.
*
* Dynamic-imports the mermaid package so it's only loaded on pages that
* actually use it (~400 KB). Re-renders when the page theme flips.
*
* Themed to pick up Multica design tokens at runtime via getComputedStyle,
* so the diagram tracks both light / dark mode and any future token changes
* without a rebuild.
*/
export function Mermaid({ chart }: { chart: string }) {
const reactId = useId();
const { resolvedTheme } = useTheme();
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void import("mermaid").then(({ default: mermaid }) => {
const css = getComputedStyle(document.documentElement);
// Mermaid's khroma parser only understands legacy color syntax (hex /
// rgb / hsl / named). Our tokens are authored in oklch(), which
// getComputedStyle preserves verbatim, and a `color-mix(in srgb, ...)`
// round-trip still serializes as `color(srgb r g b)` per CSS Color 4.
// Rasterize each token through a 1x1 canvas: fillStyle accepts any CSS
// <color>, getImageData returns concrete 8-bit sRGB bytes regardless
// of the input's color space.
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const v = (name: string, fallback: string) => {
const raw = css.getPropertyValue(name).trim();
if (!raw || !ctx) return fallback;
// fillStyle silently ignores unparseable input; prime with a known
// baseline so a parse failure paints black, not whatever was last set.
ctx.fillStyle = "#000";
ctx.fillStyle = raw;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
return `rgb(${r}, ${g}, ${b})`;
};
const brand = v("--brand", "#3b82f6");
const brandFg = v("--brand-foreground", "#ffffff");
const background = v("--background", "#ffffff");
const foreground = v("--foreground", "#111111");
const muted = v("--muted", "#f5f5f5");
const mutedFg = v("--muted-foreground", "#6b7280");
const border = v("--border", "#e5e5e5");
const accent = v("--accent", muted);
mermaid.initialize({
startOnLoad: false,
theme: "base",
securityLevel: "strict",
fontFamily: "inherit",
themeVariables: {
// Canvas
background,
mainBkg: background,
// Nodes — soft muted fill with full-contrast text and a subtle border
primaryColor: muted,
primaryTextColor: foreground,
primaryBorderColor: border,
secondaryColor: accent,
secondaryTextColor: foreground,
secondaryBorderColor: border,
tertiaryColor: background,
tertiaryTextColor: foreground,
tertiaryBorderColor: border,
// Edges + labels
lineColor: mutedFg,
textColor: foreground,
edgeLabelBackground: background,
labelBackground: background,
// Clusters (subgraph boxes)
clusterBkg: accent,
clusterBorder: border,
titleColor: foreground,
// Notes / callouts
noteBkgColor: muted,
noteTextColor: foreground,
noteBorderColor: border,
// Brand accent — used for active / start states in state diagrams,
// user-decision diamonds in flowcharts, etc.
activeTaskBkgColor: brand,
activeTaskBorderColor: brand,
altBackground: muted,
// Sequence / git diagrams (harmless if unused)
actorBkg: muted,
actorBorder: border,
actorTextColor: foreground,
actorLineColor: mutedFg,
signalColor: foreground,
signalTextColor: foreground,
// Fine print
errorBkgColor: muted,
errorTextColor: foreground,
},
});
// mermaid requires a DOM-valid id; useId returns ":r0:" which isn't.
const domId = `mermaid-${reactId.replace(/:/g, "")}`;
mermaid
.render(domId, chart.trim())
.then((result) => {
if (!cancelled) {
setSvg(result.svg);
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setSvg(null);
}
});
});
return () => {
cancelled = true;
};
}, [chart, reactId, resolvedTheme]);
if (error) {
return (
<pre className="my-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
Mermaid error: {error}
</pre>
);
}
if (!svg) {
return (
<div className="my-4 text-sm text-muted-foreground">
Rendering diagram
</div>
);
}
return (
<div
className="my-6 flex justify-center overflow-x-auto rounded-md border border-border/60 bg-muted/20 p-6 [&_.label_foreignObject>div]:!font-[inherit] [&_.nodeLabel]:!font-[inherit] [&_.edgeLabel]:!font-[inherit] [&_text]:!font-[inherit]"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -0,0 +1,127 @@
---
title: Create and configure an agent
description: The minimum fields to create an agent, plus every optional setting — system instructions, environment variables, visibility, concurrency limit, and archiving.
---
import { Callout } from "fumadocs-ui/components/callout";
Creating an [agent](/agents) takes only two things: **a name** and **a choice of [AI coding tool](/providers)**. Everything else is optional — system instructions, model, environment variables, CLI arguments, visibility, concurrency limit — the defaults work fine. Get it running first and tune later; every field can be changed at any time.
## Create an agent
Prerequisite: you already have at least one supported [AI coding tool](/providers) installed on your machine (Claude Code, Codex, etc.) and a [daemon](/daemon-runtimes) running. If you're not there yet, start with [Cloud quickstart](/cloud-quickstart) or [Self-host quickstart](/self-host-quickstart).
Once that's in place, go to the **Agents** page in your workspace and click **+ New**, or use the CLI:
```bash
multica agent create
```
The form has only two required fields: **name** (unique within the workspace) and **runtime** (= pick an AI coding tool). Every other field is covered section by section below.
## Pick an AI coding tool
Each runtime is backed by a specific AI coding tool. Multica supports 10 of them. The most common choices:
| Tool | Good for |
|---|---|
| **Claude Code** | Anthropic's official tool, most complete feature set; **best first pick** |
| **Codex** | OpenAI, the mainstream alternative |
| **Cursor** | Users in the Cursor editor ecosystem |
| **Copilot** | Teams leveraging their GitHub account entitlements |
| **Gemini** | Users in the Google ecosystem |
The other five (Hermes, Kimi, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
## Writing system instructions
**System instructions** (`instructions`) are prepended to every task, telling the agent what role it plays and what rules to follow:
```text
You're a frontend code-review agent. When an issue comes in, read the diff first. Focus only on:
- Styling issues (tailwind class names, box model)
- Accessibility (a11y)
Don't change code — leave suggestions in a comment.
```
When left blank (the default), the agent uses the native behavior of its underlying AI coding tool with no extra constraints.
## Picking a model
Most AI coding tools support model selection (for example, Claude Code lets you pick between Sonnet and Opus). Leave it blank and the tool's own default is used; pick one explicitly and that's what runs. Each tool's supported models are listed in [AI coding tools comparison](/providers).
Changing the model **only applies to new tasks**. Already-dispatched tasks continue with the model that was locked in at dispatch time.
## Custom environment variables (custom_env)
**Custom environment variables** (`custom_env`) let you inject extra env vars at task execution time — typical uses are API keys or switching the upstream endpoint:
```
ANTHROPIC_API_KEY = sk-...
ANTHROPIC_BASE_URL = https://my-proxy.example.com
```
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
<Callout type="warning">
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
</Callout>
## Custom CLI arguments (custom_args)
**Custom CLI arguments** (`custom_args`) is a string array appended one-by-one to the AI coding tool's command line:
```json
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
```
The final command comes out as:
```bash
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
```
Arguments are passed as-is, not through a shell (no injection risk), but whether a given flag is recognized is up to the AI coding tool itself — tools differ substantially here.
<Callout type="tip">
`custom_env` and `custom_args` have no hard caps, but in practice **keep each under 10 entries**. Too many makes the command line long, slows startup, and gets harder to maintain.
</Callout>
## Visibility
- **Workspace** (`workspace`) — any member of the workspace can assign it
- **Private** (`private`) — only workspace owners, admins, or the agent's creator can assign it
New agents default to `private`.
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
## Concurrency limit
**Concurrency limit** (`max_concurrent_tasks`) controls how many tasks this agent can run in parallel at once. The default is **6**. New tasks that hit the cap queue up — they aren't rejected.
This is only the "agent layer" of a two-tier limit — the daemon itself enforces a broader cap (default 20), and whichever is tighter wins. Details in [Daemon and runtimes → How many tasks can run in parallel](/daemon-runtimes#how-many-tasks-can-run-in-parallel).
Changing this value **does not cancel tasks already running** — it only applies to the next task about to be picked up.
## Attaching domain expertise: Skills
A created agent can have **Skills** attached — **knowledge packs** (`SKILL.md` + supporting files) automatically delivered to the AI coding tool at task execution time. You can create a new skill, import from GitHub or ClawHub, or scan one from an existing skill directory on your machine. See [Skills](/skills).
## Archive and restore
Agents you no longer use can be **archived** — they disappear from everyday views, but their historical data (tasks run, comments posted) is fully preserved. **Restore** them anytime to put them back to work.
<Callout type="warning">
**Archiving immediately cancels every unfinished task belonging to the agent** — running, dispatched, and queued tasks are all marked `cancelled` and won't continue. If you have an important task in flight, let it finish before archiving.
</Callout>
Archived agents can't be assigned new tasks.
## Next steps
- [Skills](/skills) — attach knowledge packs to an agent
- [AI coding tools comparison](/providers) — full capability matrix across all 10 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work

View File

@@ -0,0 +1,127 @@
---
title: 创建和配置智能体
description: 创建一个智能体的最小字段,以及所有可选配置项——系统指令、环境变量、可见性、并发上限,和归档机制。
---
import { Callout } from "fumadocs-ui/components/callout";
创建一个 [智能体](/agents) 只要两件事:**名字** 和 **选一款 [AI 编程工具](/providers)**。其他全部可选——系统指令、模型、环境变量、命令行参数、可见性、并发上限——默认值都能用,先跑起来再慢慢调,所有字段随时能改。
## 创建一个智能体
前置条件:你本机已经装好至少一款受支持的 [AI 编程工具](/providers)Claude Code、Codex 等),并跑着 [守护进程](/daemon-runtimes)。如果还没走到这一步,先看 [Cloud 快速开始](/cloud-quickstart) 或 [自部署快速开始](/self-host-quickstart)。
满足之后,在工作区的**智能体**页点 **+ 新建**,或者用命令行:
```bash
multica agent create
```
表单里只有两项必填:**名字**(工作区内唯一)和 **运行时**= 选一款 AI 编程工具)。其他字段下面一节一节讲。
## 选一款 AI 编程工具
运行时背后是一款具体的 AI 编程工具。Multica 支持 10 款,最常用的几款:
| 工具 | 适合 |
|---|---|
| **Claude Code** | Anthropic 官方,功能最完整;**新手首选** |
| **Codex** | OpenAI主流替代 |
| **Cursor** | Cursor 编辑器生态用户 |
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
另外 5 款Hermes、Kimi、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
## 写系统指令
**系统指令**`instructions`)会被拼在每次任务最前面,告诉这个智能体它扮演什么角色、遵守什么规则:
```text
你是一个前端代码审查智能体。拿到 issue 先读 diff只关注
- 样式问题tailwind 类名、盒模型)
- 可访问性a11y
不改代码,只在评论里给建议。
```
留空时(默认),智能体用它背后 AI 编程工具的原生行为,没有额外约束。
## 选模型
大多数 AI 编程工具支持选模型(例如 Claude Code 能在 Sonnet / Opus 里选)。留空 → 用工具自己的默认;明确选了 → 用选的。每款工具支持的模型见 [AI 编程工具对照](/providers)。
改模型**只对新任务生效**。已经派发出去的任务继续用派发时固化下来的模型。
## 自定义环境变量custom_env
**自定义环境变量**`custom_env`)让你在任务执行时注入额外的 env var——典型用途是 API key 或切换上游 endpoint
```
ANTHROPIC_API_KEY = sk-...
ANTHROPIC_BASE_URL = https://my-proxy.example.com
```
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn不会报错
<Callout type="warning">
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值API 返回 `****`但数据库备份、DB 审计里仍然能看到。
**不要把高价值 secret 放进 `custom_env`**生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT定期轮换。
</Callout>
## 自定义命令行参数custom_args
**自定义命令行参数**`custom_args`)是一串字符串数组,会被逐个追加到 AI 编程工具的命令行尾部:
```json
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
```
拼完会是:
```bash
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
```
参数按原样传,不走 shell 解析(没有注入风险),但传什么 flag 能不能被识别看 AI 编程工具本身——不同工具差异很大。
<Callout type="tip">
`custom_env` 和 `custom_args` 没有硬限制,但**实际使用建议控制在 10 条以内**。太多会让命令行变长、启动变慢,也更难维护。
</Callout>
## 可见性
- **工作区可见**`workspace`)—— 工作区里任何成员都能分配
- **私有**`private`)—— 只有工作区的 owner、admin或智能体的创建者能分配
新建默认 `private`。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
## 并发上限
**并发上限**`max_concurrent_tasks`)决定这个智能体同一时间最多同时跑几个任务,默认 **6**。达到上限的新任务留在队列排队,不会被拒绝。
这只是两层限额里的"智能体层"——守护进程本身还有一层更粗的限额(默认 20两层中更紧的那层生效。详见 [守护进程与运行时 → 一次能并发跑多少任务](/daemon-runtimes#一次能并发跑多少任务)。
修改这个值**不会取消已经在跑的任务**——只对下一个要被领走的任务生效。
## 挂专业知识Skill
创建好的智能体可以挂 **Skill**——一种**专业知识包**`SKILL.md` + 支持文件),任务执行时自动送到对应的 AI 编程工具。可以新建、从 GitHub / ClawHub 导入、或从你本机已有的 skill 目录扫入。详见 [Skills](/skills)。
## 归档和恢复
不再用的智能体可以**归档**——它从日常视图里消失,但历史数据(跑过的任务、发过的评论)全部保留。想重新用时**恢复**即可。
<Callout type="warning">
**归档会立刻取消这个智能体所有未结束的任务**——正在跑的、已派发的、还在排队的都会被标为 `cancelled`,不会继续执行。如果有重要任务在跑,先让它完成再归档。
</Callout>
已归档的智能体无法被分配新任务。
## 下一步
- [Skills](/skills) —— 给智能体挂专业知识包
- [AI 编程工具对照](/providers) —— 10 款工具的完整能力差别
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来

View File

@@ -0,0 +1,48 @@
---
title: Agents
description: "An agent is a first-class member of a Multica workspace — it can be assigned issues, post comments, and be @-mentioned. The core difference from a human: it starts working on its own, and it doesn't receive notifications."
---
import { Callout } from "fumadocs-ui/components/callout";
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
## What an agent can do
Agents use the same "member" surface as humans, and the UI barely distinguishes them:
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
## How it differs from a human
A few key differences only surface once you actually start using agents:
- **It starts on its own** — after you assign it an issue or `@` it, Multica dispatches the task to its runtime immediately. Unlike a human, it doesn't wait to see the message and respond. For trigger details, see [Assigning issues to agents](/assigning-issues) and [@-mentioning agents in comments](/mentioning-agents).
- **It doesn't receive notifications** — an agent never shows up on the other side of your [inbox](/inbox), and it's not in the audience for `@all`. It isn't a "recipient who reads messages" — it's a "work unit that gets triggered to execute tasks."
- **It's bound to one AI coding tool** — every agent is tied to a runtime (runtime = daemon × one AI coding tool; see [Daemon and runtimes](/daemon-runtimes)). If the tool is offline, the agent can't work; new tasks wait until the runtime comes back.
- **It can be archived** — archive an agent you don't use anymore and it disappears from everyday views; restore it whenever you want. Archiving cancels any tasks currently running.
## Who can assign an agent
When you create an agent, you pick a **visibility** that controls who can assign it to an issue or set it as project lead:
- **Workspace** — any member of the workspace can assign it
- **Private** — only workspace owners, admins, or the agent's creator can assign it
New agents default to **private**. To make one available to the whole workspace, set visibility to `workspace` at creation time, or change it later in the agent's config. For the full role-permission matrix, see [Members and roles](/members-roles).
<Callout type="info">
**Private means "restricted who can assign," not "hidden from everyone else."** Every member of the workspace sees a private agent's name and description in the agents list — they just can't see its config details (custom environment variables, MCP config, and other sensitive fields are masked). If you need "visible to only one person," that's not currently possible.
</Callout>
## Next steps
- [Create and configure an agent](/agents-create) — how to build one
- [Skills](/skills) — attach knowledge packs to an agent
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run

View File

@@ -0,0 +1,48 @@
---
title: 智能体
description: 智能体agent是 Multica 工作区里的一等公民成员——能被分配 issue、发评论、被 @ 点名;和人最大的不同是它自动开工、不收通知。
---
import { Callout } from "fumadocs-ui/components/callout";
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
## 智能体能做什么
智能体和人用的是同一套"成员"接口,界面上几乎没有区别:
- **[被分配 issue](/assigning-issues)** —— 作为 assignee分配后它会自动开工
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
## 它和人不一样在哪
几个关键差异在你真正开始用之后才会浮现:
- **它自动开工**——分配 issue 或 `@` 它之后Multica 会立刻把任务派给它所在的运行时。不像人那样要等 TA 看到消息再响应。触发方式的细节见 [分配 issue 给智能体](/assigning-issues) 和 [在评论里 @智能体](/mentioning-agents)。
- **它不收通知**——智能体永远不会出现在你的 [收件箱](/inbox) 对面;它也不在 `@all` 的接收范围内。它不是"读消息的收信人",而是"被触发执行任务的工作单元"。
- **它绑一款 AI 编程工具**——每个智能体关联一个运行时runtime = 守护进程 × 一款 AI 编程工具,详见 [守护进程与运行时](/daemon-runtimes))。工具不在线,它干不了活,新任务会等到运行时回来。
- **它可以被归档**——不用时把它归档起来,会从日常视图里消失;以后想用随时恢复。归档时正在跑的任务会被取消。
## 谁能把智能体分配出去
创建智能体时会选一个**可见性**visibility决定谁能把它分配给 issue 或设为 project lead
- **工作区可见workspace** —— 工作区里任何成员都能分配
- **私有private** —— 只有工作区的 owner、admin或智能体的创建者能分配
新建的智能体**默认是私有的**。想让全工作区都能用,在创建时把可见性选为 `workspace`,或之后在配置里改。角色权限完整对照见 [成员与权限](/members-roles)。
<Callout type="info">
**私有 = 限制谁能分配,不是对其他人隐藏**。工作区里所有成员都能在智能体列表里看到私有智能体的名字和描述——只是看不到它的配置细节自定义环境变量、MCP 配置等敏感字段被打码)。如果你需要"只对一个人可见",目前做不到。
</Callout>
## 下一步
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
- [Skills](/skills) —— 给智能体挂上专业知识包
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么

View File

@@ -0,0 +1,81 @@
---
title: Assign issues to agents
description: Hand an issue to an agent and it takes over as the official assignee until the work is done — with full context and the ability to change issue status and fields.
---
import { Callout } from "fumadocs-ui/components/callout";
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|---|---|---|---|---|---|
| **Assign** | Hand an agent ownership | Changes assignee | Issue + all comments | Inherits from issue | ✓ |
| [**@-mention**](/mentioning-agents) | Pull it in to take a look | No changes | Issue + trigger comment | Inherits from issue | ✓ |
| [**Chat**](/chat) | One-to-one conversation outside any issue | No issue involved | Current conversation history | Fixed medium | ✓ |
| [**Routines**](/routines) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by routine | ✗ |
"Auto retry" refers to retries after infrastructure failures (runtime offline, timeout). Business errors on the agent side (for example, the model reporting an error) are not retried. See [**Tasks**](/tasks) for details.
## Assign from the UI
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
A few rules:
- **Workspace agents** can be assigned by any member; **private agents** can only be assigned by their owner or a workspace admin.
- You can only assign to agents that have **an online runtime** — agents with no one running them show as unavailable in the picker.
- When the issue status is **Backlog**, assigning **does not trigger the agent** — Backlog is a parking lot; the agent only gets enqueued once you move the issue to Todo or In Progress.
## Assign from the CLI
The command-line equivalent:
```bash
multica issue assign MUL-42 --to alice
```
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
Unassign:
```bash
multica issue assign MUL-42 --unassign
```
## What happens after assignment
When a non-Backlog issue is assigned to an agent, Multica immediately does the following in the background:
1. Enqueues a `queued` `task` with priority inherited from the issue, routed to the runtime where the agent lives.
2. The agent's daemon picks up the `task` on its next poll and transitions it to `dispatched`.
3. The agent starts working and the `task` moves to `running`; on completion it becomes `completed` or `failed`.
4. During execution the agent can change the issue's status, post comments, and edit fields — these actions appear under the agent's identity.
**If the agent is offline**, the `task` waits in the queue — **it times out and fails after 5 minutes** with reason `runtime_offline`. For retryable sources (assign, @-mention, chat), Multica automatically re-enqueues it. See [**Tasks**](/tasks) for the full retry rules.
Assigning also auto-subscribes the agent to the issue — but in Multica **agents do not receive inbox notifications** (only members do). This subscription is internal bookkeeping with no user-visible side effect.
## Reassign or unassign
When you change the assignee from Agent A to Agent B:
1. **Everything A has in flight is cancelled** — every `task` in `queued`, `dispatched`, or `running` state is marked `cancelled`.
2. **B is enqueued a new `task` immediately** (if the issue is not in Backlog and B has an online runtime).
<Callout type="warning">
**Reassignment cancels every active `task` on this issue — not just the old assignee's.** If another agent is working on this issue because of an @-mention, its `task` is cancelled too. There is currently no UI action to cancel a single agent's `task` in isolation.
</Callout>
Unassigning (`--unassign` or picking "none" in the picker) marks all active `task` entries as `cancelled` and **does not enqueue a new one**. Existing subscriptions are not cleared automatically — the old assignee stays on the subscription list (but still receives no inbox notifications).
## Why only one active `task` per agent per issue
**A single agent can have at most one `queued` or `dispatched` `task` on the same issue at any time.** A unique index at the database level plus the claim logic enforces this — it prevents duplicate enqueues and concurrent executions overwriting each other.
But **different agents can work on the same issue in parallel** — for example, Agent A is the assignee and Agent B is @-mentioned; both `task` entries can coexist, each running on its own runtime. See [**Tasks**](/tasks) for the full serial/concurrent rules.
## Next
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Routines**](/routines) — let agents start work automatically on a schedule

View File

@@ -0,0 +1,81 @@
---
title: 分配 issue 给智能体
description: 把 issue 交给智能体,它作为正式负责人一直工作到结束——拿到完整上下文,也能改 issue 状态和字段。
---
import { Callout } from "fumadocs-ui/components/callout";
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|---|---|---|---|---|---|
| **分配** | 让智能体正式负责 | 改 assignee | issue + 全部 comments | 继承 issue | ✓ |
| [**@ 提及**](/mentioning-agents) | 评论里让它看一眼 | 都不改 | issue + 触发评论 | 继承 issue | ✓ |
| [**对话**](/chat) | 独立于 issue 的一对一聊天 | 不涉及 issue | 当前对话历史 | 固定中 | ✓ |
| [**Routines**](/routines) | 定时 / 手动自动化 | 视模式 | 视模式 | routine 自定 | ✗ |
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
## 在界面里分配
在 issue 详情页点 **Assignee** 选择器会列出工作区里所有成员和未归档的智能体。选一个智能体issue 立刻分给它。
几条规则:
- **工作区智能体**任何成员都能分配;**私人智能体**只有它的 owner 或工作区 admin 能分配
- 只能分配给**有在线运行时**的智能体——没人在跑的智能体picker 会提示不可选
- Issue 状态是 **Backlog** 时,分配**不会立刻触发**智能体——Backlog 是停泊场,切到 Todo / In Progress 才会真正入队
## 用 CLI 分配
等价的命令行操作:
```bash
multica issue assign MUL-42 --to alice
```
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配。
取消分配:
```bash
multica issue assign MUL-42 --unassign
```
## 分配之后会发生什么
非 Backlog 的 issue 分配给智能体之后Multica 会立刻在后台做以下事情:
1. 入队一个 `queued` 状态的 `task`,优先级继承自 issue路由到该智能体所在的运行时
2. 该智能体的守护进程下次轮询时把 `task` 领走,状态变成 `dispatched`
3. 智能体开始执行,`task` 转成 `running`;完成后转成 `completed` / `failed`
4. 执行过程中智能体可以改 issue 状态、发评论、改字段——这些动作以智能体的身份出现
**如果智能体离线**`task` 会在队列里等——**5 分钟没被领走就超时失败**,失败原因 `runtime_offline`。对可重试的来源(分配、@ 提及、对话Multica 会自动重新排队;完整重试规则见 [**执行任务**](/tasks)。
分配还会自动把这个智能体加进 issue 的订阅列表——但 Multica 里**智能体不接收 inbox 通知**(只有成员收)。这个订阅只是内部 bookkeeping用户侧没有可见的副作用。
## 换分配人或取消分配
把 assignee 从 Agent A 换成 Agent B 时:
1. **A 这边在跑的一切都被取消**——所有 `queued` / `dispatched` / `running` 状态的 `task` 都被标记 `cancelled`
2. **B 立刻被入队一个新 `task`**(如果 issue 不是 Backlog 且 B 有在线运行时)
<Callout type="warning">
**换分配人会 cancel 掉这个 issue 上所有活跃的 `task`——不只是旧 assignee 的**。如果另一个智能体因为 @ 提及也正在这个 issue 上干活,它的 `task` 也会被一并取消。目前没有只 cancel 单个智能体 `task` 的 UI 操作。
</Callout>
取消分配(`--unassign` 或 picker 里选"无")把所有活跃 `task` 标记 `cancelled`**不入队新的**。已有的订阅关系不会自动清除——旧 assignee 仍留在订阅名单里(但同样收不到 inbox 通知)。
## 为什么同一 issue 同时只能一个活跃 `task`
**同一个智能体在同一个 issue 上,同时只能有一个 `queued` 或 `dispatched` 的 `task`**。数据库层的 unique index 加上 claim 逻辑保证这一点——避免重复入队、避免并发执行互相覆盖。
但**不同智能体在同一个 issue 上可以各自独立跑**——比如 Agent A 是 assigneeAgent B 被 @ 提及,两者的 `task` 可以同时存在,各走各的运行时。完整的串行 / 并发规则见 [**执行任务**](/tasks)。
## 下一步
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Routines**](/routines) —— 让智能体定时自动开工

View File

@@ -0,0 +1,121 @@
---
title: Sign-in and signup configuration
description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica supports two sign-in methods: **email + verification code** (default) and **Google OAuth** (optional). On successful sign-in, the server issues a JWT cookie with a 30-day lifetime. This page covers how to configure each method, how to restrict who can sign up, and the single biggest trap for self-hosted deployments.
For the list of environment variables referenced below, see [Environment variables](/environment-variables); for token usage and lifecycle details, see [Authentication and tokens](/auth-tokens).
## How email + verification code sign-in works
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
1. Create a Resend account and verify your domain
2. Create an API key
3. Set the environment variables:
```bash
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
```
4. Restart the server
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
## The 888888 trap
<Callout type="warning">
**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.**
Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification.
**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself.
</Callout>
To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**.
## Google OAuth configuration
Optional. Without it, only email + verification code is available; with it, the sign-in page gets a "Sign in with Google" button.
1. Create an OAuth 2.0 client in the [Google Cloud Console](https://console.cloud.google.com/)
2. Set the **Authorized redirect URIs** to your Multica frontend address plus `/auth/callback`, for example:
```text
https://multica.yourdomain.com/auth/callback
```
3. Once you have the client ID and client secret, set three environment variables:
```bash
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
```
4. Restart the server.
**Takes effect at runtime**: the frontend reads these settings at runtime via `/api/config` — after changing them, restart the server and the frontend picks up the new values with no rebuild or redeploy.
<Callout type="warning">
**The redirect URI must match exactly in both the Google Console and `GOOGLE_REDIRECT_URI`** — including protocol (`http` vs `https`), trailing slash, and port. Any mismatch and Google rejects the entire OAuth flow; the error shown to the user is `redirect_uri_mismatch`.
</Callout>
## Restricting who can sign up
Three environment variables combine by priority:
<Mermaid chart={`
graph TD
Start[New user first sign-in] --> A{Email in<br/>ALLOWED_EMAILS?}
A -- Yes --> Allow[Allow signup]
A -- No --> B{Domain in<br/>ALLOWED_EMAIL_DOMAINS?}
B -- Yes --> Allow
B -- No --> C{Any allowlist<br/>non-empty?}
C -- Yes --> Block[Reject]
C -- No --> D{ALLOW_SIGNUP<br/>= true?}
D -- Yes --> Allow
D -- No --> Block
`} />
**Existing users can always sign in again** — the signup allowlist only applies to **first-time signup**, not returning users.
- **`ALLOWED_EMAILS`** (highest priority) — explicit email allowlist, comma-separated. **When non-empty, only listed emails can sign up.**
- **`ALLOWED_EMAIL_DOMAINS`** — domain allowlist, comma-separated (for example `company.io,partner.com`).
- **`ALLOW_SIGNUP`** — master switch, default `true`. Set `false` to disable signup entirely.
<Callout type="warning">
**The three layers are AND semantics, not OR.** A common wrong intuition is that `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` means "allow company.io plus everyone else." It does **not**. If any layer has a non-empty value, **emails not matching it are rejected outright** — `ALLOW_SIGNUP=true` does not override that.
To actually "allow everyone," leave all three variables empty (or keep `ALLOW_SIGNUP=true`).
</Callout>
**Typical configurations**:
| Goal | Configuration |
|---|---|
| Internal only, employees of `company.io` | `ALLOWED_EMAIL_DOMAINS=company.io` |
| Internal + a few external collaborators | `ALLOWED_EMAIL_DOMAINS=company.io` + collaborator addresses added to `ALLOWED_EMAILS` |
| Disable self-serve signup entirely, invite-only | `ALLOW_SIGNUP=false` |
| Open signup (not recommended for production) | All three empty |
## Can you still invite people when signup is disabled?
**Only people who already have a Multica account.** Accepting an invite doesn't check the signup allowlist — if the invitee has signed up already (for example in another workspace), clicking the invite link and signing in lets them accept.
**But people who have never signed up cannot be rescued by an invite.** Before accepting, they must sign in, and the first step of sign-in (requesting the verification code) passes through the signup allowlist check. If `ALLOW_SIGNUP=false`, or their email isn't in `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS`, they **cannot complete signup**, and therefore cannot accept the invite.
To invite an external collaborator who hasn't signed up yet: temporarily add their email to `ALLOWED_EMAILS`, wait for them to sign up and accept the invite, then remove the entry.
For how to create and use invites, see [Members and roles](/members-roles).
## Next
- [Environment variables](/environment-variables) — full definitions of every variable used on this page
- [Authentication and tokens](/auth-tokens) — JWT / PAT / daemon token categories and usage
- [Troubleshooting](/troubleshooting) — verification code not received, OAuth `redirect_uri_mismatch`, signup rejected

View File

@@ -0,0 +1,121 @@
---
title: 登录与注册配置
description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱。
上面用到的环境变量的清单见 [环境变量](/environment-variables)token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
## Email + 验证码登录怎么工作
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
1. 在 Resend 建账号、验证你的域名
2. 创建 API key
3. 设环境变量:
```bash
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com # 必须是 Resend 已验证的域名
```
4. 重启 server
**不配 `RESEND_API_KEY` 的后果**server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
## 888888 陷阱
<Callout type="warning">
**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。**
Multica 有一个开发用的主验证码master code`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过。
**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。
</Callout>
检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`**整个实例处于完全开放状态**。
## 怎么配 Google OAuth
可选。不配就只有 Email + 验证码登录;配了后登录页会多出「用 Google 登录」按钮。
1. 去 [Google Cloud Console](https://console.cloud.google.com/) 创建一个 OAuth 2.0 client
2. **授权的回调 URI**Authorized redirect URIs填你的 Multica 前端地址加 `/auth/callback`,例如:
```text
https://multica.yourdomain.com/auth/callback
```
3. 拿到 client ID 和 client secret 后设三个环境变量:
```bash
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
```
4. 重启 server。
**热生效**:前端通过 `/api/config` 运行时读这些配置——改完只要重启 server前端不用重建镜像、不用重新部署。
<Callout type="warning">
**回调 URI 在 Google Console 和 `GOOGLE_REDIRECT_URI` 两处必须完全一致**,包括协议(`http` vs `https`)、尾部斜杠、端口。不一致 Google 会拒绝整个 OAuth 流程,用户看到的错误是 `redirect_uri_mismatch`。
</Callout>
## 怎么限制谁能注册
三层环境变量按优先级组合:
<Mermaid chart={`
graph TD
Start[新用户首次登录] --> A{email 在<br/>ALLOWED_EMAILS 里?}
A -- 是 --> Allow[允许注册]
A -- 否 --> B{domain 在<br/>ALLOWED_EMAIL_DOMAINS 里?}
B -- 是 --> Allow
B -- 否 --> C{任一白名单<br/>非空?}
C -- 是 --> Block[拒绝]
C -- 否 --> D{ALLOW_SIGNUP<br/>= true?}
D -- 是 --> Allow
D -- 否 --> Block
`} />
**已经登录过的老用户永远可以再次登录**——signup 白名单只对**首次注册**生效,不拦截老用户。
- **`ALLOWED_EMAILS`**(最高优先级)—— 显式邮箱白名单,逗号分隔。**非空时只有列表里的邮箱能注册**。
- **`ALLOWED_EMAIL_DOMAINS`**—— 域名白名单,逗号分隔(例如 `company.io,partner.com`)。
- **`ALLOW_SIGNUP`** —— 总开关,默认 `true`。设 `false` 完全关闭注册。
<Callout type="warning">
**三层白名单是 AND 语义,不是 OR。** 很多人第一直觉是「设 `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` 就是允许 company.io 和其他所有人」——**不是**。任何一层白名单只要设了非空值,**不匹配的邮箱直接拒**`ALLOW_SIGNUP=true` 挡不住。
要真的「允许所有人」,所有三个环境变量都留空(或 `ALLOW_SIGNUP=true`)。
</Callout>
**典型配法**
| 需求 | 配置 |
|---|---|
| 公司内网,只允许 `company.io` 员工 | `ALLOWED_EMAIL_DOMAINS=company.io` |
| 公司内网 + 几个外部合作者 | `ALLOWED_EMAIL_DOMAINS=company.io` + 合作者个人邮箱加到 `ALLOWED_EMAILS` |
| 完全关闭自助注册,只能邀请 | `ALLOW_SIGNUP=false` |
| 开放注册(不推荐生产用)| 三个都留空 |
## 关了注册还能邀请人进来吗
**只对已经有 Multica 账号的人能**。接受邀请那一步不检查 signup 白名单——如果对方已经注册过(比如在别的工作区),他们点链接登录就能直接接受。
**但还没注册过的人,邀请救不了他们**。他们接受邀请前必须先登录,登录的第一步(发验证码)会过 signup 白名单检查。如果你 `ALLOW_SIGNUP=false`、或他们的邮箱不在 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 里,他们**没法完成注册**,也就没法接受邀请。
要邀请一个还没注册的外部协作者:临时把他们的邮箱加到 `ALLOWED_EMAILS`,等他们注册 + 接受邀请之后再把这条移掉。
邀请的创建和使用见 [成员与权限](/members-roles)。
## 下一步
- [环境变量](/environment-variables) —— 这一页用到的环境变量完整定义
- [认证与令牌](/auth-tokens) —— JWT / PAT / Daemon Token 的分类和使用
- [故障排查](/troubleshooting) —— 验证码收不到、OAuth 报 `redirect_uri_mismatch`、注册被拒的常见排查

View File

@@ -0,0 +1,80 @@
---
title: Authentication and tokens
description: Multica has three kinds of tokens — one each for the browser, the CLI, and the daemon. When to use which.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica has three kinds of tokens, one for each context: the browser Web UI, the command line and scripts, and the daemon. All three represent the same you, but their scopes and lifetimes differ.
## The three tokens
| Token | Format | Where it's used | Lifetime |
|---|---|---|---|
| **JWT cookie** | `multica_auth` cookie (HttpOnly) | Web browser | 30 days |
| **Personal access token (PAT)** | Prefixed with `mul_` | CLI, scripts, direct API calls | No expiry by default; when you create one via the API you can pass `expires_in_days` |
| **Daemon token** | Prefixed with `mdt_` | Daemon-to-server communication | Managed by the daemon itself |
In day-to-day use you'll only touch the first two directly. The **[daemon](/daemon-runtimes) token** is created and refreshed automatically by `multica daemon login` — you don't have to think about it.
## What each token can hit
| API route | JWT cookie | PAT | Daemon token |
|---|---|---|---|
| `/api/user/*` (user-level actions) | ✓ | ✓ | ✗ |
| `/api/workspaces/:id/*` (workspace-level) | ✓ | ✓ | ✗ |
| `/api/daemon/*` (daemon-only) | ✗ | ✓ | ✓ |
| WebSocket `/ws` (real-time push) | ✓ (cookie) | ✓ (authenticates via first message) | ✗ |
**A PAT can hit almost anything** — it represents "the full you." A daemon token can only do what the daemon needs: fetch tasks and report results.
**Both can hit `/api/daemon/*`, but their scopes differ.** A PAT represents an **entire user** — once authenticated, it can see every workspace you belong to. A daemon token is pinned to a single workspace at creation time and can only touch resources in that workspace. In production, run your daemon with a daemon token — don't take the shortcut of using a PAT, or you'll be granting far more privilege than the daemon needs.
## Logging in
### Email + verification code
1. Enter your email; the server sends a 6-digit code.
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
<Callout type="warning">
**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup).
</Callout>
### Google OAuth
Click **Sign in with Google** and go through the standard OAuth callback. Self-hosting requires `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and the redirect URI to be configured — see [Self-host auth configuration](/auth-setup).
## Creating, viewing, and revoking a PAT
**Creating** a PAT can be done two ways:
- **Web UI**: Settings → Personal Access Tokens → New token
- **CLI**: `multica login` creates one automatically if there's no local PAT yet
<Callout type="warning">
**The full PAT is displayed exactly once when it's created.** After you refresh or close the dialog, you won't be able to see it again.
Multica stores only the hash of the PAT in the database — not even the server can retrieve the original. Copy and save it immediately. If you lose it, your only option is to revoke it and create a new one.
</Callout>
**Viewing** existing PATs (name, creation time, last-used time — **not** the full token) lives under Settings → Personal Access Tokens.
**Revoking** a PAT: click Revoke in the list. Revocation takes effect immediately — the next request made with that PAT will be rejected with a 401.
## Logging out only deletes the local token
When you run `multica auth logout` or click log out in the Web UI:
- **The local token is cleared** — the CLI removes the PAT from `~/.multica/config.json`; the browser deletes the cookie.
- **The PAT is still valid on the server** — if someone obtained your PAT before you logged out (for example, by copying it to another machine), they **can still use it**.
<Callout type="warning">
**If you suspect your PAT has leaked, don't just log out.** Go to Settings → Personal Access Tokens and **revoke** the token. Only revocation invalidates a leaked token immediately.
</Callout>
## Next steps
- [CLI command reference](/cli) — authentication is automatic for every CLI command
- [Self-host auth configuration](/auth-setup) — how to configure email, OAuth, and signup allowlists when self-hosting
- [Daemon and runtimes](/daemon-runtimes) — where the daemon token comes from

View File

@@ -0,0 +1,80 @@
---
title: 认证与令牌
description: Multica 有三种令牌——浏览器、CLI、守护进程各用一种。什么场景用哪种。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令行 / 脚本、守护进程daemon。三种都代表同一个你但作用域和有效期不同。
## 三种令牌
| 令牌 | 格式 | 用在哪 | 有效期 |
|---|---|---|---|
| **JWT Cookie** | `multica_auth` cookieHttpOnly | Web 浏览器 | 30 天 |
| **个人访问令牌PAT** | 以 `mul_` 开头 | CLI / 脚本 / 直接调 API | 默认不过期;用 API 创建时可选传 `expires_in_days` |
| **守护进程令牌Daemon Token** | 以 `mdt_` 开头 | Daemon 内部和 server 通信 | 由 daemon 自己管理 |
日常使用你只会直接接触前两种。**[守护进程](/daemon-runtimes)令牌**是 `multica daemon login` 自动生成和刷新的,你不用关心。
## 三种令牌能访问哪些 API
| API 路由 | JWT Cookie | PAT | Daemon Token |
|---|---|---|---|
| `/api/user/*`(用户级操作) | ✓ | ✓ | ✗ |
| `/api/workspaces/:id/*`(工作区级) | ✓ | ✓ | ✗ |
| `/api/daemon/*`daemon 专用) | ✗ | ✓ | ✓ |
| WebSocket `/ws`(实时推送) | ✓cookie | ✓(首条消息认证) | ✗ |
**PAT 几乎什么都能命中**——它代表"完整的你"。Daemon Token 能做的事非常有限,只够 daemon 拉任务和汇报结果。
**同样是访问 `/api/daemon/*`,两者作用域不同**PAT 代表**一整个用户**——进来之后能看到你所有的工作区daemon token 在创建时就绑死一个工作区,只能动这一个工作区的资源。生产部署用 daemon token 跑 daemon不要图方便用 PAT——权限会被放大。
## 登录
### Email + 验证码
1. 填邮箱server 发一封带 6 位验证码的邮件
2. 输入验证码server 签发 JWT cookie浏览器或交换出 PATCLI
<Callout type="warning">
**自部署运维注意**:如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
</Callout>
### Google OAuth
点 **Sign in with Google**,走标准 OAuth 回调。自部署时需要配好 `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / redirect URI——详见 [自部署的认证配置](/auth-setup)。
## 创建、查看、撤销 PAT
**创建**有两种方式:
- **Web UI**Settings → Personal Access Tokens → New token
- **CLI**`multica login` 在本地没有 PAT 时会自动创建一个
<Callout type="warning">
**PAT 创建时完整内容只显示一次。** 刷新页面或关闭对话框之后就看不到了。
Multica 在数据库里只保存 PAT 的哈希值——服务端也查不回来。创建时**立即复制保存**。丢了只能撤销后重新创建。
</Callout>
**查看**已签发的 PAT 列表(名字、创建时间、最后使用时间,**不含**完整令牌Settings → Personal Access Tokens。
**撤销** PAT在列表里点 Revoke。撤销是立即生效的——被撤销的 PAT 下一次请求就 401。
## 退出登录只是删本地令牌
执行 `multica auth logout` 或在 Web UI 点退出时:
- **本地令牌被清除** —— CLI 从 `~/.multica/config.json` 里删掉 PATWeb 删 cookie
- **服务端的 PAT 仍然有效** —— 如果登出前有人已经拿到过你的 PAT比如复制到了另一台机器他们**还能继续用**
<Callout type="warning">
**如果怀疑 PAT 泄露,不要只 logout。** 去 Settings → Personal Access Tokens 把那个 PAT **撤销**。撤销才会让泄露出去的令牌立刻失效。
</Callout>
## 下一步
- [CLI 命令速查](/cli) —— 每条 CLI 命令的认证是自动的
- [自部署的认证配置](/auth-setup) —— 自部署时怎么配邮件 / OAuth / signup 白名单
- [守护进程与运行时](/daemon-runtimes) —— 守护进程令牌是从哪来的

View File

@@ -0,0 +1,63 @@
---
title: Chat
description: One-to-one conversation with an agent outside any issue — fully sandboxed. The agent cannot see or change issues, and nobody else can see the conversation.
---
import { Callout } from "fumadocs-ui/components/callout";
**Chat is a one-to-one conversation between you and an [agent](/agents)** — stepping outside the [issue](/issues) board. The agent sees no issues and cannot change any issue, and the entire conversation is **fully private** (nobody else in the [workspace](/workspaces), including admins, can see it). It fits discussing an approach with an agent, brainstorming, or asking a question that does not belong to any issue.
## Why not just @-mention the agent?
[@-mention](/mentioning-agents) **pulls the agent into** an issue's context — it reads the issue description and every historical comment, and it can change the issue. Chat flips this: **it pulls you out of** the issue — the agent only sees this single conversation, has no awareness of any issue, and has no entry point to modify one.
Two rules of thumb:
- You want feedback grounded in the context of a specific issue → [@-mention](/mentioning-agents)
- You want to discuss a topic unrelated to any issue (or you do not want anyone else to see the discussion) → Chat
## Start a conversation
Open **Chat** from the sidebar, pick an agent, and start a new conversation. The interface feels like any messaging app: you send a message, the agent replies. Each message triggers a run in the background (an enqueued `task`), so replies may take a few seconds.
## What an agent can and cannot do in chat
Agents run in a **fully sandboxed** mode inside a conversation.
**Can do:**
- Answer the questions in your current message
- Use its configured [skills](/skills) and MCP
- Read and write files in its own working directory
- Call `multica` CLI commands that do not need issue context (for example, querying basic workspace info)
**Cannot do:**
- **See any issue** — the prompt the agent receives has no issue IDs, and commands like `multica issue list` return empty
- **Change any issue** — without issue context, API calls are blocked by permission checks
- **See other conversations** — conversations are fully isolated
- **@-mention anyone or any agent** — chat is a private space with no path to notify others
## How multi-turn context is preserved
Chat maintains multi-turn context via **provider session resumption** — the agent establishes a provider session on its first reply (for example, a Claude session), and the session ID is stored. On the next message, the task dispatch passes that ID back so the agent **resumes from where it left off** without re-reading history every time.
If **one turn fails**, Multica looks up the previous task that had established a session ID (whether that task succeeded or failed) and tries to resume — a single failure in the middle does not drop the memory of the whole conversation.
Note: not every provider actually implements session resumption — see the [**Providers Matrix**](/providers) for support status.
## Archive a conversation
Conversations you no longer want to see can be archived — right-click in the conversation list or use the "Archive" button on the detail page. After archiving:
- The conversation disappears from the active list (you can still find it in the "Archived" view)
- Historical messages, session ID, and the working directory are all preserved — nothing is deleted
<Callout type="warning">
**There is no "restore" button after archiving.** There is currently no entry point to move an archived conversation back to active. If you want to continue the thread later, you will need to start a new conversation. To revisit content in an archived conversation, open the "Archived" view and read through the history.
</Callout>
## Next
- [**Routines**](/routines) — let agents start work automatically on a schedule
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board

View File

@@ -0,0 +1,63 @@
---
title: 对话
description: 和智能体一对一独立聊天——完全沙盒,智能体看不到 issue、改不了 issue也没人能看到你的对话。
---
import { Callout } from "fumadocs-ui/components/callout";
**对话Chat是你和 [智能体](/agents) 的一对一独立沟通**——跳出 [issue](/issues) 看板,智能体看不到任何 issue、也改不了 issue整段对话**完全私人**[工作区](/workspaces) 里其他人、包括 admin 都看不到)。适合和智能体讨论方案、做 brainstorming、问一个不属于任何 issue 的问题。
## 为什么不用 @ 智能体就够
[@ 提及](/mentioning-agents) 把智能体**拉进** issue 的上下文——它会读 issue 的描述和所有历史评论,也能改 issue。对话反过来**把你拉出** issue——智能体只看得到这一次对话不知道 issue 存在,也没有修改 issue 的入口。
两条判据:
- 要智能体基于某个具体 issue 的上下文给反馈 → [@ 提及](/mentioning-agents)
- 要和智能体聊一个不属于任何 issue 的话题(或不想让任何人看到讨论)→ 对话
## 开始一次对话
从侧边栏的 **Chat** 入口进,选一个智能体,开一段新对话。界面和普通聊天软件一样:你发消息,智能体回复。每条消息都会在后台触发一次执行(入队一个 `task`),所以回复可能要等几秒。
## 智能体在对话里能做什么、不能做什么
智能体在对话里跑在**完全沙盒**下。
**能做的**
- 回答你当前消息里提的问题
- 使用自己配置的 [skill](/skills) 和 MCP
- 在自己的工作目录里读写文件
- 调用不需要 issue 上下文的 `multica` CLI 命令(比如查询工作区基本信息)
**不能做的**
- **看到任何 issue**——智能体收到的提示里没有 issue ID`multica issue list` 之类命令对它返回空
- **改任何 issue**——没有 issue 上下文API 调用会被权限 check 拦截
- **看到别的对话**——对话之间完全隔离
- **@ 任何人或智能体**——对话是私人空间,没有通知别人的路径
## 多轮对话怎么保留上下文
对话用 **provider 会话恢复**机制维持多轮上下文——智能体第一次回复时建立一个 provider 会话(比如 Claude 的 sessionsession ID 被存起来;下一条消息派任务时把这个 ID 传回去,智能体**接着上次的状态继续**,不需要每次重新读历史。
如果**某一轮失败**Multica 会查找上一轮建立过 session ID 的任务(不论它当时成功还是失败)并尝试 resume——不会因为中间一次出错就丢掉整段对话的记忆。
注意:并非所有 provider 都真正实现了 session 恢复——支持情况见 [**Providers Matrix**](/providers)。
## 归档对话
不想再看到的对话可以归档——在对话列表右键或详情页的"归档"按钮。归档后:
- 对话从活跃列表隐藏(可以在"已归档"视图里翻到)
- 历史消息、session ID、工作目录完整保留不会被删
<Callout type="warning">
**归档之后没有"恢复"按钮**——目前没有把归档对话重新设回活跃的入口。如果后续还想继续这段对话,只能另起一个新对话。需要翻看归档对话里的内容时,去"已归档"视图读历史消息。
</Callout>
## 下一步
- [**Routines**](/routines) —— 让智能体定时自动开工
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上

View File

@@ -0,0 +1,131 @@
---
title: CLI command reference
description: One-page overview of every top-level Multica CLI command. For full usage, run `multica <command> --help`.
---
import { Callout } from "fumadocs-ui/components/callout";
The Multica CLI mirrors almost everything the Web UI can do (create [issues](/issues), assign [agents](/agents), start the [daemon](/daemon-runtimes), and more). This page lists every top-level command with a one-line description. For the full set of flags and examples, run `multica <command> --help`.
## Getting authenticated
Run this the first time you use the CLI to obtain a **personal access token (PAT)**:
```bash
multica login
```
Your browser opens automatically. After you approve in the web app, the CLI saves the PAT (prefixed with `mul_`) to `~/.multica/config.json`. Every subsequent command authenticates with that PAT.
<Callout type="tip">
For CI or headless environments, skip the browser flow: create a PAT in the web app under **Settings → Personal Access Tokens**, then run `multica login --token <mul_...>` to supply it directly.
</Callout>
For the difference between token types, see [Authentication and tokens](/auth-tokens).
## Auth and setup
| Command | Purpose |
|---|---|
| `multica login` | Log in and save a PAT |
| `multica auth status` | Show current login status, user, and workspace |
| `multica auth logout` | Clear the local PAT |
| `multica setup cloud` | One-shot setup for Multica Cloud (login + install daemon) |
| `multica setup self-host` | One-shot setup for a self-hosted backend |
## Workspaces and members
| Command | Purpose |
|---|---|
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace members` | List members of the current workspace |
## Issues and projects
| Command | Purpose |
|---|---|
| `multica issue list` | List issues |
| `multica issue get <id>` | Show a single issue |
| `multica issue create --title "..."` | Create a new issue |
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
| `multica issue status <id> --set <status>` | Shortcut to change status |
| `multica issue search <query>` | Keyword search |
| `multica issue runs <id>` | Show agent runs on an issue |
| `multica issue rerun <id>` | Rerun the most recent agent task |
| `multica issue comment <id> ...` | Nested: view / post comments |
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
| `multica project list/get/create/update/delete/status` | Project CRUD |
## Agents and skills
| Command | Purpose |
|---|---|
| `multica agent list` | List the workspace's agents |
| `multica agent get <slug>` | Show an agent's configuration |
| `multica agent create ...` | Create an agent |
| `multica agent update <slug> ...` | Update an agent |
| `multica agent archive <slug>` | Archive |
| `multica agent restore <slug>` | Restore an archived agent |
| `multica agent tasks <slug>` | Show an agent's task history |
| `multica agent skills ...` | Nested: attach / detach skills |
| `multica skill list/get/create/update/delete` | Skill CRUD |
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
| `multica skill files ...` | Nested: manage a skill's files |
## Routines (CLI command name: `autopilot`)
In the docs this feature is called **Routines**, but the CLI subcommand name is still `autopilot` — a future release will unify the two. If you're searching for "routines" and can't find it, try `multica autopilot --help`.
| Command | Purpose |
|---|---|
| `multica autopilot list` | List every routine in the workspace |
| `multica autopilot get <id>` | Show a single routine |
| `multica autopilot create ...` | Create a routine |
| `multica autopilot update <id> ...` | Update |
| `multica autopilot delete <id>` | Delete |
| `multica autopilot runs <id>` | Show run history |
| `multica autopilot trigger <id>` | Trigger a run manually |
## Daemon and runtimes
| Command | Purpose |
|---|---|
| `multica daemon start` | Start the daemon (background by default; add `--foreground` to run in the foreground) |
| `multica daemon stop` | Stop the daemon |
| `multica daemon restart` | Restart the daemon |
| `multica daemon status` | Check whether the daemon is online and its concurrency |
| `multica daemon logs` | View daemon logs |
| `multica runtime list` | List runtimes in the current workspace |
| `multica runtime usage` | Show resource usage |
| `multica runtime activity` | Recent activity log |
| `multica runtime ping <id>` | Ping a runtime to check it's online |
| `multica runtime update <id> ...` | Update a runtime's configuration |
## Miscellaneous
| Command | Purpose |
|---|---|
| `multica repo checkout <url>` | Clone a repo locally for agents to use |
| `multica config` | View or edit local CLI configuration |
| `multica version` | Print the CLI version |
| `multica update` | Upgrade the CLI to the latest release |
| `multica attachment download <id>` | Download an attachment from an issue or comment |
## Getting full flags
Every command supports `--help`:
```bash
multica issue create --help
multica agent update --help
```
v2 will ship a dedicated detailed reference page for each command.
## Next steps
- [Authentication and tokens](/auth-tokens) — PAT vs. JWT vs. daemon token
- [Daemon and runtimes](/daemon-runtimes) — how the `daemon` commands work under the hood
- [Creating and configuring agents](/agents-create) — all options for `multica agent create`

View File

@@ -0,0 +1,131 @@
---
title: CLI 命令速查
description: Multica CLI 的所有顶级命令一页概览。完整用法查 `multica <命令> --help`。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica CLI 把 Web UI 能做的事几乎全部搬到了命令行上(创建 [issue](/issues)、分配 [智能体](/agents)、启动 [守护进程](/daemon-runtimes) 等等)。这一页把所有顶级命令列出来,每条配一句用途。完整 flag 和示例用 `multica <命令> --help` 查。
## 认证入口
第一次用 CLI 时先登录,拿一个**个人访问令牌Personal Access TokenPAT**
```bash
multica login
```
浏览器会自动打开,你在 Web 端同意后CLI 把 PAT`mul_` 前缀)保存到 `~/.multica/config.json`。此后所有命令都会自动用这个 PAT 认证。
<Callout type="tip">
CI / 无浏览器环境跳过浏览器流程:先在 Web 端 **Settings → Personal Access Tokens** 创建一个 PAT然后 `multica login --token <mul_...>` 直接填入。
</Callout>
Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
## 认证与初始化
| 命令 | 用途 |
|---|---|
| `multica login` | 登录并保存 PAT |
| `multica auth status` | 查看当前登录状态、用户、工作区 |
| `multica auth logout` | 清除本地 PAT |
| `multica setup cloud` | Multica Cloud 一键初始化(登录 + 装 daemon |
| `multica setup self-host` | 自部署后端的一键初始化 |
## 工作区和成员
| 命令 | 用途 |
|---|---|
| `multica workspace list` | 列出你有权访问的所有工作区 |
| `multica workspace get <slug>` | 查看一个工作区的详情 |
| `multica workspace members` | 列出当前工作区的成员 |
## Issue 和 Project
| 命令 | 用途 |
|---|---|
| `multica issue list` | 列出 issue |
| `multica issue get <id>` | 查看单条 issue |
| `multica issue create --title "..."` | 创建新 issue |
| `multica issue update <id> ...` | 修改 issue状态、优先级、分配人等 |
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务) |
| `multica issue status <id> --set <status>` | 快捷改状态 |
| `multica issue search <query>` | 关键字搜索 |
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
| `multica issue rerun <id>` | 重跑最近一次智能体任务 |
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
| `multica project list/get/create/update/delete/status` | Project CRUD |
## 智能体和 Skill
| 命令 | 用途 |
|---|---|
| `multica agent list` | 列出工作区的智能体 |
| `multica agent get <slug>` | 查看智能体配置 |
| `multica agent create ...` | 创建智能体 |
| `multica agent update <slug> ...` | 修改智能体 |
| `multica agent archive <slug>` | 归档 |
| `multica agent restore <slug>` | 恢复归档的智能体 |
| `multica agent tasks <slug>` | 查看智能体的任务历史 |
| `multica agent skills ...` | 嵌套:挂载 / 卸载 Skill |
| `multica skill list/get/create/update/delete` | Skill CRUD |
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
## RoutinesCLI 命令名:`autopilot`
文档里叫 **Routines**,但 CLI 子命令名保留为 `autopilot`——后续版本会统一。如果你在搜索 "routines" 相关命令但找不到,用 `multica autopilot --help`。
| 命令 | 用途 |
|---|---|
| `multica autopilot list` | 列出工作区所有 routine |
| `multica autopilot get <id>` | 查看单个 routine |
| `multica autopilot create ...` | 创建 routine |
| `multica autopilot update <id> ...` | 修改 |
| `multica autopilot delete <id>` | 删除 |
| `multica autopilot runs <id>` | 查看运行历史 |
| `multica autopilot trigger <id>` | 手动触发一次 |
## 守护进程和运行时
| 命令 | 用途 |
|---|---|
| `multica daemon start` | 启动 daemon默认后台加 `--foreground` 前台跑)|
| `multica daemon stop` | 停止 daemon |
| `multica daemon restart` | 重启 daemon |
| `multica daemon status` | 查看 daemon 是否在线 + 并发情况 |
| `multica daemon logs` | 查看 daemon 日志 |
| `multica runtime list` | 列出当前工作区的 runtime |
| `multica runtime usage` | 查看资源使用情况 |
| `multica runtime activity` | 近期活动记录 |
| `multica runtime ping <id>` | 立即戳一次 runtime 检查在线 |
| `multica runtime update <id> ...` | 更新 runtime 配置 |
## 杂项
| 命令 | 用途 |
|---|---|
| `multica repo checkout <url>` | 把 repo 拉到本地以供智能体使用 |
| `multica config` | 查看 / 修改 CLI 本地配置 |
| `multica version` | 显示 CLI 版本 |
| `multica update` | 升级 CLI 到最新版 |
| `multica attachment download <id>` | 下载 issue / 评论的附件 |
## 查完整 flag
每条命令都支持 `--help`
```bash
multica issue create --help
multica agent update --help
```
v2 会给每条命令一个独立的详细 reference 页。
## 下一步
- [认证与令牌](/auth-tokens) —— PAT / JWT / Daemon Token 的区别
- [守护进程与运行时](/daemon-runtimes) —— `daemon` 命令背后的工作机制
- [创建和配置智能体](/agents-create) —— `multica agent create` 的完整选项

View File

@@ -0,0 +1,119 @@
---
title: Cloud quickstart
description: From sign-up to assigning your first task to an agent in 5 minutes.
---
import { Callout } from "fumadocs-ui/components/callout";
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
## 1. Create an account
Sign up at [multica.ai](https://multica.ai). You can log in with email (6-digit verification code) or Google.
After sign-up you're automatically placed in a default workspace (generated from your account name). You can rename it later, or create new workspaces.
## 2. Install the Multica CLI
**macOS / Linux (Homebrew recommended)**:
```bash
brew install multica-ai/tap/multica
```
**macOS / Linux (no Homebrew)**:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
**Windows (PowerShell)**:
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Verify the install:
```bash
multica version
```
## 3. Log in + start the daemon
A single command handles login and starts the daemon:
```bash
multica setup
```
`multica setup` will:
1. Configure the CLI to connect to Multica Cloud
2. Open your browser for login (same email verification code / Google OAuth as the web)
3. Store the generated PAT in `~/.multica/config.json`
4. **Start the daemon automatically** — it begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds
<Callout type="info">
**Using the desktop app?** The desktop app **starts the daemon automatically** on launch — no need to run `multica setup` by hand. See [Desktop app](/desktop-app).
</Callout>
Verify the daemon is running:
```bash
multica daemon status
```
`online` means it has registered with the server.
## 4. Verify the runtime is online
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
## 5. Create an agent
In the web UI, go to **Settings → Agents** and click **New Agent**:
- **Name** — the name shown for this agent on boards and in comments. Pick something you like
- **Provider** — choose an AI coding tool you have installed locally (the dropdown only lists tools detected by your runtimes)
- **Model** (optional) — the model selection inside that tool (a static list or dynamic discovery, depending on the provider)
- **Instructions** (optional) — system prompt for this agent
Once created, the agent shows up in your workspace member list and can be assigned work like a human member.
## 6. Assign your first task
Create an issue in the web UI, or from the CLI:
```bash
multica issue create --title "Add an ASCII architecture diagram to the README"
```
Assign the issue to the agent you just created — click its avatar in the web UI, or use the CLI:
```bash
multica issue assign MUL-1 --to my-agent-name
```
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
**What happens next from the daemon**:
1. It picks up the task within 3 seconds (status goes from `queued` to `dispatched`)
2. It invokes the matching AI coding tool to start work (status becomes `running`)
3. The AI works locally — it may read your code directory, run commands, edit files
4. When done, it reports the result back to Multica (status becomes `completed` or `failed`, depending on whether auto-retry kicks in)
The web UI updates in **real time** (via WebSocket) — no refresh needed.
## Next steps
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
- [Tasks](/tasks) — task lifecycle and retry rules
- [AI coding tools compared](/providers) — capability differences across the 10 tools
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
- [Self-host quickstart](/self-host-quickstart) — run your own backend

View File

@@ -0,0 +1,119 @@
---
title: Cloud 快速上手
description: 5 分钟从注册到给智能体分配第一个任务。
---
import { Callout } from "fumadocs-ui/components/callout";
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
## 1. 注册账号
到 [multica.ai](https://multica.ai) 注册账号。可以用邮箱6 位验证码)或 Google 登录。
注册完成后你会被自动分到一个默认工作区(以你的账号名生成)。之后可以改名字,也可以创建新的工作区。
## 2. 装 Multica 命令行工具
**macOS / Linux推荐走 Homebrew**
```bash
brew install multica-ai/tap/multica
```
**macOS / Linux没有 Homebrew**
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
**WindowsPowerShell**
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
装完验证一下:
```bash
multica version
```
## 3. 登录 + 启动守护进程
一条命令完成登录 + 启动守护进程:
```bash
multica setup
```
`multica setup` 会:
1. 把命令行工具配置成连接 Multica Cloud
2. 打开浏览器让你登录(和 Web 登录一样的邮箱验证码 / Google OAuth
3. 把生成的 PAT 存到 `~/.multica/config.json`
4. **自动启动守护进程**——开始每 3 秒轮询任务、每 15 秒发心跳
<Callout type="info">
**用的是桌面应用?** 桌面应用启动时**自动拉起守护进程**,不需要手动跑 `multica setup`。见 [桌面应用](/desktop-app)。
</Callout>
验证守护进程在运行:
```bash
multica daemon status
```
看到 `online` 就说明它成功注册到服务器了。
## 4. 验证 Runtime 在线
到 Web 界面的 **Settings → Runtimes**,你应该能看到你刚启动的守护进程作为一个或多个活跃 Runtime 列出——每款你本地装好的 AI 编程工具对应一个。
看到"离线"不要慌,先看 [故障排查 → 守护进程连不上服务器](/troubleshooting#守护进程连不上服务器)。
## 5. 创建智能体
到 Web 界面的 **Settings → Agents**,点 **New Agent**
- **名字**——智能体在看板上、评论里显示的名字,自己起一个
- **Provider**——选一款你本地装好的 AI 编程工具(下拉里只会出现运行时里检测到的那些)
- **Model**(可选)——这款工具内部的模型选择(静态列表或动态发现,取决于 provider
- **Instructions**(可选)——给这个智能体的系统提示词
创建完成后智能体就进入你的工作区成员列表,可以像人类成员一样被分配任务。
## 6. 分配第一个任务
在 Web 界面创建一条 issue或者用命令行
```bash
multica issue create --title "给 README 加一段 ASCII 架构图"
```
把这条 issue 分配给你刚创建的那个智能体——可以在 Web 上点它的头像,或用命令行:
```bash
multica issue assign MUL-1 --to my-agent-name
```
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
**接下来守护进程会**
1. 3 秒内领走这条任务(任务状态从 `queued` 变 `dispatched`
2. 调用对应的 AI 编程工具开始执行(状态变 `running`
3. AI 在本地工作——可能会读你的代码目录、执行命令、编辑文件
4. 结束后把结果发回 Multica状态变 `completed` 或 `failed`,根据是否自动重试)
Web 界面会**实时**(通过 WebSocket显示进度——不需要刷新。
## 下一步
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
- [执行任务](/tasks) —— 任务生命周期、重试规则
- [AI 编程工具对照](/providers) —— 10 款工具的能力差异
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套

View File

@@ -0,0 +1,59 @@
---
title: Comments and mentions
description: Collaborating under an issue — comments, replies, `@` mentions, reactions, and triggering agents from a comment.
---
import { Callout } from "fumadocs-ui/components/callout";
Every [issue](/issues) has a comment thread. Post comments, reply to someone, `@` a [member](/members-roles) or an [agent](/agents), add reactions — the same moves you make in any task manager you've used. The one difference: **mentioning an agent with `@` triggers it to start working.**
## Posting a comment
Type into the input at the bottom of the issue detail page and hit **Send**. The comment appears in the thread immediately. Comments support Markdown — headings, lists, code blocks, links, all available.
## Replying to a comment
Click **Reply** on the top-right of any comment to open a nested input underneath it. Your reply is displayed as a child of that comment, forming a conversation thread. Replies can have their own replies, nesting as deep as you need.
The issue list shows only the top-level comment count; opening the issue reveals the full conversation tree.
## Reactions
Each comment has a reaction button in the top-right for quick signals (👍, 👀, 🎉) — no need to post a "+1" comment to agree.
## `@` mentions
Typing `@` in a comment opens a picker. Choose a member or an agent, and `@` plus the target's slug gets inserted (`@alice` or `@reviewer-bot`). The mentioned party gets a notification in their [inbox](/inbox).
**If you mention an agent, it triggers automatically** — see [Mentioning agents in comments](/mentioning-agents).
Mentioning the same person multiple times in one comment still produces **only one** notification.
### `@all` notifies the entire workspace
`@all` is a special target: it pushes a notification to every member of the workspace. Both people and agents can use `@all` — which means an agent reporting progress could also `@all`, so remind agents in their instructions to use it sparingly.
<Callout type="warning">
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not routine updates.
</Callout>
## Editing and deleting a comment
Only the author of a comment can edit or delete it.
Deleting a comment also **deletes every reply** under it (including replies to replies). To change content only, use edit instead.
<Callout type="warning">
**Adding an `@` while editing a comment does not trigger the agent.** The trigger fires the moment a comment is **created** — editing to add a new `@`, or changing the target, does not send a new notification or wake the agent. To summon an agent you missed, **post a new comment** that `@`s it.
</Callout>
---
Everything we've covered so far is "the human world" — workspaces, members, issues, projects, comments. If you've used Linear or Jira, none of it should feel unfamiliar.
But Multica's defining trait hasn't entered the picture yet: **treating agents as first-class members of a workspace**. That's what we turn to next.
## Next
- [Agents](/agents) — what they are, and how they differ from people
- [Mentioning agents in comments](/mentioning-agents) — use `@` in a comment to start an agent

View File

@@ -0,0 +1,59 @@
---
title: 评论与提及
description: 在 issue 下协作——评论、回复、@ 提及、表情反应,以及在评论里触发智能体工作。
---
import { Callout } from "fumadocs-ui/components/callout";
每个 [issue](/issues) 都有一个评论区。你可以在里面发评论、回复别人、用 `@` 点名 [成员](/members-roles) 或 [智能体](/agents)、加表情反应——和你在熟悉的任务管理工具里做的是同一件事。唯一不同的是:**`@` 一个智能体会自动触发它开始工作**。
## 发评论
在 issue 详情页底部的输入框里写内容,点**发送**,评论立刻出现在评论流里。评论支持 Markdown——标题、列表、代码块、链接都能用。
## 回复某条评论
点任意一条评论右上角的**回复**,会在这条评论下方展开嵌套输入框。你写的回复会显示为这条评论的子项,形成一条对话线。回复之下还能继续回复,层层展开。
在 issue 列表里看到的只是顶层评论数,点进 issue 里才能看到完整的对话树。
## 表情反应
每条评论右上角可以加表情反应(比如 👍、👀、🎉),用来快速表态——不用为了赞同单独发一条"+1"。
## `@` 提及
在评论里输入 `@` 会弹出提示,从里面选一个成员或智能体,`@` 后面会填入对方的 slug比如 `@alice` 或 `@reviewer-bot`)。被提及的人会在自己的 [收件箱](/inbox) 里收到通知。
**如果你提及的是一个智能体,它会被自动触发开始工作**——详见 [在评论里召唤智能体](/mentioning-agents)。
同一条评论里 `@` 同一个人多次,对方只会收到**一条**通知。
### `@all` 会通知整个工作区
`@all` 是一个特殊目标:它会把通知推送给工作区里的每一个成员。人和智能体都能发 `@all`——这意味着被触发的智能体在汇报进展时也可能 `@all`,需要在智能体的指令里提醒它谨慎使用。
<Callout type="warning">
**谨慎使用 `@all`**。工作区人数较多时,一条 `@all` 的评论会瞬间生成同等数量的收件箱通知。只在确实需要全员知晓的重大事项上使用——不是日常琐事。
</Callout>
## 编辑和删除评论
只有评论的作者能编辑或删除自己的评论。
删除一条评论会**一并删除**它下面的所有回复(包括回复的回复)。如果只是想改内容,用编辑功能。
<Callout type="warning">
**编辑评论里加 `@` 不会触发智能体**。触发发生在评论**创建**那一刻——事后修改评论内容加入新的 `@`、或改 `@` 对象,系统不会重新发通知、也不会唤醒智能体。要召唤一个没触发到的智能体,**发一条新的评论** `@` 它。
</Callout>
---
到这里,我们讲的都是"人的世界"——工作区、成员、issue、project、评论。如果你熟悉 Linear 或 Jira 之类的产品,到目前为止的内容应该没有陌生感。
但 Multica 的核心特色还没登场:**把智能体作为工作区的一等公民成员**。下一章开始,我们正式认识这个新物种。
## 下一步
- [智能体](/agents) —— 它们是什么、和人有什么区别
- [在评论里召唤智能体](/mentioning-agents) —— 用 `@` 在评论里触发智能体开工

View File

@@ -0,0 +1,111 @@
---
title: Daemon and runtimes
description: Agents don't run on Multica's servers — they run on your own machines.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
In Multica, [agents](/agents) do **not** run on our servers — they run on your own machines, driven by a small program called the **daemon** that invokes the [AI coding tools](/providers) installed locally. The Multica server only coordinates: it stores [issues](/issues), queues [tasks](/tasks), and dispatches them to the right **runtime** (runtime = daemon × one AI coding tool).
This structure is the biggest difference between Multica and Linear / Jira: **your API keys, toolchain, and code directories stay on your machine** — the Multica server never sees any of them. That means "my agent isn't working" is almost always a local problem — the daemon isn't running, an AI tool isn't installed, a key has expired. Check locally first; see [Troubleshooting](/troubleshooting) for a guide.
## Starting the daemon
The daemon is part of the Multica CLI. Once you've installed the [Multica CLI](/cli), run on your own machine:
```bash
multica daemon start
```
On startup it does four things:
1. Reads the credentials saved when you logged in
2. Detects AI coding tools installed on your `PATH` (10 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. Registers itself with the server, along with a runtime for each detected tool
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
Common commands:
| Command | Purpose |
|---|---|
| `multica daemon start` | Start (background by default; add `--foreground` to run in the foreground) |
| `multica daemon stop` | Stop |
| `multica daemon restart` | Restart |
| `multica daemon status` | Show status |
| `multica daemon logs` | Show logs (add `-f` to follow) |
Full CLI reference in [CLI commands](/cli).
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup.
## Why one machine has multiple runtimes
A runtime is not a server and not a container — it's the combination of "**daemon × one AI coding tool**". For example: you start the daemon on a MacBook with both Claude Code and Codex installed, and you're a member of two workspaces. Multica then registers 4 runtimes:
<Mermaid chart={`
graph TD
D["Your daemon<br/>MacBook"]
D --> R1["Runtime<br/>Workspace A × Claude Code"]
D --> R2["Runtime<br/>Workspace A × Codex"]
D --> R3["Runtime<br/>Workspace B × Claude Code"]
D --> R4["Runtime<br/>Workspace B × Codex"]
`} />
Key points:
- **One daemon can map to multiple runtimes** — one per combination of installed tool and workspace you belong to
- **The same daemon, workspace, and tool produces exactly one runtime** — restarting the daemon never creates duplicate records
- The **Runtimes** page in the Multica UI lists these rows
<Callout type="info">
**Cloud runtimes are coming**, currently in a waitlist phase. Once available, you'll be able to execute agent tasks directly on Multica Cloud without running a local daemon. Sign up with your email on the [download page](https://multica.ai/download) to get notified.
</Callout>
## When a runtime is marked offline
Multica uses heartbeats to decide whether a runtime is online. Three key numbers:
| Event | Threshold |
|---|---|
| Daemon heartbeat frequency | Every **15 seconds** |
| Marked as missing | No heartbeat for **45 seconds** (3 missed beats) |
| Auto-deleted | Missing with no associated agents for over **7 days** |
Missing is not permanent — as soon as the daemon sends another heartbeat it returns to online, and the runtime record is preserved. Restarting the daemon does not lose runtimes.
<Callout type="warning">
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Routines-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
</Callout>
## How many tasks can run in parallel
Multica enforces concurrency limits at two layers:
- **Daemon layer**: **20 concurrent tasks** by default (tunable via env var `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`)
- **Agent layer**: **6 concurrent tasks per agent** by default (configured per-agent)
The tighter of the two wins. If your daemon is already running 20 tasks, new tasks wait even if an agent still has headroom.
If you see tasks stuck in `queued` without moving to `dispatched`, one of these two limits is usually saturated.
## What happens to in-flight tasks after a daemon crash
When the daemon crashes or is force-killed, the tasks it had picked up are left in `dispatched` or `running`. On the next start, the daemon tells the server: "these tasks are no longer mine, please mark them failed." The server flips them to `failed` with reason `runtime_recovery` — for retryable sources, the tasks are automatically requeued.
Even if this step fails due to a network issue, there's a server-side scan **every 30 seconds** as a backstop: any runtime without a heartbeat for over 45 seconds is marked missing, and its tasks are reclaimed along with it.
## Troubleshooting agents that aren't working
When you hit a "my agent isn't working" problem, run this three-step checklist first:
1. Run `multica daemon status` — confirm the daemon is running and online
2. Run `multica daemon logs -f` — check for errors
3. Open the **Runtimes** page in the Multica UI — confirm your runtime shows "online"
More scenarios in [Troubleshooting](/troubleshooting).
## Next
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools

View File

@@ -0,0 +1,111 @@
---
title: 守护进程与运行时
description: 智能体不在 Multica 服务器上运行——它们跑在你自己的机器上。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
在 Multica 里,[智能体](/agents) **不**在我们的服务器上运行——它们跑在你自己的机器上,由一个叫**守护进程**daemon的小程序调用本地安装的 [AI 编程工具](/providers)。Multica 服务器只做协调:存 [issue](/issues)、排 [任务](/tasks)、派发给正确的**运行时**runtime = 守护进程 × 一款 AI 编程工具)。
这个结构带来 Multica 和 Linear / Jira 最大的差别:**你的 API 密钥、工具链、代码目录都留在本地**Multica 服务器一个都看不到。"我的智能体不工作"类问题几乎都是本地问题——守护进程没启动、某款 AI 工具没装、密钥过期——请先从本地查起;定位指引见 [故障排查](/troubleshooting)。
## 启动守护进程
守护进程是 Multica CLI 的一部分。装好 [Multica CLI](/cli) 后,在自己机器上跑:
```bash
multica daemon start
```
启动后它会做四件事:
1. 读取你登录时保存的凭证
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 10 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
常用命令:
| 命令 | 作用 |
|---|---|
| `multica daemon start` | 启动(默认后台,加 `--foreground` 前台运行)|
| `multica daemon stop` | 停止 |
| `multica daemon restart` | 重启 |
| `multica daemon status` | 查看状态 |
| `multica daemon logs` | 查看日志(加 `-f` 跟随)|
完整 CLI 参考见 [CLI 命令速查](/cli)。
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。
## 为什么一台机器会有多个运行时
运行时不是一个服务器,也不是一个容器——它是「**守护进程 × 一款 AI 编程工具**」的组合。举例:你在一台 MacBook 上启动守护进程,本机装了 Claude Code 和 Codex你是两个工作区的成员。那么 Multica 会注册 4 个运行时:
<Mermaid chart={`
graph TD
D["你的守护进程<br/>MacBook"]
D --> R1["运行时<br/>工作区 A × Claude Code"]
D --> R2["运行时<br/>工作区 A × Codex"]
D --> R3["运行时<br/>工作区 B × Claude Code"]
D --> R4["运行时<br/>工作区 B × Codex"]
`} />
关键的点:
- **一个守护进程可以对应多个运行时**——装了多款工具、加入了多个工作区,每个组合就各一个
- **同一个守护进程在同一个工作区同一款工具上只会有一条运行时**——重启守护进程不会产生重复记录
- Multica 界面的 **Runtimes** 页面列的就是这些行
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
</Callout>
## 运行时什么时候被判定为离线
Multica 用心跳判断运行时是否在线。三个关键数字:
| 事件 | 阈值 |
|---|---|
| 守护进程心跳频率 | 每 **15 秒** |
| 标记为失联 | 超过 **45 秒** 没心跳(漏了 3 次)|
| 自动删除 | 失联且无关联智能体超过 **7 天** |
失联不是永久的——守护进程只要再次发出心跳就立刻回到在线,运行时记录也会保留。重启守护进程不会丢运行时。
<Callout type="warning">
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`。对可重试的来源issue、chatMultica 会自动重新排队Routines 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
</Callout>
## 一次能并发跑多少任务
Multica 对并发有两层限额:
- **守护进程层**:默认 **20 个执行任务并发**(环境变量 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` 可调)
- **智能体层**:每个智能体默认 **6 个执行任务并发**(智能体配置里改)
两层中更紧的那层生效。如果你的守护进程已经在跑 20 个任务,即使某个智能体还有余量,新的任务也要等。
如果你看到执行任务卡在 `queued` 状态不 `dispatched`,通常就是这两层里某一层打满了。
## 守护进程崩溃后,没跑完的任务会怎样
守护进程崩溃或被强行结束时,它领走的执行任务会停在 `dispatched` 或 `running` 状态。下次启动时,守护进程会告诉服务器:「这些任务不是我的了,请标记失败。」服务器把它们改成 `failed`,失败原因 `runtime_recovery`——对可重试的来源,任务自动重新排队。
即使这一步因网络问题没完成,还有**每 30 秒**一次的服务器端扫描作为后备:超过 45 秒没心跳的运行时会被统一标记为失联,上面的任务也一并回收。
## Agent 不工作怎么排查
遇到「我的智能体不工作」类问题,先过一遍这三步:
1. 跑 `multica daemon status`,确认守护进程在运行且在线
2. 跑 `multica daemon logs -f`,看是否有错误
3. 去 Multica 界面的 **Runtimes** 页面,确认你的运行时显示「在线」
更多场景见 [Troubleshooting](/troubleshooting)。
## 下一步
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照

View File

@@ -0,0 +1,77 @@
---
title: Desktop app
description: What Multica Desktop is, how it differs from the web app, and when it's worth using.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop is a native desktop app for macOS, Windows, and Linux. It talks to the same backend as the web app and shows the same data, but it adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
## Desktop or web — which to pick
| | Web | Desktop |
|---|---|---|
| Access | Open a URL in your browser | Install a native app |
| Multiple tabs | Your browser's own tabs (no workspace separation) | **One independent tab group per workspace** |
| Daemon | You run `multica daemon start` yourself | **Started automatically** on launch |
| Upgrades | Refresh to get the latest | App checks in the background and installs on next launch |
| Signed-in data | Identical | Identical |
**Pick web** for one-off use, working on someone else's machine, or when you'd rather not install anything.
**Pick desktop** for daily use, juggling multiple workspaces, or avoiding manual daemon management.
## Multiple tabs: what happens when you switch workspaces
Desktop maintains an independent tab group for **every workspace you've joined**. When you switch workspaces, the current workspace's tabs are hidden as a unit and the previous workspace's tabs are restored as you left them — similar to VSCode's multi-workspace behavior or switching workspaces in Slack.
Example: you open 3 issue tabs in workspace A and switch to workspace B. A's 3 tabs disappear, and B shows whatever you last had open in B. Switch back to A and those 3 tabs come back exactly as they were. **Tabs never leak across workspaces.**
Logging out **clears every workspace's tab state**, so you don't leak data when a machine is shared between users.
## How Desktop auto-updates
On launch, Desktop checks GitHub Releases for a newer version. If one is found:
1. It downloads the new version silently in the background.
2. It tells you "ready — will install on next launch."
3. When you quit (or next restart), the app installs the update before closing.
4. The next launch runs the new version.
The whole process **doesn't interrupt what you're working on**.
<Callout type="warning">
**On Windows, ARM64 and x64 are separate update channels** — install the wrong architecture and updates won't be detected. When you download, pick the `.exe` that matches your machine (the ARM build has an `arm64` suffix).
</Callout>
The macOS build is signed and notarized, so you won't see an "unidentified developer" warning on first launch. The Linux build is an `.AppImage` — auto-updates rely on electron-updater, which can be flaky on some distros. **If auto-update doesn't work, download the new version manually and replace the old file.**
## Do I still need the standalone CLI and daemon?
**No.** Desktop ships with the same `multica` CLI binary embedded inside it, and it launches its own daemon profile at startup (isolated from any daemon you may be running manually from the terminal).
If you've already installed the CLI and run `multica daemon start` by hand, Desktop won't take over your daemon — it starts its own with a separate profile. Both register as **different runtimes**, and you'll see two independent runtimes in the UI.
If you want to run CLI commands in your terminal, Desktop doesn't offer a special path — use the CLI you installed separately, or run the bundled copy at `resources/bin/multica` inside the app's resources directory.
## Downloading and installing
Grab the installer for your platform from the [Multica downloads page](https://multica.ai/download):
| Platform | File |
|---|---|
| macOS (Intel or Apple Silicon) | `.dmg` |
| Windows x64 | `.exe` (standard) |
| Windows ARM64 | `.exe` (with `arm64` suffix) |
| Linux | `.AppImage` |
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
<Callout type="info">
**Which backend Desktop connects to** is determined by the address you select at sign-in. It defaults to Multica Cloud; if you're running self-hosted, click "Connect to a self-hosted instance" on the first login screen and fill in your server address.
</Callout>
## Next steps
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
- [Self-Host Quickstart](/self-host-quickstart) — connecting Desktop to a self-hosted backend
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)

View File

@@ -0,0 +1,77 @@
---
title: 桌面应用
description: Multica Desktop 是什么、和 Web 有什么区别、什么时候值得用。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端,看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
## Desktop 和 Web 该用哪个
| | Web | Desktop |
|---|---|---|
| 访问方式 | 浏览器打开 URL | 装一个本地应用 |
| 多标签页 | 浏览器自己的标签页(不区分工作区)| **每个工作区一组独立标签页** |
| 守护进程 | 要你自己跑 `multica daemon start` | 启动时**自动拉起** |
| 升级 | 刷新页面就是最新 | 应用自动检查 + 下次启动安装 |
| 登录后的数据 | 完全一样 | 完全一样 |
**选 Web**:临时用、在别人电脑上、不想装应用的场景。
**选 Desktop**:每天用 Multica、会同时操作多个工作区、不想自己管守护进程的场景。
## 多 tab工作区之间切换怎么表现
Desktop 为**每个你加入的工作区**独立维护一组标签页。切换工作区时,当前工作区的标签页会被整体隐藏,上次那个工作区的标签页会原样恢复——像 VSCode 的多 workspace 行为或 Slack 的 workspace 切换。
举例:你在工作区 A 打开了 3 个 issue 标签页,切到工作区 BA 的那 3 个标签页消失B 里显示你上次在 B 留下的标签页;切回 A那 3 个原样回来。**不同工作区的标签页不会互相串到对方**。
登出会**清空所有工作区的标签页状态**,防止多用户共用同一台机器时的数据泄露。
## Desktop 怎么自动更新
Desktop 启动时会去 GitHub Releases 检查新版本。检查到新版本:
1. 在后台静默下载新版本
2. 提示你「准备就绪,下次启动时安装」
3. 你点击退出(或下次重启)时,应用关闭前把新版本装好
4. 再次打开时就是新版本
整个过程**不中断你正在做的事**。
<Callout type="warning">
**Windows 的 ARM64 和 x64 是独立的更新通道**——装错架构会识别不到更新。安装时下载对应你机器架构的那个 `.exe`(带 `arm64` 后缀的是 ARM 版)。
</Callout>
macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的警告。Linux 版是 `.AppImage`——自动更新机制依赖 electron-updater在某些发行版可能不稳定**不工作时手动下载新版本覆盖**。
## 还要单独装 CLI 和守护进程吗
**不用**。Desktop 包里**内置了同一个 `multica` CLI 二进制**——Desktop 启动时会自动启动守护进程的独立 profile和你命令行手动跑的守护进程互不干扰
如果你已经装过 CLI 并手动跑过 `multica daemon start`Desktop 不会抢占你那个守护进程——它起自己的,用不同的 profile 隔离。两边注册的是**不同的运行时**,在 UI 里能看到两个独立运行时。
想在终端里跑 CLI 命令Desktop 不提供特殊方式——照常用系统的 CLI如果你单独装了或者用 Desktop 自带的版本(在应用的资源目录里,`resources/bin/multica`)。
## 怎么下载安装
去 [多卡下载页](https://multica.ai/download) 拿对应平台的安装包:
| 平台 | 文件 |
|---|---|
| macOSIntel 或 Apple Silicon| `.dmg` |
| Windows x64 | `.exe`(常规)|
| Windows ARM64 | `.exe`(带 `arm64` 后缀)|
| Linux | `.AppImage` |
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
<Callout type="info">
**桌面版连哪个后端** 由登录时选的地址决定。默认连 Multica Cloud如果你用自部署版本在首次登录页点"连接到自部署实例"填你的 server 地址即可。
</Callout>
## 下一步
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
- [Self-Host Quickstart](/self-host-quickstart) —— Desktop 连自部署后端
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制Desktop 自动起它,但行为一样)

View File

@@ -169,6 +169,16 @@ Stop PostgreSQL and keep local databases:
make db-down
```
Reset only the current checkout's database (drops `POSTGRES_DB`, recreates it, re-runs all migrations). Other worktree databases are untouched.
```bash
make stop
make db-reset
make start
```
> `make db-reset` refuses to run if `DATABASE_URL` points at a remote host.
Wipe all local PostgreSQL data:
```bash

View File

@@ -0,0 +1,151 @@
---
title: Environment variables
description: The full list of environment variables for running a self-hosted Multica server.
---
import { Callout } from "fumadocs-ui/components/callout";
A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup).
## The five required at startup
These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly.
| Variable | Default | Required in production? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** |
| `PORT` | `8080` | No (unless you change the port) |
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) |
| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) |
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
<Callout type="warning">
**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap).
</Callout>
### Database connection pool
| Variable | Default | Description |
|---|---|---|
| `DATABASE_MAX_CONNS` | `25` | pgxpool max connections. The daemon polls frequently (every 3s) and uses connections; larger deployments may need a higher value |
| `DATABASE_MIN_CONNS` | `5` | Minimum idle connections |
**When unset**, the values above are used — **not** pgx's built-in 4/NumCPU defaults, which previously caused pool exhaustion in production.
## Email configuration
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
| Variable | Default | Description |
|---|---|---|
| `RESEND_API_KEY` | empty | Resend API key |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
## Google OAuth configuration
Optional. Leave unset for email + verification code only; configure it to add "Sign in with Google" on the sign-in page.
| Variable | Default | Description |
|---|---|---|
| `GOOGLE_CLIENT_ID` | empty | Google Cloud OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | empty | Google Cloud OAuth secret |
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth callback URL (self-host: replace with your frontend domain) |
**Takes effect at runtime**: the frontend reads these settings via `/api/config` at runtime, so **changing them requires no frontend rebuild or redeploy** — restart the server and they apply.
Full setup (including Google Cloud Console steps) is in [Sign-in and signup configuration](/auth-setup#google-oauth-configuration).
## File storage configuration
Multica stores user-uploaded attachments (images and files in comments). **S3 is preferred**; if S3 is not configured, it falls back to local disk.
### S3 / S3-compatible storage
| Variable | Default | Description |
|---|---|---|
| `S3_BUCKET` | empty | Setting this enables S3 storage |
| `S3_REGION` | `us-west-2` | AWS region |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
### Local disk (when S3 is not configured)
| Variable | Default | Description |
|---|---|---|
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | Local storage directory |
| `LOCAL_UPLOAD_BASE_URL` | empty (returns relative paths) | Public base URL — leave unset and the frontend can't resolve a full URL for attachments |
### CloudFront (optional)
If you front S3 with CloudFront, three variables apply: `CLOUDFRONT_DOMAIN`, `CLOUDFRONT_KEY_PAIR_ID`, `CLOUDFRONT_PRIVATE_KEY` (or `CLOUDFRONT_PRIVATE_KEY_SECRET` to read from Secrets Manager). Skip them if you don't use CloudFront — they don't conflict with S3 configuration.
### Cookie domain
| Variable | Default | Description |
|---|---|---|
| `COOKIE_DOMAIN` | empty | Scope of the session cookie |
- **Empty**: the cookie is valid only on the exact host visited (correct for single-host deployments)
- **Set to `.example.com`**: the cookie is shared across subdomains (so `app.example.com` and `api.example.com` share a sign-in session)
- Warning: it cannot be an IP address (browsers ignore it)
## Restricting who can sign up
Three allowlist layers combine by priority. **If any layer is set to a non-empty value, emails that don't match are rejected** — even `ALLOW_SIGNUP=true` won't override that.
| Variable | Default | Description |
|---|---|---|
| `ALLOWED_EMAILS` | empty | Explicit email allowlist (comma-separated). When non-empty, only listed emails can sign up |
| `ALLOWED_EMAIL_DOMAINS` | empty | Domain allowlist (comma-separated). When non-empty, only listed domains can sign up |
| `ALLOW_SIGNUP` | `true` | Signup master switch. Set `false` to disable signup entirely |
**The counterintuitive part**: `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` does **not** mean "allow company.io or everyone" — it means **only allow company.io**. The allowlist layers are AND semantics — the full decision tree is in [Sign-in and signup configuration → Signup allowlists](/auth-setup#restricting-who-can-sign-up).
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Daemon tuning parameters
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
| Variable | Default | Description |
|---|---|---|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | Server address (self-host: replace with your domain) |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat interval |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | Task polling interval |
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | Max concurrent tasks |
| `MULTICA_<PROVIDER>_PATH` | matches the CLI name | Path to each AI coding tool's executable (for example `MULTICA_CLAUDE_PATH`) |
| `MULTICA_<PROVIDER>_MODEL` | empty | Default model for each AI coding tool |
For a full explanation of how each parameter affects daemon behavior, see [Daemon and runtimes](/daemon-runtimes).
## Frontend access control
| Variable | Default | Description |
|---|---|---|
| `FRONTEND_ORIGIN` | empty | Frontend address. Invite email links, the CORS allowlist, and the cookie domain are all derived from this. When unset, invite email links fall back to the hosted domain `https://app.multica.ai` — self-host must set this explicitly |
| `CORS_ALLOWED_ORIGINS` | empty | Additional allowed CORS origins (comma-separated) |
| `ALLOWED_ORIGINS` | empty | WebSocket-specific origin allowlist (comma-separated); when unset, fallback order is `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` |
<Callout type="warning">
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
</Callout>
## Usage analytics
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
| Variable | Default | Description |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | Set `true` to disable backend analytics entirely |
| `POSTHOG_API_KEY` | built-in default key | Set when pointing at your own PostHog instance |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | Change to your own host if you self-host PostHog |
## Next
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do

View File

@@ -0,0 +1,151 @@
---
title: 环境变量
description: self-host Multica 服务器需要配置的环境变量清单。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
## 启动必填的五个
这五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。
| 环境变量 | 默认值 | 生产必须设? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** |
| `PORT` | `8080` | 否(除非换端口)|
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)|
| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱)|
| `FRONTEND_ORIGIN` | 空 | **是**self-host 要填你自己的域名)|
<Callout type="warning">
**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码master code`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
</Callout>
### 数据库连接池
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `DATABASE_MAX_CONNS` | `25` | pgxpool 最大连接数。守护进程高频轮询(每 3 秒)会占用连接;大规模部署可能需要调高 |
| `DATABASE_MIN_CONNS` | `5` | 最小常驻连接 |
**不设时**使用上表默认值,**不是** pgx 内置的 4/NumCPU——后者在生产曾引发连接池耗尽。
## 怎么配邮件
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `RESEND_API_KEY` | 空 | Resend API key |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
**不设 `RESEND_API_KEY` 时的行为**server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
## 怎么配 Google OAuth
可选。不设则只有邮箱 + 验证码登录;设了就在登录页出现「用 Google 登录」。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GOOGLE_CLIENT_ID` | 空 | Google Cloud OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | 空 | Google Cloud OAuth secret |
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth 回调地址self-host 换成你的前端域名)|
**热生效**:前端在运行时通过 `/api/config` 拿这些配置,**改了不用重启前端也不用重建镜像**——改完重启 server 即可。
完整配置步骤(含 Google Cloud Console 操作)详见 [登录与注册配置](/auth-setup#怎么配-google-oauth)。
## 怎么配文件存储
Multica 存储用户上传的附件(评论里的图片、文件等)。**优先走 S3**;不配 S3 就回落本地磁盘。
### S3 / S3 兼容存储
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `S3_BUCKET` | 空 | 设了就启用 S3 存储 |
| `S3_REGION` | `us-west-2` | AWS 区域 |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链IAM role / 环境凭证)|
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
**`S3_BUCKET` 未设时**server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
### 本地磁盘S3 未配时)
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | 本地存储目录 |
| `LOCAL_UPLOAD_BASE_URL` | 空(返回相对路径)| 公开访问的 base URL——不设前端就拿不到附件的完整 URL |
### CloudFront可选
如果你用 CloudFront 给 S3 做 CDN三个相关的环境变量`CLOUDFRONT_DOMAIN`、`CLOUDFRONT_KEY_PAIR_ID`、`CLOUDFRONT_PRIVATE_KEY`(或从 Secrets Manager 读的 `CLOUDFRONT_PRIVATE_KEY_SECRET`)。不用 CloudFront 就不用管——和配置 S3 不冲突。
### Cookie 域
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `COOKIE_DOMAIN` | 空 | session cookie 的作用域 |
- **空**cookie 只对访问的那个 host 生效(单主机部署正确)
- **设成 `.example.com`**cookie 在所有子域共享(让 `app.example.com` 和 `api.example.com` 共用登录态)
- ⚠️ 不能是 IP 地址(浏览器会忽略)
## 怎么限制谁能注册
三层白名单按优先级组合。**任何一层白名单一旦设置非空,不匹配的邮箱就会被拒**——即使 `ALLOW_SIGNUP=true` 也挡不住。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `ALLOWED_EMAILS` | 空 | 显式邮箱白名单(逗号分隔)。非空时只有列表里的邮箱能注册 |
| `ALLOWED_EMAIL_DOMAINS` | 空 | 域名白名单(逗号分隔)。非空时只有列表里的域名能注册 |
| `ALLOW_SIGNUP` | `true` | 注册总开关。设 `false` 完全关闭注册 |
**不直观的点**`ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` 的组合**不是**「允许 company.io 或所有人」,而是**只允许 company.io**。白名单的 AND 语义——决策树详见 [登录与注册配置 → Signup 白名单](/auth-setup#怎么限制谁能注册)。
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 守护进程的调节参数
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | server 地址self-host 换成你的域名)|
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 心跳频率 |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 任务轮询频率 |
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 并发任务上限 |
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`|
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
完整解释每个参数对守护进程行为的影响,见 [守护进程与运行时](/daemon-runtimes)。
## 前端访问控制
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `FRONTEND_ORIGIN` | 空 | 前端地址。邀请邮件里的链接、CORS 白名单、cookie domain 都从这里推导。邮件链接在不设时会 fallback 到托管版域名 `https://app.multica.ai`——self-host 必须显式填 |
| `CORS_ALLOWED_ORIGINS` | 空 | 额外的 CORS 允许来源(逗号分隔)|
| `ALLOWED_ORIGINS` | 空 | WebSocket 专用的 origin 白名单(逗号分隔);不设就按 `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` 顺序回落 |
<Callout type="warning">
**`FRONTEND_ORIGIN` 不设就有两个静默失败**1邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例2WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
</Callout>
## 用量统计
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `ANALYTICS_DISABLED` | `false` | 设 `true` 完全关闭后端上报 |
| `POSTHOG_API_KEY` | 内置默认 key | 换成你自己的 PostHog 实例时填 |
| `POSTHOG_HOST` | `https://us.i.posthog.com` | 自建 PostHog 的话改成你自己的地址 |
## 下一步
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -53,13 +53,17 @@ make selfhost
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
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`.
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
@@ -70,6 +74,8 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
<Callout>
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
</Callout>
@@ -151,14 +157,15 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Rebuilding After Updates
## Upgrading
```bash
git pull
make selfhost
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
Migrations run automatically on backend startup.
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
---
@@ -191,6 +198,18 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
@@ -202,7 +221,14 @@ For file uploads and attachments, configure S3 and CloudFront:
| `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 |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
### Server

View File

@@ -0,0 +1,54 @@
---
title: How Multica works
description: How the three core components (server / daemon / AI coding tool) coordinate to run an agent's work.
---
import { ArchitectureDiagram } from "@/components/architecture-diagram";
Multica is a **distributed** platform. The web interface you see is just the front of house — the real work is done by three components: the **Multica server** owns the data ([workspaces](/workspaces), [issues](/issues), [members](/members-roles), the [task](/tasks) queue, and so on); the **[daemon](/daemon-runtimes)** runs on your own machine, picks up tasks, and drives the AI coding tool; and the **[AI coding tool](/providers)** (Claude Code, Codex, and other local CLIs) is the component that actually writes code. This is the biggest difference between Multica and Linear or Jira — **[agents](/agents) don't run on our servers, they run on your machine**.
## The three core components
<ArchitectureDiagram />
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
- **AI coding tools** — one of the ten (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.
## The lifecycle of a task
Take the most common scenario — you assign an issue to an agent:
1. You click assign in the web UI. The browser sends an HTTP request to the Multica server.
2. The server sets the assignee on that issue to the agent and, at the same time, creates an execution task in the task queue with status `queued`.
3. The daemon on your machine picks up the task on its next poll (within 3 seconds). Task status becomes `dispatched`.
4. The daemon creates an isolated working directory locally and invokes the corresponding AI coding tool. Task status becomes `running`.
5. The AI writes code locally, runs tests, and posts comments back to the server.
6. Execution ends. The daemon reports the result (success / failure) to the server, and task status becomes `completed` or `failed`. You see the progress update in real time in the web UI (via WebSocket).
For the detailed mechanics, see [Daemon and runtimes](/daemon-runtimes) and [Tasks](/tasks).
## Four ways to get an agent working
It's not only "assign an issue" — Multica has 4 triggers, one per collaboration style:
| How | Typical scenario | Docs |
|---|---|---|
| **Assign an issue** | The most common. Assign an issue to an agent and it starts on its own | [Assigning issues](/assigning-issues) |
| **@mention an agent in a comment** | "Take a look at this one for me" — don't change the assignee or status, just fire off a comment | [Mentioning agents](/mentioning-agents) |
| **Direct chat** | Standalone conversation, not tied to an issue — ask questions, have it draft an issue | [Chat](/chat) |
| **Routines (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Routines](/routines) |
## Runtimes: where it runs, and how many tools
A **runtime** is the pairing of "daemon × one AI coding tool." If the daemon on one machine has both Claude Code and Codex installed and is joined to two workspaces, Multica registers 4 independent runtimes (2 workspaces × 2 tools).
Only the **local daemon** runtime model is supported today. Cloud runtimes (where you don't need your own machine running) are **coming soon**, currently waitlist-only — sign up on the [Downloads](https://multica.ai/download) page.
## Next steps
- [Cloud Quickstart](/cloud-quickstart) — connect to Multica Cloud in 5 minutes
- [Self-Host Quickstart](/self-host-quickstart) — run your own backend
- [Daemon and runtimes](/daemon-runtimes) — a deep dive into the component the architecture rests on

View File

@@ -0,0 +1,54 @@
---
title: Multica 是怎么工作的
description: 三个核心组件server / 守护进程 / AI 编程工具)怎么协同完成一次智能体工作。
---
import { ArchitectureDiagram } from "@/components/architecture-diagram";
Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——真正干活的有三个组件:**Multica 服务器**管数据([工作区](/workspaces)、[issue](/issues)、[成员](/members-roles)、[任务](/tasks) 队列等);**[守护进程](/daemon-runtimes)** 跑在你自己机器上,领任务、调用 AI 编程工具;**[AI 编程工具](/providers)**Claude Code、Codex 等本地 CLI是真正写代码的那一环。这个结构是 Multica 和 Linear / Jira 最大的差别——**[智能体](/agents) 不跑在我们的服务器上,而是在你自己的机器上**。
## 系统的三个核心组件
<ArchitectureDiagram />
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
- **守护进程**daemon——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server开始每 3 秒领一次任务、每 15 秒发一次心跳。
- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 十款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。
## 一个任务从创建到完成会经历什么
以"你把一个 issue 分配给某个智能体"这个最常见的场景为例:
1. 你在 Web 上点击分配。浏览器发 HTTP 请求到 Multica 服务器。
2. 服务器把这条 issue 的 assignee 改成那个智能体,**同时**在任务队列里创建一条执行任务,状态 `queued`。
3. 你机器上的守护进程下一次轮询3 秒内)把这条任务领走。任务状态变 `dispatched`。
4. 守护进程在本地创建隔离工作目录、调用对应 AI 编程工具开始执行。任务状态变 `running`。
5. AI 在本地写代码、跑测试、发评论回服务器。
6. 执行结束。守护进程把结果(成功 / 失败)汇报给服务器,任务状态变 `completed` 或 `failed`。你在 Web 上看到进度实时更新WebSocket 推送)。
详细机制见 [守护进程与运行时](/daemon-runtimes) 和 [执行任务](/tasks)。
## 让智能体开工的四种方式
不只是"分配 issue"——Multica 有 4 种触发方式,对应不同协作场景:
| 方式 | 典型场景 | 文档 |
|---|---|---|
| **分配 issue** | 最常见。把一条 issue 指派给智能体,它自动开工 | [分配 issue](/assigning-issues) |
| **在评论里 @智能体** | "这条你帮我看一下"——不改 assignee、不改状态用一条评论触发 | [在评论里 @智能体](/mentioning-agents) |
| **直接聊天** | 独立对话,不绑 issue——问问题、让它帮起草任务 | [聊天](/chat) |
| **Routines定时** | 长期指令——每周一早上做 standup 总结之类 | [Routines](/routines) |
## 运行时:在哪里跑,跑几家工具
**运行时**runtime是"守护进程 × 一款 AI 编程工具"的组合。同一台机器上的守护进程装了 Claude Code 和 Codex两个工作区都加入了那么 Multica 会注册 4 个独立运行时2 工作区 × 2 工具)。
目前只支持**本地守护进程**这一种运行模式。云端运行时(不需要你自己开机)**即将开放**,当前处于等待名单阶段——在 [下载页面](https://multica.ai/download) 登记邮箱。
## 下一步
- [Cloud Quickstart](/cloud-quickstart) —— 5 分钟接入 Multica Cloud
- [Self-Host Quickstart](/self-host-quickstart) —— 在自己的服务器上跑一套
- [守护进程与运行时](/daemon-runtimes) —— 架构的灵魂组件深度讲解

View File

@@ -0,0 +1,65 @@
---
title: Inbox and subscriptions
description: When Multica notifies you, and how to mute issues you don't care about.
---
import { Callout } from "fumadocs-ui/components/callout";
The inbox is where Multica **interrupts** you — [issues](/issues) assigned to you, [`@` mentions](/comments), and activity on issues you're subscribed to all land here.
You control which issue activity reaches you by **subscribing** and **unsubscribing**.
## What shows up in your inbox
The following events deliver a notification to your inbox:
- **Issue assigned / unassigned / reassigned** — you're notified when you're the new (or former) assignee
- **Status, priority, or due date change on an issue you're subscribed to**
- **New comment on an issue you're subscribed to**
- **You're `@`-mentioned in a comment** — delivered whether or not you're subscribed
- **Someone reacts to your issue or comment**
- **An agent [task](/tasks) you assigned fails**
## `@all` notifies the entire workspace
`@all` is a special target — it pushes a notification to **every member** of the workspace.
<Callout type="warning">
**Use `@all` sparingly.** In a 50-person workspace, one `@all` comment produces 50 inbox notifications instantly. Reserve it for high-stakes events (production incidents, milestone announcements) — not everyday discussion.
</Callout>
## Agents never receive notifications
Agents **never** get inbox notifications — not even when they're the assignee, creator, or `@`-mentioned in a comment.
This isn't a bug: agents don't read an inbox. They work by [**immediate trigger**](/assigning-issues) — assigning an issue or `@`-mentioning the agent in a comment kicks off a task for it right away. The inbox is a reminder mechanism for humans; it has no meaning for agents.
## Subscription rules
You're **auto-subscribed** to an issue in four situations:
- You **created** it
- You were **assigned** to it
- You **commented** on it
- You were **`@`-mentioned** on it or in one of its comments
Auto-subscription happens once — being both the creator and a mentionee doesn't subscribe you twice.
<Callout type="warning">
**Reassignment doesn't auto-unsubscribe you.** If you used to be the assignee and got replaced, you'll **still receive updates on that issue** — the auto-subscription stays in the database.
To stop getting notified, open the issue and unsubscribe manually.
</Callout>
You can also **manually subscribe** to any issue (even unrelated ones), or **manually unsubscribe** from any auto-subscription. In the UI, use the right panel on the issue page; in the CLI, use `multica issue subscriber add/remove`.
## Sub-issue status changes bubble up to the parent
When a sub-issue's **status** changes, subscribers of the parent issue are notified too — even if they haven't subscribed to the sub-issue.
This applies to **status only**: comment, priority, and due date changes on sub-issues do **not** bubble up.
## Next
- [Comments and mentions](/comments) — how `@` mentions work and the gotchas
- [Assigning issues to agents](/assigning-issues) — how agents are triggered (and why they don't read the inbox)

View File

@@ -0,0 +1,65 @@
---
title: 收件箱与订阅
description: Multica 什么时候通知你,怎么静音不关心的 issue。
---
import { Callout } from "fumadocs-ui/components/callout";
收件箱Inbox是你在 Multica 里**被打扰**的地方——分配给你的 [issue](/issues)、有人 [`@` 你](/comments)、你订阅的 issue 有动态时,都会出现在这里。
你通过**订阅 / 取消订阅** issue 来控制哪些 issue 的变化会打扰你。
## 收件箱里会收到什么
下面这些事件会往你的收件箱里送一条通知:
- **issue 被分配 / 取消分配 / 换了分配人** —— 你是新分配人(或前分配人)时收到
- **你订阅的 issue 改了状态、优先级、截止日期**
- **你订阅的 issue 下有新评论**
- **你在评论里被 `@` 提及** —— 无论是否订阅都会收到
- **你的 issue 或评论被加了表情反应**
- **你分配的智能体[任务](/tasks)失败了**
## `@all` 会通知整个工作区
`@all` 是个特殊的目标——它会把通知推送给工作区里的**每一个成员**。
<Callout type="warning">
**谨慎使用 `@all`**。在 50 人的工作区里发一条 `@all` 评论,会瞬间产生 50 条收件箱通知。只在重大事项(生产事故、里程碑宣布)上用——不是日常讨论。
</Callout>
## 智能体永远不会收到通知
智能体agent**永远**不会收到收件箱通知——即使它是 issue 的分配人、创建者、或者在评论里被 `@` 了。
这不是 bug智能体不看收件箱。它的工作方式是被[**立即触发**](/assigning-issues)——分配 issue 或在评论里 `@` 它,系统会马上起一个任务给它执行。收件箱是给人用的提醒机制,对智能体没有意义。
## 订阅规则
四种情况下你会被**自动订阅**一个 issue
- 你**创建**了它
- 你**被分配**为 assignee
- 你**在它下面发过评论**
- 你**在它或它的评论里被 `@` 提及**
自动订阅只发生一次——你创建了又被 @ 了,不会订阅两次。
<Callout type="warning">
**换了分配人不会自动取消你的订阅。** 如果你之前是某 issue 的分配人,后来被换掉了,你**仍然会收到这个 issue 的后续动态**——因为自动订阅留在了数据库里。
如果不想再被打扰,去 issue 页面手动取消订阅。
</Callout>
你也可以**手动订阅**任何 issue即使和你无关或**手动取消订阅**任何自动订阅。UI 上在 issue 详情页右侧CLI 用 `multica issue subscriber add/remove`。
## 子 issue 状态变化会冒泡到父 issue
如果一个 sub-issue 的**状态**发生变化,父 issue 的订阅者也会收到通知——即使他们没订阅这个 sub-issue。
这只对**状态**生效sub-issue 的评论、优先级、截止日期变化**不会**冒泡到父 issue。
## 下一步
- [评论与提及](/comments) —— `@` 提及的用法和陷阱
- [把 issue 分配给智能体](/assigning-issues) —— 智能体的触发机制(为什么它不看收件箱)

View File

@@ -1,47 +1,50 @@
---
title: Introduction
description: Multica — the open-source managed agents platform. Turn coding agents into real teammates.
title: Welcome
description: A task collaboration platform — humans and AI agents working together in the same workspace.
---
## What is Multica?
import { Callout } from "fumadocs-ui/components/callout";
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.
Multica is a task collaboration platform where humans and AI [agents](/agents) work together in the same [workspace](/workspaces). You can [assign an issue to an agent](/assigning-issues) the way you'd hand work to a teammate — it executes the work, reports progress, and replies in the comments. You can also [open a chat window and talk to it directly](/chat), asking it to draft an issue, answer a question, or handle a one-off request.
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**, **Gemini CLI**, **OpenClaw**, **OpenCode**, and **Hermes**.
This page explains where agents run and the ways you can start using Multica.
## Features
## Where agents run
- **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.
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
## Architecture
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Ten are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
| 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, Gemini CLI, OpenClaw, OpenCode, or Hermes |
<Callout type="info">
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.
</Callout>
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│Gemini/Hermes │
└──────────────┘
```
## Three ways to use Multica
## Next Steps
The first two cards are **backend choices** — where the Multica server runs. The third is a **client choice** — which interface you use. The desktop app pairs with either backend.
- [Cloud Quickstart](/getting-started/cloud-quickstart)
- [Self-Hosting](/getting-started/self-hosting)
- [CLI Installation](/cli/installation)
- [Contributing](/developers/contributing)
<NumberedCards>
<NumberedCard number="01" title="Multica Cloud" href="/cloud-quickstart" tag="Waitlist">
Managed backend. Install the CLI, run the daemon locally, and connect to the Multica-hosted server. Takes about 5 minutes.
</NumberedCard>
<NumberedCard number="02" title="Self-host" href="/self-host-quickstart" tag="Docker · Helm">
Run the full backend on your own server with Docker Compose. Database, server, and storage all live on your infrastructure.
</NumberedCard>
<NumberedCard number="03" title="Desktop app" href="/desktop-app" tag="Recommended">
Native multi-tab window. Ships with the CLI built in and starts the daemon on launch — zero commands to run after install. Connects to Multica Cloud or your self-hosted backend.
</NumberedCard>
</NumberedCards>
## Next steps
<NumberedSteps>
<Step number="01" title="Start with the runtime model">
[How Multica works](/how-multica-works) — 30 seconds to read, and it settles the "server doesn't run agents, agents run on your machine" point once and for all.
</Step>
<Step number="02" title="Pick a way to start">
Choose one of the three above — most people start with the [desktop app](/desktop-app). No CLI setup, up and running in 5 minutes.
</Step>
<Step number="03" title="Assign your first issue">
Create an [issue](/issues) and pick an agent as the assignee instead of a teammate. Wait for it to deliver.
</Step>
</NumberedSteps>

View File

@@ -0,0 +1,50 @@
---
title: 欢迎
description: 一个任务协作平台——人类和 AI 智能体在同一个工作区里共同工作。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在同一个 [工作区](/workspaces) 里共同工作。你可以像给同事派活一样,[把一个任务分配给智能体](/assigning-issues) ——由它去执行、汇报进展、在评论里回复你;也可以[打开聊天窗口直接和它对话](/chat),让它帮你起草任务、回答问题、或完成一次性请求。
这一页讲清楚智能体在哪里运行,以及你有哪几种方式开始使用 Multica。
## 智能体在哪里运行
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置十种:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
</Callout>
## 三种使用方式
前两张卡是**后端选择**——Multica 服务器运行在哪里;第三张是**客户端选择**——你从哪个界面使用。桌面应用可以搭配前两种后端中的任意一种。
<NumberedCards>
<NumberedCard number="01" title="Multica Cloud" href="/cloud-quickstart" tag="等待名单">
托管后端。安装命令行工具并在本地运行守护进程,连接到 Multica 托管的服务器。约 5 分钟完成。
</NumberedCard>
<NumberedCard number="02" title="自部署" href="/self-host-quickstart" tag="Docker · Helm">
用 Docker Compose 在自己的服务器上运行完整后端。数据库、服务器、存储都在你自己的基础设施上。
</NumberedCard>
<NumberedCard number="03" title="桌面应用" href="/desktop-app" tag="推荐">
原生多标签窗口。内置命令行工具并在启动时自动拉起守护进程——安装后无需运行任何命令即可使用。可连接 Multica Cloud 或你自部署的后端。
</NumberedCard>
</NumberedCards>
## 下一步
<NumberedSteps>
<Step number="01" title="先理解运行模型">
[Multica 是怎么工作的](/how-multica-works) — 30 秒读完,把"server 不跑 agentagent 跑在你本地"这件事一次讲透。
</Step>
<Step number="02" title="挑一种使用方式开始">
上面三种里选一种——大多数人从 [桌面应用](/desktop-app) 起步零命令行配置5 分钟跑起来。
</Step>
<Step number="03" title="派出第一个任务">
创建一个 [Issue](/issues),把执行人选成智能体而不是同事。等它来交活。
</Step>
</NumberedSteps>

View File

@@ -0,0 +1,79 @@
---
title: Issues and projects
description: Multica's core unit of work — assignable to a person or to an agent.
---
import { Callout } from "fumadocs-ui/components/callout";
An issue is a self-contained unit of work in Multica — a bug, a new feature, a thing that needs doing. Every issue has a **title**, a **description** (Markdown supported), a **status**, a **priority**, an **assignee**, and optionally belongs to a **project**. If you've used Linear or Jira, this is the same shape.
**Multica's defining trait is that an issue's assignee can be a person or an [agent](/agents)** — which is where we'll start.
## Assigning an issue to an agent
[Assigning](/assigning-issues) an issue to an agent hands that work over to it. The agent **starts automatically** — executing within seconds, reporting progress in comments, and flipping the status to done when finished. The only difference from handing work to a teammate is that an agent doesn't go offline, doesn't need reminders, and is available 24/7.
<Callout type="info">
For agent identity, configuration, and where they run, see [Agents](/agents).
</Callout>
Private agents can only be assigned to issues by workspace owners and admins. For role permissions, see [Members and roles](/members-roles).
## Status
Multica has seven statuses. **Any status can move directly to any other** — Multica doesn't impose a workflow, and won't stop you from jumping from `backlog` straight to `done`.
| Status | Meaning |
|---|---|
| `backlog` | Not scheduled yet |
| `todo` | Scheduled, ready to start |
| `in_progress` | Being worked on |
| `in_review` | Awaiting review |
| `done` | Completed |
| `blocked` | Stuck on an external factor |
| `cancelled` | Cancelled |
Once an issue is assigned to an agent, the agent automatically moves the status from `backlog` / `todo` to `in_progress`, then to `done` on completion. You can also change it manually at any time.
## Priority
Priority has five levels, used to order the default issue list:
| Priority | Use |
|---|---|
| `No priority` | Not decided yet (default) |
| `Urgent` | Urgent |
| `High` | High |
| `Medium` | Medium |
| `Low` | Low |
## Issue numbers
Every issue has a workspace-unique number in the format `<prefix>-<digits>` — for example `MUL-123`. The number is assigned by the system at creation time and **never changes**. See [Workspaces → Issue numbers](/workspaces#issue-numbers).
## Comments
The comment thread under an issue is where collaboration happens — reply to a comment, `@` a person or agent, add a reaction.
`@` an agent in a comment and **it triggers automatically** — this is the second way to start an agent, alongside "assign to." See [Comments and mentions](/comments) and [Mentioning agents in comments](/mentioning-agents).
## Deleting an issue
<Callout type="warning">
Deleting an issue **immediately** clears every comment, reaction, and attachment under it, along with any queued agent tasks (running tasks are cancelled). **It cannot be undone.**
If you just want the issue out of sight, **changing the status to `cancelled` is safer than deleting** — the data stays, and you can pull it back later.
</Callout>
## Projects
A project is a container that groups multiple issues together. An issue belongs to at most one project, or to no project at all.
Projects have their own **lead** — **just like an issue's assignee, a lead can be a person or an agent**.
Deleting a project **does not delete the issues inside it**: those issues simply detach from the project and remain in the workspace.
## Next
- [Comments and mentions](/comments) — collaborating under an issue
- [Agents](/agents) — understand how "assign to an agent" actually works

View File

@@ -0,0 +1,79 @@
---
title: Issue 与 project
description: Multica 的核心工作单位——可以分配给人,也可以分配给智能体。
---
import { Callout } from "fumadocs-ui/components/callout";
Issue工作项是 Multica 里一个独立工作的单位——一条 bug、一个新功能、一项要做的事。每个 issue 有**标题**、**描述**(支持 Markdown、**状态**、**优先级**、**分配人assignee**,可选地还能归入某个 **project**。如果你用过 Linear 或 Jira它们是同类东西。
**Multica 最大的特色是issue 的分配人可以是人,也可以是 [智能体](/agents)**——这是下面先讲的第一件事。
## 把 issue 分配给智能体
把 issue [分配](/assigning-issues) 给某个智能体等于把这项工作交给它。智能体会**自动开工**——在几秒内开始执行、在评论里汇报进展、完成后把状态改到 done。和给同事派活的区别只在于它不下线、不需要你提醒、7×24 可用。
<Callout type="info">
智能体的身份、配置、运行位置详见 [智能体](/agents)。
</Callout>
私有智能体private agent只有工作区的 owner 和 admin 能分配到 issue 上。角色权限详见 [成员与权限](/members-roles)。
## 状态
Multica 提供七种状态。**任何状态可以直接改到任何状态**——Multica 不强加工作流,不会因为你从 `backlog` 直接跳到 `done` 就拦你。
| 状态 | 含义 |
|---|---|
| `backlog` | 还没排期 |
| `todo` | 已排期、准备开工 |
| `in_progress` | 正在做 |
| `in_review` | 等待 review |
| `done` | 已完成 |
| `blocked` | 被外部因素卡住 |
| `cancelled` | 已取消 |
把 issue 分配给智能体后,智能体会自动把状态从 `backlog` / `todo` 推到 `in_progress`,完成后推到 `done`。你也可以随时手动改。
## 优先级
优先级分五档,用来排列 issue 列表的默认顺序:
| 优先级 | 用途 |
|---|---|
| `No priority` | 还没决定(默认值) |
| `Urgent` | 紧急 |
| `High` | 高 |
| `Medium` | 中 |
| `Low` | 低 |
## Issue 编号
每个 issue 有一个工作区内唯一的编号,格式是 `<前缀>-<数字>`,比如 `MUL-123`。编号在创建时由系统自动分配、**永不改变**。详见 [工作区 → Issue 编号](/workspaces#issue-编号)。
## 评论
Issue 下面的评论区是协作发生的地方——回复某条评论、`@` 点名人或智能体、加表情反应。
在评论里 `@` 一个智能体会**自动触发它开工**——这是除了"分配给"之外的第二种触发方式。详见 [评论与提及](/comments) 和 [在评论里召唤智能体](/mentioning-agents)。
## 删除 issue
<Callout type="warning">
删除一个 issue 会**立即**清除它下面的所有评论、表情反应、附件,以及它上面已排队的智能体任务(正在执行的任务会被取消)。**无法恢复**。
如果只是想把 issue 移出视野,**把状态改成 `cancelled` 比删除更安全**——数据还在,以后想捞回来也能捞。
</Callout>
## Project
Project项目是把多个 issue 组织在一起的容器。一个 issue 最多属于一个 project也可以不属于任何 project。
Project 有自己的**负责人lead**——**和 issue 的 assignee 一样lead 可以是人,也可以是智能体**。
删除 project **不会删除它下面的 issue**:这些 issue 只是从这个 project 里脱离,还留在工作区里。
## 下一步
- [评论与提及](/comments) —— 在 issue 下协作
- [智能体](/agents) —— 理解"分配给智能体"的工作原理

View File

@@ -0,0 +1,60 @@
---
title: Members and roles
description: What each of the three workspace roles — owner, admin, member — can do, and how to bring people in.
---
import { Callout } from "fumadocs-ui/components/callout";
Everyone in a [workspace](/workspaces) has a role, and the role decides what they can do. Multica has three: **owner** (the workspace's owner), **admin**, and **member**. Most day-to-day work — creating [issues](/issues), writing [comments](/comments), using [agents](/agents) — is available to all three roles. **The differences cluster around team management.**
## Permissions at a glance
The table below lists the most important differences across team-management actions:
| Action | owner | admin | member |
|---|---|---|---|
| Invite a new admin or member | ✓ | ✓ | ✗ |
| **Invite a new owner** | ✓ | ✗ | ✗ |
| Demote / remove an admin or member | ✓ | ✓ | ✗ |
| **Demote / remove another owner** | ✓ | ✗ | ✗ |
| Delete the workspace | ✓ | ✗ | ✗ |
**Members can't invite anyone** — inviting is an admin-tier permission. **Only owners can promote someone to owner** — admins can promote and demote members or other admins, but they can't create a new owner. Likewise, admins can remove members or other admins but **can't touch existing owners**. The point is to make sure the highest tier can only be granted by someone who already holds it — permissions don't leak upward.
<Callout type="info">
Agent visibility comes in two flavors: "workspace" and "private." Private agents can only be assigned to issues by owners and admins — this protects configurations meant for a specific set of people. See [Agents](/agents).
</Callout>
## Inviting a new member
Multica invites new members by email:
1. On the workspace settings page, click **Invite member**, enter the email, and pick a role.
2. Multica sends an invitation email containing a unique link.
3. The recipient clicks the link, logs in (or signs up), and **accepts the invitation** to join the workspace.
The invited email **does not need to be registered with Multica in advance** — if no account exists, one is created when the invitation is accepted.
If the invitation email fails to deliver (wrong address, mail service hiccup), the invitation record is still retained; you can resend the email from workspace settings, or share the invitation link through another channel.
Invitations are **valid for 7 days**. After that, clicking the link shows an "expired" message, and the inviter needs to send a new one.
## Always at least one owner
Every workspace **must have at least one owner at all times**. This constraint automatically blocks two operations:
- The last owner can't demote themselves.
- Other owners or admins can't remove the last owner.
<Callout type="warning">
If you're the last owner and about to leave the team, **transfer the owner role to another member first**, then try to leave or hand off the workspace. Otherwise the operation will be rejected.
</Callout>
## Removing a member
Owners and admins can remove other members from a workspace. A removed member loses access immediately; issues, comments, and other content they created are retained in the workspace.
## Next
- [Issues and projects](/issues) — what members work on
- [Comments and mentions](/comments) — collaborating under an issue

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